[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
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650import { close, fixed, grow, open, type OpenElement, rgba, text,} from "../ops.ts";import { createTerm } from "../term.ts";import { describe, expect, it } from "./suite.ts";import { print } from "./print.ts";
const decode = (b: Uint8Array) => new TextDecoder().decode(b);
/* ── Deterministic test colors ────────────────────────────────────── */
const WHITE = rgba(255, 255, 255);const RED = rgba(255, 0, 0);const GREEN = rgba(0, 255, 0);const BLUE = rgba(0, 0, 255);const YELLOW = rgba(255, 255, 0);const MAGENTA = rgba(255, 0, 255);const CYAN = rgba(0, 255, 255);
const FG = { white: "\x1b[38;2;255;255;255", red: "\x1b[38;2;255;0;0", green: "\x1b[38;2;0;255;0", blue: "\x1b[38;2;0;0;255", yellow: "\x1b[38;2;255;255;0", magenta: "\x1b[38;2;255;0;255", cyan: "\x1b[38;2;0;255;255",};
const BG = { white: "\x1b[48;2;255;255;255", red: "\x1b[48;2;255;0;0", green: "\x1b[48;2;0;255;0", blue: "\x1b[48;2;0;0;255", yellow: "\x1b[48;2;255;255;0", magenta: "\x1b[48;2;255;0;255", cyan: "\x1b[48;2;0;255;255",};
/* ── ANSI cell parser ─────────────────────────────────────────────── */
type ParsedCell = { x: number; y: number; ch: string; fg?: string; bg?: string;};
function cells(ansi: string): ParsedCell[] { let result: ParsedCell[] = []; let fg: string | undefined; let bg: string | undefined; let x = 0; let y = 0;
for (let i = 0; i < ansi.length;) { if (ansi[i] === "\x1b" && ansi[i + 1] === "[") { let end = i + 2; while (end < ansi.length && !/[A-Za-z]/.test(ansi[end])) { end++; }
let seq = ansi.slice(i, end + 1); if (seq === "\x1b[0m") { fg = undefined; bg = undefined; } else if (seq.startsWith("\x1b[38;2;") && seq.endsWith("m")) { fg = seq.slice(0, -1); } else if (seq.startsWith("\x1b[48;2;") && seq.endsWith("m")) { bg = seq.slice(0, -1); }
i = end + 1; continue; }
if (ansi[i] === "\n") { y++; x = 0; i++; continue; }
result.push({ x, y, ch: ansi[i], fg, bg }); x++; i++; }
return result;}
function at(parsed: ParsedCell[], x: number, y: number): ParsedCell { let cell = parsed.find((c) => c.x === x && c.y === y); expect(cell).toBeDefined(); return cell!;}
function glyphs(parsed: ParsedCell[], chars: string): ParsedCell[] { return parsed.filter((c) => chars.includes(c.ch));}
const CORNERS = "┌┐└┘╭╮╰╯";
/* ── Render helper ────────────────────────────────────────────────── */
type OpenProps = Omit<OpenElement, "directive" | "id">;
/** Renders an 8x4 "box" element at the origin of a 12x5 term in line * mode and parses the full-frame output into cells. Box corners are at * (0,0), (7,0), (0,3), (7,3). */async function renderBox(props: OpenProps): Promise<ParsedCell[]> { let term = await createTerm({ width: 12, height: 5 }); let ansi = decode( term.render([ open("box", { layout: { width: fixed(8), height: fixed(4) }, ...props, }), close(), ], { mode: "line" }).output, ); return cells(ansi);}
/* ── Tests ────────────────────────────────────────────────────────── */
describe("scalar sides", () => { it("renders a full box from scalar widths with the shared color", async () => { let parsed = await renderBox({ border: { color: WHITE, top: 1, right: 1, bottom: 1, left: 1 }, });
expect(at(parsed, 0, 0).ch).toBe("┌"); expect(at(parsed, 7, 0).ch).toBe("┐"); expect(at(parsed, 0, 3).ch).toBe("└"); expect(at(parsed, 7, 3).ch).toBe("┘"); expect(at(parsed, 3, 0).ch).toBe("─"); expect(at(parsed, 3, 3).ch).toBe("─"); expect(at(parsed, 0, 1).ch).toBe("│"); expect(at(parsed, 7, 1).ch).toBe("│");
for (let cell of glyphs(parsed, "┌┐└┘─│")) { expect(cell.fg).toBe(FG.white); } });
it("applies shared bg to scalar sides", async () => { let parsed = await renderBox({ border: { color: WHITE, bg: BLUE, top: 1, right: 1, bottom: 1, left: 1 }, });
for (let cell of glyphs(parsed, "┌┐└┘─│")) { expect(cell.bg).toBe(BG.blue); } });});
describe("structured sides", () => { it("accepts every structured side form and resolves fallbacks", async () => { let parsed = await renderBox({ border: { color: WHITE, bg: MAGENTA, top: { width: 1 }, right: { width: 1, color: RED }, bottom: { width: 1, bg: BLUE }, left: { width: 1, color: GREEN, bg: YELLOW }, }, });
// top: shared color, shared bg expect(at(parsed, 3, 0).ch).toBe("─"); expect(at(parsed, 3, 0).fg).toBe(FG.white); expect(at(parsed, 3, 0).bg).toBe(BG.magenta);
// right: own color, shared bg expect(at(parsed, 7, 1).ch).toBe("│"); expect(at(parsed, 7, 1).fg).toBe(FG.red); expect(at(parsed, 7, 1).bg).toBe(BG.magenta);
// bottom: shared color, own bg expect(at(parsed, 3, 3).ch).toBe("─"); expect(at(parsed, 3, 3).fg).toBe(FG.white); expect(at(parsed, 3, 3).bg).toBe(BG.blue);
// left: own color, own bg expect(at(parsed, 0, 1).ch).toBe("│"); expect(at(parsed, 0, 1).fg).toBe(FG.green); expect(at(parsed, 0, 1).bg).toBe(BG.yellow); });
it("overrides shared color per side; omitted colors inherit it", async () => { let parsed = await renderBox({ border: { color: WHITE, top: { width: 1, color: RED }, bottom: { width: 1, color: GREEN }, left: 1, right: { width: 1 }, }, });
expect(at(parsed, 3, 0).fg).toBe(FG.red); expect(at(parsed, 3, 3).fg).toBe(FG.green); expect(at(parsed, 0, 1).fg).toBe(FG.white); expect(at(parsed, 7, 1).fg).toBe(FG.white); });
it("overrides shared bg per side; omitted bgs inherit it", async () => { let parsed = await renderBox({ border: { color: WHITE, bg: BLUE, top: 1, bottom: { width: 1 }, left: { width: 1, bg: RED }, right: 1, }, });
expect(at(parsed, 3, 0).bg).toBe(BG.blue); // scalar side, shared bg expect(at(parsed, 3, 3).bg).toBe(BG.blue); // structured side, shared bg expect(at(parsed, 0, 1).bg).toBe(BG.red); // structured side, own bg expect(at(parsed, 7, 1).bg).toBe(BG.blue); // scalar side, shared bg });
it("preserves the element bg when no border bg is provided", async () => { let parsed = await renderBox({ bg: CYAN, border: { color: WHITE, top: { width: 1, color: RED }, right: { width: 1 }, bottom: 1, left: 1, }, });
for (let cell of glyphs(parsed, "┌┐└┘─│")) { expect(cell.bg).toBe(BG.cyan); } });
it("emits no border bg when neither side nor shared bg is set", async () => { let parsed = await renderBox({ border: { color: WHITE, top: { width: 1, color: RED }, right: 1, bottom: { width: 1 }, left: 1, }, });
for (let cell of glyphs(parsed, "┌┐└┘─│")) { expect(cell.bg).toBeUndefined(); } });
it("does not retain a prior frame's side bg", async () => { let term = await createTerm({ width: 12, height: 5 }); let frame = (bg?: number) => [ open("box", { layout: { width: fixed(8), height: fixed(4) }, border: { color: WHITE, top: bg === undefined ? { width: 1 } : { width: 1, bg }, right: 1, bottom: 1, left: 1, }, }), close(), ];
term.render(frame(BLUE)); let ansi = decode(term.render(frame()).output);
expect(ansi).not.toContain(BG.blue); let top = cells(ansi).find((c) => c.ch === "─"); expect(top).toBeDefined(); expect(top!.bg).toBeUndefined(); });});
describe("independent sides", () => { it("draws only sides with resolved width > 0", async () => { let drawn = await renderBox({ border: { color: WHITE, top: { width: 1 } }, }); expect(glyphs(drawn, "─").length).toBe(8);
let zeroObject = await renderBox({ border: { color: WHITE, top: { width: 0 }, left: 1 }, }); expect(glyphs(zeroObject, "─").length).toBe(0);
let zeroScalar = await renderBox({ border: { color: WHITE, top: 0, left: 1 }, }); expect(glyphs(zeroScalar, "─").length).toBe(0); });
it("renders a left-only border as a straight vertical line", async () => { let parsed = await renderBox({ border: { color: WHITE, left: 1 } }); expect(glyphs(parsed, "│").length).toBe(4); expect(glyphs(parsed, "│").every((c) => c.x === 0)).toBe(true); expect(glyphs(parsed, CORNERS).length).toBe(0); });
it("renders a right-only border as a straight vertical line", async () => { let parsed = await renderBox({ border: { color: WHITE, right: 1 } }); expect(glyphs(parsed, "│").length).toBe(4); expect(glyphs(parsed, "│").every((c) => c.x === 7)).toBe(true); expect(glyphs(parsed, CORNERS).length).toBe(0); });
it("renders a top-only border as a straight horizontal line", async () => { let parsed = await renderBox({ border: { color: WHITE, top: 1 } }); expect(glyphs(parsed, "─").length).toBe(8); expect(glyphs(parsed, "─").every((c) => c.y === 0)).toBe(true); expect(glyphs(parsed, CORNERS).length).toBe(0); });
it("renders a bottom-only border as a straight horizontal line", async () => { let parsed = await renderBox({ border: { color: WHITE, bottom: 1 } }); expect(glyphs(parsed, "─").length).toBe(8); expect(glyphs(parsed, "─").every((c) => c.y === 3)).toBe(true); expect(glyphs(parsed, CORNERS).length).toBe(0); });
it("renders top + bottom as two straight lines without corners", async () => { let parsed = await renderBox({ border: { color: WHITE, top: 1, bottom: 1 }, }); expect(glyphs(parsed, "─").length).toBe(16); expect(glyphs(parsed, "│").length).toBe(0); expect(glyphs(parsed, CORNERS).length).toBe(0); });});
describe("corners", () => { it("creates a corner only where both adjacent sides are enabled", async () => { let tl = await renderBox({ border: { color: WHITE, top: 1, left: 1 } }); expect(at(tl, 0, 0).ch).toBe("┌"); expect(glyphs(tl, CORNERS).length).toBe(1);
let tr = await renderBox({ border: { color: WHITE, top: 1, right: 1 } }); expect(at(tr, 7, 0).ch).toBe("┐"); expect(glyphs(tr, CORNERS).length).toBe(1);
let bl = await renderBox({ border: { color: WHITE, bottom: 1, left: 1 } }); expect(at(bl, 0, 3).ch).toBe("└"); expect(glyphs(bl, CORNERS).length).toBe(1);
let br = await renderBox({ border: { color: WHITE, bottom: 1, right: 1 } }); expect(at(br, 7, 3).ch).toBe("┘"); expect(glyphs(br, CORNERS).length).toBe(1); });
it("draws no corner when an adjacent side has zero width", async () => { let scalarZero = await renderBox({ border: { color: WHITE, top: 1, left: 0 }, }); expect(glyphs(scalarZero, CORNERS).length).toBe(0);
let objectZero = await renderBox({ border: { color: WHITE, bottom: 1, right: { width: 0 } }, }); expect(glyphs(objectZero, CORNERS).length).toBe(0); });
it("styles top corners from top and bottom corners from bottom", async () => { let parsed = await renderBox({ border: { color: WHITE, top: { width: 1, color: RED, bg: MAGENTA }, right: { width: 1, color: YELLOW }, bottom: { width: 1, color: GREEN, bg: CYAN }, left: { width: 1, color: BLUE }, }, });
// top corners take top attributes expect(at(parsed, 0, 0).ch).toBe("┌"); expect(at(parsed, 0, 0).fg).toBe(FG.red); expect(at(parsed, 0, 0).bg).toBe(BG.magenta); expect(at(parsed, 7, 0).ch).toBe("┐"); expect(at(parsed, 7, 0).fg).toBe(FG.red); expect(at(parsed, 7, 0).bg).toBe(BG.magenta);
// bottom corners take bottom attributes expect(at(parsed, 0, 3).ch).toBe("└"); expect(at(parsed, 0, 3).fg).toBe(FG.green); expect(at(parsed, 0, 3).bg).toBe(BG.cyan); expect(at(parsed, 7, 3).ch).toBe("┘"); expect(at(parsed, 7, 3).fg).toBe(FG.green); expect(at(parsed, 7, 3).bg).toBe(BG.cyan);
// horizontal edges remain continuous with their corners expect(at(parsed, 3, 0).fg).toBe(FG.red); expect(at(parsed, 3, 3).fg).toBe(FG.green);
// non-corner vertical edge cells take left/right attributes expect(at(parsed, 0, 1).fg).toBe(FG.blue); expect(at(parsed, 0, 2).fg).toBe(FG.blue); expect(at(parsed, 7, 1).fg).toBe(FG.yellow); expect(at(parsed, 7, 2).fg).toBe(FG.yellow); });
it("keeps rounded corner glyphs; side attrs only restyle them", async () => { let parsed = await renderBox({ cornerRadius: { tl: 1, tr: 1, bl: 1, br: 1 }, border: { color: WHITE, top: { width: 1, color: RED }, right: 1, bottom: { width: 1, color: GREEN }, left: 1, }, });
expect(at(parsed, 0, 0).ch).toBe("╭"); expect(at(parsed, 7, 0).ch).toBe("╮"); expect(at(parsed, 0, 3).ch).toBe("╰"); expect(at(parsed, 7, 3).ch).toBe("╯"); expect(at(parsed, 0, 0).fg).toBe(FG.red); expect(at(parsed, 7, 0).fg).toBe(FG.red); expect(at(parsed, 0, 3).fg).toBe(FG.green); expect(at(parsed, 7, 3).fg).toBe(FG.green); });});
describe("directive model", () => { it("keeps structured side declarations as plain data", () => { let directive = open("box", { border: { color: WHITE, top: { width: 1, color: RED } }, });
expect(Object.getPrototypeOf(directive)).toBe(Object.prototype); expect(directive.border?.top).toEqual({ width: 1, color: RED }); });});
describe("instances", () => { it("does not share side attributes between Term instances", async () => { let a = await createTerm({ width: 12, height: 5 }); let b = await createTerm({ width: 12, height: 5 });
let frame = (top: number, bottom: number) => [ open("box", { layout: { width: fixed(8), height: fixed(4) }, border: { color: WHITE, top: { width: 1, color: top }, bottom: { width: 1, color: bottom }, }, }), close(), ];
let ansiA = decode(a.render(frame(RED, GREEN), { mode: "line" }).output); let ansiB = decode(b.render(frame(BLUE, YELLOW), { mode: "line" }).output);
expect(ansiA).toContain(FG.red); expect(ansiA).toContain(FG.green); expect(ansiA).not.toContain(FG.blue); expect(ansiA).not.toContain(FG.yellow);
expect(ansiB).toContain(FG.blue); expect(ansiB).toContain(FG.yellow); expect(ansiB).not.toContain(FG.red); expect(ansiB).not.toContain(FG.green);
// re-rendering A must not affect B's next frame a.render(frame(MAGENTA, CYAN), { mode: "line" }); let again = decode(b.render(frame(BLUE, YELLOW), { mode: "line" }).output); expect(again).not.toContain(FG.magenta); expect(again).not.toContain(FG.cyan); });});
const trim = (s: string) => s.split("\n").map((l) => l.trimEnd()).join("\n");
describe("box model", () => { it("full border with no padding reserves space: children visible, box is 3 rows", async () => { let term = await createTerm({ width: 20, height: 10 }); let result = term.render([ open("root", { layout: { width: grow(), height: grow(), direction: "ttb" }, }), open("box", { layout: { width: fixed(14), direction: "ttb" }, border: { color: WHITE, top: 1, right: 1, bottom: 1, left: 1 }, }), text("CASE A"), close(), close(), ]);
expect(result.info.get("box")?.bounds.height).toBe(3); expect(decode(result.output)).toContain("CASE A"); });
it("partial borders (top+left) reserve only their sides", async () => { let term = await createTerm({ width: 20, height: 10 }); let result = term.render([ open("root", { layout: { width: grow(), height: grow(), direction: "ttb" }, }), open("box", { layout: { width: fixed(14), direction: "ttb" }, border: { color: WHITE, top: 1, left: 1 }, }), text("CASE B"), close(), close(), ]);
// top border reserves 1 row, no bottom border so no bottom reservation expect(result.info.get("box")?.bounds.height).toBe(2); expect(decode(result.output)).toContain("CASE B"); });
it("padding is additive: border=1 alone gives height 3; border=1 plus padding=1 gives height 5", async () => { let nopad = await createTerm({ width: 20, height: 10 }); let r1 = nopad.render([ open("root", { layout: { width: grow(), height: grow(), direction: "ttb" }, }), open("box", { layout: { width: fixed(14), direction: "ttb" }, border: { color: WHITE, top: 1, right: 1, bottom: 1, left: 1 }, }), text("CONTENT"), close(), close(), ]);
let withpad = await createTerm({ width: 20, height: 10 }); let r2 = withpad.render([ open("root", { layout: { width: grow(), height: grow(), direction: "ttb" }, }), open("box", { layout: { width: fixed(14), direction: "ttb", padding: { top: 1, right: 1, bottom: 1, left: 1 }, }, border: { color: WHITE, top: 1, right: 1, bottom: 1, left: 1 }, }), text("CONTENT"), close(), close(), ]);
// border=1, no padding: effective = 0+1 = 1 per side → height = 1+text+1 = 3 expect(r1.info.get("box")?.bounds.height).toBe(3); // border=1, padding=1: effective = 1+1 = 2 per side → height = 2+text+2 = 5 expect(r2.info.get("box")?.bounds.height).toBe(5); });
it("explicit padding > border width adds breathing room inside the border", async () => { let term = await createTerm({ width: 20, height: 10 }); let result = term.render([ open("root", { layout: { width: grow(), height: grow(), direction: "ttb" }, }), open("box", { layout: { width: fixed(14), direction: "ttb", padding: { top: 2, bottom: 2 }, }, border: { color: WHITE, top: 1, bottom: 1 }, }), text("CONTENT"), close(), close(), ]);
// effective_top = 2+1 = 3, effective_bottom = 2+1 = 3 // height = 3 + 1 text + 3 = 7 expect(result.info.get("box")?.bounds.height).toBe(7); });
it("nested two-tone bevel lays out without manual padding compensation", async () => { let term = await createTerm({ width: 20, height: 10 }); let result = term.render([ open("root", { layout: { width: grow(), height: grow(), direction: "ttb" }, }), open("outer", { layout: { width: fixed(16), direction: "ttb" }, border: { color: WHITE, top: 1, left: 1 }, }), open("inner", { layout: { width: grow(), direction: "ttb" }, border: { color: WHITE, bottom: 1, right: 1 }, }), text("NESTED"), close(), close(), close(), ]);
// inner: effective_bottom=1, effective_right=1 → height = 0 + text(1) + 1 = 2 // outer: effective_top=1, effective_left=1 → height = 1 + inner(2) + 0 = 3 expect(result.info.get("outer")?.bounds.height).toBe(3); expect(decode(result.output)).toContain("NESTED"); });
it("visual: full border renders border glyphs around content", async () => { let term = await createTerm({ width: 20, height: 10 }); let out = trim( print( decode( term.render([ open("root", { layout: { width: grow(), height: grow(), direction: "ttb" }, }), open("box", { layout: { width: fixed(14), direction: "ttb" }, border: { color: WHITE, top: 1, right: 1, bottom: 1, left: 1 }, }), text("CASE A"), close(), close(), ]).output, ), 20, 10, ), );
let lines = out.split("\n"); expect(lines[0]).toBe("┌────────────┐"); expect(lines[1]).toBe("│CASE A │"); expect(lines[2]).toBe("└────────────┘"); });});