using System.Text; using Avalonia; using Avalonia.Controls; using Avalonia.Input; using Avalonia.Input.TextInput; using Avalonia.Media; namespace DodoSSH.Client.Android.Views; /// /// The terminal's keyboard, as an Avalonia control that draws nothing. /// /// /// /// This exists so that the software keyboard is configured by us rather than by Chromium, and that /// single sentence is the whole of ADR 0015. A phone keyboard over a terminal has to be told to stop /// autocorrecting, stop predicting, stop capitalising and stop offering a suggestion strip, and on Android /// the only setting that actually achieves it is an EditorInfo.inputType carrying /// TYPE_TEXT_VARIATION_VISIBLE_PASSWORD. A page inside a WebView cannot reach that field — /// Chromium derives the whole EditorInfo from the focused DOM node, and xterm.js already sets every /// attribute the web platform offers (autocorrect, autocapitalize, spellcheck) to no /// effect, because they are advisory and Gboard ignores them. /// /// /// Avalonia can reach it, on the version this repository already pins. /// AndroidInputMethod.OnCreateInputConnection in Avalonia.Android 12.1.1 reads: /// /// 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 exact /// pair Termux's own TerminalView sets, and the reason typing in Termux feels like a terminal. The /// 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 /// target does not need to cover the terminal — and must not, because the renderer is a native child view /// composited above everything Avalonia paints, so a control stretched over it would be invisible, unable /// to receive the touches the WebView consumes first, and in the way of the connect page that shares /// this Panel when no session is open. A 1×1 control in the corner is focusable, costs one layout slot, and /// is the whole of what an input target has to be. Taps on the terminal itself are noticed by /// instead, which sees them where they actually land. /// /// /// Nothing is retained. reports no surrounding text and no preedit, /// because a terminal has no document for an input method to edit — the remote holds the line and the only /// honest answer to "what is around the cursor" is "ask the host, which you cannot". Committed text is /// turned into bytes and forwarded the instant it arrives. /// /// internal sealed class TerminalInputSurface : Control { /// /// Bytes the shell should receive, already encoded. /// /// /// Raised rather than sent here, because the Ctrl latch belongs to the accessory row and the session to /// the view model. TerminalScreen.SendAsync owns both and applies the latch to anything one byte /// long, which is exactly the set a latch should reach: a typed letter, yes; a pasted line or a cursor /// key's escape sequence, no. /// internal event EventHandler? Input; private readonly TerminalImeClient client; /// /// Whether the text event about to arrive has already been sent as a control byte. /// /// /// /// Necessary because handling a key event does not stop the text that follows it. Avalonia's /// AndroidKeyboardEventsHelper raises the key event and then, independently, raises a text input /// event for any key whose UnicodeChar is 32 or above — it reads the handled flag from the first /// only to combine into its own return value, never to skip the second. So Ctrl-C on a hardware keyboard /// produces both a KeyDown this class turns into 0x03 and a text input carrying a literal /// c, and without this flag the shell receives an interrupt followed by a stray character. /// /// /// A plain field rather than anything cleverer because the two events are raised one after the other in /// the same dispatch, and it is cleared at the top of every so a chord that /// somehow produced no text cannot swallow the next thing typed. /// /// /// The keys in need no such guard — Enter, Tab, Escape and Backspace all /// carry a UnicodeChar below 32, so no text event is raised for them in the first place. /// /// private bool controlByteAlreadySent; public TerminalInputSurface() { client = new TerminalImeClient(this); Focusable = true; // Drawn nowhere and hit-tested never — see the remarks. The size is 1 rather than 0 only because a // zero-area control is a shape no layout pass has a reason to keep. Width = 1; Height = 1; HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Left; VerticalAlignment = Avalonia.Layout.VerticalAlignment.Top; IsHitTestVisible = false; // 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); } /// /// Takes focus and asks for the keyboard. /// /// /// /// The request is made even when focus was already here, which is the case that matters: the system back /// gesture puts the keyboard away without moving focus, so the way back has to be an activation request /// rather than a focus change that would be a no-op. AndroidInputMethod answers /// InputPaneActivationRequested with ShowSoftInput, which is precisely this. /// /// /// Focusing for the first time raises the keyboard on its own — SetClient calls /// RestartInput and ShowSoftInput when it takes a non-null client — so the ordering here is /// belt and braces rather than two mechanisms disagreeing. /// /// internal void Acquire() { Focus(); client.RequestKeyboard(); } private void OnTextInputMethodClientRequested(object? sender, TextInputMethodClientRequestedEventArgs e) => e.Client = client; /// /// Everything the IME commits and everything a hardware keyboard types that is not in /// 's table. UTF-8 because that is what the pty is told the session is, and /// because a multi-byte character has to arrive as one write or a partial sequence reaches the remote. /// protected override void OnTextInput(TextInputEventArgs e) { if (controlByteAlreadySent) { controlByteAlreadySent = false; e.Handled = true; return; } if (string.IsNullOrEmpty(e.Text)) { return; } // 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); } /// /// /// Only the keys that have no text of their own are handled here; everything printable is left alone so /// that it arrives once, through . Handling both would double every letter. /// /// /// Ctrl with a letter is translated here rather than left to the latch, because a hardware keyboard can /// hold a modifier and this is the only place that sees the chord. /// /// protected override void OnKeyDown(KeyEventArgs e) { controlByteAlreadySent = false; if (e.KeyModifiers.HasFlag(KeyModifiers.Control) && TryControlChord(e.Key, out var chord)) { Input?.Invoke(this, new TerminalInputEventArgs(chord)); // The letter's own text event is still coming; see the field's remarks. controlByteAlreadySent = true; e.Handled = true; return; } if (TryTranslate(e.Key, out var bytes)) { Input?.Invoke(this, new TerminalInputEventArgs(bytes)); e.Handled = true; return; } base.OnKeyDown(e); } /// /// The ASCII rule and nothing cleverer, matching TerminalScreen.SendAsync's latch: Ctrl-A through /// Ctrl-Z are 0x01–0x1A, which is the letter masked with 0x1F. /// private static bool TryControlChord(Key key, out byte[] bytes) { if (key is >= Key.A and <= Key.Z) { bytes = [(byte)((byte)('a' + (key - Key.A)) & 0x1F)]; return true; } bytes = []; return false; } /// /// The keys a terminal expects as escape sequences rather than as text. /// /// /// /// Cursor keys are written as CSI rather than SS3, which is the same choice — and the same reasoning — /// as the accessory row's table in TerminalScreen: the difference between CSI A and /// SS3 A is the difference between working in bash and not, and nothing here tracks the remote's /// application-cursor mode. /// /// /// is deliberately spent on backspace, and it is the one lossy choice in /// this file. Avalonia's Android input connection turns an IME's deleteSurroundingText into a /// dispatched KEYCODE_FORWARD_DEL — see TextEditBuffer.Remove — so a soft keyboard that /// deletes through the edit buffer rather than through a real KEYCODE_DEL arrives here as /// Delete. Mapping it to CSI 3 ~ would be correct for a hardware forward-delete and would /// make backspace delete forwards on any keyboard taking the other route, which is the worse failure by /// a wide margin: a phone has no forward-delete key to miss, and a broken backspace makes the terminal /// unusable. Revisit if a hardware keyboard's Delete is ever worth more than this insurance. /// /// private static bool TryTranslate(Key key, out byte[] bytes) { bytes = key switch { Key.Enter => [0x0D], Key.Tab => [0x09], Key.Escape => [0x1B], Key.Back or Key.Delete => [0x7F], Key.Up => [0x1B, (byte)'[', (byte)'A'], Key.Down => [0x1B, (byte)'[', (byte)'B'], Key.Right => [0x1B, (byte)'[', (byte)'C'], Key.Left => [0x1B, (byte)'[', (byte)'D'], Key.Home => [0x1B, (byte)'[', (byte)'H'], Key.End => [0x1B, (byte)'[', (byte)'F'], Key.Insert => [0x1B, (byte)'[', (byte)'2', (byte)'~'], Key.PageUp => [0x1B, (byte)'[', (byte)'5', (byte)'~'], Key.PageDown => [0x1B, (byte)'[', (byte)'6', (byte)'~'], _ => [], }; return bytes.Length > 0; } /// /// What Android's input method is told about the text it is editing, which is that there is none. /// /// /// /// SupportsSurroundingText and SupportsPreedit are both false, and /// is empty, because they are the truth: the line being edited lives on /// the far side of an SSH channel and this client has never been told what is on it. Claiming otherwise /// would invite the IME to reason about text it cannot see and to issue edits against positions that /// mean nothing here. /// /// /// has to be settable because TextEditBuffer writes it before every /// replacement, so it is stored and ignored rather than refused — a property that threw would take the /// input connection down with it. /// /// /// is empty for the same reason the control is 1×1: it positions an IME's /// composition popup, and a field that never composes has no popup to place. /// /// private sealed class TerminalImeClient(Visual visual) : TextInputMethodClient { public override Visual TextViewVisual => visual; public override bool SupportsPreedit => false; public override bool SupportsSurroundingText => false; public override string SurroundingText => string.Empty; public override Rect CursorRectangle => default; public override TextSelection Selection { get; set; } /// Exposed because RaiseInputPaneActivationRequested is protected on the base. internal void RequestKeyboard() => RaiseInputPaneActivationRequested(); } }