[READ-ONLY] Mirror of https://github.com/bombshell-dev/tty. Platform independent 2D layout engine for terminal applications based on Clay
5

Configure Feed

Select the types of activity you want to include in your feed.

✨ add line rendering mode for pipe-friendly output

Add { mode: "line" } option to render() that emits newline-separated
rows instead of CUP positioning sequences. Line mode writes every cell
and primes the front buffer, so subsequent diff renders work without
a full redraw.

Charles Lowell (Apr 10, 2026, 4:06 PM -0500) 17c12cd4 4971170e

+115 -7
+43 -3
src/clayterm.c
··· 191 191 } 192 192 } 193 193 194 + static void present_lines(struct Clayterm *ct) { 195 + for (int y = 0; y < ct->h; y++) { 196 + if (y > 0) 197 + buf_put(&ct->out, "\n", 1); 198 + for (int x = 0; x < ct->w;) { 199 + Cell *back = cell_at(ct, ct->back, x, y); 200 + Cell *front = cell_at(ct, ct->front, x, y); 201 + 202 + int w = wcwidth(back->ch); 203 + if (w < 1) 204 + w = 1; 205 + 206 + *front = *back; 207 + 208 + emit_attr(ct, back->fg, back->bg); 209 + 210 + if (w > 1 && x >= ct->w - (w - 1)) { 211 + for (int i = x; i < ct->w; i++) { 212 + buf_char(&ct->out, ' '); 213 + } 214 + } else { 215 + uint32_t ch = back->ch; 216 + if (!iswprint(ch)) 217 + ch = 0xfffd; 218 + buf_char(&ct->out, ch); 219 + for (int i = 1; i < w; i++) { 220 + Cell *fw = cell_at(ct, ct->front, x + i, y); 221 + fw->ch = 0xffffffff; 222 + fw->fg = 0xffffffff; 223 + fw->bg = 0xffffffff; 224 + } 225 + } 226 + x += w; 227 + } 228 + } 229 + } 230 + 194 231 /* ── Color conversion ─────────────────────────────────────────────── */ 195 232 196 233 static uint32_t color(Clay_Color c) { ··· 384 421 return ct; 385 422 } 386 423 387 - void reduce(struct Clayterm *ct, uint32_t *buf, int len) { 424 + void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode) { 388 425 int i = 0; 389 426 uint32_t idx = 0; 390 427 ··· 550 587 } 551 588 } 552 589 553 - /* diff front vs back, emit escape sequences */ 554 - present(ct); 590 + if (mode == 1) { 591 + present_lines(ct); 592 + } else { 593 + present(ct); 594 + } 555 595 } 556 596 557 597 char *output(struct Clayterm *ct) { return ct->out.data; }
+1 -1
src/clayterm.h
··· 12 12 /* WASM exports */ 13 13 int clayterm_size(int w, int h); 14 14 struct Clayterm *init(void *mem, int w, int h, int row); 15 - void reduce(struct Clayterm *ct, uint32_t *buf, int len); 15 + void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode); 16 16 char *output(struct Clayterm *ct); 17 17 int length(struct Clayterm *ct); 18 18 void measure(int ret, int txt);
+2 -2
term-native.ts
··· 2 2 memory: WebAssembly.Memory; 3 3 statePtr: number; 4 4 opsBuf: number; 5 - reduce(ct: number, buf: number, len: number): void; 5 + reduce(ct: number, buf: number, len: number, mode: number): void; 6 6 output(ct: number): number; 7 7 length(ct: number): number; 8 8 setPointer(x: number, y: number, down: boolean): void; ··· 48 48 __heap_base: WebAssembly.Global; 49 49 clayterm_size(w: number, h: number): number; 50 50 init(mem: number, w: number, h: number, row: number): number; 51 - reduce(ct: number, buf: number, len: number): void; 51 + reduce(ct: number, buf: number, len: number, mode: number): void; 52 52 output(ct: number): number; 53 53 length(ct: number): number; 54 54 Clay_SetPointerState(vec: number, down: number): void;
+3 -1
term.ts
··· 8 8 } 9 9 10 10 export interface RenderOptions { 11 + mode?: "line"; 11 12 pointer?: { 12 13 x: number; 13 14 y: number; ··· 41 42 return { 42 43 render(ops: Op[], options?: RenderOptions): RenderResult { 43 44 let len = pack(ops, memory.buffer, opsBuf, memory.buffer.byteLength); 44 - native.reduce(statePtr, opsBuf, len); 45 + let mode = options?.mode === "line" ? 1 : 0; 46 + native.reduce(statePtr, opsBuf, len, mode); 45 47 46 48 if (options?.pointer) { 47 49 let { x, y, down } = options.pointer;
+4
test/print.ts
··· 38 38 // SGR — ignore 39 39 } 40 40 // ignore all other CSI sequences (?25l, ?25h, etc.) 41 + } else if (ansi[i] === "\n") { 42 + y++; 43 + x = 0; 44 + i++; 41 45 } else { 42 46 // regular character — could be multi-byte UTF-8 43 47 let cp = ansi.codePointAt(i)!;
+62
test/term.test.ts
··· 4 4 import { print } from "./print.ts"; 5 5 6 6 const decode = (bytes: Uint8Array) => new TextDecoder().decode(bytes); 7 + const trim = (s: string) => s.split("\n").map((l) => l.trimEnd()).join("\n"); 7 8 8 9 describe("term", () => { 9 10 let term: Term; ··· 87 88 │ │ 88 89 │ │ 89 90 ╰──────────────────────────────────────╯`.trim()); 91 + }); 92 + 93 + describe("line mode", () => { 94 + let box = (msg: string) => [ 95 + open("root", { 96 + layout: { width: grow(), height: grow(), direction: "ttb" }, 97 + }), 98 + open("box", { 99 + layout: { 100 + width: grow(), 101 + height: grow(), 102 + direction: "ttb", 103 + padding: { left: 1, top: 1 }, 104 + }, 105 + border: { 106 + color: rgba(255, 255, 255), 107 + left: 1, 108 + right: 1, 109 + top: 1, 110 + bottom: 1, 111 + }, 112 + }), 113 + text(msg), 114 + close(), 115 + close(), 116 + ]; 117 + 118 + it("renders with newlines instead of CUP sequences", async () => { 119 + let term = await createTerm({ width: 20, height: 5 }); 120 + 121 + let out = decode( 122 + term.render(box("hello world"), { mode: "line" }).output, 123 + ); 124 + // deno-lint-ignore no-control-regex 125 + expect(out).not.toMatch(/\x1b\[\d+;\d+H/); 126 + expect(out.split("\n").length).toBe(5); 127 + expect(trim(print(out, 20, 5))).toEqual(` 128 + ┌──────────────────┐ 129 + │hello world │ 130 + │ │ 131 + │ │ 132 + └──────────────────┘`.trim()); 133 + }); 134 + 135 + it("primes front buffer for subsequent diff render", async () => { 136 + let term = await createTerm({ width: 20, height: 5 }); 137 + 138 + let first = decode( 139 + term.render(box("hello world"), { mode: "line" }).output, 140 + ); 141 + let second = decode(term.render(box("goodbye")).output); 142 + 143 + expect(trim(print(first + second, 20, 5))).toEqual(` 144 + ┌──────────────────┐ 145 + │goodbye │ 146 + │ │ 147 + │ │ 148 + └──────────────────┘`.trim()); 149 + 150 + expect(second.length).toBeLessThan(first.length); 151 + }); 90 152 }); 91 153 92 154 describe("row offset", () => {