Give the phone's terminal its own keyboard, and stop refereeing a focus fight
ci / build and test (push) Successful in 3m9s
ci / android head (push) Successful in 3m43s
ci / desktop nightly (push) Successful in 1m22s
ci / api image (push) Successful in 26s

Typing into a shell on the phone has been the worst thing about this head, and
the reason is not the renderer. It is that the keyboard over the terminal was
configured by Chromium rather than by us: a prose keyboard, with a suggestion
strip, autocorrect, autocapitalisation and a predictive layer that inserts text
ahead of the cursor — over a surface where every character is a command.

The obvious fix does not exist. xterm.js already sets every hint the web
platform has on its helper textarea — autocorrect, autocapitalize and
spellcheck are all off in the vendored bundle — and Android ignores all three,
because they are advisory. What actually silences a keyboard on this platform
is an inputType carrying TYPE_TEXT_VARIATION_VISIBLE_PASSWORD, which is what
Termux's own TerminalView sets and the reason typing in Termux feels like a
terminal. A page inside a WebView cannot reach that field: Chromium derives the
whole EditorInfo from the focused DOM node, and no attribute produces the
visible-password variation.

Avalonia can reach it, on the version already pinned here. Decompiled from
Avalonia.Android 12.1.1, AndroidInputMethod.OnCreateInputConnection:

    if ((int)outAttrs.InputType == 1 && options.ShowSuggestions == false)
        outAttrs.InputType = (InputTypes)(outAttrs.InputType | 0x80080);

0x80080 is VISIBLE_PASSWORD | NO_SUGGESTIONS — Termux's pair exactly. So the
keyboard behaviour wanted is one attached property away from any Avalonia
control, and permanently out of reach of the WebView. That asymmetry is the
whole decision; ADR 0015 records it, along with the two stages deliberately not
taken and what would falsify the premise.

◆ THE INPUT PATH LEAVES THE WEBVIEW. THE RENDERER DOES NOT MOVE.

TerminalInputSurface is a 1x1 focusable control that draws nothing and is where
every character typed into a shell now arrives. It is not stretched over the
renderer, which would be the obvious shape and is wrong three times over: the
WebView composites above everything Avalonia paints so it would be invisible,
it would never see the touches the WebView consumes first, and it would sit
over the connect page sharing that Panel whenever no session is open. Focus
does not need area.

Its IME client retains nothing and claims nothing — no surrounding text, no
preedit — because a terminal has no document for an input method to edit. The
line being edited is on the far side of an SSH channel and this client has
never been told what is on it.

◆ THE FOCUS FIGHT ENDS BY REMOVING A CONTESTANT.

TerminalFocus is deleted rather than amended. It existed to hand Android's own
view focus back to the WebView after AvaloniaView.DispatchTouchEvent took it at
both ends of every touch, and it was correct for as long as the input
connection belonged to the page. It no longer does, so the focus the platform
hands to Avalonia's view is now the focus the keyboard wants. The accessory
row's two posted counter-moves go with it; the keys stay Focusable=false, which
is now what keeps Avalonia's focus on the input surface.

RendererFocus replaces it with the opposite move: the WebView is made
non-focusable, so it cannot take an input connection and Chromium's own
showSoftInput cannot succeed from it. Non-focusable is not non-interactive —
focusability governs keyboard input, not touch delivery — so the page keeps its
pan-y scrollback untouched. What it can no longer do is raise a keyboard, so
taps on it are observed with Handled=false throughout and reported, filtered by
the platform's own ScaledTouchSlop. Without that filter, dragging the
scrollback would raise the keyboard over the output being scrolled to.

◆ TWO THINGS FOUND ON THE WAY, ONE OF THEM OLD.

The Ctrl latch never worked with the software keyboard. Not a regression and
not new — everything typed went straight into the page and never passed
SendAsync, which is where the latch lives, so `ctrl` then `c` was inert unless
the second key also came from the accessory row. Routing input through Avalonia
fixes it as a side effect, and check 11.10d exists to keep it fixed.

Avalonia raises a text-input event for any key whose UnicodeChar is 32 or
above, whether or not the key event was handled — it reads the handled flag
only to fold into its own return value. Ctrl-C on a hardware keyboard therefore
produced 0x03 and then a literal c. controlByteAlreadySent swallows the second.
The keys in TryTranslate need no such guard, their UnicodeChars being below 32.

One deliberate loss, in the open: Delete sends backspace. Avalonia turns an
IME's deleteSurroundingText into a dispatched KEYCODE_FORWARD_DEL, so a soft
keyboard taking that route is indistinguishable here from a real Delete key —
and a backspace that deletes forwards on every phone is worse than a missing
forward-delete on the few devices that have one.

Nothing here is verifiable without a device, so the checks are written rather
than the tests: 11.10b is the falsification test and should be run first,
because everything staged on this stands or falls on whether one attached
property actually silences a real keyboard.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
jaap-janandClaude Opus 5 committed 2026-08-25 10:12:14 +02:00
1 parent c0396b4f4d
commit c0dd5a147e
9 files changed
+904 -119

No files matched your search

@@ -0,0 +1,155 @@
using global::Android.Views;
namespace DodoSSH.Client.Android.Platform;
/// <summary>
/// Takes the keyboard away from the terminal's <c>WebView</c>, and reports the taps that should bring it
/// back.
/// </summary>
/// <remarks>
/// <para>
/// <b>This replaces <c>TerminalFocus</c>, and it is the opposite move.</b> That class handed Android's own
/// view focus <em>back</em> to the <c>WebView</c> after Avalonia's <c>DispatchTouchEvent</c> took it, because
/// the input connection the keyboard was attached to belonged to the page. Under ADR 0015 it no longer does:
/// <see cref="Views.TerminalInputSurface"/> is the input target, and it is an Avalonia control, so the focus
/// the platform requests for Avalonia's view after every handled touch is now the focus we want. The fight
/// ends by removing one of the contestants rather than by refereeing it faster.
/// </para>
/// <para>
/// <b>Non-focusable, not non-interactive, and the distinction is the whole design.</b> Focusability governs
/// where keyboard and D-pad input go; it has nothing to do with touch delivery. A <c>WebView</c> with
/// <c>Focusable=false</c> still receives, scrolls and hit-tests every touch it is given — so the page keeps
/// its <c>touch-action: pan-y</c> scrollback exactly as before — but it can no longer take an input
/// connection, and Chromium's own <c>showSoftInput</c> for the focused DOM node cannot succeed from an
/// unfocused view. That is precisely the split wanted: the page keeps the finger, Avalonia keeps the
/// keyboard.
/// </para>
/// <para>
/// <b>Why a tap has to be reported at all.</b> With the page unable to raise a keyboard, the ordinary way
/// back after the system back gesture lowers one — touch the terminal — would otherwise do nothing. The
/// listener is attached with <c>Handled = false</c> throughout, so it observes without consuming: the
/// <c>WebView</c>'s own <c>OnTouchEvent</c> runs after it exactly as if nothing were listening.
/// </para>
/// <para>
/// A tap is distinguished from a scroll the way every Android view does it, by
/// <c>ViewConfiguration.ScaledTouchSlop</c>. Without that test, dragging the scrollback would raise the
/// keyboard over the very output being scrolled to.
/// </para>
/// <para>
/// Found by walking the decor view rather than asked of the <c>NativeWebView</c> control, for the reason
/// <c>TerminalFocus</c> gave and which has not changed: the control does not expose its platform child, and
/// this application only ever has the one <c>WebView</c>, so the walk's first match is necessarily the
/// terminal. Every step is allowed to be absent — there is no activity while backgrounded, and no
/// <c>WebView</c> until the terminal surface has been shown once.
/// </para>
/// </remarks>
internal static class RendererFocus
{
/// <summary>The view already configured, so that showing the terminal twice does not listen twice.</summary>
/// <remarks>
/// Weak, because this is a static holding an Android view: a strong reference would keep the whole view
/// hierarchy, and the activity behind it, alive for the life of the process.
/// </remarks>
private static WeakReference<global::Android.Webkit.WebView>? configured;
/// <summary>
/// Makes the renderer unable to hold the keyboard, and calls <paramref name="onTapped"/> when somebody
/// taps it.
/// </summary>
/// <remarks>
/// Idempotent, and called on every transition into the terminal rather than once at startup, because the
/// <c>WebView</c> does not exist until the surface it lives on has been shown — and because Android is
/// free to tear its renderer down and build a new one, which is a case this head already handles
/// elsewhere (see <c>docs/android-port.md</c>).
/// </remarks>
public static void Suppress(Action onTapped)
{
if (PhoneEnvironment.CurrentActivity?.Window?.DecorView is not ViewGroup decor)
{
return;
}
if (FindWebView(decor) is not { } webView)
{
return;
}
if (configured is not null && configured.TryGetTarget(out var already) && ReferenceEquals(already, webView))
{
return;
}
webView.Focusable = false;
webView.FocusableInTouchMode = false;
webView.ClearFocus();
WatchForTaps(webView, onTapped);
configured = new WeakReference<global::Android.Webkit.WebView>(webView);
}
/// <summary>
/// Calls <paramref name="onTapped"/> for a tap, and stays out of the way of everything else.
/// </summary>
/// <remarks>
/// A tap is a press and a release that did not travel further than the platform's own
/// <c>ScaledTouchSlop</c> — the same test every Android view uses, and necessary rather than fastidious:
/// without it, dragging the scrollback would raise the keyboard over the output being scrolled to.
/// </remarks>
private static void WatchForTaps(global::Android.Webkit.WebView webView, Action onTapped)
{
// The fallback is the platform's own historical default, for a context that has somehow gone.
var slop = webView.Context is { } context ? ViewConfiguration.Get(context)?.ScaledTouchSlop ?? 8 : 8;
var downX = 0f;
var downY = 0f;
var moved = false;
webView.Touch += (_, e) =>
{
// Observed, never consumed. The WebView's own OnTouchEvent still runs, so the page scrolls.
e.Handled = false;
if (e.Event is not { } motion)
{
return;
}
switch (motion.Action)
{
case MotionEventActions.Down:
downX = motion.GetX();
downY = motion.GetY();
moved = false;
break;
case MotionEventActions.Move:
moved |= Math.Abs(motion.GetX() - downX) > slop || Math.Abs(motion.GetY() - downY) > slop;
break;
case MotionEventActions.Up when !moved:
onTapped();
break;
}
};
}
private static global::Android.Webkit.WebView? 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;
}
}
@@ -1,79 +0,0 @@
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>:
/// <c>AvaloniaView.DispatchTouchEvent</c> (decompiled from Avalonia.Android 12.1.1) ends every handled
/// touch — DOWN and UP alike — with a <c>RequestFocus()</c> for Avalonia's own view. 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>
/// <b>Posted, not called — the posting is the fix's second attempt, and the first one's failure is why.</b>
/// The first version called <c>RequestFocus()</c> from the keys' own Click handlers, which fire
/// <em>inside</em> the UP event's dispatch — and the platform's own request runs <em>after</em> dispatch
/// returns, so it undid ours a few microseconds later and the terminal stayed unfocused. A posted runnable
/// runs on the next main-looper message, after the platform has taken its turn, so ours is the request that
/// sticks. The focus check lives inside the posted runnable for the same reason: the answer at call time is
/// about to be made stale by the very mechanism this exists to counter.
/// </para>
/// <para>
/// The page inside the WebView never noticed any of this — 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.
/// </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 not { } webView)
{
return;
}
webView.Post(() =>
{
if (!webView.IsFocused)
{
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;
}
}
@@ -0,0 +1,16 @@
namespace DodoSSH.Client.Android.Views;
/// <summary>
/// One keystroke's worth of bytes, on its way from <see cref="TerminalInputSurface"/> to the shell.
/// </summary>
/// <remarks>
/// Bytes rather than text, because half of what a terminal receives has no text form: a cursor key is an
/// escape sequence and Ctrl-C is a single byte below the printable range. Encoding at the surface — where
/// the key is still known — rather than downstream is what keeps the send path from having to guess which
/// kind it was handed.
/// </remarks>
/// <param name="bytes">The encoded keystroke.</param>
internal sealed class TerminalInputEventArgs(byte[] bytes) : EventArgs
{
public byte[] Bytes { get; } = bytes;
}
@@ -0,0 +1,304 @@
using System.Text;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Input.TextInput;
using Avalonia.Media;
namespace DodoSSH.Client.Android.Views;
/// <summary>
/// The terminal's keyboard, as an Avalonia control that draws nothing.
/// </summary>
/// <remarks>
/// <para>
/// <b>This exists so that the software keyboard is configured by us rather than by Chromium</b>, and that
/// single sentence is the whole of ADR 0015. A phone keyboard over a terminal has to be told to stop
/// autocorrecting, stop predicting, stop capitalising and stop offering a suggestion strip, and on Android
/// the only setting that actually achieves it is an <c>EditorInfo.inputType</c> carrying
/// <c>TYPE_TEXT_VARIATION_VISIBLE_PASSWORD</c>. A page inside a <c>WebView</c> cannot reach that field —
/// Chromium derives the whole <c>EditorInfo</c> from the focused DOM node, and xterm.js already sets every
/// attribute the web platform offers (<c>autocorrect</c>, <c>autocapitalize</c>, <c>spellcheck</c>) to no
/// effect, because they are advisory and Gboard ignores them.
/// </para>
/// <para>
/// <b>Avalonia can reach it, on the version this repository already pins.</b>
/// <c>AndroidInputMethod.OnCreateInputConnection</c> in Avalonia.Android 12.1.1 reads:
/// <code>
/// if ((int)outAttrs.InputType == 1 &amp;&amp; options.ShowSuggestions == false)
/// outAttrs.InputType = (InputTypes)(outAttrs.InputType | 0x80080);
/// </code>
/// <c>0x80080</c> is <c>TYPE_TEXT_VARIATION_VISIBLE_PASSWORD | TYPE_TEXT_FLAG_NO_SUGGESTIONS</c> — the exact
/// pair Termux's own <c>TerminalView</c> sets, and the reason typing in Termux feels like a terminal. The
/// guard is why the constructor sets <em>only</em> <see cref="TextInputOptions.ShowSuggestionsProperty"/>
/// and leaves <c>ContentType</c>, <c>Multiline</c> and <c>AutoCapitalization</c> alone: any of those would
/// move <c>InputType</c> off <c>1</c> and the branch would never be taken. <c>ShowSuggestions</c> is a
/// <c>bool?</c> defaulting to <c>null</c>, so it must be set to <c>false</c> rather than merely left.
/// </para>
/// <para>
/// <b>One pixel, and never drawn.</b> Focus in Avalonia has nothing to do with hit-testing, so the input
/// target does not need to cover the terminal — and must not, because the renderer is a native child view
/// composited above everything Avalonia paints, so a control stretched over it would be invisible, unable
/// to receive the touches the <c>WebView</c> consumes first, and in the way of the connect page that shares
/// this Panel when no session is open. A 1×1 control in the corner is focusable, costs one layout slot, and
/// is the whole of what an input target has to be. Taps on the terminal itself are noticed by
/// <see cref="Platform.RendererFocus"/> instead, which sees them where they actually land.
/// </para>
/// <para>
/// <b>Nothing is retained.</b> <see cref="TerminalImeClient"/> reports no surrounding text and no preedit,
/// because a terminal has no document for an input method to edit — the remote holds the line and the only
/// honest answer to "what is around the cursor" is "ask the host, which you cannot". Committed text is
/// turned into bytes and forwarded the instant it arrives.
/// </para>
/// </remarks>
internal sealed class TerminalInputSurface : Control
{
/// <summary>
/// Bytes the shell should receive, already encoded.
/// </summary>
/// <remarks>
/// Raised rather than sent here, because the Ctrl latch belongs to the accessory row and the session to
/// the view model. <c>TerminalScreen.SendAsync</c> owns both and applies the latch to anything one byte
/// long, which is exactly the set a latch should reach: a typed letter, yes; a pasted line or a cursor
/// key's escape sequence, no.
/// </remarks>
internal event EventHandler<TerminalInputEventArgs>? Input;
private readonly TerminalImeClient client;
/// <summary>
/// Whether the text event about to arrive has already been sent as a control byte.
/// </summary>
/// <remarks>
/// <para>
/// <b>Necessary because handling a key event does not stop the text that follows it.</b> Avalonia's
/// <c>AndroidKeyboardEventsHelper</c> raises the key event and then, independently, raises a text input
/// event for any key whose <c>UnicodeChar</c> is 32 or above — it reads the handled flag from the first
/// only to combine into its own return value, never to skip the second. So Ctrl-C on a hardware keyboard
/// produces both a <c>KeyDown</c> this class turns into <c>0x03</c> and a text input carrying a literal
/// <c>c</c>, and without this flag the shell receives an interrupt followed by a stray character.
/// </para>
/// <para>
/// A plain field rather than anything cleverer because the two events are raised one after the other in
/// the same dispatch, and it is cleared at the top of every <see cref="OnKeyDown"/> so a chord that
/// somehow produced no text cannot swallow the next thing typed.
/// </para>
/// <para>
/// The keys in <see cref="TryTranslate"/> need no such guard — Enter, Tab, Escape and Backspace all
/// carry a <c>UnicodeChar</c> below 32, so no text event is raised for them in the first place.
/// </para>
/// </remarks>
private bool controlByteAlreadySent;
public TerminalInputSurface()
{
client = new TerminalImeClient(this);
Focusable = true;
// Drawn nowhere and hit-tested never — see the remarks. The size is 1 rather than 0 only because a
// zero-area control is a shape no layout pass has a reason to keep.
Width = 1;
Height = 1;
HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Left;
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Top;
IsHitTestVisible = false;
// The one line that fixes the keyboard. See the remarks for why it is the only option set.
TextInputOptions.SetShowSuggestions(this, false);
AddHandler(TextInputMethodClientRequestedEvent, OnTextInputMethodClientRequested);
}
/// <summary>
/// Takes focus and asks for the keyboard.
/// </summary>
/// <remarks>
/// <para>
/// The request is made even when focus was already here, which is the case that matters: the system back
/// gesture puts the keyboard away without moving focus, so the way back has to be an activation request
/// rather than a focus change that would be a no-op. <c>AndroidInputMethod</c> answers
/// <c>InputPaneActivationRequested</c> with <c>ShowSoftInput</c>, which is precisely this.
/// </para>
/// <para>
/// Focusing for the first time raises the keyboard on its own — <c>SetClient</c> calls
/// <c>RestartInput</c> and <c>ShowSoftInput</c> when it takes a non-null client — so the ordering here is
/// belt and braces rather than two mechanisms disagreeing.
/// </para>
/// </remarks>
internal void Acquire()
{
Focus();
client.RequestKeyboard();
}
private void OnTextInputMethodClientRequested(object? sender, TextInputMethodClientRequestedEventArgs e) =>
e.Client = client;
/// <remarks>
/// Everything the IME commits and everything a hardware keyboard types that is not in
/// <see cref="TryTranslate"/>'s table. UTF-8 because that is what the pty is told the session is, and
/// because a multi-byte character has to arrive as one write or a partial sequence reaches the remote.
/// </remarks>
protected override void OnTextInput(TextInputEventArgs e)
{
if (controlByteAlreadySent)
{
controlByteAlreadySent = false;
e.Handled = true;
return;
}
if (string.IsNullOrEmpty(e.Text))
{
return;
}
Input?.Invoke(this, new TerminalInputEventArgs(Encoding.UTF8.GetBytes(e.Text)));
e.Handled = true;
base.OnTextInput(e);
}
/// <remarks>
/// <para>
/// Only the keys that have no text of their own are handled here; everything printable is left alone so
/// that it arrives once, through <see cref="OnTextInput"/>. Handling both would double every letter.
/// </para>
/// <para>
/// Ctrl with a letter is translated here rather than left to the latch, because a hardware keyboard can
/// hold a modifier and this is the only place that sees the chord.
/// </para>
/// </remarks>
protected override void OnKeyDown(KeyEventArgs e)
{
controlByteAlreadySent = false;
if (e.KeyModifiers.HasFlag(KeyModifiers.Control) && TryControlChord(e.Key, out var chord))
{
Input?.Invoke(this, new TerminalInputEventArgs(chord));
// The letter's own text event is still coming; see the field's remarks.
controlByteAlreadySent = true;
e.Handled = true;
return;
}
if (TryTranslate(e.Key, out var bytes))
{
Input?.Invoke(this, new TerminalInputEventArgs(bytes));
e.Handled = true;
return;
}
base.OnKeyDown(e);
}
/// <remarks>
/// The ASCII rule and nothing cleverer, matching <c>TerminalScreen.SendAsync</c>'s latch: Ctrl-A through
/// Ctrl-Z are <c>0x01</c>–<c>0x1A</c>, which is the letter masked with <c>0x1F</c>.
/// </remarks>
private static bool TryControlChord(Key key, out byte[] bytes)
{
if (key is >= Key.A and <= Key.Z)
{
bytes = [(byte)((byte)('a' + (key - Key.A)) & 0x1F)];
return true;
}
bytes = [];
return false;
}
/// <summary>
/// The keys a terminal expects as escape sequences rather than as text.
/// </summary>
/// <remarks>
/// <para>
/// Cursor keys are written as CSI rather than SS3, which is the same choice — and the same reasoning —
/// as the accessory row's table in <c>TerminalScreen</c>: the difference between <c>CSI A</c> and
/// <c>SS3 A</c> is the difference between working in bash and not, and nothing here tracks the remote's
/// application-cursor mode.
/// </para>
/// <para>
/// <b><see cref="Key.Delete"/> is deliberately spent on backspace, and it is the one lossy choice in
/// this file.</b> Avalonia's Android input connection turns an IME's <c>deleteSurroundingText</c> into a
/// dispatched <c>KEYCODE_FORWARD_DEL</c> — see <c>TextEditBuffer.Remove</c> — so a soft keyboard that
/// deletes through the edit buffer rather than through a real <c>KEYCODE_DEL</c> arrives here as
/// <c>Delete</c>. Mapping it to <c>CSI 3 ~</c> would be correct for a hardware forward-delete and would
/// make backspace delete forwards on any keyboard taking the other route, which is the worse failure by
/// a wide margin: a phone has no forward-delete key to miss, and a broken backspace makes the terminal
/// unusable. Revisit if a hardware keyboard's Delete is ever worth more than this insurance.
/// </para>
/// </remarks>
private static bool TryTranslate(Key key, out byte[] bytes)
{
bytes = key switch
{
Key.Enter => [0x0D],
Key.Tab => [0x09],
Key.Escape => [0x1B],
Key.Back or Key.Delete => [0x7F],
Key.Up => [0x1B, (byte)'[', (byte)'A'],
Key.Down => [0x1B, (byte)'[', (byte)'B'],
Key.Right => [0x1B, (byte)'[', (byte)'C'],
Key.Left => [0x1B, (byte)'[', (byte)'D'],
Key.Home => [0x1B, (byte)'[', (byte)'H'],
Key.End => [0x1B, (byte)'[', (byte)'F'],
Key.Insert => [0x1B, (byte)'[', (byte)'2', (byte)'~'],
Key.PageUp => [0x1B, (byte)'[', (byte)'5', (byte)'~'],
Key.PageDown => [0x1B, (byte)'[', (byte)'6', (byte)'~'],
_ => [],
};
return bytes.Length > 0;
}
/// <summary>
/// What Android's input method is told about the text it is editing, which is that there is none.
/// </summary>
/// <remarks>
/// <para>
/// <c>SupportsSurroundingText</c> and <c>SupportsPreedit</c> are both false, and
/// <see cref="SurroundingText"/> is empty, because they are the truth: the line being edited lives on
/// the far side of an SSH channel and this client has never been told what is on it. Claiming otherwise
/// would invite the IME to reason about text it cannot see and to issue edits against positions that
/// mean nothing here.
/// </para>
/// <para>
/// <see cref="Selection"/> has to be settable because <c>TextEditBuffer</c> writes it before every
/// replacement, so it is stored and ignored rather than refused — a property that threw would take the
/// input connection down with it.
/// </para>
/// <para>
/// <see cref="CursorRectangle"/> is empty for the same reason the control is 1×1: it positions an IME's
/// composition popup, and a field that never composes has no popup to place.
/// </para>
/// </remarks>
private sealed class TerminalImeClient(Visual visual) : TextInputMethodClient
{
public override Visual TextViewVisual => visual;
public override bool SupportsPreedit => false;
public override bool SupportsSurroundingText => false;
public override string SurroundingText => string.Empty;
public override Rect CursorRectangle => default;
public override TextSelection Selection { get; set; }
/// <remarks>Exposed because <c>RaiseInputPaneActivationRequested</c> is protected on the base.</remarks>
internal void RequestKeyboard() => RaiseInputPaneActivationRequested();
}
}
@@ -471,6 +471,29 @@
design's #171A26. See WebAssets/terminal.css.
-->
<NativeWebView x:Name="Renderer" IsVisible="{Binding IsTerminalShowing, FallbackValue=False}" />
<!--
============ the keyboard ============
One pixel in the corner, drawn never, and it is where every character typed into a shell on this
phone arrives. ADR 0015 has the whole argument; the short of it is that a software keyboard over a
terminal has to be told to stop autocorrecting, and on Android the only setting that works is an
inputType on a native input connection — which a page inside a WebView cannot reach and an Avalonia
control can, on the version already pinned here.
Not stretched over the renderer, which would be the obvious shape and is wrong three times: the
WebView composites above everything Avalonia paints so it would be invisible, it would never see the
touches the WebView consumes first, and it would sit over the connect page that shares this Panel
whenever no session is open. Focus does not need area. Taps on the terminal are noticed by
RendererFocus, where they actually land.
Collapsed with the renderer, on the same binding and for a reason of its own: a focusable control is
a tab stop and a focus candidate wherever it lives, and this Panel is also the connect page when no
session is open. Left always-visible it would be a stray target on a screen with a real text box on
it, offering a keyboard configured for a shell to somebody typing a hostname.
-->
<views:TerminalInputSurface x:Name="Input"
IsVisible="{Binding IsTerminalShowing, FallbackValue=False}" />
</Panel>
<!-- ============ accessory keys ============ -->
@@ -1,3 +1,4 @@
using System.ComponentModel;
using System.Text;
using Avalonia;
@@ -7,6 +8,7 @@ using Avalonia.Layout;
using Avalonia.Markup.Xaml;
using Avalonia.Media;
using Avalonia.Platform;
using Avalonia.Threading;
using DodoSSH.Client.Android.Platform;
using DodoSSH.Client.Shell.ViewModels;
@@ -67,26 +69,119 @@ internal sealed partial class TerminalScreen : UserControl
private Button? controlKey;
private readonly TerminalInputSurface input;
/// <summary>The shell whose <c>PropertyChanged</c> this screen is subscribed to, so it can unsubscribe.</summary>
private MainWindowViewModel? subscribed;
/// <summary>
/// What <see cref="MainWindowViewModel.IsTerminalShowing"/> was last seen to be.
/// </summary>
/// <remarks>
/// The keyboard is raised on the transition into the terminal and not on every notification of it.
/// <c>IsTerminalShowing</c> is a computed property re-raised by several other setters, so acting on the
/// notification rather than the edge would call <c>ShowSoftInput</c> repeatedly for one arrival.
/// </remarks>
private bool terminalWasShowing;
public TerminalScreen()
{
AvaloniaXamlLoader.Load(this);
BuildAccessoryRow();
input = this.FindControl<TerminalInputSurface>("Input")!;
input.Input += (_, e) => SendAsync(e.Bytes);
var renderer = this.FindControl<NativeWebView>("Renderer")!;
renderer.EnvironmentRequested += OnRendererEnvironmentRequested;
renderer.EnvironmentRequested += (sender, e) =>
{
OnRendererEnvironmentRequested(sender, e);
// The precise moment the platform WebView is being built, which is the earliest one at which
// there is a native view to take the keyboard away from — and the one that fires again if
// Android reclaims the renderer and the page comes back. Posted rather than called, because the
// child is not in the hierarchy yet while its environment is still being asked for.
Dispatcher.UIThread.Post(AdoptRenderer, DispatcherPriority.Loaded);
};
// The URL is only known once the loopback listener has bound a port, so it cannot be set in XAML.
DataContextChanged += (_, _) =>
{
if (subscribed is { } previous)
{
previous.PropertyChanged -= OnShellPropertyChanged;
previous.TerminalFocusRequested -= OnTerminalFocusRequested;
subscribed = null;
}
if (DataContext is MainWindowViewModel shell)
{
renderer.Source = shell.TerminalPageUrl;
shell.PropertyChanged += OnShellPropertyChanged;
// The snippets screen's "TYPE INTO {tab}" lands here as well as on the desktop. It types at
// a prompt the user is about to want to keep typing at, so the keyboard should be up when
// they look. See MainWindowViewModel.TerminalFocusRequested.
shell.TerminalFocusRequested += OnTerminalFocusRequested;
subscribed = shell;
terminalWasShowing = shell.IsTerminalShowing;
}
};
}
/// <summary>
/// Raises the keyboard when a shell arrives, and never while one is merely being redrawn.
/// </summary>
private void OnShellPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName is not nameof(MainWindowViewModel.IsTerminalShowing) ||
sender is not MainWindowViewModel shell)
{
return;
}
var showing = shell.IsTerminalShowing;
if (showing == terminalWasShowing)
{
return;
}
terminalWasShowing = showing;
if (!showing)
{
return;
}
// Posted to Loaded so the renderer's own native child, which becomes visible on this same change,
// exists by the time AdoptRenderer goes looking for it. Idempotent with the EnvironmentRequested
// path above: whichever runs first configures the view and the other finds it already done.
Dispatcher.UIThread.Post(
() =>
{
AdoptRenderer();
input.Acquire();
},
DispatcherPriority.Loaded);
}
private void OnTerminalFocusRequested(object? sender, EventArgs e) =>
Dispatcher.UIThread.Post(input.Acquire, DispatcherPriority.Loaded);
/// <summary>
/// Takes the keyboard off the renderer and puts the taps on it to use.
/// </summary>
/// <remarks>
/// See <see cref="RendererFocus"/> for why a <c>WebView</c> that cannot hold focus still scrolls, and why
/// the tap has to be reported rather than left to the page.
/// </remarks>
private void AdoptRenderer() => RendererFocus.Suppress(input.Acquire);
/// <summary>
/// Puts the keyboard away on the way to the connect menu.
/// </summary>
@@ -139,14 +234,13 @@ internal sealed partial class TerminalScreen : UserControl
{
var row = this.FindControl<StackPanel>("AccessoryKeys")!;
// Both halves of a press steal Android's own focus — the platform requests it for Avalonia's view
// after dispatching every handled touch, DOWN and UP alike; TerminalFocus carries the decompiled
// citation. Countered at the row rather than inside each key's Click, and for two reasons: Click
// only exists for the UP half, so a keyboard detached at DOWN would stay detached for the whole
// length of the press; and the Return is posted past the current dispatch, so its ordering against
// the key's own handler does not matter — which is what lets one pair of handlers cover ten keys.
row.AddHandler(PointerPressedEvent, (_, _) => TerminalFocus.Return(), RoutingStrategies.Tunnel);
row.AddHandler(PointerReleasedEvent, (_, _) => TerminalFocus.Return(), RoutingStrategies.Tunnel);
// ── nothing to hand back any more ──────────────────────────────────────────────────────────────
// This row used to counter both halves of every press with a posted TerminalFocus.Return(), because
// the platform requests Android's own focus for Avalonia's view after dispatching each handled
// touch, and the keyboard belonged to the WebView's input connection. Under ADR 0015 it belongs to
// TerminalInputSurface, which is an Avalonia control — so the focus the platform takes is the focus
// the keyboard already wants, and the counter-move is not merely unnecessary but wrong. The keys
// stay Focusable=false, which is now what keeps Avalonia's own focus on the input surface.
foreach (var (label, bytes, latches) in Keys)
{
@@ -198,22 +292,16 @@ internal sealed partial class TerminalScreen : UserControl
// ◆ 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.
// ordinary buttons they take Avalonia's focus on tap, which takes it off whatever the
// terminal's input target is — and a modifier row that dismisses the keyboard every time it
// is used is not a modifier row. Focusable=false is what a toolbar button is, and it means
// the focused element never changes: TerminalInputSurface is still it, so the input
// connection is never torn down and nothing has to be handed back.
//
// 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 row's own pointer handlers in BuildAccessoryRow hand
// that half back; see TerminalFocus for the whole story, including why the handing back has
// to be posted rather than done inline.
// This used to be only half a fix, because the target was the NativeWebView and Android keeps
// a focus of its own that the platform hands to Avalonia's view after every touch it
// dispatches. That half is gone rather than countered: under ADR 0015 the input target is an
// Avalonia control, so the focus Android moves is already where the keyboard should be.
Focusable = false,
};