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

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:
jaap-jan committed 2026-08-09 11:35:15 +02:00
1 parent cc8bf37321
commit 506d2803a2
3 files changed
+143 -42

No files matched your search

@@ -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);
}
}
/// <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
{
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;