Repository Public
1/**2 * Terminal key sequences for the mobile toolbar and sticky modifiers. Pure functions (unit-tested).3 * Modifier state: 0 = off, 1 = one-shot (applies to the next key), 2 = locked.4 */5export type ModState = 0 | 1 | 2;6export interface Mods {7 ctrl: ModState;8 alt: ModState;9 shift: ModState;10}11export type ModName = keyof Mods;12export const NO_MODS: Mods = { ctrl: 0, alt: 0, shift: 0 };13export const MOD_NAMES: readonly ModName[] = ['ctrl', 'alt', 'shift'];1415export const hasMods = (m: Mods): boolean => m.ctrl > 0 || m.alt > 0 || m.shift > 0;16/** One-shot modifiers are consumed by a key press; locked ones stay. */17export const consumeMods = (m: Mods): Mods => ({ ctrl: m.ctrl === 2 ? 2 : 0, alt: m.alt === 2 ? 2 : 0, shift: m.shift === 2 ? 2 : 0 });18/** tap: off → one-shot → off; double tap: → locked; tap while locked → off. */19export function tapMod(state: ModState, doubleTap: boolean): ModState {20 if (state === 2) return 0;21 if (state === 1) return doubleTap ? 2 : 0;22 return 1;23}2425/** Control code for a printable character (Ctrl+C → \x03), or null if there is none. */26export function ctrlChar(ch: string): string | null {27 if (ch.length !== 1) return null;28 const c = ch.toUpperCase().charCodeAt(0);29 if (c >= 0x40 && c <= 0x5f) return String.fromCharCode(c & 0x1f); // @ A-Z [ \ ] ^ _30 if (ch === ' ' || ch === '2') return '\x00';31 if (ch === '?' || ch === '8') return '\x7f';32 if (ch === '/' || ch === '-') return '\x1f';33 if (ch >= '3' && ch <= '7') return String.fromCharCode(0x1b + (ch.charCodeAt(0) - 0x33));34 return null;35}3637/** Apply sticky modifiers to text produced by the (soft or hardware) keyboard. */38export function applyModsToInput(data: string, m: Mods): string {39 if (!hasMods(m)) return data;40 const chars = Array.from(data);41 if (chars.length !== 1) return data; // pastes, escape sequences, IME commits: leave untouched42 let out = chars[0] as string;43 if (out === '\t' && m.shift) return (m.alt ? '\x1b' : '') + '\x1b[Z';44 if (m.shift && out.length === 1) out = out.toUpperCase();45 if (m.ctrl) out = ctrlChar(out) ?? out;46 if (m.alt) out = '\x1b' + out;47 return out;48}4950const CSI_FINAL: Record<string, string> = { up: 'A', down: 'B', right: 'C', left: 'D', home: 'H', end: 'F' };51const CSI_TILDE: Record<string, number> = { pgup: 5, pgdn: 6, del: 3, ins: 2 };5253/**54 * Sequence for a toolbar key. `appCursor` = DECCKM (application cursor keys) as reported by55 * xterm (`term.modes.applicationCursorKeysMode`). Unknown ids are literal characters.56 */57export function keySequence(key: string, m: Mods = NO_MODS, appCursor = false): string {58 const mod = 1 + (m.shift ? 1 : 0) + (m.alt ? 2 : 0) + (m.ctrl ? 4 : 0);59 const fin = CSI_FINAL[key];60 if (fin) {61 if (mod > 1) return `\x1b[1;${mod}${fin}`;62 return appCursor ? `\x1bO${fin}` : `\x1b[${fin}`;63 }64 const tilde = CSI_TILDE[key];65 if (tilde) return mod > 1 ? `\x1b[${tilde};${mod}~` : `\x1b[${tilde}~`;66 switch (key) {67 case 'esc':68 return m.alt ? '\x1b\x1b' : '\x1b';69 case 'tab':70 return (m.alt ? '\x1b' : '') + (m.shift ? '\x1b[Z' : '\t');71 case 'enter':72 return (m.alt ? '\x1b' : '') + '\r';73 case 'backspace':74 return (m.alt ? '\x1b' : '') + (m.ctrl ? '\x08' : '\x7f');75 default:76 return applyModsToInput(key, m);77 }78}7980export interface ToolbarKeyDef {81 id: string;82 label: string;83 aria: string;84 kind: 'mod' | 'key';85 repeat?: boolean;86}87const NAMED: Record<string, Omit<ToolbarKeyDef, 'id'>> = {88 esc: { label: 'esc', aria: 'Escape', kind: 'key' },89 tab: { label: 'tab', aria: 'Tab', kind: 'key' },90 ctrl: { label: 'ctrl', aria: 'Control modifier', kind: 'mod' },91 alt: { label: 'alt', aria: 'Alt modifier', kind: 'mod' },92 shift: { label: 'shift', aria: 'Shift modifier', kind: 'mod' },93 up: { label: '↑', aria: 'Arrow up', kind: 'key', repeat: true },94 down: { label: '↓', aria: 'Arrow down', kind: 'key', repeat: true },95 left: { label: '←', aria: 'Arrow left', kind: 'key', repeat: true },96 right: { label: '→', aria: 'Arrow right', kind: 'key', repeat: true },97 enter: { label: '⏎', aria: 'Enter', kind: 'key' },98 home: { label: 'home', aria: 'Home', kind: 'key' },99 end: { label: 'end', aria: 'End', kind: 'key' },100 pgup: { label: 'pgup', aria: 'Page up', kind: 'key', repeat: true },101 pgdn: { label: 'pgdn', aria: 'Page down', kind: 'key', repeat: true },102 del: { label: 'del', aria: 'Delete', kind: 'key', repeat: true },103 backspace: { label: '⌫', aria: 'Backspace', kind: 'key', repeat: true },104};105export const TOOLBAR_KEY_IDS: readonly string[] = [...Object.keys(NAMED), '/', '|', '~', '-', '_', ':', '.', '*', '$', '\\'];106export function toolbarKey(id: string): ToolbarKeyDef {107 const named = NAMED[id];108 if (named) return { id, ...named };109 return { id, label: id, aria: `Character ${id}`, kind: 'key' };110}111