Public Access
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>
336 lines
16 KiB
C#
336 lines
16 KiB
C#
using System.Text;
|
||
|
||
using Avalonia;
|
||
using Avalonia.Controls;
|
||
using Avalonia.Input;
|
||
using Avalonia.Input.TextInput;
|
||
using Avalonia.Media;
|
||
|
||
namespace DodoSSH.Client.Android.Views;
|
||
|
||
/// <summary>
|
||
/// The terminal's keyboard, as an Avalonia control that draws nothing.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// <b>This exists so that the software keyboard is configured by us rather than by Chromium</b>, 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 <c>EditorInfo.inputType</c> carrying
|
||
/// <c>TYPE_TEXT_VARIATION_VISIBLE_PASSWORD</c>. A page inside a <c>WebView</c> cannot reach that field —
|
||
/// Chromium derives the whole <c>EditorInfo</c> from the focused DOM node, and xterm.js already sets every
|
||
/// attribute the web platform offers (<c>autocorrect</c>, <c>autocapitalize</c>, <c>spellcheck</c>) to no
|
||
/// effect, because they are advisory and Gboard ignores them.
|
||
/// </para>
|
||
/// <para>
|
||
/// <b>Avalonia can reach it, on the version this repository already pins.</b>
|
||
/// <c>AndroidInputMethod.OnCreateInputConnection</c> in Avalonia.Android 12.1.1 reads:
|
||
/// <code>
|
||
/// if ((int)outAttrs.InputType == 1 && options.ShowSuggestions == false)
|
||
/// outAttrs.InputType = (InputTypes)(outAttrs.InputType | 0x80080);
|
||
/// </code>
|
||
/// <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
|
||
/// guard is why the constructor leaves <c>ContentType</c>, <c>Multiline</c> and <c>AutoCapitalization</c>
|
||
/// alone: any of those would move <c>InputType</c> off <c>1</c> and the branch would never be taken.
|
||
/// <c>ShowSuggestions</c> is a <c>bool?</c> defaulting to <c>null</c>, so it must be set to <c>false</c>
|
||
/// 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>
|
||
/// <b>One pixel, and never drawn.</b> 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 <c>WebView</c> 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
|
||
/// <see cref="Platform.RendererFocus"/> instead, which sees them where they actually land.
|
||
/// </para>
|
||
/// <para>
|
||
/// <b>Nothing is retained.</b> <see cref="TerminalImeClient"/> 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.
|
||
/// </para>
|
||
/// </remarks>
|
||
internal sealed class TerminalInputSurface : Control
|
||
{
|
||
/// <summary>
|
||
/// Bytes the shell should receive, already encoded.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// Raised rather than sent here, because the Ctrl latch belongs to the accessory row and the session to
|
||
/// the view model. <c>TerminalScreen.SendAsync</c> 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.
|
||
/// </remarks>
|
||
internal event EventHandler<TerminalInputEventArgs>? Input;
|
||
|
||
private readonly TerminalImeClient client;
|
||
|
||
/// <summary>
|
||
/// Whether the text event about to arrive has already been sent as a control byte.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// <b>Necessary because handling a key event does not stop the text that follows it.</b> Avalonia's
|
||
/// <c>AndroidKeyboardEventsHelper</c> raises the key event and then, independently, raises a text input
|
||
/// event for any key whose <c>UnicodeChar</c> 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 <c>KeyDown</c> this class turns into <c>0x03</c> and a text input carrying a literal
|
||
/// <c>c</c>, and without this flag the shell receives an interrupt followed by a stray character.
|
||
/// </para>
|
||
/// <para>
|
||
/// 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 <see cref="OnKeyDown"/> so a chord that
|
||
/// somehow produced no text cannot swallow the next thing typed.
|
||
/// </para>
|
||
/// <para>
|
||
/// The keys in <see cref="TryTranslate"/> need no such guard — Enter, Tab, Escape and Backspace all
|
||
/// carry a <c>UnicodeChar</c> below 32, so no text event is raised for them in the first place.
|
||
/// </para>
|
||
/// </remarks>
|
||
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);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Takes focus and asks for the keyboard.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// 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. <c>AndroidInputMethod</c> answers
|
||
/// <c>InputPaneActivationRequested</c> with <c>ShowSoftInput</c>, which is precisely this.
|
||
/// </para>
|
||
/// <para>
|
||
/// Focusing for the first time raises the keyboard on its own — <c>SetClient</c> calls
|
||
/// <c>RestartInput</c> and <c>ShowSoftInput</c> when it takes a non-null client — so the ordering here is
|
||
/// belt and braces rather than two mechanisms disagreeing.
|
||
/// </para>
|
||
/// </remarks>
|
||
internal void Acquire()
|
||
{
|
||
Focus();
|
||
client.RequestKeyboard();
|
||
}
|
||
|
||
private void OnTextInputMethodClientRequested(object? sender, TextInputMethodClientRequestedEventArgs e) =>
|
||
e.Client = client;
|
||
|
||
/// <remarks>
|
||
/// Everything the IME commits and everything a hardware keyboard types that is not in
|
||
/// <see cref="TryTranslate"/>'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.
|
||
/// </remarks>
|
||
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);
|
||
}
|
||
|
||
/// <remarks>
|
||
/// <para>
|
||
/// Only the keys that have no text of their own are handled here; everything printable is left alone so
|
||
/// that it arrives once, through <see cref="OnTextInput"/>. Handling both would double every letter.
|
||
/// </para>
|
||
/// <para>
|
||
/// 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.
|
||
/// </para>
|
||
/// </remarks>
|
||
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);
|
||
}
|
||
|
||
/// <remarks>
|
||
/// The ASCII rule and nothing cleverer, matching <c>TerminalScreen.SendAsync</c>'s latch: Ctrl-A through
|
||
/// Ctrl-Z are <c>0x01</c>–<c>0x1A</c>, which is the letter masked with <c>0x1F</c>.
|
||
/// </remarks>
|
||
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;
|
||
}
|
||
|
||
/// <summary>
|
||
/// The keys a terminal expects as escape sequences rather than as text.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// 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 <c>TerminalScreen</c>: the difference between <c>CSI A</c> and
|
||
/// <c>SS3 A</c> is the difference between working in bash and not, and nothing here tracks the remote's
|
||
/// application-cursor mode.
|
||
/// </para>
|
||
/// <para>
|
||
/// <b><see cref="Key.Delete"/> is deliberately spent on backspace, and it is the one lossy choice in
|
||
/// this file.</b> Avalonia's Android input connection turns an IME's <c>deleteSurroundingText</c> into a
|
||
/// dispatched <c>KEYCODE_FORWARD_DEL</c> — see <c>TextEditBuffer.Remove</c> — so a soft keyboard that
|
||
/// deletes through the edit buffer rather than through a real <c>KEYCODE_DEL</c> arrives here as
|
||
/// <c>Delete</c>. Mapping it to <c>CSI 3 ~</c> 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.
|
||
/// </para>
|
||
/// </remarks>
|
||
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;
|
||
}
|
||
|
||
/// <summary>
|
||
/// What Android's input method is told about the text it is editing, which is that there is none.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// <c>SupportsSurroundingText</c> and <c>SupportsPreedit</c> are both false, and
|
||
/// <see cref="SurroundingText"/> 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.
|
||
/// </para>
|
||
/// <para>
|
||
/// <see cref="Selection"/> has to be settable because <c>TextEditBuffer</c> 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.
|
||
/// </para>
|
||
/// <para>
|
||
/// <see cref="CursorRectangle"/> 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.
|
||
/// </para>
|
||
/// </remarks>
|
||
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; }
|
||
|
||
/// <remarks>Exposed because <c>RaiseInputPaneActivationRequested</c> is protected on the base.</remarks>
|
||
internal void RequestKeyboard() => RaiseInputPaneActivationRequested();
|
||
}
|
||
}
|