Hand Android's own focus back to the terminal after an accessory key
ci / android head (push) Successful in 3m22s
ci / desktop nightly (push) Successful in 41s
ci / api image (push) Successful in 26s
ci / build and test (push) Successful in 2m27s

Focusable=false was only ever half the fix, and its remark now says so:
Avalonia's focus stays on the NativeWebView, but the touch that presses
a key still hands Android's native focus to Avalonia's input view — the
platform moves it before Avalonia decides anything. The WebView's input
connection dies with it, the keyboard swaps to its no-input layout, and
the inset churn parks it over the very row that was tapped.

Each key now returns that focus once its byte is on the wire, through a
sibling of SoftKeyboard that walks the decor view to the one WebView
this application has. Free when nothing moved. Check 11.10a is the
phone-in-hand proof.
This commit is contained in:
2026-08-09 11:35:15 +02:00
parent cc8bf37321
commit 506d2803a2
3 changed files with 143 additions and 42 deletions
+15
View File
@@ -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 Worth doing on the software keyboard too, where the same fault shows as the keyboard closing on the first
tap of an arrow key. 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 ### 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. Open a shell, close its tab, then open a different one from HOSTS.
@@ -0,0 +1,64 @@
using global::Android.Views;
namespace DodoSSH.Client.Android.Platform;
/// <summary>
/// Hands native focus back to the terminal's WebView after Avalonia chrome took it.
/// </summary>
/// <remarks>
/// <para>
/// <b>The sibling of <see cref="SoftKeyboard"/>, and it exists for the same reason that one does:</b> 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 <c>Focusable=false</c> — see TerminalScreen — so Avalonia's
/// idea of focus never leaves the terminal when one is tapped. What still moves is <em>Android's</em>: 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// <para>
/// Found by walking the decor view rather than asked of the <c>NativeWebView</c> 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
/// <see cref="SoftKeyboard.Hide"/>'s are: no activity while backgrounded, no WebView while the terminal
/// surface has never been shown, and nothing to do in either case.
/// </para>
/// </remarks>
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;
}
}
@@ -141,7 +141,37 @@ internal sealed partial class TerminalScreen : UserControl
foreach (var (label, bytes, latches) in Keys) foreach (var (label, bytes, latches) in Keys)
{ {
var key = new Button 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();
};
}
else
{
key.Click += (_, _) =>
{
SendAsync(bytes);
TerminalFocus.Return();
};
}
row.Children.Add(key);
}
}
/// <summary>One key of the accessory row, before its click is wired.</summary>
/// <remarks>Split from <see cref="BuildAccessoryRow"/> for length rather than for reuse.</remarks>
private static Button CreateKey(string label) =>
new()
{ {
Content = new TextBlock Content = new TextBlock
{ {
@@ -179,23 +209,15 @@ internal sealed partial class TerminalScreen : UserControl
// //
// Focusable=false is what a toolbar button is, and it means the focused element never // 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. // 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, Focusable = false,
}; };
if (latches)
{
controlKey = key;
key.Click += (_, _) => ToggleControl();
}
else
{
key.Click += (_, _) => SendAsync(bytes);
}
row.Children.Add(key);
}
}
private void ToggleControl() private void ToggleControl()
{ {
controlLatched = !controlLatched; controlLatched = !controlLatched;