From df26d627d2f4cd366d6423cb1c69c9066df78327 Mon Sep 17 00:00:00 2001 From: Chris Pelatari Date: Sat, 6 Jun 2026 10:56:43 -0500 Subject: [PATCH 1/2] fix keyboard hiding editor on Android --- Platforms/Android/MainActivity.cs | 7 +- Resources/Raw/editor/index.html | 47 ++------ Views/EditorPage.xaml.cs | 63 ----------- src/PostXING.ViewModels/EditorViewModel.cs | 23 +--- .../EditorViewModelTests.cs | 105 ------------------ 5 files changed, 14 insertions(+), 231 deletions(-) diff --git a/Platforms/Android/MainActivity.cs b/Platforms/Android/MainActivity.cs index 64f7fa8..3564f92 100644 --- a/Platforms/Android/MainActivity.cs +++ b/Platforms/Android/MainActivity.cs @@ -10,10 +10,9 @@ namespace PostXING.App; Theme = "@style/Maui.SplashTheme", MainLauncher = true, LaunchMode = LaunchMode.SingleTop, - // AdjustResize is kept as a hint, but target SDK 35+ edge-to-edge enforcement defeats it: the - // window no longer shrinks for the soft keyboard. So EditorPage reads the IME inset natively and - // pushes it into the editor JS, which sizes the editor above the keyboard (GH #39). Harmless - // where AdjustResize still works. + // AdjustResize so the soft keyboard shrinks the WebView viewport instead of panning the + // window up; CodeMirror inside the editor then auto-scrolls the cursor into view. Without + // this the keyboard can sit on top of the last few lines of text. WindowSoftInputMode = SoftInput.AdjustResize, ConfigurationChanges = ConfigChanges.ScreenSize | ConfigChanges.Orientation | ConfigChanges.UiMode | ConfigChanges.ScreenLayout | ConfigChanges.SmallestScreenSize | ConfigChanges.Density)] public class MainActivity : MauiAppCompatActivity diff --git a/Resources/Raw/editor/index.html b/Resources/Raw/editor/index.html index b85fc95..307fa6a 100644 --- a/Resources/Raw/editor/index.html +++ b/Resources/Raw/editor/index.html @@ -749,30 +749,20 @@ setCaretOffset(editor, offset); } - // Keep the editor sized to the actual visible area above the Android soft keyboard. The visual - // viewport does NOT shrink for the soft keyboard on the Android WebView (measured: innerHeight, - // visualViewport.height and this var all stay full-height whether the keyboard is up or down), so - // the JS can't detect the keyboard on its own. Instead the Android host reads the IME inset - // natively and pushes it here (in device px) via setKeyboardInsetPx; we subtract it from - // innerHeight. On Windows hostInsetPx stays 0 and we use the visual viewport height unchanged - // (there's no soft keyboard to exclude there). - let hostInsetPx = 0; // soft-keyboard height in *device* px, pushed by the Android host (0 = hidden) + // Keep the editor sized to the visual viewport (the actual visible area, excluding the + // Android soft keyboard). Without this, AdjustResize doesn't always shrink the WebView's + // layout viewport, and content scrolls "off the bottom" behind the keyboard. function syncEditorHeight() { - let h; - if (hostInsetPx > 0) { - const dpr = window.devicePixelRatio || 1; - h = Math.max(120, window.innerHeight - hostInsetPx / dpr); - } else { - h = window.visualViewport ? window.visualViewport.height : window.innerHeight; - } - document.documentElement.style.setProperty('--vv-h', h + 'px'); + const vv = window.visualViewport; + if (!vv) return; + document.documentElement.style.setProperty('--vv-h', vv.height + 'px'); } syncEditorHeight(); if (window.visualViewport) { window.visualViewport.addEventListener('resize', () => { syncEditorHeight(); - // Once the new height is applied, bring the caret back into view; a frame's delay so the - // height has taken effect and the rects are fresh. + // Once the keyboard finishes appearing, bring the caret back into view; a frame's + // delay so the new height has been applied and rects are fresh. requestAnimationFrame(scrollCaretIntoView); }); } @@ -853,19 +843,6 @@ scheduleOutgoing(); }); - // Keep the caret visible as it moves — typing, newlines, arrows, taps — regardless of IME - // composition state. The input handler above defers to compositionend while composing, which - // would otherwise let the caret slide behind the keyboard mid-word; selectionchange fires on - // every caret move, so it covers that gap. Throttled to one frame. - let caretScrollScheduled = false; - document.addEventListener('selectionchange', () => { - if (caretScrollScheduled) return; - const sel = window.getSelection(); - if (!sel || !sel.rangeCount || !editor.contains(sel.anchorNode)) return; - caretScrollScheduled = true; - requestAnimationFrame(() => { caretScrollScheduled = false; scrollCaretIntoView(); }); - }); - // Paste as plain text (strip styles from the OS clipboard). editor.addEventListener('paste', (e) => { e.preventDefault(); @@ -932,14 +909,6 @@ focus() { editor.focus(); }, - // Android host -> JS: the soft-keyboard height in device px (0 when hidden). The visual viewport - // doesn't shrink for the keyboard on the Android WebView, so this is the only reliable signal; - // we resize the editor to the visible area above the keyboard and pull the caret back into view. - setKeyboardInsetPx(px) { - hostInsetPx = (typeof px === 'number' && px > 0) ? px : 0; - syncEditorHeight(); - requestAnimationFrame(scrollCaretIntoView); - }, getText() { return editor.innerText; }, diff --git a/Views/EditorPage.xaml.cs b/Views/EditorPage.xaml.cs index aa53088..cb78b02 100644 --- a/Views/EditorPage.xaml.cs +++ b/Views/EditorPage.xaml.cs @@ -4,10 +4,6 @@ using Microsoft.Maui.ApplicationModel; using Microsoft.Maui.Storage; using PostXING.ViewModels; -#if ANDROID -using AndroidX.Core.View; -using AView = Android.Views.View; -#endif namespace PostXING.App.Views; @@ -83,10 +79,6 @@ public EditorPage(EditorViewModel vm, IPendingPostBox box, IPreviewBox previewBo #if ANDROID // Android's JS->host bridge can't live-sync edits, so pull the editor text on save. vm.SyncBeforeSaveAsync = () => SyncEditorTextBeforeSaveAsync(); - - // Edge-to-edge (target SDK 36) defeats AdjustResize, so the soft keyboard hides the lower - // editor (GH #39). Hook the IME-inset -> editor-JS bridge once the WebView's handler exists. - EditorWebView.HandlerChanged += (_, _) => HookAndroidImeInsets(); #endif // HybridWebView serves Resources/Raw/editor (HybridRoot) and exposes a raw @@ -107,7 +99,6 @@ protected override void OnAppearing() _ = SeedEditorAsync(); #if ANDROID StartDirtyPoll(); - HookAndroidImeInsets(); // backstop: the handler may already exist before HandlerChanged fired #endif } @@ -329,59 +320,5 @@ private static string DecodeEvalString(string? raw) try { return JsonSerializer.Deserialize(json) ?? raw; } catch { return raw; } } - - // Edge-to-edge enforcement (target SDK 35+) means WindowSoftInputMode=AdjustResize no longer - // shrinks the window for the soft keyboard, so the lower editor lines hide behind the IME (GH - // #39). The JS can't detect the keyboard either: on this Android WebView window.innerHeight and - // visualViewport.height stay full-height whether the keyboard is up or down (measured via remote - // debugging). So we read the IME inset natively here and push it (device px) into the editor JS, - // which sizes the editor to the real visible area above the keyboard and keeps the caret in view. - // Scoped to the WebView so MAUI's own system-bar inset handling elsewhere is left alone. - private bool _imeInsetsHooked; - - private void HookAndroidImeInsets() - { - if (_imeInsetsHooked) return; - if (EditorWebView.Handler?.PlatformView is not AView platformView) return; - _imeInsetsHooked = true; - ViewCompat.SetOnApplyWindowInsetsListener(platformView, new ImeInsetListener(PushKeyboardInsetToJs)); - ViewCompat.RequestApplyInsets(platformView); - BridgeLog.Write("ime inset listener attached to WebView"); - } - - private void PushKeyboardInsetToJs(int imeBottomPx) - { - BridgeLog.Write($"ime inset bottom={imeBottomPx}"); - // Fire-and-forget like the other host->JS pushes: the awaited Task never completes on this - // Android HybridWebView but the JS still runs. The if-guard makes a pre-load push harmless. - MainThread.BeginInvokeOnMainThread(() => - { - try { _ = EditorWebView.EvaluateJavaScriptAsync($"if(window.PostXING&&window.PostXING.setKeyboardInsetPx){{window.PostXING.setKeyboardInsetPx({imeBottomPx})}}"); } - catch (Exception ex) { BridgeLog.Write($"PushKeyboardInset threw {ex.GetType().Name}: {ex.Message}"); } - }); - } - - // Reads the IME (soft keyboard) inset on each window-insets pass and forwards its height in - // device px when it changes. Returns the insets unconsumed — the WebView is a leaf, nothing - // downstream depends on them; the diff-guard avoids redundant pushes on unrelated passes. - private sealed class ImeInsetListener : Java.Lang.Object, IOnApplyWindowInsetsListener - { - private readonly Action _onInsetChanged; - private int _lastBottom = -1; - - public ImeInsetListener(Action onInsetChanged) => _onInsetChanged = onInsetChanged; - - public WindowInsetsCompat? OnApplyWindowInsets(AView? v, WindowInsetsCompat? insets) - { - if (insets is null) return insets; - var imeBottom = insets.GetInsets(WindowInsetsCompat.Type.Ime())?.Bottom ?? 0; - if (imeBottom != _lastBottom) - { - _lastBottom = imeBottom; - _onInsetChanged(imeBottom); - } - return insets; - } - } #endif } diff --git a/src/PostXING.ViewModels/EditorViewModel.cs b/src/PostXING.ViewModels/EditorViewModel.cs index 325eb2e..ec72da1 100644 --- a/src/PostXING.ViewModels/EditorViewModel.cs +++ b/src/PostXING.ViewModels/EditorViewModel.cs @@ -177,26 +177,9 @@ public void LoadPost(PostHandle handle, string contents) partial void OnRawMarkdownChanged(string value) { if (!_seeding) IsDirty = true; - - // This runs on every keystroke, and while the user is mid-edit the YAML front matter is - // routinely, transiently invalid (an unterminated quote, a half-typed list) -- the parser - // throws out of Parse. Never let that escape the setter: on desktop it would crash the - // async-void WebView "change" handler; on Android it re-threw out of the ~750ms dirty-poll - // every tick. Keep the last-good FrontMatter and still refresh the word count off the raw - // buffer so metadata stays live until the YAML parses again. - string body; - try - { - var parsed = _parser.Parse(value); - FrontMatter = parsed.FrontMatter; - body = parsed.Body; - } - catch - { - body = value; - } - - var words = body.Split([' ', '\t', '\n', '\r'], StringSplitOptions.RemoveEmptyEntries).Length; + var parsed = _parser.Parse(value); + FrontMatter = parsed.FrontMatter; + var words = parsed.Body.Split([' ', '\t', '\n', '\r'], StringSplitOptions.RemoveEmptyEntries).Length; WordCount = words; ReadingTimeMinutes = Math.Max(1, (int)Math.Ceiling(words / 200.0)); } diff --git a/tests/PostXING.ViewModels.Tests/EditorViewModelTests.cs b/tests/PostXING.ViewModels.Tests/EditorViewModelTests.cs index d7883a7..126ee82 100644 --- a/tests/PostXING.ViewModels.Tests/EditorViewModelTests.cs +++ b/tests/PostXING.ViewModels.Tests/EditorViewModelTests.cs @@ -1,5 +1,4 @@ using NSubstitute; -using NSubstitute.ExceptionExtensions; using PostXING.Core.Domain; using PostXING.GitHub; using PostXING.Markdown; @@ -298,108 +297,4 @@ public void Merge_is_disabled_when_no_pr_is_pending() var vm = GitHubVm(out _); vm.MergeCommand.CanExecute(null).ShouldBeFalse(); } - - // ---- Malformed front matter while typing must not crash the editor ---- - // - // OnRawMarkdownChanged runs on every keystroke. While the user is mid-edit the YAML front - // matter is routinely, transiently invalid (an unterminated quote, a half-typed list) and the - // parser throws out of Parse. That exception used to propagate out of the RawMarkdown setter: - // on desktop it escaped the async-void WebView "change" handler (crash); on Android it re-threw - // out of the ~750ms dirty-poll every tick (PXBRIDGE log spam + frozen WordCount/FrontMatter). - // The setter must swallow the failure, keep the last-good FrontMatter, and keep the count live. - - private static EditorViewModel NewVm(IFrontMatterParser parser) - { - var gateway = Substitute.For(); - gateway.CheckAuthAsync(Arg.Any()).Returns(new GhAuthStatus(false, null, "stubbed")); - var settings = Substitute.For(); - settings.Current.Returns(AppSettings.Default); - var local = Substitute.For(); - return new EditorViewModel(parser, gateway, settings, local, TimeProvider.System, StubGit(), new GitHubPublishService(gateway)); - } - - [Fact] - public void Typing_malformed_frontmatter_does_not_throw_out_of_the_RawMarkdown_setter() - { - const string malformed = - "---\n" + - "title: \"half-typed quote\n" + // opens a double-quoted scalar that never closes - "---\n\n" + - "body text\n"; - - // Guard: the real parser genuinely throws on this input, so the assertion below isn't vacuous. - Should.Throw(() => new YamlFrontMatterParser().Parse(malformed)); - - var vm = NewVm(new YamlFrontMatterParser()); - vm.CancelTitlePromptCommand.Execute(null); - vm.IsDirty = false; - - Should.NotThrow(() => vm.RawMarkdown = malformed); - - vm.IsDirty.ShouldBeTrue("a keystroke still marks the buffer dirty even when the YAML can't parse"); - vm.RawMarkdown.ShouldBe(malformed); - } - - [Fact] - public void Malformed_frontmatter_keeps_the_word_count_live_over_the_raw_buffer() - { - // 7 whitespace-separated tokens across the whole buffer (---, oops:, "unterminated, ---, - // alpha, beta, gamma). With the front matter unparseable we can't split it from the body, - // so the whole raw buffer is counted -- the point is the count stays live, not frozen at 0. - const string malformed = - "---\n" + - "oops: \"unterminated\n" + - "---\n" + - "alpha beta gamma\n"; - - var vm = NewVm(new YamlFrontMatterParser()); - vm.CancelTitlePromptCommand.Execute(null); - - vm.RawMarkdown = malformed; - - vm.WordCount.ShouldBe(7); - vm.ReadingTimeMinutes.ShouldBe(1); - } - - [Fact] - public void Valid_frontmatter_after_a_malformed_edit_recovers_the_metadata() - { - var vm = NewVm(new YamlFrontMatterParser()); - vm.CancelTitlePromptCommand.Execute(null); - - // 1) a clean document parses normally - vm.RawMarkdown = "---\ntitle: Alpha\n---\n\nfirst body\n"; - vm.FrontMatter.Title.ShouldBe("Alpha"); - - // 2) the YAML goes transiently invalid: no throw, and the last-good title is retained - vm.RawMarkdown = "---\ntitle: \"Alpha and then some\n---\n\nstill editing\n"; - vm.FrontMatter.Title.ShouldBe("Alpha", "a transient parse failure keeps the last-good front matter"); - - // 3) the YAML is valid again: metadata recovers from the new front matter - vm.RawMarkdown = "---\ntitle: Bravo\n---\n\nrecovered body here\n"; - vm.FrontMatter.Title.ShouldBe("Bravo"); - vm.WordCount.ShouldBe(3, "the body word count tracks the recovered document"); - } - - [Fact] - public void Any_parser_exception_is_swallowed_and_the_last_good_frontmatter_is_kept() - { - // The VM defends against *any* IFrontMatterParser throwing, not just YamlDotNet -- the - // interface doesn't promise Parse won't throw, so the setter must never let it escape. - var parser = Substitute.For(); - parser.Parse(Arg.Any()).Returns(new ParsedDocument(FrontMatter.Default.WithTitle("Good"), "good body")); - var vm = NewVm(parser); - vm.CancelTitlePromptCommand.Execute(null); - - vm.RawMarkdown = "anything"; - vm.FrontMatter.Title.ShouldBe("Good"); - - parser.Parse("now totally broken text").Throws(new InvalidOperationException("kaboom")); - - Should.NotThrow(() => vm.RawMarkdown = "now totally broken text"); - - vm.FrontMatter.Title.ShouldBe("Good", "the last-good front matter survives a parse failure"); - vm.IsDirty.ShouldBeTrue(); - vm.WordCount.ShouldBe(4, "word count falls back to the raw buffer (now, totally, broken, text)"); - } } From a0e72d9444f4137e8d83a622c0c67bb80bb5799d Mon Sep 17 00:00:00 2001 From: Chris Pelatari Date: Sat, 6 Jun 2026 16:32:15 +0000 Subject: [PATCH 2/2] style(xaml): reindent XAML to 2-space and lock the convention (#22) (#51) XAML has no required indent width; 4-space was just convention. Reindent all 12 XAML files from 4-space to 2-space: - structural nesting indent halved (N*4 -> N*2) - column-aligned multi-line attributes and comments re-aligned to the first attribute / comment text under the new (halved) structural indent - hanging-indent attribute lists (element name alone on its line) halved Pure whitespace change: 879 insertions / 879 deletions across the 12 files, every changed line a 1:1 leading-whitespace replacement (no content moved). Lock the convention so it stays 2-space: - .editorconfig: [*.{xaml}] indent_size 4 -> 2 - .vscode/settings.json: force 2-space for XML/XAML, detectIndentation off - .vscode/extensions.json: recommend EditorConfig so .editorconfig is honored - .gitignore: whitelist the two shared .vscode files (the dir was ignored) Verified four independent ways: a deterministic only-leading-whitespace gate (12/12), XML well-formedness (12/12), an independent adversarial per-file review (12/12, zero defects), and a CI-parity build where XamlC compiles every XAML (0 warn / 0 err) plus xUnit (176 passed). Closes #22 Co-authored-by: Claude Opus 4.8 (1M context) --- .editorconfig | 2 +- .gitignore | 6 +- .vscode/extensions.json | 7 + .vscode/settings.json | 13 ++ App.xaml | 16 +- AppShell.xaml | 2 +- Platforms/Windows/App.xaml | 8 +- Resources/Styles/Colors.xaml | 76 ++++---- Resources/Styles/Styles.xaml | 336 ++++++++++++++++---------------- Views/AboutPage.xaml | 180 +++++++++--------- Views/EditorPage.xaml | 358 +++++++++++++++++------------------ Views/GhTerminalPage.xaml | 102 +++++----- Views/OpenPostPage.xaml | 202 ++++++++++---------- Views/PreviewPage.xaml | 26 +-- Views/SettingsPage.xaml | 314 +++++++++++++++--------------- Views/SplashPage.xaml | 136 ++++++------- 16 files changed, 904 insertions(+), 880 deletions(-) create mode 100644 .vscode/extensions.json create mode 100644 .vscode/settings.json diff --git a/.editorconfig b/.editorconfig index 939f463..abb18f4 100644 --- a/.editorconfig +++ b/.editorconfig @@ -30,4 +30,4 @@ indent_size = 2 indent_size = 2 [*.{xaml}] -indent_size = 4 +indent_size = 2 diff --git a/.gitignore b/.gitignore index de9d934..cf42f4d 100644 --- a/.gitignore +++ b/.gitignore @@ -8,7 +8,11 @@ **/BundleArtifacts/ **/GeneratedArtifacts/ **/.vs/ -**/.vscode/ +# Ignore editor-local VSCode/VSCodium files, but commit the shared workspace +# config (issue #22: 2-space XAML for VSCodium). +**/.vscode/* +!.vscode/settings.json +!.vscode/extensions.json *.user *.suo *.userosscache diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..7fb3b6e --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,7 @@ +{ + // EditorConfig makes VSCodium honor .editorconfig (including 2-space XAML) for + // every file type, not just the [xml]/[xaml] overrides in settings.json. + "recommendations": [ + "editorconfig.editorconfig" + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..4af7e19 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,13 @@ +{ + // XAML is treated as XML by VSCodium. Force 2-space indentation and stop the + // editor from guessing indent width from file contents (issue #22). + "editor.detectIndentation": false, + "[xml]": { + "editor.tabSize": 2, + "editor.insertSpaces": true + }, + "[xaml]": { + "editor.tabSize": 2, + "editor.insertSpaces": true + } +} diff --git a/App.xaml b/App.xaml index 807e8bc..498a7ad 100644 --- a/App.xaml +++ b/App.xaml @@ -2,12 +2,12 @@ - - - - - - - - + + + + + + + + diff --git a/AppShell.xaml b/AppShell.xaml index 52fff8d..5da7606 100644 --- a/AppShell.xaml +++ b/AppShell.xaml @@ -4,5 +4,5 @@ xmlns:views="clr-namespace:PostXING.App.Views" x:Class="PostXING.App.AppShell" Shell.FlyoutBehavior="Disabled"> - + diff --git a/Platforms/Windows/App.xaml b/Platforms/Windows/App.xaml index a6d30e6..57e09c1 100644 --- a/Platforms/Windows/App.xaml +++ b/Platforms/Windows/App.xaml @@ -1,7 +1,7 @@ + x:Class="PostXING.App.WinUI.App" + xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:maui="using:Microsoft.Maui"> diff --git a/Resources/Styles/Colors.xaml b/Resources/Styles/Colors.xaml index 461dee0..a7882a0 100644 --- a/Resources/Styles/Colors.xaml +++ b/Resources/Styles/Colors.xaml @@ -2,43 +2,43 @@ - - - - #1E5BFF - #4D7DFF - #0A2BC2 - #06B6D4 - #0E7490 - #E08A2E - #D97706 - #B25C00 - - - #7F170E - - - #08081A - #0F0F1F - #1A1A2E - #2D2D4A - - - #FFFFFF - #E5E7EB - #9095A4 - #6B7080 - - - #10B981 - #F59E0B - #EF4444 - - - #8C000000 - - - #2D2D4A - #E5E7EB + + + + #1E5BFF + #4D7DFF + #0A2BC2 + #06B6D4 + #0E7490 + #E08A2E + #D97706 + #B25C00 + + + #7F170E + + + #08081A + #0F0F1F + #1A1A2E + #2D2D4A + + + #FFFFFF + #E5E7EB + #9095A4 + #6B7080 + + + #10B981 + #F59E0B + #EF4444 + + + #8C000000 + + + #2D2D4A + #E5E7EB diff --git a/Resources/Styles/Styles.xaml b/Resources/Styles/Styles.xaml index 37c6b15..b9aa5f1 100644 --- a/Resources/Styles/Styles.xaml +++ b/Resources/Styles/Styles.xaml @@ -2,180 +2,180 @@ - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + diff --git a/Views/AboutPage.xaml b/Views/AboutPage.xaml index decda4e..95816a8 100644 --- a/Views/AboutPage.xaml +++ b/Views/AboutPage.xaml @@ -5,107 +5,107 @@ BackgroundColor="{AppThemeBinding Light={StaticResource Paper100}, Dark={StaticResource Ink950}}" Shell.NavBarIsVisible="False" Title="About"> - - - + + + -