[READ-ONLY] Mirror of https://github.com/bombshell-dev/tty. Platform independent 2D layout engine for terminal applications based on Clay
1/**
2 * Terminal input parser.
3 *
4 * Thin wrapper around WASM `input_*` functions that decode raw VT/ANSI
5 * escape sequences into structured events. All parsing logic, state
6 * management, and buffering lives in WASM.
7 */
8
9import {
10 createInputNative,
11 EVENT_CURSOR,
12 EVENT_KEY,
13 EVENT_MOUSE,
14 EVENT_RESIZE,
15 KEY_ALT_LEFT,
16 KEY_ALT_RIGHT,
17 KEY_ARROW_DOWN,
18 KEY_ARROW_LEFT,
19 KEY_ARROW_RIGHT,
20 KEY_ARROW_UP,
21 KEY_BACKSPACE,
22 KEY_BACKTAB,
23 KEY_CAPS_LOCK,
24 KEY_CONTROL_LEFT,
25 KEY_CONTROL_RIGHT,
26 KEY_DELETE,
27 KEY_END,
28 KEY_ENTER,
29 KEY_ESC,
30 KEY_F1,
31 KEY_F10,
32 KEY_F11,
33 KEY_F12,
34 KEY_F2,
35 KEY_F3,
36 KEY_F4,
37 KEY_F5,
38 KEY_F6,
39 KEY_F7,
40 KEY_F8,
41 KEY_F9,
42 KEY_HOME,
43 KEY_HYPER_LEFT,
44 KEY_HYPER_RIGHT,
45 KEY_INSERT,
46 KEY_META_LEFT,
47 KEY_META_RIGHT,
48 KEY_MOUSE_LEFT,
49 KEY_MOUSE_MIDDLE,
50 KEY_MOUSE_RELEASE,
51 KEY_MOUSE_RIGHT,
52 KEY_MOUSE_WHEEL_DOWN,
53 KEY_MOUSE_WHEEL_UP,
54 KEY_NUM_LOCK,
55 KEY_NUMPAD_0,
56 KEY_NUMPAD_1,
57 KEY_NUMPAD_2,
58 KEY_NUMPAD_3,
59 KEY_NUMPAD_4,
60 KEY_NUMPAD_5,
61 KEY_NUMPAD_6,
62 KEY_NUMPAD_7,
63 KEY_NUMPAD_8,
64 KEY_NUMPAD_9,
65 KEY_NUMPAD_ADD,
66 KEY_NUMPAD_DECIMAL,
67 KEY_NUMPAD_DIVIDE,
68 KEY_NUMPAD_ENTER,
69 KEY_NUMPAD_EQUAL,
70 KEY_NUMPAD_MULTIPLY,
71 KEY_NUMPAD_SUBTRACT,
72 KEY_PGDN,
73 KEY_PGUP,
74 KEY_SCROLL_LOCK,
75 KEY_SHIFT_LEFT,
76 KEY_SHIFT_RIGHT,
77 KEY_SUPER_LEFT,
78 KEY_SUPER_RIGHT,
79 KEY_TAB,
80 MAX_TERMINFO,
81 MOD_ALT,
82 MOD_CTRL,
83 MOD_MOTION,
84 MOD_RELEASE,
85 MOD_SHIFT,
86 type NativeInputEvent,
87 readEvent,
88 SCAN_BUFFER_SIZE,
89} from "./input-native.ts";
90
91/**
92 * Modifier keys held during a key or mouse event.
93 */
94export interface KeyModifiers {
95 alt?: true;
96 ctrl?: true;
97 shift?: true;
98}
99
100/**
101 * Physical key identity on a US PC-101 layout.
102 */
103export type KeyCode =
104 | "a"
105 | "b"
106 | "c"
107 | "d"
108 | "e"
109 | "f"
110 | "g"
111 | "h"
112 | "i"
113 | "j"
114 | "k"
115 | "l"
116 | "m"
117 | "n"
118 | "o"
119 | "p"
120 | "q"
121 | "r"
122 | "s"
123 | "t"
124 | "u"
125 | "v"
126 | "w"
127 | "x"
128 | "y"
129 | "z"
130 | "0"
131 | "1"
132 | "2"
133 | "3"
134 | "4"
135 | "5"
136 | "6"
137 | "7"
138 | "8"
139 | "9"
140 | "`"
141 | "-"
142 | "="
143 | "["
144 | "]"
145 | "\\"
146 | ";"
147 | "'"
148 | ","
149 | "."
150 | "/"
151 | " "
152 | "F1"
153 | "F2"
154 | "F3"
155 | "F4"
156 | "F5"
157 | "F6"
158 | "F7"
159 | "F8"
160 | "F9"
161 | "F10"
162 | "F11"
163 | "F12"
164 | "ArrowUp"
165 | "ArrowDown"
166 | "ArrowLeft"
167 | "ArrowRight"
168 | "Home"
169 | "End"
170 | "Insert"
171 | "Delete"
172 | "PageUp"
173 | "PageDown"
174 | "Backtab"
175 | "Backspace"
176 | "Tab"
177 | "Enter"
178 | "Escape"
179 | "Numpad0"
180 | "Numpad1"
181 | "Numpad2"
182 | "Numpad3"
183 | "Numpad4"
184 | "Numpad5"
185 | "Numpad6"
186 | "Numpad7"
187 | "Numpad8"
188 | "Numpad9"
189 | "NumpadDecimal"
190 | "NumpadDivide"
191 | "NumpadMultiply"
192 | "NumpadSubtract"
193 | "NumpadAdd"
194 | "NumpadEnter"
195 | "NumpadEqual"
196 | "ShiftLeft"
197 | "ShiftRight"
198 | "ControlLeft"
199 | "ControlRight"
200 | "AltLeft"
201 | "AltRight"
202 | "SuperLeft"
203 | "SuperRight"
204 | "HyperLeft"
205 | "HyperRight"
206 | "MetaLeft"
207 | "MetaRight"
208 | "CapsLock"
209 | "NumLock"
210 | "ScrollLock";
211
212/**
213 * Shared key information present on all keyboard events.
214 */
215export interface KeyInfo extends KeyModifiers {
216 key: string;
217 code: KeyCode;
218}
219
220/**
221 * A key was pressed. Emitted at all enhancement levels.
222 * On legacy terminals, this is the only keyboard event type.
223 */
224export interface KeyDown extends KeyInfo {
225 type: "keydown";
226 shifted?: string;
227 text?: string;
228}
229
230/**
231 * A key is being held down (auto-repeat). Only emitted with
232 * Kitty enhancement level 2+ (report event types).
233 */
234export interface KeyRepeat extends KeyInfo {
235 type: "keyrepeat";
236 shifted?: string;
237 text?: string;
238}
239
240/**
241 * A key was released. Only emitted with Kitty enhancement
242 * level 2+ (report event types). Does not carry text,
243 * shifted, or base fields.
244 */
245export interface KeyUp extends KeyInfo {
246 type: "keyup";
247}
248
249export type KeyEvent = KeyDown | KeyRepeat | KeyUp;
250
251/**
252 * A mouse button was pressed.
253 */
254export interface MouseDownEvent extends KeyModifiers {
255 type: "mousedown";
256 /**
257 * Which mouse button triggered this event.
258 */
259 button: "left" | "right" | "middle";
260
261 /**
262 * `x` coordinate of this event
263 */
264 x: number;
265
266 /**
267 * `y` coordinate of this event
268 */
269 y: number;
270}
271
272/**
273 * A mouse button was released.
274 */
275export interface MouseUpEvent extends KeyModifiers {
276 type: "mouseup";
277 /**
278 * Which mouse button triggered this event.
279 *
280 * Note: "release" is not technically a button, but is used by
281 * `VT200` and `urxvt` protocols where the terminal reports a button
282 * release without indicating which button.
283 */
284 button: "left" | "right" | "middle" | "release";
285
286 /**
287 * `x` coordinate of this event
288 */
289 x: number;
290
291 /**
292 * `y` coordinate of this event
293 */
294 y: number;
295}
296
297/**
298 * Mouse movement while a button is held.
299 */
300export interface MouseMoveEvent extends KeyModifiers {
301 type: "mousemove";
302 /**
303 * Which mouse button is being held during the drag.
304 */
305 button: "left" | "right" | "middle";
306 /**
307 * Cursor column (0-based).
308 */
309 x: number;
310 /**
311 * Cursor row (0-based).
312 */
313 y: number;
314}
315
316/**
317 * A scroll wheel tick.
318 */
319export interface WheelEvent extends KeyModifiers {
320 type: "wheel";
321 /**
322 * Did the wheel move up or down
323 */
324 direction: "up" | "down";
325
326 /**
327 * Cursor column at the time of the scroll (0-based).
328 */
329 x: number;
330
331 /**
332 * Cursor row at the time of the scroll (0-based).
333 */
334 y: number;
335}
336
337/**
338 * Terminal resize notification.
339 */
340export interface ResizeEvent {
341 type: "resize";
342
343 /**
344 * New terminal width in columns.
345 */
346 width: number;
347
348 /**
349 * New terminal height in rows.
350 */
351 height: number;
352}
353
354/**
355 * Cursor position report (DSR response).
356 *
357 * Emitted when the terminal responds to a Device Status Report
358 * query (`\x1b[6n`) with the current cursor position.
359 */
360export interface CursorEvent {
361 type: "cursor";
362
363 /**
364 * Cursor row (1-based). Matches ECMA-48 DSR native format.
365 */
366 row: number;
367
368 /**
369 * Cursor column (1-based). Matches ECMA-48 DSR native format.
370 */
371 column: number;
372}
373
374import type { PointerEvent } from "./term.ts";
375
376export type InputEvent =
377 | KeyEvent
378 | MouseDownEvent
379 | MouseUpEvent
380 | MouseMoveEvent
381 | WheelEvent
382 | ResizeEvent
383 | CursorEvent
384 | PointerEvent;
385
386/**
387 * Result of a single scan() call.
388 *
389 * When `pending` is present, a lone ESC is buffered and the caller should
390 * re-call scan() with an empty buffer after `pending.delay` milliseconds.
391 */
392export interface ScanResult {
393 events: InputEvent[];
394 pending?: { delay: number };
395}
396
397export interface Input {
398 /**
399 * Feed raw bytes from stdin into the parser and return any events
400 * produced. Call with no arguments to flush a pending ESC after the
401 * latency period has elapsed.
402 *
403 * @example
404 * ```ts
405 * let { events, pending } = input.scan(bytes);
406 * for (let event of events) {
407 * dispatch(event);
408 * }
409 * if (pending) {
410 * // there is a pending ESC event. wait for the delay
411 * await sleep(pending.delay);
412 *
413 * // re-scan
414 * let flush = input.scan();
415 *
416 * //dispatch the flushed ESC
417 * for (let event of flush.events) {
418 * dispatch(event)
419 * }
420 * }
421 * ```
422 */
423 scan(bytes?: Uint8Array): ScanResult;
424}
425
426export interface InputOptions {
427 /**
428 * Milliseconds to wait before resolving a lone ESC byte as the Escape
429 * key rather than the start of an escape sequence. Lower values feel
430 * snappier but risk misinterpreting sequences on slow connections.
431 *
432 * For reference, Vim's `ttimeoutlen` defaults to 100ms and ncurses
433 * `ESCDELAY` defaults to 1000ms. The default of 25ms is tuned for
434 * local terminals where escape sequences arrive within microseconds.
435 *
436 * @default 25
437 */
438 escLatency?: number;
439
440 /**
441 * Compiled terminfo binary to load terminal-specific escape sequences.
442 *
443 * This is the format used by files like /usr/lib/terminfo/78/xterm-256color
444 * and they can be directly loaded from disk into this option.
445 *
446 * If no terminfo is provided it will use xterm capabilities as the default
447 */
448 terminfo?: Uint8Array;
449}
450
451export async function createInput(options: InputOptions = {}): Promise<Input> {
452 let { escLatency = 25, terminfo } = options;
453
454 if (terminfo && terminfo.byteLength > MAX_TERMINFO) {
455 throw new RangeError(
456 `terminfo exceeds ${MAX_TERMINFO} byte limit (got ${terminfo.byteLength})`,
457 );
458 }
459
460 let native = await createInputNative(escLatency);
461
462 return {
463 scan(bytes: Uint8Array = new Uint8Array(0)): ScanResult {
464 let now = Date.now();
465 let events: InputEvent[] = [];
466 let offset = 0;
467
468 while (offset < bytes.length || (offset === 0 && bytes.length === 0)) {
469 let chunk = bytes.subarray(offset, offset + SCAN_BUFFER_SIZE);
470 if (chunk.length > 0) {
471 new Uint8Array(native.memory.buffer).set(chunk, native.buffer);
472 }
473
474 let accepted = native.scan(
475 native.state,
476 native.buffer,
477 chunk.length,
478 now,
479 );
480
481 let count = native.count(native.state);
482 let view = new DataView(native.memory.buffer);
483 for (let i = 0; i < count; i++) {
484 let ptr = native.event(native.state, i);
485 if (ptr !== 0) {
486 events.push(mapEvent(readEvent(view, ptr)));
487 }
488 }
489
490 offset += accepted;
491
492 if (accepted < chunk.length) {
493 break;
494 }
495 if (bytes.length === 0) {
496 break;
497 }
498 }
499
500 let delay = native.delay(native.state);
501 if (delay > 0) {
502 return { events, pending: { delay } };
503 }
504 return { events };
505 },
506 };
507}
508
509const KEY_NAMES = new Map<number, string>([
510 [KEY_F1, "F1"],
511 [KEY_F2, "F2"],
512 [KEY_F3, "F3"],
513 [KEY_F4, "F4"],
514 [KEY_F5, "F5"],
515 [KEY_F6, "F6"],
516 [KEY_F7, "F7"],
517 [KEY_F8, "F8"],
518 [KEY_F9, "F9"],
519 [KEY_F10, "F10"],
520 [KEY_F11, "F11"],
521 [KEY_F12, "F12"],
522 [KEY_ARROW_UP, "ArrowUp"],
523 [KEY_ARROW_DOWN, "ArrowDown"],
524 [KEY_ARROW_LEFT, "ArrowLeft"],
525 [KEY_ARROW_RIGHT, "ArrowRight"],
526 [KEY_HOME, "Home"],
527 [KEY_END, "End"],
528 [KEY_INSERT, "Insert"],
529 [KEY_DELETE, "Delete"],
530 [KEY_PGUP, "PageUp"],
531 [KEY_PGDN, "PageDown"],
532 [KEY_BACKTAB, "Backtab"],
533 [KEY_BACKSPACE, "Backspace"],
534 [KEY_TAB, "Tab"],
535 [KEY_ENTER, "Enter"],
536 [KEY_ESC, "Escape"],
537 [KEY_NUMPAD_0, "Numpad0"],
538 [KEY_NUMPAD_1, "Numpad1"],
539 [KEY_NUMPAD_2, "Numpad2"],
540 [KEY_NUMPAD_3, "Numpad3"],
541 [KEY_NUMPAD_4, "Numpad4"],
542 [KEY_NUMPAD_5, "Numpad5"],
543 [KEY_NUMPAD_6, "Numpad6"],
544 [KEY_NUMPAD_7, "Numpad7"],
545 [KEY_NUMPAD_8, "Numpad8"],
546 [KEY_NUMPAD_9, "Numpad9"],
547 [KEY_NUMPAD_DECIMAL, "NumpadDecimal"],
548 [KEY_NUMPAD_DIVIDE, "NumpadDivide"],
549 [KEY_NUMPAD_MULTIPLY, "NumpadMultiply"],
550 [KEY_NUMPAD_SUBTRACT, "NumpadSubtract"],
551 [KEY_NUMPAD_ADD, "NumpadAdd"],
552 [KEY_NUMPAD_ENTER, "NumpadEnter"],
553 [KEY_NUMPAD_EQUAL, "NumpadEqual"],
554 [KEY_SHIFT_LEFT, "ShiftLeft"],
555 [KEY_SHIFT_RIGHT, "ShiftRight"],
556 [KEY_CONTROL_LEFT, "ControlLeft"],
557 [KEY_CONTROL_RIGHT, "ControlRight"],
558 [KEY_ALT_LEFT, "AltLeft"],
559 [KEY_ALT_RIGHT, "AltRight"],
560 [KEY_SUPER_LEFT, "SuperLeft"],
561 [KEY_SUPER_RIGHT, "SuperRight"],
562 [KEY_HYPER_LEFT, "HyperLeft"],
563 [KEY_HYPER_RIGHT, "HyperRight"],
564 [KEY_META_LEFT, "MetaLeft"],
565 [KEY_META_RIGHT, "MetaRight"],
566 [KEY_CAPS_LOCK, "CapsLock"],
567 [KEY_NUM_LOCK, "NumLock"],
568 [KEY_SCROLL_LOCK, "ScrollLock"],
569]);
570
571const BUTTON_NAMES = new Map<number, MouseUpEvent["button"]>([
572 [KEY_MOUSE_LEFT, "left"],
573 [KEY_MOUSE_RIGHT, "right"],
574 [KEY_MOUSE_MIDDLE, "middle"],
575 [KEY_MOUSE_RELEASE, "release"],
576]);
577
578function mods(native: NativeInputEvent): KeyModifiers {
579 let m: KeyModifiers = {};
580 if (native.mod & MOD_ALT) m.alt = true;
581 if (native.mod & MOD_CTRL) m.ctrl = true;
582 if (native.mod & MOD_SHIFT) m.shift = true;
583 return m;
584}
585
586function keyName(native: NativeInputEvent): string {
587 let name = KEY_NAMES.get(native.key);
588 if (name) {
589 return name;
590 } else if (native.key === 0 && native.ch > 0) {
591 return String.fromCodePoint(native.ch);
592 } else if (native.key > 0 && native.key < 0x20) {
593 return String.fromCharCode(native.key + 0x60);
594 } else {
595 return String.fromCodePoint(native.ch || native.key);
596 }
597}
598
599function textFromNative(native: NativeInputEvent): string | undefined {
600 if (native.text.length === 0) {
601 return undefined;
602 } else {
603 return String.fromCodePoint(...native.text);
604 }
605}
606
607function mapKeyEvent(native: NativeInputEvent): KeyEvent {
608 let key = keyName(native);
609 let code =
610 (native.base > 0 ? String.fromCodePoint(native.base) : key) as KeyCode;
611 let m = mods(native);
612 let isChar = !KEY_NAMES.has(native.key);
613 let text = textFromNative(native);
614
615 if (native.action === 3) {
616 return { type: "keyup", key, code, ...m };
617 }
618
619 let type: "keydown" | "keyrepeat" = native.action === 2
620 ? "keyrepeat"
621 : "keydown";
622
623 let ev: KeyDown | KeyRepeat = { type, key, code, ...m };
624
625 if (native.shifted > 0) ev.shifted = String.fromCodePoint(native.shifted);
626 if (text) {
627 ev.text = text;
628 } else if (isChar && type === "keydown" && native.ch > 0) {
629 ev.text = String.fromCodePoint(native.ch);
630 }
631
632 return ev;
633}
634
635function mapEvent(native: NativeInputEvent): InputEvent {
636 switch (native.type) {
637 case EVENT_KEY: {
638 return mapKeyEvent(native);
639 }
640 case EVENT_MOUSE: {
641 if (
642 native.key === KEY_MOUSE_WHEEL_UP || native.key === KEY_MOUSE_WHEEL_DOWN
643 ) {
644 return {
645 type: "wheel",
646 direction: native.key === KEY_MOUSE_WHEEL_UP ? "up" : "down",
647 x: native.x,
648 y: native.y,
649 ...mods(native),
650 };
651 }
652 if (native.mod & MOD_MOTION) {
653 let button = BUTTON_NAMES.get(native.key) ?? "left";
654 return {
655 type: "mousemove" as const,
656 button: button === "release" ? "left" as const : button,
657 x: native.x,
658 y: native.y,
659 ...mods(native),
660 };
661 }
662 let button = BUTTON_NAMES.get(native.key) ?? "left";
663 if (native.mod & MOD_RELEASE) {
664 return {
665 type: "mouseup" as const,
666 button,
667 x: native.x,
668 y: native.y,
669 ...mods(native),
670 };
671 } else {
672 return {
673 type: "mousedown" as const,
674 button: button as MouseDownEvent["button"],
675 x: native.x,
676 y: native.y,
677 ...mods(native),
678 };
679 }
680 }
681 case EVENT_RESIZE: {
682 return { type: "resize", width: native.w, height: native.h };
683 }
684 case EVENT_CURSOR: {
685 return { type: "cursor", row: native.y, column: native.x };
686 }
687 default: {
688 return mapKeyEvent(native);
689 }
690 }
691}