SPB Git

Repository Public

keys.ts 4.7 KB 111 lines typescript

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
Files main
Menu

Theme