diff --git a/docs/manual-checks.md b/docs/manual-checks.md index df9f537..6ea93fd 100644 --- a/docs/manual-checks.md +++ b/docs/manual-checks.md @@ -1745,6 +1745,21 @@ is a terminal that answers the buttons and ignores the keyboard: it reads as the Worth doing on the software keyboard too, where the same fault shows as the keyboard closing on the first tap of an arrow key. +### 11.10a The accessory keys do not cost the terminal its *software* keyboard either + +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 +everything typed after the tap still reaches the terminal. The accessory row stays visible above the +keyboard throughout. + +**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. + ### 11.11 Closing a connection and opening a new one both take you somewhere real Open a shell, close its tab, then open a different one from HOSTS. diff --git a/src/DodoSSH.Client.Android/Platform/TerminalFocus.cs b/src/DodoSSH.Client.Android/Platform/TerminalFocus.cs new file mode 100644 index 0000000..a2d4617 --- /dev/null +++ b/src/DodoSSH.Client.Android/Platform/TerminalFocus.cs @@ -0,0 +1,64 @@ +using global::Android.Views; + +namespace DodoSSH.Client.Android.Platform; + +/// +/// Hands native focus back to the terminal's WebView after Avalonia chrome took it. +/// +/// +/// +/// 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. +/// +/// +/// 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. +/// +/// +/// Found by walking the decor view rather than asked of the NativeWebView control, because the +/// control does not expose its platform child and this application only ever has the one WebView — the +/// walk's first match is necessarily the terminal. Every step is allowed to be absent, exactly as +/// 's are: no activity while backgrounded, no WebView while the terminal +/// surface has never been shown, and nothing to do in either case. +/// +/// +internal static class TerminalFocus +{ + public static void Return() + { + if (PhoneEnvironment.CurrentActivity?.Window?.DecorView is not ViewGroup decor) + { + return; + } + + if (FindWebView(decor) is { IsFocused: false } webView) + { + webView.RequestFocus(); + } + } + + private static View? FindWebView(ViewGroup parent) + { + for (var i = 0; i < parent.ChildCount; i++) + { + switch (parent.GetChildAt(i)) + { + case global::Android.Webkit.WebView webView: + return webView; + + case ViewGroup child when FindWebView(child) is { } found: + return found; + } + } + + return null; + } +} diff --git a/src/DodoSSH.Client.Android/Views/TerminalScreen.axaml.cs b/src/DodoSSH.Client.Android/Views/TerminalScreen.axaml.cs index a8e92c7..3e2d417 100644 --- a/src/DodoSSH.Client.Android/Views/TerminalScreen.axaml.cs +++ b/src/DodoSSH.Client.Android/Views/TerminalScreen.axaml.cs @@ -141,61 +141,83 @@ internal sealed partial class TerminalScreen : UserControl foreach (var (label, bytes, latches) in Keys) { - var key = new Button - { - Content = new TextBlock - { - Text = label, - FontFamily = (FontFamily)Application.Current!.FindResource("MonoFont")!, - FontSize = 11, - HorizontalAlignment = HorizontalAlignment.Center, - VerticalAlignment = VerticalAlignment.Center, - }, - - // 44 wide, and that is the number that matters: the design draws these flexed across the - // width, which at 360dp with ten keys is 32 pixels each — under every thumb-target - // guideline there is. The height came down with the row it sits in, from 38 to 30, and it - // costs nothing a width does: the keys are a single row with the terminal above and the - // system's gesture bar below, so there is no neighbour a short press can land on instead. - MinWidth = 44, - Height = 30, - Padding = new Thickness(10, 0), - CornerRadius = new CornerRadius(9), - Background = Palette("Panel"), - BorderBrush = Palette("BorderMid"), - BorderThickness = new Thickness(1), - Foreground = Palette("TextDim"), - HorizontalContentAlignment = HorizontalAlignment.Center, - - // ◆ NOT FOCUSABLE, AND THAT IS THE WHOLE CONTROL RATHER THAN A DETAIL. - // - // These keys are an extension of the keyboard, not a place the keyboard should go. As - // ordinary buttons they took Avalonia's focus on tap, which takes it off the NativeWebView - // — and the package's own OnLostFocus then calls the adapter's ResignFocus(). So pressing - // Tab or an arrow handed the terminal one byte and took the keyboard away from it: the next - // thing typed on a hardware keyboard went nowhere, and the row went on working because its - // buttons are pressed rather than typed into, which is what makes it look like the terminal - // had died instead. - // - // Focusable=false is what a toolbar button is, and it means the focused element never - // changes: the WebView is still it, so nothing resigns and nothing has to be handed back. - Focusable = false, - }; + 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(); + key.Click += (_, _) => + { + ToggleControl(); + TerminalFocus.Return(); + }; } else { - key.Click += (_, _) => SendAsync(bytes); + key.Click += (_, _) => + { + SendAsync(bytes); + TerminalFocus.Return(); + }; } row.Children.Add(key); } } + /// One key of the accessory row, before its click is wired. + /// Split from for length rather than for reuse. + private static Button CreateKey(string label) => + new() + { + Content = new TextBlock + { + Text = label, + FontFamily = (FontFamily)Application.Current!.FindResource("MonoFont")!, + FontSize = 11, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center, + }, + + // 44 wide, and that is the number that matters: the design draws these flexed across the + // width, which at 360dp with ten keys is 32 pixels each — under every thumb-target + // guideline there is. The height came down with the row it sits in, from 38 to 30, and it + // costs nothing a width does: the keys are a single row with the terminal above and the + // system's gesture bar below, so there is no neighbour a short press can land on instead. + MinWidth = 44, + Height = 30, + Padding = new Thickness(10, 0), + CornerRadius = new CornerRadius(9), + Background = Palette("Panel"), + BorderBrush = Palette("BorderMid"), + BorderThickness = new Thickness(1), + Foreground = Palette("TextDim"), + HorizontalContentAlignment = HorizontalAlignment.Center, + + // ◆ NOT FOCUSABLE, AND THAT IS THE WHOLE CONTROL RATHER THAN A DETAIL. + // + // These keys are an extension of the keyboard, not a place the keyboard should go. As + // ordinary buttons they took Avalonia's focus on tap, which takes it off the NativeWebView + // — and the package's own OnLostFocus then calls the adapter's ResignFocus(). So pressing + // Tab or an arrow handed the terminal one byte and took the keyboard away from it: the next + // thing typed on a hardware keyboard went nowhere, and the row went on working because its + // buttons are pressed rather than typed into, which is what makes it look like the terminal + // had died instead. + // + // Focusable=false is what a toolbar button is, and it means the focused element never + // changes: the WebView is still it, so nothing resigns and nothing has to be handed back. + // + // 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. + Focusable = false, + }; + private void ToggleControl() { controlLatched = !controlLatched;