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