using System.Collections.Frozen; using System.Text; namespace DodoSSH.Client.Shell.Keyboard; /// /// Every key the phone ships with, in the groups the quick-access keyboard draws them in. /// /// /// /// The ids are a stored format. 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. /// /// /// Cursor and function keys are written as the sequences xterm sends in its default mode — CSI for /// the arrows, SS3 for F1 to F4 — for the reason TerminalInputSurface 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. /// /// internal static class TerminalKeyCatalog { /// What every custom key's id starts with, so one can never collide with a shipped key. internal const string CustomPrefix = "custom:"; /// The id of the group custom keys are drawn in. internal const string CustomGroupId = "custom"; /// The most keys the bar holds. /// /// Two rows of . 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. /// internal const int MaximumBarKeys = 16; /// The most keys one row of the bar divides its width between. /// /// 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. /// 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]; /// Ctrl-B and then a key: one press of a tmux binding under the default prefix. private static TerminalKey Tmux(string id, string key, string hint) => new(id, $"ctrl+B {key}", TerminalKeyKind.Send, [0x02, .. Encoding.ASCII.GetBytes(key)], hint); /// /// Escape first, then the command: whatever mode vi was in, this is the one it is meant for. /// 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 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", }; /// The groups as shipped, in their shipped order. internal static IReadOnlyList 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~")), ]), ]; /// /// The bar as shipped: the keys this head's old two fixed rows carried, plus Alt and paste. /// /// /// 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. /// internal static IReadOnlyList DefaultBar { get; } = [ "esc", "tab", "ctrl", "alt", "pipe", "dash", "slash", "tilde", "left", "down", "up", "right", "paste", ]; /// Every shipped key by id. internal static FrozenDictionary ById { get; } = Groups.SelectMany(g => g.Keys).ToFrozenDictionary(k => k.Id, StringComparer.Ordinal); /// Builds the key a stored custom entry describes, or null when its sequence does not parse. 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; }