[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
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889// deno-lint-ignore-file no-control-regeximport { beforeEach, describe, expect, it } from "./suite.ts";import { createTerm, type Term } from "../term.ts";import type { InputEvent } from "../input.ts";import { close, fixed, grow, type Op, open, rgba, snapshot, text,} from "../ops.ts";import { print } from "./print.ts";import { trueColorDetect } from "./caps.ts";
const decode = (bytes: Uint8Array) => new TextDecoder().decode(bytes);const trim = (s: string) => s.split("\n").map((l) => l.trimEnd()).join("\n");
describe("term", () => { let term: Term;
beforeEach(async () => { term = await createTerm({ width: 40, height: 10 }); });
it("renders hello world", () => { let out = print( decode( term.render([ open("root", { layout: { width: grow(), height: grow(), direction: "ttb" }, }), text("Hello, World!"), close(), ]).output, ), 40, 10, );
expect(out).toContain("Hello, World!"); });
it("text inherits parent background", () => { let ansi = decode( term.render([ open("root", { layout: { width: grow(), height: grow(), direction: "ttb" }, bg: rgba(255, 0, 0), }), text("hi"), close(), ]).output, );
// The SGR active when "h" is emitted should include the parent's red // background, not the terminal default. let before = ansi.slice(0, ansi.indexOf("h")); expect(before).toContain("\x1b[48;2;255;0;0"); });
it("renders borders and padding", () => { let out = print( decode( term.render([ open("box", { layout: { width: grow(), height: grow(), padding: { left: 5, top: 5 }, direction: "ttb", }, border: { color: rgba(0, 255, 0), left: 1, right: 1, top: 1, bottom: 1, }, cornerRadius: { tl: 1, tr: 1, bl: 1, br: 1 }, }), text("padded"), close(), ]).output, ), 40, 10, );
expect(out).toEqual(`╭──────────────────────────────────────╮│ ││ ││ ││ ││ ││ padded ││ ││ │╰──────────────────────────────────────╯`.trim()); });
describe("line mode", () => { let box = (msg: string) => [ open("root", { layout: { width: grow(), height: grow(), direction: "ttb" }, }), open("box", { layout: { width: grow(), height: grow(), direction: "ttb", }, border: { color: rgba(255, 255, 255), left: 1, right: 1, top: 1, bottom: 1, }, }), text(msg), close(), close(), ];
it("renders with newlines instead of CUP sequences", async () => { let term = await createTerm({ width: 20, height: 5 });
let out = decode( term.render(box("hello world"), { mode: "line" }).output, );
expect(out).not.toMatch(/\x1b\[\d+;\d+H/); expect(out.split("\n").length).toBe(5); expect(trim(print(out, 20, 5))).toEqual(`┌──────────────────┐│hello world ││ ││ │└──────────────────┘`.trim()); });
it("primes front buffer for subsequent diff render", async () => { let term = await createTerm({ width: 20, height: 5 });
let first = decode( term.render(box("hello world"), { mode: "line" }).output, ); let second = decode(term.render(box("goodbye")).output);
expect(trim(print(first + second, 20, 5))).toEqual(`┌──────────────────┐│goodbye ││ ││ │└──────────────────┘`.trim());
expect(second.length).toBeLessThan(first.length); }); });
describe("info", () => { it("returns bounds for named elements", async () => { let term = await createTerm({ width: 40, height: 10 }); let result = term.render([ open("root", { layout: { width: grow(), height: grow(), direction: "ttb" }, }), open("child", { layout: { width: fixed(20), height: fixed(5) }, }), close(), close(), ]);
let root = result.info.get("root"); expect(root).toBeDefined(); expect(root!.bounds).toEqual({ x: 0, y: 0, width: 40, height: 10 });
let child = result.info.get("child"); expect(child).toBeDefined(); expect(child!.bounds).toEqual({ x: 0, y: 0, width: 20, height: 5 }); });
it("returns undefined for unknown ids", async () => { let term = await createTerm({ width: 20, height: 5 }); term.render([ open("root", { layout: { width: grow(), height: grow() } }), close(), ]);
let result = term.render([ open("root", { layout: { width: grow(), height: grow() } }), close(), ]);
expect(result.info.get("nonexistent")).toBeUndefined(); expect(result.info.get("")).toBeUndefined(); }); });
it("renders a floating frame with structured attach points", () => { let out = print( decode( term.render([ open("root", { layout: { width: fixed(40), height: fixed(10), direction: "ttb" }, }), open("frame", { layout: { width: fixed(12), height: fixed(5), direction: "ttb", }, border: { color: rgba(255, 255, 255), left: 1, right: 1, top: 1, bottom: 1, }, floating: { x: 3, y: 1, attachTo: "root", attachPoints: { element: "center-center", parent: "center-center", }, }, }), text("box"), close(), close(), ]).output, ), 40, 10, );
expect(out).toContain("│box │"); expect(out).toContain("┌──────────┐"); });
describe("snapshot", () => { it("produces identical output to direct ops", async () => { let ops = [ open("root", { layout: { width: grow(), height: grow(), direction: "ttb" }, bg: rgba(0, 0, 128), }), open("child", { layout: { width: grow(), padding: { left: 1 }, direction: "ttb", }, border: { color: rgba(255, 255, 255), left: 1, right: 1, top: 1, bottom: 1, }, }), text("snapshot test"), close(), close(), ];
let direct = await createTerm({ width: 40, height: 10 }); let snapped = await createTerm({ width: 40, height: 10 });
let expected = direct.render(ops, { mode: "line" }).output; let actual = snapped.render([snapshot(ops)], { mode: "line" }).output;
expect(decode(actual)).toEqual(decode(expected)); });
it("renders inside another element", async () => { let child = snapshot([ open("child", { layout: { width: grow(), direction: "ttb" }, }), text("inner"), close(), ]);
let direct = await createTerm({ width: 20, height: 5 }); let snapped = await createTerm({ width: 20, height: 5 });
let wrapper = (content: Op[]) => [ open("root", { layout: { width: grow(), height: grow(), direction: "ttb", }, border: { color: rgba(255, 255, 255), left: 1, right: 1, top: 1, bottom: 1, }, }), ...content, close(), ];
let expected = direct.render( wrapper([ open("child", { layout: { width: grow(), direction: "ttb" }, }), text("inner"), close(), ]), { mode: "line" }, ).output;
let actual = snapped.render( wrapper([child]), { mode: "line" }, ).output;
expect(decode(actual)).toEqual(decode(expected)); expect(trim(print(decode(actual), 20, 5))).toEqual(`┌──────────────────┐│inner ││ ││ │└──────────────────┘`.trim()); }); });
describe("grapheme clusters", () => { let border = { color: rgba(255, 255, 255), left: 1, right: 1, top: 1, bottom: 1, };
async function render(content: string): Promise<string> { let t = await createTerm({ width: 12, height: 3 }); let ansi = decode( t.render([ open("root", { layout: { width: grow(), height: grow(), direction: "ttb" }, border, }), text(content), close(), ]).output, ); return trim(print(ansi, 12, 3)); }
it("preserves combining accent (e + U+0301)", async () => { expect(await render("cafe\u0301")).toEqual(`\┌──────────┐│cafe\u0301 │└──────────┘`); });
it("preserves kitty-graphics placeholder cluster (base + 2 combining marks)", async () => { expect(await render("\u{10EEEE}\u0305\u030D")).toEqual(`\┌──────────┐│\u{10EEEE}\u0305\u030D │└──────────┘`); });
it("preserves ZWJ (ZWJ is per-cell combining; following emoji start new cells)", async () => { // The trailing half of each wide emoji is the blank column after it. expect(await render("👨\u200D👩\u200D👧\u200D👦")).toEqual(`\┌──────────┐│👨\u200D 👩\u200D 👧\u200D 👦 │└──────────┘`); });
it("truncates excess combining marks from the end (first 8 survive)", async () => { let grave = "\u0300"; expect(await render("a" + grave.repeat(9))).toEqual(`\┌──────────┐│a${grave.repeat(8)} │└──────────┘`); });
it("reports COMBINING_MARKS_EXCEEDED once per frame on truncation", async () => { let t = await createTerm({ width: 12, height: 3 }); let frame = (content: string) => [ open("root", { layout: { width: grow(), height: grow(), direction: "ttb" }, }), text(content), close(), ]; let types = (content: string) => t.render(frame(content)).errors.map((e) => e.type); let grave = "̀";
expect(types("a" + grave.repeat(9) + "b" + grave.repeat(9))).toEqual([ "COMBINING_MARKS_EXCEEDED", ]); expect(types("a" + grave.repeat(8))).toEqual([]); expect(types("c" + grave.repeat(10))).toEqual([ "COMBINING_MARKS_EXCEEDED", ]); });
it("drops combining marks whose base cell is clipped", async () => { let t = await createTerm({ width: 6, height: 1 }); let ansi = decode( t.render([ open("root", { layout: { width: grow(), height: grow(), direction: "ltr" }, }), open("clip", { layout: { width: fixed(2), height: fixed(1) }, clip: { horizontal: true, vertical: true }, }), text("abe\u0301"), close(), close(), ]).output, ); expect(print(ansi, 6, 1)).toEqual("ab "); }); });
describe("caret placement", () => { // These tests use `print()`, which marks the terminal's final // cursor position by appending U+0332 COMBINING LOW LINE to that // cell's base character (so the underlying char is preserved and // the underline spans its rendered width, including wide chars).
it("renders the caret at the declared offset", async () => { let t = await createTerm({ width: 6, height: 1 }); let ansi = decode( t.render([ open("root", { layout: { width: grow(), height: grow(), direction: "ttb" }, }), // caret at code-point 2 sits before the first 'l' — that 'l' // is the underlined cell. text("Hello", { caret: 2 }), close(), ]).output, ); expect(trim(print(ansi, 6, 1))).toEqual("Hel̲lo"); });
it("uses the first caret declaration when multiple are present", async () => { let t = await createTerm({ width: 2, height: 2 }); let ansi = decode( t.render([ open("root", { layout: { width: grow(), height: grow(), direction: "ttb" }, }), text("AA", { caret: 1 }), text("BB", { caret: 2 }), close(), ]).output, ); // First caret wins: offset 1 of "AA" → underline the second 'A'. // No caret marker appears anywhere in "BB". expect(trim(print(ansi, 2, 2))).toEqual(`\AA̲BB`); });
it("accounts for wide characters when positioning the caret", async () => { let t = await createTerm({ width: 5, height: 1 }); let ansi = decode( t.render([ open("root", { layout: { width: grow(), height: grow(), direction: "ttb" }, }), // 中 is a 2-cell wide char. Caret at offset 1 sits after 中, // on 'h' at col 3. The extra space between 中 and h in the // grid is 中's trailing half (the emulator's grid stores each // codepoint in one cell regardless of wcwidth). text("中hi", { caret: 1 }), close(), ]).output, ); expect(trim(print(ansi, 5, 1))).toEqual("中 h̲i"); });
it("places the caret on the correct wrapped line", async () => { let t = await createTerm({ width: 5, height: 2 }); let ansi = decode( t.render([ open("root", { layout: { width: grow(), height: grow(), direction: "ttb" }, }), // "hello world" wraps to "hello" / "world" at width 5. // Code-point 7 is the second 'o' of "world" — caret on it. text("hello world", { caret: 7 }), close(), ]).output, ); expect(trim(print(ansi, 5, 2))).toEqual(`\hellowo̲rld`); });
it("snaps to the next wrapped line for a caret at the wrap seam", async () => { let t = await createTerm({ width: 5, height: 2 }); let ansi = decode( t.render([ open("root", { layout: { width: grow(), height: grow(), direction: "ttb" }, }), // The space between "hello" and "world" is on the wrap seam. // Code-point 6 ('w') starts the second line; the caret lands // there rather than orphaned off the end of the first line. text("hello world", { caret: 6 }), close(), ]).output, ); expect(trim(print(ansi, 5, 2))).toEqual(`\hellow̲orld`); });
it("places the caret at the start of a wrapped line when whitespace is consumed", async () => { let t = await createTerm({ width: 6, height: 2 }); let ansi = decode( t.render([ open("root", { layout: { width: grow(), height: grow(), direction: "ttb" }, }), // "abc def" wraps to "abc" / "def" at width 6. Code-point 4 // ('d') starts the second line; the dropped space between // words is handled by the pre-check that snaps the caret to // the slice origin when the slice's first byte is past the // target. text("abc def", { caret: 4 }), close(), ]).output, ); expect(trim(print(ansi, 6, 2))).toEqual(`\abcd̲ef`); });
it("snaps to the next wrapped line before a wide char at the wrap seam", async () => { // "hi 中x" at width 4 wraps to "hi" / "中x". Caret before 中 snaps // through the dropped space to the origin of the wrapped line // and underlines the wide char. let t = await createTerm({ width: 4, height: 2 }); let ansi = decode( t.render([ open("root", { layout: { width: grow(), height: grow(), direction: "ttb" }, }), text("hi 中x", { caret: 3 }), close(), ]).output, ); expect(trim(print(ansi, 4, 2))).toEqual(`\hi中̲ x`); });
it("advances past a wide char when placing the caret on a wrapped line", async () => { // "hi 中x" at width 4 wraps to "hi" / "中x". Caret at offset 4 // (after 中, before x) — the render walk correctly advances by // wcwidth(中)=2 and underlines x's cell. let t = await createTerm({ width: 4, height: 2 }); let ansi = decode( t.render([ open("root", { layout: { width: grow(), height: grow(), direction: "ttb" }, }), text("hi 中x", { caret: 4 }), close(), ]).output, ); expect(trim(print(ansi, 4, 2))).toEqual(`\hi中 x̲`); });
it("places the caret one past the last char after a wrapped wide-char line", async () => { // "hi 中x" at width 4 wraps to "hi" / "中x". Caret at offset 5 // (end-of-content) — the trailing-cell fallback puts the caret // one cell past 'x' (underlining the trailing space). let t = await createTerm({ width: 4, height: 2 }); let ansi = decode( t.render([ open("root", { layout: { width: grow(), height: grow(), direction: "ttb" }, }), text("hi 中x", { caret: 5 }), close(), ]).output, ); expect(trim(print(ansi, 4, 2))).toEqual(`\hi中 x ̲`); });
it("places the caret one cell past the last character when offset == length", async () => { let t = await createTerm({ width: 4, height: 1 }); let ansi = decode( t.render([ open("root", { layout: { width: grow(), height: grow(), direction: "ttb" }, }), text("Hi", { caret: 2 }), close(), ]).output, ); expect(trim(print(ansi, 4, 1))).toEqual("Hi ̲"); });
it("renders no caret when no caret has ever been declared", async () => { let t = await createTerm({ width: 4, height: 1 }); let ansi = decode( t.render([ open("root", { layout: { width: grow(), height: grow(), direction: "ttb" }, }), text("Hi"), close(), ]).output, ); // No caret marker anywhere in the grid; also no visibility bytes. expect(trim(print(ansi, 4, 1))).toEqual("Hi"); expect(ansi).not.toContain("\x1b[?25h"); expect(ansi).not.toContain("\x1b[?25l"); });
it("renders the caret at the text origin when content is empty and caret is 0", async () => { // Declaring a `caret` on empty content is a rendering commitment: // the renderer places the cursor at the cell the caret would // occupy if the content were a single space (row 1, col 1). let t = await createTerm({ width: 3, height: 1 }); let ansi = decode( t.render([ open("root", { layout: { width: grow(), height: grow(), direction: "ttb" }, }), text("", { caret: 0 }), close(), ]).output, ); expect(trim(print(ansi, 3, 1))).toEqual(" ̲"); });
it("hides the cursor when transitioning from caret-present to caret-absent", async () => { let t = await createTerm({ width: 3, height: 1 }); let frame1 = decode( t.render([ open("root", { layout: { width: grow(), height: grow(), direction: "ttb" }, }), text("Hi", { caret: 1 }), close(), ]).output, ); let frame2 = decode( t.render([ open("root", { layout: { width: grow(), height: grow(), direction: "ttb" }, }), text("Hi"), close(), ]).output, ); // Streaming both frames through the emulator: frame 1 turns the // cursor on; frame 2 MUST emit `?25l` to turn it back off, // otherwise the caret marker would linger at frame 1's cell. expect(trim(print(frame1 + frame2, 3, 1))).toEqual("Hi"); }); });
describe("row offset", () => { it("renders two frames at the offset position", async () => { let term = await createTerm({ width: 20, height: 5 }); let box = (msg: string) => [ open("root", { layout: { width: grow(), height: grow(), direction: "ttb" }, }), open("box", { layout: { width: grow(), height: grow(), direction: "ttb", }, border: { color: rgba(255, 255, 255), left: 1, right: 1, top: 1, bottom: 1, }, }), text(msg), close(), close(), ];
let header = await createTerm({ width: 20, height: 5 }); let banner = decode(header.render(box("hello")).output);
let first = decode(term.render(box("world"), { row: 6 }).output); expect(print(banner + first, 20, 10)).toEqual(`\┌──────────────────┐│hello ││ ││ │└──────────────────┘┌──────────────────┐│world ││ ││ │└──────────────────┘`);
let second = decode(term.render(box("universe"), { row: 6 }).output); expect(print(banner + first + second, 20, 10)).toEqual(`\┌──────────────────┐│hello ││ ││ │└──────────────────┘┌──────────────────┐│universe ││ ││ │└──────────────────┘`); }); });
describe("capabilities", () => { it("starts from the 256-color baseline without terminfo", () => { expect(term.capabilities.colors).toBe(256); expect(term.capabilities.trueColor).toBe(false); });
it("seeds static capabilities from terminfo", async () => { let terminfo = await trueColorDetect(); let seeded = await createTerm({ width: 40, height: 10, terminfo }); expect(seeded.capabilities.trueColor).toBe(true); for (let [key, value] of Object.entries(terminfo.capabilities)) { expect(seeded.capabilities).toHaveProperty(key, value); } }); });
describe("update", () => { let frame: Op[] = [ open("root", { layout: { width: grow(), height: grow(), direction: "ttb" }, }), text("Hi"), close(), ];
it("emits a complete redraw on the first render after update", () => { term.render(frame); expect(term.render(frame).output.length).toBe(0); term.update([{ type: "resize", width: 20, height: 5 }]); let out = decode(term.render(frame).output); expect(trim(print(out, 20, 5))).toContain("Hi"); expect(out.length).toBeGreaterThan(0); });
it("is a no-op when dimensions are unchanged", () => { term.render(frame); term.update([{ type: "resize", width: 40, height: 10 }]); expect(term.render(frame).output.length).toBe(0); });
it("throws on non-positive or non-integer dimensions", () => { expect(() => term.update([{ type: "resize", width: 0, height: 10 }])) .toThrow(RangeError); expect(() => term.update([{ type: "resize", width: 40, height: -1 }])) .toThrow(RangeError); expect(() => term.update([{ type: "resize", width: 40.5, height: 10 }])) .toThrow( RangeError, ); });
it("accepts an update array, last resize wins, non-resize updates applied", () => { term.update([ { type: "resize", width: 30, height: 8 }, { type: "capability", key: "sync-output", value: false }, { type: "resize", width: 12, height: 4 }, ]); let result = term.render(frame); expect(result.info.get("root")?.bounds).toEqual({ x: 0, y: 0, width: 12, height: 4, }); });
it("treats an update array with no resize as a no-op for layout", () => { term.render(frame); term.update([{ type: "capability", key: "sync-output", value: false }]); expect(term.render(frame).output.length).toBe(0); term.update([]); expect(term.render(frame).output.length).toBe(0); });
it('resizes only on events tagged type: "resize"', () => { term.render(frame); let untagged = { width: 12, height: 4 } as unknown as InputEvent; term.update([untagged]); expect(term.render(frame).output.length).toBe(0); });
it("ignores input events that are neither resize nor capability", () => { term.render(frame); let before = term.capabilities; let out = term.update([ { type: "keydown", key: "a", code: "a", text: "a" }, { type: "mousemove", button: "left", x: 1, y: 1 }, ]); expect(out).toEqual(new Uint8Array(0)); expect(term.capabilities).toEqual(before); expect(term.render(frame).output.length).toBe(0); });
it("discards pointer interaction state on resize", () => { let pointer = { x: 1, y: 0, down: false }; let first = term.render(frame, { pointer }); expect(first.events).toContainEqual({ type: "pointerenter", id: "root" }); expect(term.render(frame, { pointer }).events).toEqual([]); term.update([{ type: "resize", width: 20, height: 5 }]); let after = term.render(frame, { pointer }); expect(after.events).toContainEqual({ type: "pointerenter", id: "root" }); });
it("resizes in place and lays out at the new dimensions", () => { term.render(frame); term.update([{ type: "resize", width: 12, height: 4 }]); let result = term.render(frame); expect(result.info.get("root")?.bounds).toEqual({ x: 0, y: 0, width: 12, height: 4, }); expect(trim(print(decode(result.output), 12, 4))).toBe( trim( [ "Hi", "", "", "", ].join("\n"), ), ); });
it("survives downsize then upsize past the original size", () => { term.render(frame); term.update([{ type: "resize", width: 10, height: 3 }]); let small = term.render(frame); expect(small.info.get("root")?.bounds).toEqual({ x: 0, y: 0, width: 10, height: 3, }); term.update([{ type: "resize", width: 120, height: 40 }]); let large = term.render(frame); expect(large.info.get("root")?.bounds).toEqual({ x: 0, y: 0, width: 120, height: 40, }); expect(trim(print(decode(large.output), 120, 40))).toContain("Hi"); }); });});