[READ-ONLY] Mirror of https://github.com/bombshell-dev/tty. Platform independent 2D layout engine for terminal applications based on Clay bomb.sh/docs/tty
Something went wrong. Try again.
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747import { beforeEach, describe, expect, it } from "./suite.ts";import { createInput, type Input } from "../input.ts";
function bytes(...values: number[]): Uint8Array { return new Uint8Array(values);}
function str(s: string): Uint8Array { return new TextEncoder().encode(s);}
describe("input", () => { let input: Input;
beforeEach(async () => { input = await createInput({ escLatency: 25 }); });
describe("printable ASCII", () => { it("parses a single character", () => { let result = input.scan(bytes(0x61)); // 'a' expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "keydown", key: "a" }); });
it("parses multiple characters", () => { let result = input.scan(str("hello")); expect(result.events.length).toBe(5); expect(result.events[0]).toMatchObject({ type: "keydown", key: "h" }); expect(result.events[4]).toMatchObject({ type: "keydown", key: "o" }); }); });
describe("control characters", () => { it("parses Ctrl+C", () => { let result = input.scan(bytes(0x03)); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "keydown", key: "c", ctrl: true, }); });
it("parses Tab without ctrl modifier", () => { let result = input.scan(bytes(0x09)); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "keydown", key: "Tab" }); });
it("parses Enter without ctrl modifier", () => { let result = input.scan(bytes(0x0d)); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "keydown", key: "Enter" }); }); });
describe("backspace", () => { it("parses 0x7f as backspace", () => { let result = input.scan(bytes(0x7f)); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "keydown", key: "Backspace", }); }); });
describe("arrow keys", () => { it("parses arrow up (CSI)", () => { let result = input.scan(bytes(0x1b, 0x5b, 0x41)); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "keydown", key: "ArrowUp", }); });
it("parses arrow down (SS3)", () => { let result = input.scan(bytes(0x1b, 0x4f, 0x42)); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "keydown", key: "ArrowDown", }); }); });
describe("modifier combos", () => { it("parses Ctrl+Arrow Up", () => { let result = input.scan(bytes(0x1b, 0x5b, 0x31, 0x3b, 0x35, 0x41)); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "keydown", key: "ArrowUp", ctrl: true, }); });
it("parses Shift+Arrow Up", () => { let result = input.scan(bytes(0x1b, 0x5b, 0x31, 0x3b, 0x32, 0x41)); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "keydown", key: "ArrowUp", shift: true, }); });
it("parses Ctrl+Alt+Shift combo", () => { let result = input.scan(bytes(0x1b, 0x5b, 0x31, 0x3b, 0x38, 0x41)); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "keydown", key: "ArrowUp", ctrl: true, alt: true, shift: true, }); }); });
describe("function keys", () => { it("parses F1 (SS3)", () => { let result = input.scan(bytes(0x1b, 0x4f, 0x50)); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "keydown", key: "F1" }); });
it("parses F5 (CSI ~)", () => { let result = input.scan(str("\x1b[15~")); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "keydown", key: "F5" }); }); });
describe("navigation keys", () => { it("parses Home", () => { let result = input.scan(bytes(0x1b, 0x4f, 0x48)); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "keydown", key: "Home" }); });
it("parses End", () => { let result = input.scan(bytes(0x1b, 0x4f, 0x46)); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "keydown", key: "End" }); });
it("parses Delete", () => { let result = input.scan(str("\x1b[3~")); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "keydown", key: "Delete", }); });
it("parses PgDn with Ctrl", () => { let result = input.scan(str("\x1b[6;5~")); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "keydown", key: "PageDown", ctrl: true, }); }); });
describe("mouse", () => { it("parses SGR mouse press", () => { let result = input.scan(str("\x1b[<0;35;12M")); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "mousedown", button: "left", x: 34, y: 11, }); });
it("parses SGR left release", () => { let result = input.scan(str("\x1b[<0;10;5m")); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "mouseup", button: "left", x: 9, y: 4, }); });
it("parses SGR right release", () => { let result = input.scan(str("\x1b[<2;10;5m")); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "mouseup", button: "right", x: 9, y: 4, }); });
it("parses SGR middle release", () => { let result = input.scan(str("\x1b[<1;10;5m")); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "mouseup", button: "middle", x: 9, y: 4, }); });
it("parses VT200 release", () => { // button 3 = release (0x23 = 3 + 0x20), x=10 (0x2b), y=5 (0x26) let result = input.scan(bytes(0x1b, 0x5b, 0x4d, 0x23, 0x2b, 0x26)); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "mouseup", button: "release", x: 10, y: 5, }); });
it("parses SGR wheel up", () => { let result = input.scan(str("\x1b[<64;15;20M")); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "wheel", direction: "up", x: 14, y: 19, }); });
it("parses SGR wheel down", () => { let result = input.scan(str("\x1b[<65;15;20M")); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "wheel", direction: "down", x: 14, y: 19, }); });
it("parses VT200 mouse", () => { // \x1b[M + button(0+0x20=0x20) + x(10+0x21=0x2b) + y(5+0x21=0x26) let result = input.scan(bytes(0x1b, 0x5b, 0x4d, 0x20, 0x2b, 0x26)); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "mousedown", button: "left", x: 10, y: 5, }); });
it("parses SGR mouse press with shift", () => { // btn=4 (shift bit) => left + shift let result = input.scan(str("\x1b[<4;10;5M")); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "mousedown", button: "left", x: 9, y: 4, shift: true, }); });
it("parses SGR mouse press with ctrl", () => { // btn=16 (ctrl bit) => left + ctrl let result = input.scan(str("\x1b[<16;10;5M")); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "mousedown", button: "left", x: 9, y: 4, ctrl: true, }); });
it("parses SGR mouse press with alt", () => { // btn=8 (alt bit) => left + alt let result = input.scan(str("\x1b[<8;10;5M")); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "mousedown", button: "left", x: 9, y: 4, alt: true, }); });
it("parses SGR mouse press with ctrl+shift", () => { // btn=20 (ctrl=16 + shift=4) => left + ctrl + shift let result = input.scan(str("\x1b[<20;10;5M")); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "mousedown", button: "left", x: 9, y: 4, ctrl: true, shift: true, }); }); });
describe("ESC timeout", () => { it("returns pending for lone ESC", () => { let result = input.scan(bytes(0x1b)); expect(result.events.length).toBe(0); expect(result.pending).toBeDefined(); expect(result.pending!.delay).toBe(25); });
it("resolves ESC after timeout", async () => { input.scan(bytes(0x1b)); await new Promise((r) => setTimeout(r, 30)); let result = input.scan(new Uint8Array(0)); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "keydown", key: "Escape", }); expect(result.pending).toBeUndefined(); });
it("resolves as escape sequence when more bytes arrive", () => { let r1 = input.scan(bytes(0x1b)); expect(r1.events.length).toBe(0);
let r2 = input.scan(bytes(0x5b, 0x41)); expect(r2.events.length).toBe(1); expect(r2.events[0]).toMatchObject({ type: "keydown", key: "ArrowUp" }); expect(r2.pending).toBeUndefined(); });
it("resolves pending ESC as Alt modifier when plain byte arrives", () => { let r1 = input.scan(bytes(0x1b)); expect(r1.events.length).toBe(0); expect(r1.pending).toBeDefined();
// "x" arrives — ESC + x = Alt+x let r2 = input.scan(bytes(0x78)); // 'x' expect(r2.events.length).toBe(1); expect(r2.events[0]).toMatchObject({ type: "keydown", key: "x", alt: true, }); expect(r2.pending).toBeUndefined(); });
it("completes pending ESC sequence and parses trailing bytes", () => { let r1 = input.scan(bytes(0x1b)); expect(r1.events.length).toBe(0); expect(r1.pending).toBeDefined();
// rest of ArrowUp + "hi" let r2 = input.scan(bytes(0x5b, 0x41, 0x68, 0x69)); expect(r2.events.length).toBe(3); expect(r2.events[0]).toMatchObject({ type: "keydown", key: "ArrowUp" }); expect(r2.events[1]).toMatchObject({ type: "keydown", key: "h" }); expect(r2.events[2]).toMatchObject({ type: "keydown", key: "i" }); expect(r2.pending).toBeUndefined(); }); });
describe("Alt combinations", () => { it("parses Alt+a as unrecognized ESC sequence", () => { let result = input.scan(bytes(0x1b, 0x61)); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "keydown", key: "a", alt: true, }); }); });
describe("multi-event burst", () => { it("parses multiple escape sequences in one call", () => { // Arrow Up + Arrow Down + "hi" let result = input.scan( bytes(0x1b, 0x5b, 0x41, 0x1b, 0x5b, 0x42, 0x68, 0x69), ); expect(result.events.length).toBe(4); expect(result.events[0]).toMatchObject({ type: "keydown", key: "ArrowUp", }); expect(result.events[1]).toMatchObject({ type: "keydown", key: "ArrowDown", }); expect(result.events[2]).toMatchObject({ type: "keydown", key: "h" }); expect(result.events[3]).toMatchObject({ type: "keydown", key: "i" }); }); });
describe("partial sequences", () => { it("buffers partial escape sequence", () => { let r1 = input.scan(bytes(0x1b, 0x5b)); expect(r1.events.length).toBe(0);
let r2 = input.scan(bytes(0x41)); expect(r2.events.length).toBe(1); expect(r2.events[0]).toMatchObject({ type: "keydown", key: "ArrowUp" }); }); });
describe("Kitty keyboard protocol (CSI u)", () => { it("parses character with alt modifier", () => { let result = input.scan(str("\x1b[97;3u")); // a + Alt expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "keydown", key: "a", alt: true, }); });
it("parses Tab with alt modifier", () => { let result = input.scan(str("\x1b[9;3u")); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "keydown", key: "Tab", alt: true, }); });
it("parses Enter with ctrl modifier", () => { let result = input.scan(str("\x1b[13;5u")); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "keydown", key: "Enter", ctrl: true, }); });
it("parses Escape with shift modifier", () => { let result = input.scan(str("\x1b[27;2u")); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "keydown", key: "Escape", shift: true, }); });
it("parses Backspace without modifiers", () => { let result = input.scan(str("\x1b[127u")); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "keydown", key: "Backspace", }); });
it("parses combined modifiers", () => { let result = input.scan(str("\x1b[105;7u")); // i + Ctrl+Alt expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "keydown", key: "i", ctrl: true, alt: true, }); });
it("parses F1 functional key codepoint", () => { let result = input.scan(str("\x1b[57376;2u")); // F1 + Shift expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "keydown", key: "F1", shift: true, }); });
it("parses arrow key codepoint", () => { let result = input.scan(str("\x1b[57352;5u")); // ArrowUp + Ctrl expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "keydown", key: "ArrowUp", ctrl: true, }); }); });
describe("Kitty action types", () => { it("parses press as keydown", () => { let result = input.scan(str("\x1b[97;1:1u")); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "keydown", key: "a" }); });
it("parses repeat as keyrepeat", () => { let result = input.scan(str("\x1b[97;1:2u")); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "keyrepeat", key: "a" }); });
it("parses release as keyup", () => { let result = input.scan(str("\x1b[97;1:3u")); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "keyup", key: "a" }); });
it("parses F1 press with shift", () => { let result = input.scan(str("\x1b[57376;2:1u")); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "keydown", key: "F1", shift: true, }); }); });
describe("Kitty alternate keys", () => { it("parses shifted key", () => { let result = input.scan(str("\x1b[97:65;6u")); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "keydown", key: "a", ctrl: true, shift: true, shifted: "A", }); });
it("parses shifted and base keys", () => { let result = input.scan(str("\x1b[97:65:97;3:1u")); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "keydown", key: "a", code: "a", alt: true, shifted: "A", }); });
it("parses empty shifted with base key as code", () => { let result = input.scan(str("\x1b[1057::99;5u")); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "keydown", key: "\u0421", code: "c", ctrl: true, }); }); });
describe("Kitty associated text", () => { it("parses single codepoint text", () => { let result = input.scan(str("\x1b[97;;97u")); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "keydown", key: "a", text: "a", }); });
it("parses text with shift modifier", () => { let result = input.scan(str("\x1b[97;2;65u")); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "keydown", key: "a", shift: true, text: "A", }); });
it("parses IME text event", () => { let result = input.scan(str("\x1b[0;;228u")); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "keydown", key: "\0", text: "\u00e4", }); });
it("parses multi-codepoint text", () => { let result = input.scan(str("\x1b[0;;4354:4449:4523u")); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "keydown", key: "\0", text: "\u1102\u1161\u11AB", }); });
it("keyup has no text", () => { let result = input.scan(str("\x1b[97;1:3u")); expect(result.events.length).toBe(1); let ev = result.events[0]; expect(ev).toMatchObject({ type: "keyup", key: "a" }); expect("text" in ev).toBe(false); expect("shifted" in ev).toBe(false); expect("base" in ev).toBe(false); }); });
describe("Kitty legacy CSI sequences", () => { it("parses arrow up with action", () => { let result = input.scan(str("\x1b[1;1:1A")); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "keydown", key: "ArrowUp", code: "ArrowUp", }); });
it("parses arrow up release", () => { let result = input.scan(str("\x1b[1;1:3A")); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "keyup", key: "ArrowUp", code: "ArrowUp", }); });
it("parses arrow with shift modifier", () => { let result = input.scan(str("\x1b[1;2:1D")); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "keydown", key: "ArrowLeft", code: "ArrowLeft", shift: true, }); });
it("parses plain arrow without action", () => { let result = input.scan(str("\x1b[1;1A")); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "keydown", key: "ArrowUp", code: "ArrowUp", }); });
it("parses delete with action", () => { let result = input.scan(str("\x1b[3;1:1~")); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "keydown", key: "Delete", code: "Delete", }); });
it("parses F1 with action", () => { let result = input.scan(str("\x1b[1;1:1P")); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "keydown", key: "F1", code: "F1", }); }); });
describe("cursor position (DSR response)", () => { it("parses cursor position report", () => { let result = input.scan(str("\x1b[24;80R")); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "cursor", row: 24, column: 80, }); });
it("parses cursor at origin", () => { let result = input.scan(str("\x1b[1;1R")); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "cursor", row: 1, column: 1, }); });
it("parses cursor interleaved with other input", () => { let result = input.scan(str("a\x1b[10;5Rb")); expect(result.events.length).toBe(3); expect(result.events[0]).toMatchObject({ type: "keydown", key: "a" }); expect(result.events[1]).toMatchObject({ type: "cursor", row: 10, column: 5, }); expect(result.events[2]).toMatchObject({ type: "keydown", key: "b" }); }); });
describe("UTF-8", () => { it("parses 2-byte UTF-8 (é)", () => { let result = input.scan(bytes(0xc3, 0xa9)); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "keydown", key: "\u00e9", }); });
it("parses 3-byte UTF-8 (中)", () => { let result = input.scan(bytes(0xe4, 0xb8, 0xad)); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "keydown", key: "\u4e2d", }); });
it("parses 4-byte UTF-8 (emoji 🎉)", () => { let result = input.scan(bytes(0xf0, 0x9f, 0x8e, 0x89)); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "keydown", key: "\u{1f389}", }); }); });});