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);