Public Access
The key bar above the software keyboard is now the user's own: which keys, in what order, up to sixteen in two rows that never scroll. Ctrl and Alt latch across the bar and the phone's keyboard, and Ctrl or Alt with a cursor key sends the modified CSI form. Seven predefined groups (essentials, cursor, control, symbols, tmux, vim, function) and keys of one's own, written in \e / ^C notation, are arranged on a customise sheet that saves as it goes. A grid button swaps the software keyboard for a quick-access keyboard of the same height, with KEYS, SNIPPETS, HISTORY and APPEARANCE tabs; it reopens to the tab last used. Snippets go through the shell's existing InsertSnippet. History keeps a typed command only once the remote has echoed it, so nothing typed at a password prompt is ever recorded; it is memory-only and LOCK clears it. Each session pump now keeps its last kilobyte of output and the alternate-screen state for that test (OutputTail). Long-press and drag on the terminal sends arrow keys, as a joystick with three speeds by distance; scrolling is unchanged and there is no mode switch. It takes the touch before Chromium's own long press, so a held finger no longer pastes; paste is on the bar by default instead. A switch in APPEARANCE turns the gesture off. The text-size buttons moved from the bar to APPEARANCE. On wide screens the same tabs open as a side panel beside the terminal, toggled from the top bar, with the bar's own keys repeated at the top of KEYS while the bar stands down. The model, gesture arithmetic, echo reading and history are in DodoSSH.Client.Shell and covered by TerminalKeyboardTests and OutputTailTests. Builds clean; not yet exercised on a device, because the emulator's install is locked behind a passphrase that was not recorded.
200 lines
9.3 KiB
C#
200 lines
9.3 KiB
C#
using System.Collections.Frozen;
|
|
using System.Text;
|
|
|
|
namespace DodoSSH.Client.Shell.Keyboard;
|
|
|
|
/// <summary>
|
|
/// Every key the phone ships with, in the groups the quick-access keyboard draws them in.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>The ids are a stored format.</b> The bar and the group order are written to the settings file by id,
|
|
/// so renaming one silently drops it from every bar it was on. Labels, hints and bytes can change freely —
|
|
/// that is the point of storing ids — but an id, once shipped, stays.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Cursor and function keys are written as the sequences xterm sends in its default mode</b> — CSI for
|
|
/// the arrows, SS3 for F1 to F4 — for the reason <c>TerminalInputSurface</c> gives: nothing on this side
|
|
/// tracks the remote's application-cursor mode, and CSI is what bash, zsh and every readline accept in
|
|
/// both modes.
|
|
/// </para>
|
|
/// </remarks>
|
|
internal static class TerminalKeyCatalog
|
|
{
|
|
/// <summary>What every custom key's id starts with, so one can never collide with a shipped key.</summary>
|
|
internal const string CustomPrefix = "custom:";
|
|
|
|
/// <summary>The id of the group custom keys are drawn in.</summary>
|
|
internal const string CustomGroupId = "custom";
|
|
|
|
/// <summary>The most keys the bar holds.</summary>
|
|
/// <remarks>
|
|
/// Two rows of <see cref="BarRowWidth"/>. A third row would cost the terminal another five lines on a
|
|
/// phone, and anything used rarely enough not to make the first sixteen belongs on the quick-access
|
|
/// keyboard, one tap further away.
|
|
/// </remarks>
|
|
internal const int MaximumBarKeys = 16;
|
|
|
|
/// <summary>The most keys one row of the bar divides its width between.</summary>
|
|
/// <remarks>
|
|
/// Eight across 360dp is a 41dp target after margins — just under Android's 48 and roughly what every
|
|
/// terminal on the platform ships. Nine would be 36, which is where the old scrolling row's misses began.
|
|
/// </remarks>
|
|
internal const int BarRowWidth = 8;
|
|
|
|
private static TerminalKey Send(string id, string label, string hint, params byte[] bytes) =>
|
|
new(id, label, TerminalKeyKind.Send, bytes, hint);
|
|
|
|
private static TerminalKey Text(string id, string label, string hint, string text) =>
|
|
new(id, label, TerminalKeyKind.Send, Encoding.UTF8.GetBytes(text), hint);
|
|
|
|
private static byte[] Csi(string tail) => [0x1B, (byte)'[', .. Encoding.ASCII.GetBytes(tail)];
|
|
|
|
private static byte[] Ss3(char final) => [0x1B, (byte)'O', (byte)final];
|
|
|
|
/// <summary>Ctrl-B and then a key: one press of a tmux binding under the default prefix.</summary>
|
|
private static TerminalKey Tmux(string id, string key, string hint) =>
|
|
new(id, $"ctrl+B {key}", TerminalKeyKind.Send, [0x02, .. Encoding.ASCII.GetBytes(key)], hint);
|
|
|
|
/// <summary>
|
|
/// Escape first, then the command: whatever mode vi was in, this is the one it is meant for.
|
|
/// </summary>
|
|
private static TerminalKey Vim(string id, string label, string command, string hint) =>
|
|
new(id, label, TerminalKeyKind.Send, [0x1B, .. Encoding.ASCII.GetBytes(command)], hint);
|
|
|
|
private static TerminalKey Control(char letter) =>
|
|
Send(
|
|
$"ctrl-{char.ToLowerInvariant(letter)}",
|
|
$"^{letter}",
|
|
ControlHints.GetValueOrDefault(letter, $"Ctrl+{letter}"),
|
|
(byte)(letter & 0x1F));
|
|
|
|
private static readonly Dictionary<char, string> ControlHints = new()
|
|
{
|
|
['C'] = "Interrupt the running command",
|
|
['D'] = "End of input; logs out at an empty prompt",
|
|
['Z'] = "Suspend the running command",
|
|
['L'] = "Clear the screen",
|
|
['R'] = "Search command history",
|
|
['A'] = "Start of the line",
|
|
['E'] = "End of the line",
|
|
['U'] = "Delete to the start of the line",
|
|
['W'] = "Delete the word before the cursor",
|
|
['K'] = "Delete to the end of the line",
|
|
};
|
|
|
|
/// <summary>The groups as shipped, in their shipped order.</summary>
|
|
internal static IReadOnlyList<TerminalKeyGroup> Groups { get; } =
|
|
[
|
|
new("essentials", "ESSENTIALS",
|
|
[
|
|
Send("esc", "esc", "Escape", 0x1B),
|
|
Send("tab", "tab", "Complete a command or path", 0x09),
|
|
Send("shift-tab", "shift+tab", "Backwards through completions and forms", Csi("Z")),
|
|
new("ctrl", "ctrl", TerminalKeyKind.Control, [], "Hold Ctrl for the next key"),
|
|
new("alt", "alt", TerminalKeyKind.Alt, [], "Hold Alt for the next key"),
|
|
Send("enter", "⏎", "Enter", 0x0D),
|
|
new("paste", "paste", TerminalKeyKind.Paste, [], "Paste the clipboard"),
|
|
Send("ins", "ins", "Insert", Csi("2~")),
|
|
Send("del", "del", "Delete forwards", Csi("3~")),
|
|
]),
|
|
new("cursor", "CURSOR",
|
|
[
|
|
Send("left", "←", "Left", Csi("D")),
|
|
Send("down", "↓", "Down; the next command in history", Csi("B")),
|
|
Send("up", "↑", "Up; the previous command in history", Csi("A")),
|
|
Send("right", "→", "Right", Csi("C")),
|
|
Send("home", "home", "Start of the line", Csi("H")),
|
|
Send("end", "end", "End of the line", Csi("F")),
|
|
Send("pgup", "pgup", "Page up", Csi("5~")),
|
|
Send("pgdn", "pgdn", "Page down", Csi("6~")),
|
|
]),
|
|
new("control", "CONTROL",
|
|
[
|
|
Control('C'), Control('D'), Control('Z'), Control('L'), Control('R'),
|
|
Control('A'), Control('E'), Control('U'), Control('W'), Control('K'),
|
|
]),
|
|
new("symbols", "SYMBOLS",
|
|
[
|
|
Text("pipe", "|", "Pipe", "|"),
|
|
Text("dash", "-", "Hyphen", "-"),
|
|
Text("underscore", "_", "Underscore", "_"),
|
|
Text("tilde", "~", "Home directory", "~"),
|
|
Text("slash", "/", "Slash", "/"),
|
|
Text("backslash", "\\", "Backslash", "\\"),
|
|
Text("backtick", "`", "Backtick", "`"),
|
|
Text("star", "*", "Asterisk", "*"),
|
|
Text("amp", "&", "Ampersand", "&"),
|
|
Text("dollar", "$", "Dollar", "$"),
|
|
Text("lt", "<", "Less than", "<"),
|
|
Text("gt", ">", "Greater than", ">"),
|
|
Text("lbrace", "{", "Left brace", "{"),
|
|
Text("rbrace", "}", "Right brace", "}"),
|
|
Text("lbracket", "[", "Left bracket", "["),
|
|
Text("rbracket", "]", "Right bracket", "]"),
|
|
]),
|
|
new("tmux", "TMUX",
|
|
[
|
|
Send("tmux-prefix", "^B", "The tmux prefix", 0x02),
|
|
Tmux("tmux-new", "c", "New window"),
|
|
Tmux("tmux-next", "n", "Next window"),
|
|
Tmux("tmux-prev", "p", "Previous window"),
|
|
Tmux("tmux-split-v", "%", "Split side by side"),
|
|
Tmux("tmux-split-h", "\"", "Split top and bottom"),
|
|
Tmux("tmux-other", "o", "Next pane"),
|
|
Tmux("tmux-kill", "x", "Close the pane"),
|
|
Tmux("tmux-detach", "d", "Detach"),
|
|
Tmux("tmux-copy", "[", "Scroll and copy mode"),
|
|
]),
|
|
new("vim", "VIM",
|
|
[
|
|
Vim("vim-write", ":w", ":w\r", "Save"),
|
|
Vim("vim-quit", ":q", ":q\r", "Quit"),
|
|
Vim("vim-write-quit", ":wq", ":wq\r", "Save and quit"),
|
|
Vim("vim-force-quit", ":q!", ":q!\r", "Quit without saving"),
|
|
Vim("vim-top", "gg", "gg", "First line"),
|
|
Vim("vim-bottom", "G", "G", "Last line"),
|
|
]),
|
|
new("function", "FUNCTION",
|
|
[
|
|
Send("f1", "F1", "F1", Ss3('P')),
|
|
Send("f2", "F2", "F2", Ss3('Q')),
|
|
Send("f3", "F3", "F3", Ss3('R')),
|
|
Send("f4", "F4", "F4", Ss3('S')),
|
|
Send("f5", "F5", "F5", Csi("15~")),
|
|
Send("f6", "F6", "F6", Csi("17~")),
|
|
Send("f7", "F7", "F7", Csi("18~")),
|
|
Send("f8", "F8", "F8", Csi("19~")),
|
|
Send("f9", "F9", "F9", Csi("20~")),
|
|
Send("f10", "F10", "F10", Csi("21~")),
|
|
Send("f11", "F11", "F11", Csi("23~")),
|
|
Send("f12", "F12", "F12", Csi("24~")),
|
|
]),
|
|
];
|
|
|
|
/// <summary>
|
|
/// The bar as shipped: the keys this head's old two fixed rows carried, plus Alt and paste.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Modifiers and punctuation on the first row, cursor keys on the second — the split the old keypad's
|
|
/// remarks argue for, which is unchanged: the arrows are the keys pressed repeatedly and in sequence, so
|
|
/// they get the row nearest the thumb and, being fewer, the widest targets. Paste joins them because a
|
|
/// long press on the terminal is now the cursor gesture, and was the only way to paste before.
|
|
/// </remarks>
|
|
internal static IReadOnlyList<string> DefaultBar { get; } =
|
|
[
|
|
"esc", "tab", "ctrl", "alt", "pipe", "dash", "slash", "tilde",
|
|
"left", "down", "up", "right", "paste",
|
|
];
|
|
|
|
/// <summary>Every shipped key by id.</summary>
|
|
internal static FrozenDictionary<string, TerminalKey> ById { get; } =
|
|
Groups.SelectMany(g => g.Keys).ToFrozenDictionary(k => k.Id, StringComparer.Ordinal);
|
|
|
|
/// <summary>Builds the key a stored custom entry describes, or null when its sequence does not parse.</summary>
|
|
internal static TerminalKey? FromCustom(string id, string label, string sequence) =>
|
|
KeySequence.TryParse(sequence, out var bytes) && bytes.Length > 0
|
|
? new TerminalKey(id, label, TerminalKeyKind.Send, bytes, $"Sends {sequence}")
|
|
: null;
|
|
}
|