diff --git a/docs/manual-checks.md b/docs/manual-checks.md index 1d07b81..55027a2 100644 --- a/docs/manual-checks.md +++ b/docs/manual-checks.md @@ -1750,15 +1750,18 @@ tap of an arrow key. With a shell open and the software keyboard up, tap **esc**, **tab** or an arrow on the accessory row, then keep typing on the software keyboard. -**Pass:** the keyboard does not change — not its layout, not its suggestion strip, not its height — and +**Pass:** the keyboard settles back unchanged — same layout, same suggestion strip, same height — and everything typed after the tap still reaches the terminal. The accessory row stays visible above the -keyboard throughout. +keyboard throughout. A blink during the press itself is tolerable: the platform takes the focus on both +halves of every touch and the return is posted right behind each theft, so the connection can visibly flap +for the press's own duration — what it must never do is *stay* swapped after the finger lifts. **Failure means:** Android's own view focus stayed on Avalonia's input view after the tap instead of being -handed back. This is the half `Focusable = false` cannot reach — the platform moves its focus on the touch -itself, before Avalonia decides anything — and the symptom chain is the keyboard swapping to its no-input -layout and the inset churn parking it over the very row that was tapped. See -`TerminalFocus` in the Android head's Platform folder. +handed back. This is the half `Focusable = false` cannot reach — the platform requests focus for its own +view after dispatching every handled touch — and the symptom chain is the keyboard swapping to its no-input +layout and the inset churn parking it over the very row that was tapped. The first fix for this failed by +timing alone: it handed focus back from inside the very dispatch the platform re-steals it after. See +`TerminalFocus` in the Android head's Platform folder for both the mechanism and the fix's shape. ### 11.11 Closing a connection and opening a new one both take you somewhere real diff --git a/src/DodoSSH.Client.Android/Platform/TerminalFocus.cs b/src/DodoSSH.Client.Android/Platform/TerminalFocus.cs index a2d4617..97b929e 100644 --- a/src/DodoSSH.Client.Android/Platform/TerminalFocus.cs +++ b/src/DodoSSH.Client.Android/Platform/TerminalFocus.cs @@ -10,17 +10,24 @@ namespace DodoSSH.Client.Android.Platform; /// The sibling of , and it exists for the same reason that one does: the /// keyboard over a terminal belongs to the WebView's own native view, which Avalonia's focus manager does /// not own. The accessory row's keys are already Focusable=false — see TerminalScreen — so Avalonia's -/// idea of focus never leaves the terminal when one is tapped. What still moves is Android's: the -/// tap lands on Avalonia's own input view, which is focusable-in-touch-mode because Avalonia's text boxes -/// need it to be, and the platform hands that view focus on the way to delivering the touch. The WebView's -/// input connection dies with its focus, the keyboard swaps to the layout it shows an editor that takes no -/// text, and the inset churn that follows can leave it sitting on top of the very row that was tapped. +/// idea of focus never leaves the terminal when one is tapped. What still moves is Android's: +/// AvaloniaView.DispatchTouchEvent (decompiled from Avalonia.Android 12.1.1) ends every handled +/// touch — DOWN and UP alike — with a RequestFocus() for Avalonia's own view. The WebView's input +/// connection dies with its focus, the keyboard swaps to the layout it shows an editor that takes no text, +/// and the inset churn that follows can leave it sitting on top of the very row that was tapped. /// /// -/// So each accessory key hands focus straight back once its byte is on the wire. The page inside the -/// WebView never noticed anything — its own DOM focus never moved — so regaining native focus re-establishes -/// the same input connection and the keyboard settles back to what it was. Skipped when the WebView is still -/// focused, which makes the call free on any platform arrangement where the steal never happened. +/// Posted, not called — the posting is the fix's second attempt, and the first one's failure is why. +/// The first version called RequestFocus() from the keys' own Click handlers, which fire +/// inside the UP event's dispatch — and the platform's own request runs after dispatch +/// returns, so it undid ours a few microseconds later and the terminal stayed unfocused. A posted runnable +/// runs on the next main-looper message, after the platform has taken its turn, so ours is the request that +/// sticks. The focus check lives inside the posted runnable for the same reason: the answer at call time is +/// about to be made stale by the very mechanism this exists to counter. +/// +/// +/// The page inside the WebView never noticed any of this — its own DOM focus never moved — so regaining +/// native focus re-establishes the same input connection and the keyboard settles back to what it was. /// /// /// Found by walking the decor view rather than asked of the NativeWebView control, because the @@ -39,10 +46,18 @@ internal static class TerminalFocus return; } - if (FindWebView(decor) is { IsFocused: false } webView) + if (FindWebView(decor) is not { } webView) { - webView.RequestFocus(); + return; } + + webView.Post(() => + { + if (!webView.IsFocused) + { + webView.RequestFocus(); + } + }); } private static View? FindWebView(ViewGroup parent) diff --git a/src/DodoSSH.Client.Android/Views/TerminalScreen.axaml.cs b/src/DodoSSH.Client.Android/Views/TerminalScreen.axaml.cs index 3e2d417..916637a 100644 --- a/src/DodoSSH.Client.Android/Views/TerminalScreen.axaml.cs +++ b/src/DodoSSH.Client.Android/Views/TerminalScreen.axaml.cs @@ -139,29 +139,27 @@ internal sealed partial class TerminalScreen : UserControl { var row = this.FindControl("AccessoryKeys")!; + // Both halves of a press steal Android's own focus — the platform requests it for Avalonia's view + // after dispatching every handled touch, DOWN and UP alike; TerminalFocus carries the decompiled + // citation. Countered at the row rather than inside each key's Click, and for two reasons: Click + // only exists for the UP half, so a keyboard detached at DOWN would stay detached for the whole + // length of the press; and the Return is posted past the current dispatch, so its ordering against + // the key's own handler does not matter — which is what lets one pair of handlers cover ten keys. + row.AddHandler(PointerPressedEvent, (_, _) => TerminalFocus.Return(), RoutingStrategies.Tunnel); + row.AddHandler(PointerReleasedEvent, (_, _) => TerminalFocus.Return(), RoutingStrategies.Tunnel); + foreach (var (label, bytes, latches) in Keys) { var key = CreateKey(label); - // TerminalFocus.Return in both, after the key has done its work: by Click time the touch has - // already moved Android's own focus onto Avalonia's input view, and leaving it there is what - // swaps the keyboard out from over the terminal. Returning it is free when nothing moved. if (latches) { controlKey = key; - key.Click += (_, _) => - { - ToggleControl(); - TerminalFocus.Return(); - }; + key.Click += (_, _) => ToggleControl(); } else { - key.Click += (_, _) => - { - SendAsync(bytes); - TerminalFocus.Return(); - }; + key.Click += (_, _) => SendAsync(bytes); } row.Children.Add(key); @@ -213,8 +211,9 @@ internal sealed partial class TerminalScreen : UserControl // At the Avalonia layer, that is. Android keeps a focus of its own, and the touch that // presses one of these keys hands it to Avalonia's input view regardless of what Avalonia // decides about its element — taking the keyboard's input connection off the terminal and - // swapping its layout mid-typing. The Click wiring in BuildAccessoryRow hands that half - // back; see TerminalFocus for the whole story. + // swapping its layout mid-typing. The row's own pointer handlers in BuildAccessoryRow hand + // that half back; see TerminalFocus for the whole story, including why the handing back has + // to be posted rather than done inline. Focusable = false, };