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;