diff --git a/src/clayterm.c b/src/clayterm.c index 66b3b27..ad9afc8 100644 --- a/src/clayterm.c +++ b/src/clayterm.c @@ -84,6 +84,8 @@ struct Clayterm { Clay_ErrorData errors[MAX_ERRORS]; int error_count; int animating_count; + /* wrap each full-screen frame in BSU/ESU (DEC mode 2026); host opt-out */ + int sync; }; /* Memory layout inside the arena provided by the host: @@ -94,6 +96,12 @@ struct Clayterm { */ #define OUT_BYTES_PER_CELL 64 +/* Fixed per-frame slack on top of the per-cell budget, for output that wraps + * the whole frame rather than scaling with cell count — the BSU/ESU + * synchronized-output pair. Keeps the wrap from being dropped on tiny grids + * where the per-cell budget alone is tight. */ +#define OUT_FRAME_OVERHEAD 32 + /* ── Cell buffer ops ──────────────────────────────────────────────── */ static Cell *cell_at(struct Clayterm *ct, Cell *buf, int x, int y) { @@ -200,6 +208,13 @@ static void present_cups(struct Clayterm *ct, int row) { ct->lastx = -1; ct->lasty = -1; + /* Synchronized Output (DEC mode 2026): the terminal buffers everything + * between BSU and ESU and presents it in one atomic repaint, so a frame + * never tears mid-update. Unsupported terminals ignore the private mode; + * the host can still opt out (ct->sync) for raw output. */ + if (ct->sync) + buf_str(&ct->out, "\x1b[?2026h"); + for (int y = 0; y < ct->h; y++) { for (int x = 0; x < ct->w;) { Cell *back = cell_at(ct, ct->back, x, y); @@ -234,6 +249,9 @@ static void present_cups(struct Clayterm *ct, int row) { x += w; } } + + if (ct->sync) + buf_str(&ct->out, "\x1b[?2026l"); /* ESU: end synchronized update */ } /** @@ -554,7 +572,7 @@ static int align64(int n) { return (n + 63) & ~63; } int clayterm_size(int w, int h) { int cell_count = w * h; int cell_bytes = cell_count * (int)sizeof(Cell); - int out_bytes = cell_count * OUT_BYTES_PER_CELL; + int out_bytes = cell_count * OUT_BYTES_PER_CELL + OUT_FRAME_OVERHEAD; int clay_bytes = (int)Clay_MinMemorySize(); return align8((int)sizeof(struct Clayterm)) + align8(cell_bytes) /* front */ + align8(cell_bytes) /* back */ @@ -611,11 +629,11 @@ int error_message_ptr(struct Clayterm *ct, int index) { return (int)ct->errors[index].errorText.chars; } -struct Clayterm *init(void *mem, int w, int h) { +struct Clayterm *init(void *mem, int w, int h, int sync) { struct Clayterm *ct = (struct Clayterm *)mem; int cell_count = w * h; int cell_bytes = align8(cell_count * (int)sizeof(Cell)); - int out_bytes = align8(cell_count * OUT_BYTES_PER_CELL); + int out_bytes = align8(cell_count * OUT_BYTES_PER_CELL + OUT_FRAME_OVERHEAD); char *base = (char *)mem + align8((int)sizeof(struct Clayterm)); char *clay_mem = base + cell_bytes * 2 + out_bytes; @@ -630,11 +648,13 @@ struct Clayterm *init(void *mem, int w, int h) { .h = h, .front = (Cell *)base, .back = (Cell *)(base + cell_bytes), - .out = {base + cell_bytes * 2, 0, cell_count * OUT_BYTES_PER_CELL}, + .out = {base + cell_bytes * 2, 0, + cell_count * OUT_BYTES_PER_CELL + OUT_FRAME_OVERHEAD}, .lastfg = 0xffffffff, .lastbg = 0xffffffff, .lastx = -1, .lasty = -1, + .sync = sync, }; // initialize back buffer with spaces and default fg/bg diff --git a/src/clayterm.h b/src/clayterm.h index 8a24db4..1d4557c 100644 --- a/src/clayterm.h +++ b/src/clayterm.h @@ -11,7 +11,7 @@ struct Clayterm; /* WASM exports */ int clayterm_size(int w, int h); -struct Clayterm *init(void *mem, int w, int h); +struct Clayterm *init(void *mem, int w, int h, int sync); void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row, float deltaTime); char *output(struct Clayterm *ct); diff --git a/term-native.ts b/term-native.ts index 8bb58f4..44acff9 100644 --- a/term-native.ts +++ b/term-native.ts @@ -53,6 +53,7 @@ import { compiled } from "./wasm.ts"; export async function createTermNative( w: number, h: number, + sync: boolean, ): Promise { let memory = new WebAssembly.Memory({ initial: 2 }); let exports: Record = {}; @@ -85,7 +86,7 @@ export async function createTermNative( let ct = exports as unknown as { __heap_base: WebAssembly.Global; clayterm_size(w: number, h: number): number; - init(mem: number, w: number, h: number): number; + init(mem: number, w: number, h: number, sync: number): number; reduce( ct: number, buf: number, @@ -124,7 +125,7 @@ export async function createTermNative( memory.grow(pages - current); } - let statePtr = ct.init(heap, w, h); + let statePtr = ct.init(heap, w, h, sync ? 1 : 0); let opsBuf = (heap + size + 3) & ~3; return { diff --git a/term.ts b/term.ts index 3b8ddef..c021ccb 100644 --- a/term.ts +++ b/term.ts @@ -4,6 +4,16 @@ import { type BoundingBox, createTermNative } from "./term-native.ts"; export interface TermOptions { height: number; width: number; + + /** + * Wrap every full-screen frame in Synchronized Output (DEC mode 2026: + * `CSI ?2026 h` … `CSI ?2026 l`) so the terminal presents it atomically and + * never tears mid-update. Terminals that don't support the mode ignore it, so + * this is safe to leave on. Set `false` for raw output (e.g. piping, or a + * terminal you've found misbehaves). Inline (line mode) frames are never + * wrapped. Defaults to `true`. + */ + sync?: boolean; } export interface RenderOptions { @@ -74,8 +84,8 @@ export interface Term { } export async function createTerm(options: TermOptions): Promise { - let { width, height } = options; - let native = await createTermNative(width, height); + let { width, height, sync = true } = options; + let native = await createTermNative(width, height, sync); let { memory, statePtr, opsBuf } = native; let prev = new Set(); diff --git a/test/term.test.ts b/test/term.test.ts index 121ece2..4d39cc1 100644 --- a/test/term.test.ts +++ b/test/term.test.ts @@ -52,9 +52,11 @@ describe("term", () => { ]).output, ); - // the SGR active when "h" is emitted should include the - // parent's red background (48;2;255;0;0), not terminal default - let before = ansi.slice(0, ansi.indexOf("h")); + // the SGR active when the "hi" glyphs are emitted should include the + // parent's red background (48;2;255;0;0), not terminal default. + // (anchored to the text, not a bare "h", which also appears in the + // synchronized-output BSU prefix \x1b[?2026h) + let before = ansi.slice(0, ansi.indexOf("hi")); expect(before).toContain("\x1b[48;2;255;0;0"); }); @@ -395,3 +397,58 @@ describe("term", () => { }); }); }); + +describe("synchronized output", () => { + // DEC mode 2026: every full-screen frame is wrapped in BSU/ESU so the + // terminal presents it atomically and never tears mid-update. + let BSU = "\x1b[?2026h"; + let ESU = "\x1b[?2026l"; + + it("wraps the full-screen (cups) frame in BSU/ESU", async () => { + let term = await createTerm({ width: 20, height: 5 }); + let out = decode( + term.render([ + open("root", { + layout: { width: grow(), height: grow() }, + bg: rgba(10, 20, 30), + }), + close(), + ]).output, + ); + expect(out.startsWith(BSU)).toBe(true); + expect(out.endsWith(ESU)).toBe(true); + // exactly one wrap per frame + expect(out.split(BSU).length).toBe(2); + expect(out.split(ESU).length).toBe(2); + }); + + it("does not wrap inline (line mode) output", async () => { + let term = await createTerm({ width: 20, height: 5 }); + let out = decode( + term.render([ + open("root", { + layout: { width: grow(), height: grow() }, + bg: rgba(10, 20, 30), + }), + close(), + ], { mode: "line" }).output, + ); + expect(out.includes("\x1b[?2026")).toBe(false); + }); + + it("omits the wrap when sync is disabled", async () => { + let term = await createTerm({ width: 20, height: 5, sync: false }); + let out = decode( + term.render([ + open("root", { + layout: { width: grow(), height: grow() }, + bg: rgba(10, 20, 30), + }), + close(), + ]).output, + ); + expect(out.includes("\x1b[?2026")).toBe(false); + // the actual frame content (bg SGR) is still emitted + expect(out.includes("\x1b[48;2;10;20;30")).toBe(true); + }); +});