[READ-ONLY] Mirror of https://github.com/bombshell-dev/tty. Platform independent 2D layout engine for terminal applications based on Clay
0

Configure Feed

Select the types of activity you want to include in your feed.

tty / input.ts
15 kB 691 lines
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}