Stop the terminal's Enter key dismissing the keyboard it was typed on
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

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>
This commit is contained in:
jaap-janandClaude Opus 5 committed 2026-08-25 10:29:38 +02:00
1 parent c0dd5a147e
commit 2810f7c12b
3 files changed
+83 -7

No files matched your search

+28 -1
View File
@@ -84,6 +84,7 @@ and keeps it for as long as that surface is up.
- `TextInputOptions.ShowSuggestions = false`, with `ContentType`, `Multiline` and `AutoCapitalization` - `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. 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. - 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. 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 - Committed text and key events are encoded to bytes and written to the session through the same path
@@ -144,9 +145,35 @@ software keyboard. It never did before — everything typed went straight into t
`SendAsync`, which is where the latch lives — so `ctrl` then `c` was inert unless the second key also came `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. from the accessory row. Check 11.10d covers it.
**Verify before building anything on this:** checks 11.10a–11.10d in `docs/manual-checks.md`, and 11.10b **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. 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 ### Stage 2 — the keys around it
Only once Stage 1 is proven, because each of these is cheap *given* Avalonia owning input and awkward Only once Stage 1 is proven, because each of these is cheap *given* Avalonia owning input and awkward
+18
View File
@@ -1919,6 +1919,24 @@ could not pass *before* ADR 0015 and is not a regression test but a new capabili
only to the accessory row's own keys, because everything typed went straight into the page and never passed only to the accessory row's own keys, because everything typed went straight into the page and never passed
the code that holds the latch. the code that holds the latch.
### 11.10e Enter runs the command and the keyboard stays where it is
With a shell open and the software keyboard up, type `pwd` and press Enter. Then type another command.
**Pass:** the command runs and **the keyboard is still up**, unmoved, ready for the next one. The Enter key
reads as a return arrow rather than as `DONE` or a tick. Ten commands in a row cost ten Enters and no taps
to bring the keyboard back.
**Failure means:** `TextInputOptions.ReturnKeyType` is not `Return` on `TerminalInputSurface`, so Avalonia
has mapped the field's default to `IME_ACTION_DONE` and the keyboard is doing exactly what Done means. This
was the first defect the first device run found. **Do not fix it with `Multiline = true`** — that reaches
the same `IME_ACTION_NONE` by a route that ORs `TYPE_TEXT_FLAG_MULTI_LINE` into `InputType`, which fails the
`ShowSuggestions` guard and hands the suggestion strip back; check 11.10b would then start failing and the
cause would look unrelated. See the correction in [ADR 0015](adr/0015-android-terminal-input.md).
Worth pressing Enter on an empty prompt a few times too: each one should give a fresh prompt and nothing
else. A doubled prompt per press means a line ending is arriving as both CR and LF.
### 11.11 Closing a connection and opening a new one both take you somewhere real ### 11.11 Closing a connection and opening a new one both take you somewhere real
Open a shell, close its tab, then open a different one from HOSTS. Open a shell, close its tab, then open a different one from HOSTS.
@@ -31,10 +31,17 @@ namespace DodoSSH.Client.Android.Views;
/// </code> /// </code>
/// <c>0x80080</c> is <c>TYPE_TEXT_VARIATION_VISIBLE_PASSWORD | TYPE_TEXT_FLAG_NO_SUGGESTIONS</c> — the exact /// <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 /// 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"/> /// guard is why the constructor leaves <c>ContentType</c>, <c>Multiline</c> and <c>AutoCapitalization</c>
/// and leaves <c>ContentType</c>, <c>Multiline</c> and <c>AutoCapitalization</c> alone: any of those would /// alone: any of those would move <c>InputType</c> off <c>1</c> and the branch would never be taken.
/// move <c>InputType</c> off <c>1</c> and the branch would never be taken. <c>ShowSuggestions</c> is a /// <c>ShowSuggestions</c> is a <c>bool?</c> defaulting to <c>null</c>, so it must be set to <c>false</c>
/// <c>bool?</c> defaulting to <c>null</c>, so it must be set to <c>false</c> rather than merely left. /// rather than merely left.
/// </para>
/// <para>
/// <b>Two options are set, and the second one is not optional either.</b> The default
/// <c>ReturnKeyType</c> maps to <c>IME_ACTION_DONE</c> on a non-multiline field, which closed the keyboard
/// on every Enter — that is, on every command. <c>ReturnKeyType.Return</c> is <c>IME_ACTION_NONE</c> and
/// touches nothing else. The constructor carries the full argument, including why the obvious alternative
/// of <c>Multiline</c> cannot be used without giving the suggestion strip back.
/// </para> /// </para>
/// <para> /// <para>
/// <b>One pixel, and never drawn.</b> Focus in Avalonia has nothing to do with hit-testing, so the input /// <b>One pixel, and never drawn.</b> Focus in Avalonia has nothing to do with hit-testing, so the input
@@ -105,9 +112,26 @@ internal sealed class TerminalInputSurface : Control
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Top; VerticalAlignment = Avalonia.Layout.VerticalAlignment.Top;
IsHitTestVisible = false; IsHitTestVisible = false;
// The one line that fixes the keyboard. See the remarks for why it is the only option set. // The one line that fixes the keyboard. See the remarks for why almost nothing else is set.
TextInputOptions.SetShowSuggestions(this, false); TextInputOptions.SetShowSuggestions(this, false);
// ◆ AND THE ONE THAT KEEPS THE KEYBOARD UP WHEN A COMMAND IS RUN.
//
// Avalonia maps ReturnKeyType.Default, on a field that is not Multiline, to IME_ACTION_DONE — and
// Done means done: the keyboard performs the action and dismisses itself. Every Enter therefore
// closed the keyboard, which on a terminal is every command.
//
// Return is the member that maps to IME_ACTION_NONE, leaving the key as a plain newline with no
// action attached and no reason for the IME to hide. Its whole switch arm is
// `returnKeyType - 1 => 0 => 1073741824`, which is 0x40000000, IME_ACTION_NONE.
//
// Multiline=true reaches the same IME_ACTION_NONE through the default arm and is the wrong way to
// get there: it also ORs TYPE_TEXT_FLAG_MULTI_LINE into InputType, which moves InputType off 1 and
// silently fails the ShowSuggestions guard above. The two options cannot both be had that way — one
// trades the suggestion strip back for a working Enter. ReturnKeyType costs nothing and is the
// reason this file sets it rather than Multiline.
TextInputOptions.SetReturnKeyType(this, TextInputReturnKeyType.Return);
AddHandler(TextInputMethodClientRequestedEvent, OnTextInputMethodClientRequested); AddHandler(TextInputMethodClientRequestedEvent, OnTextInputMethodClientRequested);
} }
@@ -156,7 +180,14 @@ internal sealed class TerminalInputSurface : Control
return; return;
} }
Input?.Invoke(this, new TerminalInputEventArgs(Encoding.UTF8.GetBytes(e.Text))); // Newlines arrive as carriage returns, whichever way they came. A keyboard with no editor action
// attached may commit "\n" as ordinary text rather than sending KEYCODE_ENTER, and a paste carries
// whatever the clipboard held — but a terminal's line terminator from a keyboard is CR, and a shell
// handed LF for Enter is a shell that looks like it ignored the key. Normalised rather than special
// -cased so that a pasted "\r\n" does not run the line twice.
var text = e.Text.ReplaceLineEndings("\r");
Input?.Invoke(this, new TerminalInputEventArgs(Encoding.UTF8.GetBytes(text)));
e.Handled = true; e.Handled = true;
base.OnTextInput(e); base.OnTextInput(e);