From 2810f7c12bc33ccdd1360664401eacea421ea75c Mon Sep 17 00:00:00 2001 From: Jaap-Jan de Wit | DodoTech Date: Tue, 25 Aug 2026 10:29:38 +0200 Subject: [PATCH] Stop the terminal's Enter key dismissing the keyboard it was typed on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/adr/0015-android-terminal-input.md | 29 ++++++++++++- docs/manual-checks.md | 18 ++++++++ .../Views/TerminalInputSurface.cs | 43 ++++++++++++++++--- 3 files changed, 83 insertions(+), 7 deletions(-) diff --git a/docs/adr/0015-android-terminal-input.md b/docs/adr/0015-android-terminal-input.md index 5fdc1eb..c26c765 100644 --- a/docs/adr/0015-android-terminal-input.md +++ b/docs/adr/0015-android-terminal-input.md @@ -84,6 +84,7 @@ 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 @@ -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 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. +> **⚠️ 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 diff --git a/docs/manual-checks.md b/docs/manual-checks.md index d65e4f1..ca62099 100644 --- a/docs/manual-checks.md +++ b/docs/manual-checks.md @@ -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 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 Open a shell, close its tab, then open a different one from HOSTS. diff --git a/src/DodoSSH.Client.Android/Views/TerminalInputSurface.cs b/src/DodoSSH.Client.Android/Views/TerminalInputSurface.cs index bdb37f1..6428d7b 100644 --- a/src/DodoSSH.Client.Android/Views/TerminalInputSurface.cs +++ b/src/DodoSSH.Client.Android/Views/TerminalInputSurface.cs @@ -31,10 +31,17 @@ namespace DodoSSH.Client.Android.Views; /// /// 0x80080 is TYPE_TEXT_VARIATION_VISIBLE_PASSWORD | TYPE_TEXT_FLAG_NO_SUGGESTIONS — the exact /// pair Termux's own TerminalView sets, and the reason typing in Termux feels like a terminal. The -/// guard is why the constructor sets only -/// and leaves ContentType, Multiline and AutoCapitalization alone: any of those would -/// move InputType off 1 and the branch would never be taken. ShowSuggestions is a -/// bool? defaulting to null, so it must be set to false rather than merely left. +/// guard is why the constructor leaves ContentType, Multiline and AutoCapitalization +/// alone: any of those would move InputType off 1 and the branch would never be taken. +/// ShowSuggestions is a bool? defaulting to null, so it must be set to false +/// rather than merely left. +/// +/// +/// Two options are set, and the second one is not optional either. The default +/// ReturnKeyType maps to IME_ACTION_DONE on a non-multiline field, which closed the keyboard +/// on every Enter — that is, on every command. ReturnKeyType.Return is IME_ACTION_NONE and +/// touches nothing else. The constructor carries the full argument, including why the obvious alternative +/// of Multiline cannot be used without giving the suggestion strip back. /// /// /// One pixel, and never drawn. 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; 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); + // ◆ 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); } @@ -156,7 +180,14 @@ internal sealed class TerminalInputSurface : Control 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; base.OnTextInput(e);