Download src/tui/keys.ts from bolikoto/zcode-api: direct link, hf CLI and curl.
- Browser
- Download file 4.49 kB
-
https://huggingface.co/spaces/bolikoto/zcode-api/resolve/main/src/tui/keys.ts
- Command line
-
hf download hf://spaces/bolikoto/zcode-api/src/tui/keys.ts
-
curl -L -o keys.ts https://huggingface.co/spaces/bolikoto/zcode-api/resolve/main/src/tui/keys.ts
4.49 kB
| /** | |
| * Incremental VT/ANSI input parser for the TUI keyboard loop. | |
| * | |
| * stdin is read in raw mode as a UTF-8 string stream; `KeyParser.feed` | |
| * converts chunks into actions. A chunk can end in the middle of an escape | |
| * sequence (lone `\x1b` before more bytes arrive), so unconsumed input is | |
| * buffered until the sequence completes on a later chunk. Works identically | |
| * under Bun and Node β no readline keypress events involved. | |
| * | |
| * Mouse: with SGR mouse tracking enabled (`\x1b[?1000h\x1b[?1006h`), the | |
| * terminal reports presses/releases as `ESC [ < b ; x ; y M/m` and wheel | |
| * events as button codes 64/65 β parsed into click/wheel actions so TUI | |
| * buttons are directly clickable (coordinates arrive 1-based, converted | |
| * to 0-based terminal cells). | |
| */ | |
| export type KeyAction = | |
| | { type: "char"; key: string } | |
| | { type: "up" } | |
| | { type: "down" } | |
| | { type: "pageup" } | |
| | { type: "pagedown" } | |
| | { type: "home" } | |
| | { type: "end" } | |
| | { type: "click"; x: number; y: number } | |
| | { type: "wheel-up" } | |
| | { type: "wheel-down" } | |
| | { type: "ctrl-c" } | |
| /** Recognized but without a binding (Enter, Tab, bare Esc, Alt+key, drag/release, β¦). */ | |
| | { type: "ignore" }; | |
| export class KeyParser { | |
| private pending = ""; | |
| /** Consume a raw stdin chunk and return the actions it completes. */ | |
| feed(chunk: string): KeyAction[] { | |
| // Wedge guard: no real sequence is anywhere near 64 bytes (SGR mouse is | |
| // ~16), so a pending tail this long means the parser is stuck on garbage. | |
| // Drop it β otherwise the stuck prefix would swallow every future chunk | |
| // and the TUI would stop responding to keys and clicks for good. | |
| if (this.pending.length > 64) this.pending = ""; | |
| const buf = this.pending + chunk; | |
| const actions: KeyAction[] = []; | |
| let i = 0; | |
| while (i < buf.length) { | |
| const ch = buf[i]!; | |
| if (ch === "\x1b") { | |
| if (i + 1 >= buf.length) break; // lone ESC at chunk end β wait for the rest | |
| if (buf[i + 1] === "[") { | |
| let j = i + 2; | |
| while (j < buf.length && !/[A-Za-z~]/.test(buf[j]!)) j++; | |
| if (j >= buf.length) break; // incomplete CSI β wait | |
| actions.push(csiAction(buf[j]!, buf.slice(i + 2, j))); | |
| i = j + 1; | |
| continue; | |
| } | |
| if (buf[i + 1] === "O") { | |
| if (i + 2 >= buf.length) break; // incomplete SS3 β wait | |
| actions.push(csiAction(buf[i + 2]!, "")); | |
| i += 3; | |
| continue; | |
| } | |
| // Alt+key or other two-byte sequence β no binding. | |
| actions.push({ type: "ignore" }); | |
| i += 2; | |
| continue; | |
| } | |
| if (ch === "\x03") { | |
| actions.push({ type: "ctrl-c" }); | |
| i++; | |
| continue; | |
| } | |
| if (ch < " " || ch === "\x7f") { | |
| // Remaining control bytes (Enter/Tab/backspace/β¦) carry no binding. | |
| actions.push({ type: "ignore" }); | |
| i++; | |
| continue; | |
| } | |
| actions.push({ type: "char", key: ch }); | |
| i++; | |
| } | |
| this.pending = buf.slice(i); | |
| return actions; | |
| } | |
| } | |
| function csiAction(final: string, params: string): KeyAction { | |
| if (params.startsWith("<")) return mouseAction(params.slice(1), final); | |
| switch (final) { | |
| case "A": | |
| return { type: "up" }; | |
| case "B": | |
| return { type: "down" }; | |
| case "H": | |
| return { type: "home" }; | |
| case "F": | |
| return { type: "end" }; | |
| case "~": | |
| if (params === "5" || params === "5;5") return { type: "pageup" }; | |
| if (params === "6" || params === "6;5") return { type: "pagedown" }; | |
| if (params === "1" || params === "7") return { type: "home" }; | |
| if (params === "4" || params === "8") return { type: "end" }; | |
| return { type: "ignore" }; | |
| default: | |
| return { type: "ignore" }; | |
| } | |
| } | |
| /** SGR mouse event: `<button;column;row` + `M` (press) or `m` (release). */ | |
| function mouseAction(params: string, final: string): KeyAction { | |
| if (final !== "M") return { type: "ignore" }; // releases and motion don't click | |
| const parts = params.split(";"); | |
| const button = Number(parts[0]); | |
| const x = Number(parts[1]); | |
| const y = Number(parts[2]); | |
| if (!Number.isFinite(button) || !Number.isFinite(x) || !Number.isFinite(y)) { | |
| return { type: "ignore" }; | |
| } | |
| if (button === 0) return { type: "click", x: x - 1, y: y - 1 }; | |
| if (button === 64) return { type: "wheel-up" }; | |
| if (button === 65) return { type: "wheel-down" }; | |
| return { type: "ignore" }; // right/middle buttons, drag modifiers | |
| } | |