File size: 4,491 Bytes
67d18ac | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 | /**
* 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
}
|