From c0dd5a147ef20a7aa9c6f110fbd871d12396277d Mon Sep 17 00:00:00 2001 From: Jaap-Jan de Wit | DodoTech Date: Tue, 25 Aug 2026 10:12:14 +0200 Subject: [PATCH] Give the phone's terminal its own keyboard, and stop refereeing a focus fight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/adr/0015-android-terminal-input.md | 219 +++++++++++++ docs/android-port.md | 6 + docs/manual-checks.md | 85 ++++- .../Platform/RendererFocus.cs | 155 +++++++++ .../Platform/TerminalFocus.cs | 79 ----- .../Views/TerminalInputEventArgs.cs | 16 + .../Views/TerminalInputSurface.cs | 304 ++++++++++++++++++ .../Views/TerminalScreen.axaml | 23 ++ .../Views/TerminalScreen.axaml.cs | 136 ++++++-- 9 files changed, 904 insertions(+), 119 deletions(-) create mode 100644 docs/adr/0015-android-terminal-input.md create mode 100644 src/DodoSSH.Client.Android/Platform/RendererFocus.cs delete mode 100644 src/DodoSSH.Client.Android/Platform/TerminalFocus.cs create mode 100644 src/DodoSSH.Client.Android/Views/TerminalInputEventArgs.cs create mode 100644 src/DodoSSH.Client.Android/Views/TerminalInputSurface.cs diff --git a/docs/adr/0015-android-terminal-input.md b/docs/adr/0015-android-terminal-input.md new file mode 100644 index 0000000..5fdc1eb --- /dev/null +++ b/docs/adr/0015-android-terminal-input.md @@ -0,0 +1,219 @@ +# ADR 0015 — The Android terminal's input path leaves the WebView + +- Status: proposed — **Stage 1 built, unverified on a device** +- Date: 2026-08-25 +- Builds on: [`docs/android-port.md`](../android-port.md) §9, [ADR 0011](0011-android-distribution.md) +- Does not settle: whether the renderer stays xterm.js. That is [Stage 3](#stage-3--the-renderer-deliberately-unsettled), and it is deliberately left open. + +## Context + +The phone got the desktop's terminal, and the reasons were good ones. `TerminalDataPlane` already +served the page and a binary protocol over a loopback WebSocket, the xterm.js bundles were embedded +resources and platform-neutral, and `Avalonia.Controls.WebView` shipped a `net10.0-android36.0` +target. `docs/android-port.md` called it *"better news than expected"*, and for rendering it was. + +**For typing it was not, and typing is the complaint.** Not scrollback, not colour, not throughput — +the act of putting characters into a shell. That narrows the problem usefully, because the input path +and the render path are separable, and only one of them is wrong. + +### Three things sit between a finger and a byte, and the middle one is the fault + +**1. Avalonia and the WebView fight over native focus.** `AvaloniaView.DispatchTouchEvent` ends every +handled touch — DOWN and UP alike — with a `RequestFocus()` for Avalonia's own view. The WebView's +input connection dies with its focus, so the keyboard swaps layouts and the inset churn can park it +over the accessory row that was tapped. `Platform/TerminalFocus.cs` exists to hand focus back, posted +to the next main-looper message so the platform has taken its turn first. It works. It is also a +counter-move in a fight that only exists because the thing holding the input connection is not the +thing the framework thinks is focused. + +**2. The keyboard is a prose keyboard, and no DOM attribute makes it stop.** This is the one that was +worth checking rather than assuming, and the check inverted the obvious conclusion. + +The vendored xterm.js *already* sets every hint the web platform has on its helper textarea — +`autocapitalize="off"`, `autocorrect="off"`, `spellcheck="false"`. They are set, and Gboard shows the +suggestion strip and predictive text anyway. So this is not a missing attribute; it is a ceiling. + +Termux's `TerminalView.onCreateInputConnection` says what the ceiling is. It sets: + +``` +InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS +``` + +**`VISIBLE_PASSWORD` is the load-bearing half.** `NO_SUGGESTIONS` alone is advisory and most keyboards +ignore it — a point made in Avalonia's own Android keyboard PR discussion, not only in Termux's. What +actually turns off autocorrect, the suggestion strip and the predictive composing layer is the +*variation*: a field the keyboard believes is a visible password gets no dictionary. That is an +`EditorInfo.inputType` on a native `InputConnection`, and a page inside a WebView has no way to reach +it — Chromium computes the `EditorInfo` from the focused DOM node, and no combination of attributes on +a `