[READ-ONLY] Mirror of https://github.com/bombshell-dev/tty. Platform independent 2D layout engine for terminal applications based on Clay bomb.sh/docs/tty
Something went wrong. Try again.
tty input.ts
15 kB · 691 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692/** * Terminal input parser. * * Thin wrapper around WASM `input_*` functions that decode raw VT/ANSI * escape sequences into structured events. All parsing logic, state * management, and buffering lives in WASM. */
import { createInputNative, EVENT_CURSOR, EVENT_KEY, EVENT_MOUSE, EVENT_RESIZE, KEY_ALT_LEFT, KEY_ALT_RIGHT, KEY_ARROW_DOWN, KEY_ARROW_LEFT, KEY_ARROW_RIGHT, KEY_ARROW_UP, KEY_BACKSPACE, KEY_BACKTAB, KEY_CAPS_LOCK, KEY_CONTROL_LEFT, KEY_CONTROL_RIGHT, KEY_DELETE, KEY_END, KEY_ENTER, KEY_ESC, KEY_F1, KEY_F10, KEY_F11, KEY_F12, KEY_F2, KEY_F3, KEY_F4, KEY_F5, KEY_F6, KEY_F7, KEY_F8, KEY_F9, KEY_HOME, KEY_HYPER_LEFT, KEY_HYPER_RIGHT, KEY_INSERT, KEY_META_LEFT, KEY_META_RIGHT, KEY_MOUSE_LEFT, KEY_MOUSE_MIDDLE, KEY_MOUSE_RELEASE, KEY_MOUSE_RIGHT, KEY_MOUSE_WHEEL_DOWN, KEY_MOUSE_WHEEL_UP, KEY_NUM_LOCK, KEY_NUMPAD_0, KEY_NUMPAD_1, KEY_NUMPAD_2, KEY_NUMPAD_3, KEY_NUMPAD_4, KEY_NUMPAD_5, KEY_NUMPAD_6, KEY_NUMPAD_7, KEY_NUMPAD_8, KEY_NUMPAD_9, KEY_NUMPAD_ADD, KEY_NUMPAD_DECIMAL, KEY_NUMPAD_DIVIDE, KEY_NUMPAD_ENTER, KEY_NUMPAD_EQUAL, KEY_NUMPAD_MULTIPLY, KEY_NUMPAD_SUBTRACT, KEY_PGDN, KEY_PGUP, KEY_SCROLL_LOCK, KEY_SHIFT_LEFT, KEY_SHIFT_RIGHT, KEY_SUPER_LEFT, KEY_SUPER_RIGHT, KEY_TAB, MAX_TERMINFO, MOD_ALT, MOD_CTRL, MOD_MOTION, MOD_RELEASE, MOD_SHIFT, type NativeInputEvent, readEvent, SCAN_BUFFER_SIZE,} from "./input-native.ts";
/** * Modifier keys held during a key or mouse event. */export interface KeyModifiers { alt?: true; ctrl?: true; shift?: true;}
/** * Physical key identity on a US PC-101 layout. */export type KeyCode = | "a" | "b" | "c" | "d" | "e" | "f" | "g" | "h" | "i" | "j" | "k" | "l" | "m" | "n" | "o" | "p" | "q" | "r" | "s" | "t" | "u" | "v" | "w" | "x" | "y" | "z" | "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "`" | "-" | "=" | "[" | "]" | "\\" | ";" | "'" | "," | "." | "/" | " " | "F1" | "F2" | "F3" | "F4" | "F5" | "F6" | "F7" | "F8" | "F9" | "F10" | "F11" | "F12" | "ArrowUp" | "ArrowDown" | "ArrowLeft" | "ArrowRight" | "Home" | "End" | "Insert" | "Delete" | "PageUp" | "PageDown" | "Backtab" | "Backspace" | "Tab" | "Enter" | "Escape" | "Numpad0" | "Numpad1" | "Numpad2" | "Numpad3" | "Numpad4" | "Numpad5" | "Numpad6" | "Numpad7" | "Numpad8" | "Numpad9" | "NumpadDecimal" | "NumpadDivide" | "NumpadMultiply" | "NumpadSubtract" | "NumpadAdd" | "NumpadEnter" | "NumpadEqual" | "ShiftLeft" | "ShiftRight" | "ControlLeft" | "ControlRight" | "AltLeft" | "AltRight" | "SuperLeft" | "SuperRight" | "HyperLeft" | "HyperRight" | "MetaLeft" | "MetaRight" | "CapsLock" | "NumLock" | "ScrollLock";
/** * Shared key information present on all keyboard events. */export interface KeyInfo extends KeyModifiers { key: string; code: KeyCode;}
/** * A key was pressed. Emitted at all enhancement levels. * On legacy terminals, this is the only keyboard event type. */export interface KeyDown extends KeyInfo { type: "keydown"; shifted?: string; text?: string;}
/** * A key is being held down (auto-repeat). Only emitted with * Kitty enhancement level 2+ (report event types). */export interface KeyRepeat extends KeyInfo { type: "keyrepeat"; shifted?: string; text?: string;}
/** * A key was released. Only emitted with Kitty enhancement * level 2+ (report event types). Does not carry text, * shifted, or base fields. */export interface KeyUp extends KeyInfo { type: "keyup";}
export type KeyEvent = KeyDown | KeyRepeat | KeyUp;
/** * A mouse button was pressed. */export interface MouseDownEvent extends KeyModifiers { type: "mousedown"; /** * Which mouse button triggered this event. */ button: "left" | "right" | "middle";
/** * `x` coordinate of this event */ x: number;
/** * `y` coordinate of this event */ y: number;}
/** * A mouse button was released. */export interface MouseUpEvent extends KeyModifiers { type: "mouseup"; /** * Which mouse button triggered this event. * * Note: "release" is not technically a button, but is used by * `VT200` and `urxvt` protocols where the terminal reports a button * release without indicating which button. */ button: "left" | "right" | "middle" | "release";
/** * `x` coordinate of this event */ x: number;
/** * `y` coordinate of this event */ y: number;}
/** * Mouse movement while a button is held. */export interface MouseMoveEvent extends KeyModifiers { type: "mousemove"; /** * Which mouse button is being held during the drag. */ button: "left" | "right" | "middle"; /** * Cursor column (0-based). */ x: number; /** * Cursor row (0-based). */ y: number;}
/** * A scroll wheel tick. */export interface WheelEvent extends KeyModifiers { type: "wheel"; /** * Did the wheel move up or down */ direction: "up" | "down";
/** * Cursor column at the time of the scroll (0-based). */ x: number;
/** * Cursor row at the time of the scroll (0-based). */ y: number;}
/** * Terminal resize notification. */export interface ResizeEvent { type: "resize";
/** * New terminal width in columns. */ width: number;
/** * New terminal height in rows. */ height: number;}
/** * Cursor position report (DSR response). * * Emitted when the terminal responds to a Device Status Report * query (`\x1b[6n`) with the current cursor position. */export interface CursorEvent { type: "cursor";
/** * Cursor row (1-based). Matches ECMA-48 DSR native format. */ row: number;
/** * Cursor column (1-based). Matches ECMA-48 DSR native format. */ column: number;}
import type { PointerEvent } from "./term.ts";
export type InputEvent = | KeyEvent | MouseDownEvent | MouseUpEvent | MouseMoveEvent | WheelEvent | ResizeEvent | CursorEvent | PointerEvent;
/** * Result of a single scan() call. * * When `pending` is present, a lone ESC is buffered and the caller should * re-call scan() with an empty buffer after `pending.delay` milliseconds. */export interface ScanResult { events: InputEvent[]; pending?: { delay: number };}
export interface Input { /** * Feed raw bytes from stdin into the parser and return any events * produced. Call with no arguments to flush a pending ESC after the * latency period has elapsed. * * @example * ```ts * let { events, pending } = input.scan(bytes); * for (let event of events) { * dispatch(event); * } * if (pending) { * // there is a pending ESC event. wait for the delay * await sleep(pending.delay); * * // re-scan * let flush = input.scan(); * * //dispatch the flushed ESC * for (let event of flush.events) { * dispatch(event) * } * } * ``` */ scan(bytes?: Uint8Array): ScanResult;}
export interface InputOptions { /** * Milliseconds to wait before resolving a lone ESC byte as the Escape * key rather than the start of an escape sequence. Lower values feel * snappier but risk misinterpreting sequences on slow connections. * * For reference, Vim's `ttimeoutlen` defaults to 100ms and ncurses * `ESCDELAY` defaults to 1000ms. The default of 25ms is tuned for * local terminals where escape sequences arrive within microseconds. * * @default 25 */ escLatency?: number;
/** * Compiled terminfo binary to load terminal-specific escape sequences. * * This is the format used by files like /usr/lib/terminfo/78/xterm-256color * and they can be directly loaded from disk into this option. * * If no terminfo is provided it will use xterm capabilities as the default */ terminfo?: Uint8Array;}
export async function createInput(options: InputOptions = {}): Promise<Input> { let { escLatency = 25, terminfo } = options;
if (terminfo && terminfo.byteLength > MAX_TERMINFO) { throw new RangeError( `terminfo exceeds ${MAX_TERMINFO} byte limit (got ${terminfo.byteLength})`, ); }
let native = await createInputNative(escLatency);
return { scan(bytes: Uint8Array = new Uint8Array(0)): ScanResult { let now = Date.now(); let events: InputEvent[] = []; let offset = 0;
while (offset < bytes.length || (offset === 0 && bytes.length === 0)) { let chunk = bytes.subarray(offset, offset + SCAN_BUFFER_SIZE); if (chunk.length > 0) { new Uint8Array(native.memory.buffer).set(chunk, native.buffer); }
let accepted = native.scan( native.state, native.buffer, chunk.length, now, );
let count = native.count(native.state); let view = new DataView(native.memory.buffer); for (let i = 0; i < count; i++) { let ptr = native.event(native.state, i); if (ptr !== 0) { events.push(mapEvent(readEvent(view, ptr))); } }
offset += accepted;
if (accepted < chunk.length) { break; } if (bytes.length === 0) { break; } }
let delay = native.delay(native.state); if (delay > 0) { return { events, pending: { delay } }; } return { events }; }, };}
const KEY_NAMES = new Map<number, string>([ [KEY_F1, "F1"], [KEY_F2, "F2"], [KEY_F3, "F3"], [KEY_F4, "F4"], [KEY_F5, "F5"], [KEY_F6, "F6"], [KEY_F7, "F7"], [KEY_F8, "F8"], [KEY_F9, "F9"], [KEY_F10, "F10"], [KEY_F11, "F11"], [KEY_F12, "F12"], [KEY_ARROW_UP, "ArrowUp"], [KEY_ARROW_DOWN, "ArrowDown"], [KEY_ARROW_LEFT, "ArrowLeft"], [KEY_ARROW_RIGHT, "ArrowRight"], [KEY_HOME, "Home"], [KEY_END, "End"], [KEY_INSERT, "Insert"], [KEY_DELETE, "Delete"], [KEY_PGUP, "PageUp"], [KEY_PGDN, "PageDown"], [KEY_BACKTAB, "Backtab"], [KEY_BACKSPACE, "Backspace"], [KEY_TAB, "Tab"], [KEY_ENTER, "Enter"], [KEY_ESC, "Escape"], [KEY_NUMPAD_0, "Numpad0"], [KEY_NUMPAD_1, "Numpad1"], [KEY_NUMPAD_2, "Numpad2"], [KEY_NUMPAD_3, "Numpad3"], [KEY_NUMPAD_4, "Numpad4"], [KEY_NUMPAD_5, "Numpad5"], [KEY_NUMPAD_6, "Numpad6"], [KEY_NUMPAD_7, "Numpad7"], [KEY_NUMPAD_8, "Numpad8"], [KEY_NUMPAD_9, "Numpad9"], [KEY_NUMPAD_DECIMAL, "NumpadDecimal"], [KEY_NUMPAD_DIVIDE, "NumpadDivide"], [KEY_NUMPAD_MULTIPLY, "NumpadMultiply"], [KEY_NUMPAD_SUBTRACT, "NumpadSubtract"], [KEY_NUMPAD_ADD, "NumpadAdd"], [KEY_NUMPAD_ENTER, "NumpadEnter"], [KEY_NUMPAD_EQUAL, "NumpadEqual"], [KEY_SHIFT_LEFT, "ShiftLeft"], [KEY_SHIFT_RIGHT, "ShiftRight"], [KEY_CONTROL_LEFT, "ControlLeft"], [KEY_CONTROL_RIGHT, "ControlRight"], [KEY_ALT_LEFT, "AltLeft"], [KEY_ALT_RIGHT, "AltRight"], [KEY_SUPER_LEFT, "SuperLeft"], [KEY_SUPER_RIGHT, "SuperRight"], [KEY_HYPER_LEFT, "HyperLeft"], [KEY_HYPER_RIGHT, "HyperRight"], [KEY_META_LEFT, "MetaLeft"], [KEY_META_RIGHT, "MetaRight"], [KEY_CAPS_LOCK, "CapsLock"], [KEY_NUM_LOCK, "NumLock"], [KEY_SCROLL_LOCK, "ScrollLock"],]);
const BUTTON_NAMES = new Map<number, MouseUpEvent["button"]>([ [KEY_MOUSE_LEFT, "left"], [KEY_MOUSE_RIGHT, "right"], [KEY_MOUSE_MIDDLE, "middle"], [KEY_MOUSE_RELEASE, "release"],]);
function mods(native: NativeInputEvent): KeyModifiers { let m: KeyModifiers = {}; if (native.mod & MOD_ALT) m.alt = true; if (native.mod & MOD_CTRL) m.ctrl = true; if (native.mod & MOD_SHIFT) m.shift = true; return m;}
function keyName(native: NativeInputEvent): string { let name = KEY_NAMES.get(native.key); if (name) { return name; } else if (native.key === 0 && native.ch > 0) { return String.fromCodePoint(native.ch); } else if (native.key > 0 && native.key < 0x20) { return String.fromCharCode(native.key + 0x60); } else { return String.fromCodePoint(native.ch || native.key); }}
function textFromNative(native: NativeInputEvent): string | undefined { if (native.text.length === 0) { return undefined; } else { return String.fromCodePoint(...native.text); }}
function mapKeyEvent(native: NativeInputEvent): KeyEvent { let key = keyName(native); let code = (native.base > 0 ? String.fromCodePoint(native.base) : key) as KeyCode; let m = mods(native); let isChar = !KEY_NAMES.has(native.key); let text = textFromNative(native);
if (native.action === 3) { return { type: "keyup", key, code, ...m }; }
let type: "keydown" | "keyrepeat" = native.action === 2 ? "keyrepeat" : "keydown";
let ev: KeyDown | KeyRepeat = { type, key, code, ...m };
if (native.shifted > 0) ev.shifted = String.fromCodePoint(native.shifted); if (text) { ev.text = text; } else if (isChar && type === "keydown" && native.ch > 0) { ev.text = String.fromCodePoint(native.ch); }
return ev;}
function mapEvent(native: NativeInputEvent): InputEvent { switch (native.type) { case EVENT_KEY: { return mapKeyEvent(native); } case EVENT_MOUSE: { if ( native.key === KEY_MOUSE_WHEEL_UP || native.key === KEY_MOUSE_WHEEL_DOWN ) { return { type: "wheel", direction: native.key === KEY_MOUSE_WHEEL_UP ? "up" : "down", x: native.x, y: native.y, ...mods(native), }; } if (native.mod & MOD_MOTION) { let button = BUTTON_NAMES.get(native.key) ?? "left"; return { type: "mousemove" as const, button: button === "release" ? "left" as const : button, x: native.x, y: native.y, ...mods(native), }; } let button = BUTTON_NAMES.get(native.key) ?? "left"; if (native.mod & MOD_RELEASE) { return { type: "mouseup" as const, button, x: native.x, y: native.y, ...mods(native), }; } else { return { type: "mousedown" as const, button: button as MouseDownEvent["button"], x: native.x, y: native.y, ...mods(native), }; } } case EVENT_RESIZE: { return { type: "resize", width: native.w, height: native.h }; } case EVENT_CURSOR: { return { type: "cursor", row: native.y, column: native.x }; } default: { return mapKeyEvent(native); } }}