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
@@ -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;
}
}