Files
DodoSSH/docs/adr/0015-android-terminal-input.md
jaap-janandClaude Opus 5 2810f7c12b
ci / build and test (push) Successful in 2m51s
ci / android head (push) Successful in 3m29s
ci / desktop nightly (push) Successful in 1m18s
ci / api image (push) Successful in 28s
Stop the terminal's Enter key dismissing the keyboard it was typed on
The first device run of ADR 0015 found the premise held and the field misconfigured
in a second way: the suggestion strip, autocorrect and predictive text were all
gone, and every Enter closed the keyboard. On a terminal that is every command, so
the phone asked for a tap between each one.

TerminalInputSurface had been left with Avalonia's default ReturnKeyType, and the
same OnCreateInputConnection that carries the inputType fix maps that default, on a
field that is not Multiline, to IME_ACTION_DONE:

    _ => options.Multiline ? 1073741824 : 6,   // 6 is IME_ACTION_DONE

Done means done. The keyboard performs the action and dismisses itself, which is
right for the text box the default was written for and wrong for a surface where
Enter is punctuation rather than submission.

TextInputReturnKeyType.Return is the member that maps to IME_ACTION_NONE — value 1,
so `returnKeyType - 1 => 0 => 1073741824` — leaving Enter a plain newline with no
action attached and no reason for the IME to hide.

◆ THE TRAP, WHICH IS THE ONE 11.10b ALREADY WARNED ABOUT FROM THE OTHER SIDE.

Multiline=true also reaches IME_ACTION_NONE, through that same default arm, and is
the obvious fix and the wrong one. It ORs TYPE_TEXT_FLAG_MULTI_LINE into InputType,
which moves it off 1 and silently fails the ShowSuggestions guard the whole ADR
turns on. Taking the obvious route to a working Enter would have handed the
suggestion strip back and looked like the premise had failed rather than like the
fix had. ReturnKeyType touches imeOptions only, which is why it is what is set.

Enter's byte was never the problem and is not what changed: IME_ACTION_DONE was
already synthesising KEYCODE_ENTER through AvaloniaInputConnection.PerformEditorAction,
so commands did run — the keyboard just left afterwards.

OnTextInput now also normalises line endings to CR. Belt and braces for the case
this change makes newly possible: a keyboard with no editor action attached may
commit "\n" as ordinary text rather than sending KEYCODE_ENTER, and a shell handed
LF for Enter looks like a shell that ignored the key. Normalising rather than
special-casing also stops a pasted "\r\n" running its line twice.

Check 11.10e is the regression test, and says in as many words not to fix a
recurrence with Multiline.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 10:29:38 +02:00

247 lines
15 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 `<textarea>` produces the visible-password variation.
**3. The accessory row is a workaround for a keyboard, not a keyboard.** Esc, Tab, Ctrl and the arrows
are right and stay. But every tap on one costs the focus round-trip in (1), and none of them can be
held or repeated.
### The finding that decides this ADR
Avalonia already emits Termux's exact `inputType`. Decompiled from the pinned
`Avalonia.Android` 12.1.1, `AndroidInputMethod<TView>.OnCreateInputConnection`:
```csharp
if ((int)outAttrs.InputType == 1 && options.ShowSuggestions == false)
{
outAttrs.InputType = (InputTypes)(outAttrs.InputType | 0x80080);
}
```
`0x80080` is `TYPE_TEXT_VARIATION_VISIBLE_PASSWORD | TYPE_TEXT_FLAG_NO_SUGGESTIONS`. The guard reads:
`ContentType` left at `Normal`, `Multiline` false, `AutoCapitalization` false, and
`TextInputOptions.ShowSuggestions` explicitly `false` — a `bool?` whose default is `null`, so it must
be set, not merely left alone. The same method also ORs `IME_FLAG_NO_EXTRACT_UI |
IME_FLAG_NO_FULLSCREEN` into `imeOptions`, which is the other thing Termux sets and for the same
reason: a terminal must never be replaced by a full-screen extract editor in landscape.
So the keyboard behaviour that makes Termux feel like a terminal is **already available on the
version this repository pins**, to any Avalonia control that asks for it. It is not available to the
WebView, and never will be. That asymmetry is the whole decision.
## Decision
### Avalonia owns the keystrokes. The WebView renders and nothing else.
A focusable Avalonia control — `TerminalInputSurface` — becomes the terminal's input target. It draws
nothing. It sits in the same `Panel` as the renderer, takes focus when the terminal surface is shown,
and keeps it for as long as that surface is up.
- `TextInputOptions.ShowSuggestions = false`, with `ContentType`, `Multiline` and `AutoCapitalization`
left at the defaults the guard above requires. This is the entire fix for the suggestion strip.
- `TextInputOptions.ReturnKeyType = Return`, which is the fix for Enter — see the correction below.
- It implements `TextInputMethodClient` with an empty surrounding text and a zero-length selection.
A terminal has no document for an IME to edit; committed text is forwarded and nothing is retained.
- Committed text and key events are encoded to bytes and written to the session through the same path
`TerminalScreen`'s accessory keys already use. No new transport, no change to `TerminalDataPlane`.
- The native WebView is made non-focusable (`Focusable = false`, `FocusableInTouchMode = false`) once,
at attach.
**`Platform/TerminalFocus.cs` is deleted by this, not amended.** The fight it referees ends when only
one side is a focus candidate. Its remarks should move into the new control as the record of why the
WebView is deliberately unfocusable, because that reasoning outlives the workaround.
`Platform/SoftKeyboard.cs` stays. Dismissing a keyboard on the way to a sheet is still a platform
call, and Avalonia still offers no way to close one.
### Not: patching xterm.js's textarea
Already tried by xterm.js itself, and already failing — see Context (2). The attributes are set. The
platform ignores them. There is no version of this that reaches `VISIBLE_PASSWORD` from inside a
WebView, so the effort buys nothing.
### Not: a native Android `View`, Termux-shaped, today
Termux's `TerminalView` + `TerminalRenderer` drawing to a `Canvas` is the right architecture for a
phone-only terminal, and DodoSSH is not a phone-only terminal. Writing one now means two terminal
implementations — this one and the desktop's — with two escape-sequence behaviours to keep honest
against each other, for a defect that is entirely in the input path. That is a large bill for the
wrong problem.
It also does not become unavailable later. Stage 3 keeps it on the table.
### Not: replacing xterm.js now
Rendering is not the complaint. xterm.js with the WebGL addon draws a correct, fast grid, and
`terminal.js` already handles renderer death, reattach and refit — hard-won behaviour recorded in
`docs/android-port.md` that a rewrite would have to re-earn. Replacing a working renderer to fix a
keyboard is the wrong trade, and doing it in the same change as the input move would make neither
half attributable when the result is judged on a device.
## Staging
### Stage 1 — the input surface · **built**
The decision above, and nothing else. This is the change that answers the complaint, and it should be
judged on a device on its own before anything else is built on it.
What landed:
| | |
| --- | --- |
| `Views/TerminalInputSurface.cs` | The 1×1 focusable control, `ShowSuggestions = false`, the IME client that retains nothing, and the key→escape-sequence table |
| `Views/TerminalInputEventArgs.cs` | One keystroke's bytes, on the way to `TerminalScreen.SendAsync` |
| `Platform/RendererFocus.cs` | Makes the `WebView` non-focusable and reports taps on it |
| `Platform/TerminalFocus.cs` | **Deleted.** The fight it refereed has one contestant now |
| `Views/TerminalScreen.axaml{,.cs}` | Hosts the surface, raises the keyboard on arrival, drops the two posted focus counter-moves from the accessory row |
**One thing gained that was not asked for, and it was a real defect:** the Ctrl latch now reaches the
software keyboard. It never did before — 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. Check 11.10d covers it.
**Verify before building anything on this:** checks 11.10a–11.10e in `docs/manual-checks.md`, and 11.10b
first of all, because it is the falsification test at the end of this document.
> **⚠️ Corrected on the first device run: every Enter closed the keyboard.** The premise held — the
> suggestion strip, autocorrect and predictive text were all gone — but the field had been left with
> Avalonia's default `ReturnKeyType`, and the same `OnCreateInputConnection` maps that, on a field that is
> not `Multiline`, to `IME_ACTION_DONE`:
>
> ```csharp
> _ => options.Multiline ? 1073741824 : 6, // 6 is IME_ACTION_DONE
> ```
>
> Done means done: the keyboard performs the action and dismisses itself. On a text box that is right, and
> on a terminal it fires on every command. `TextInputReturnKeyType.Return` — value 1, so
> `returnKeyType - 1 => 0 => 1073741824` — is `IME_ACTION_NONE`, which leaves Enter a plain newline with no
> action for the IME to perform and no reason for it to hide.
>
> **The trap worth recording, because it is the same trap 11.10b already warned about from the other side:**
> `Multiline = true` also reaches `IME_ACTION_NONE`, through the default arm, and is the wrong way there. It
> ORs `TYPE_TEXT_FLAG_MULTI_LINE` into `InputType`, which moves it off `1` and silently fails the
> `ShowSuggestions` guard. Taking the obvious route to a working Enter would have handed the suggestion
> strip back and looked like the whole ADR had failed. `ReturnKeyType` touches `imeOptions` only.
>
> Enter's *byte* was never in doubt and is not what changed — `IME_ACTION_DONE` was already synthesising
> `KEYCODE_ENTER` through `AvaloniaInputConnection.PerformEditorAction`, so commands ran; the keyboard just
> left afterwards. `OnTextInput` now also normalises line endings to CR, for the case where a keyboard with
> no editor action commits `"\n"` as ordinary text instead of sending a key event, and so that a pasted
> `"\r\n"` does not run its line twice.
### Stage 2 — the keys around it
Only once Stage 1 is proven, because each of these is cheap *given* Avalonia owning input and awkward
without it:
- **Hold-to-repeat** on the arrows and Backspace. Currently impossible without a focus round-trip per
tap.
- **Ctrl held rather than latched**, with latching kept as the fallback. `TerminalScreen`'s remark is
right that latching is what phone SSH clients do; held becomes possible when one control sees both
the modifier press and the following keystroke.
- **Pinch and volume keys for font size**, replacing the pinned `A−`/`A+` buttons. Both are what
JuiceSSH does and what its users name first; the buttons stay as the discoverable path.
Volume-key capture is an `onKeyDown` on the activity and is deliberately opt-in — a phone that cannot
change its volume with a shell open is a worse phone.
### Stage 3 — the renderer, deliberately unsettled
If, after Stages 1 and 2, the terminal still reads as a web page — touch selection, scrollback
survival across renderer death, scroll feel — then the renderer is the next question, and the
candidates are known:
| Option | What it is | Cost |
| --- | --- | --- |
| [XTerm.NET](https://github.com/tomlm/XTerm.NET) | MIT, headless, UI-agnostic; VT100/ANSI, dual buffers, 256/true colour, wide chars | Evaluate emulation fidelity against xterm.js |
| [VtNetCore](https://github.com/darrenstarr/VtNetCore) | MIT, .NET Standard 2.0, older but proven | Maintenance status |
| Port xterm.js's core | Exact behavioural parity with the desktop | Largest, and a permanent fork |
Rendered by a custom Avalonia `Control` via `DrawGlyphRun`, which is Termux's architecture expressed
in Avalonia and would work on the desktop too — eventually letting the WebView2 dependency go, and
with it the `targetSdk` 36 floor `Avalonia.Controls.WebView` currently forces.
**Stage 1 is not wasted if Stage 3 happens.** The input surface is exactly the layer such a control
would need, unchanged. That is the reason for splitting them.
## Consequences
**What gets better, and it is one thing done properly.** No suggestion strip, no autocorrect, no
predictive composing layer, no full-screen extract editor, and no focus flap on an accessory key.
That is the difference between typing at a shell and fighting a phone.
**CJK and other composing input is degraded, and this is a real cost.** `VISIBLE_PASSWORD` suppresses
composition, which is precisely what makes it work for a terminal and precisely what a Chinese,
Japanese or Korean user needs. Termux has the same limitation and lives with it. If it ever needs
answering, the answer is a per-session toggle that drops `ShowSuggestions = false` and accepts the
prose keyboard back — not a middle setting, because there isn't one.
**Text selection and copy are unchanged**, still xterm.js's own DOM handling, still without touch
selection handles — [xterm.js #5377](https://github.com/xtermjs/xterm.js/issues/5377) is open and
unassigned and this ADR does not fix it. Stage 3 territory.
**Scrollback still dies with the renderer process.** Unchanged, still honest about it via the replay
banner, still Stage 3 territory.
**Nothing here is verifiable headless**, for the reason `docs/manual-checks.md` §11 already gives.
Stage 1 needs new manual checks — at minimum: the suggestion strip is absent with a shell open; an
accessory key does not disturb the keyboard (§11.10a, which should now pass trivially rather than by
posted counter-move); landscape does not produce a full-screen extract editor; and a hardware
keyboard still reaches the shell.
**The hardware-keyboard path moves.** It currently arrives through the WebView; after this it arrives
through Avalonia. Behaviour should be identical and probably will not be on the first try.
## What would falsify this
If, on a device, `ShowSuggestions = false` does not suppress the suggestion strip — because the guard
was not satisfied, or the keyboard ignores the variation too — then Stage 1's premise is wrong and
the honest next step is Stage 3 with a native `InputConnection`, not more Avalonia. **Check this
first, on the smallest possible spike, before building `TerminalInputSurface` properly.** It is one
attached property on a focusable control and a look at the keyboard.