/** * 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 { 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([ [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([ [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); } } }