diff --git a/specs/renderer-spec.md b/specs/renderer-spec.md index 2bb335a..59698c2 100644 --- a/specs/renderer-spec.md +++ b/specs/renderer-spec.md @@ -379,6 +379,13 @@ This allows the caller to render into a region of the terminal starting at a row other than the top. The offset is applied to all emitted cursor positions. When omitted, it defaults to 1. +A horizontal run of identical changed cells MAY be encoded as a single glyph +followed by REP (`CSI Ps b`, repeat the preceding graphic character `Ps` times) +when doing so reduces the byte count. This is a content-encoding optimization +only; the rendered grid is identical to emitting each cell individually. The +precise encoding (the run threshold, REP versus inline repetition) is part of +the implementation surface and is not locked down by this specification. + #### 8.2.2 Line mode When `mode` is `"line"`, the renderer emits all cells as newline-separated rows @@ -610,7 +617,8 @@ terminal-management operations: These are the caller's responsibility. The renderer's output contains only the escape sequences needed to render the frame content (cursor positioning for cell -writes, SGR attributes for styling, and UTF-8 text). +writes, SGR attributes for styling, UTF-8 text, and run-length encoding of +repeated cells per §8.2.1). ### 11.3 The renderer does not own application lifecycle diff --git a/src/clayterm.c b/src/clayterm.c index a545c5f..7a3b54e 100644 --- a/src/clayterm.c +++ b/src/clayterm.c @@ -167,6 +167,30 @@ static void emit_cursor(struct Clayterm *ct, int x, int y, int row) { buf_put(&ct->out, "H", 1); } +/* decimal digits in n (n >= 0) */ +static int num_digits(int n) { + int d = 1; + while (n >= 10) { + n /= 10; + d++; + } + return d; +} + +/* glyph actually written for a cell: non-printable -> U+FFFD (see emit_ch) */ +static uint32_t glyph_cp(uint32_t ch) { return iswprint(ch) ? ch : 0xfffd; } + +/* UTF-8 byte length of a codepoint, matching buf_char's encoding */ +static int cp_utf8_len(uint32_t ch) { + if (ch < 0x80) + return 1; + if (ch < 0x800) + return 2; + if (ch < 0x10000) + return 3; + return 4; +} + static void emit_ch(struct Clayterm *ct, int x, int y, int row, uint32_t ch) { if (ct->lastx != x - 1 || ct->lasty != y) { emit_cursor(ct, x, y, row); @@ -174,9 +198,7 @@ static void emit_ch(struct Clayterm *ct, int x, int y, int row, uint32_t ch) { ct->lastx = x; ct->lasty = y; - if (!iswprint(ch)) - ch = 0xfffd; - buf_char(&ct->out, ch); + buf_char(&ct->out, glyph_cp(ch)); } /** @@ -198,29 +220,73 @@ static void present_cups(struct Clayterm *ct, int row) { if (w < 1) w = 1; - if (cell_cmp(back, front)) { - /* copy to front */ + if (!cell_cmp(back, front)) { + x += w; + continue; + } + + emit_attr(ct, back->fg, back->bg); + + if (w > 1 && x >= ct->w - (w - 1)) { + /* wide char doesn't fit, send spaces */ *front = *back; + for (int i = x; i < ct->w; i++) + emit_ch(ct, i, y, row, ' '); + x += w; + continue; + } - emit_attr(ct, back->fg, back->bg); - - if (w > 1 && x >= ct->w - (w - 1)) { - /* wide char doesn't fit, send spaces */ - for (int i = x; i < ct->w; i++) - emit_ch(ct, i, y, row, ' '); - } else { - emit_ch(ct, x, y, row, back->ch); - /* mark trailing cells of wide char as invalid in front - * so they'll diff when overwritten by narrow chars */ - for (int i = 1; i < w; i++) { - Cell *fw = cell_at(ct, ct->front, x + i, y); - fw->ch = 0xffffffff; - fw->fg = 0xffffffff; - fw->bg = 0xffffffff; - } + if (w > 1) { + *front = *back; + emit_ch(ct, x, y, row, back->ch); + /* mark trailing cells of wide char as invalid in front + * so they'll diff when overwritten by narrow chars */ + for (int i = 1; i < w; i++) { + Cell *fw = cell_at(ct, ct->front, x + i, y); + fw->ch = 0xffffffff; + fw->fg = 0xffffffff; + fw->bg = 0xffffffff; } + x += w; + continue; } - x += w; + + /* width-1 cell: extend a run of identical changed cells so an + * identical horizontal span collapses to one glyph + REP (CSI b). */ + int run = 1; + for (int nx = x + 1; nx < ct->w; nx++) { + Cell *nb = cell_at(ct, ct->back, nx, y); + Cell *nf = cell_at(ct, ct->front, nx, y); + int nw = wcwidth(nb->ch); + if (nw > 1) + break; + if (!cell_cmp(nb, nf)) + break; + if (cell_cmp(nb, back)) + break; + run++; + } + + /* copy the whole run to the front buffer */ + for (int i = 0; i < run; i++) + *cell_at(ct, ct->front, x + i, y) = *back; + + int repeats = run - 1; + int cb = cp_utf8_len(glyph_cp(back->ch)); + /* inline = run*cb bytes; REP = cb + len("\x1b[") + digits + len("b"). + * Only collapse when REP is strictly smaller. */ + if (repeats >= 1 && run * cb > cb + 3 + num_digits(repeats)) { + emit_ch(ct, x, y, row, back->ch); + buf_str(&ct->out, "\x1b["); + buf_num(&ct->out, repeats); + buf_put(&ct->out, "b", 1); + ct->lastx = x + run - 1; + ct->lasty = y; + } else { + for (int i = 0; i < run; i++) + emit_ch(ct, x + i, y, row, back->ch); + } + x += run; } } } diff --git a/test/print.ts b/test/print.ts index 1801bb3..cd692b5 100644 --- a/test/print.ts +++ b/test/print.ts @@ -15,6 +15,7 @@ export function print(ansi: string, w: number, h: number): string { let x = 0; let y = 0; let i = 0; + let lastCh = " "; // preceding graphic character, for REP (CSI b) while (i < ansi.length) { if (ansi[i] === "\x1b" && ansi[i + 1] === "[") { @@ -34,6 +35,15 @@ export function print(ansi: string, w: number, h: number): string { let parts = params.split(";"); y = (parseInt(parts[0]) || 1) - 1; x = (parseInt(parts[1]) || 1) - 1; + } else if (cmd === "b") { + // REP: repeat the preceding graphic character `params` times + let n = parseInt(params) || 0; + for (let r = 0; r < n; r++) { + if (x >= 0 && x < w && y >= 0 && y < h) { + grid[y][x] = lastCh; + } + x++; + } } else if (cmd === "m") { // SGR — ignore } @@ -46,6 +56,7 @@ export function print(ansi: string, w: number, h: number): string { // regular character — could be multi-byte UTF-8 let cp = ansi.codePointAt(i)!; let ch = String.fromCodePoint(cp); + lastCh = ch; if (x >= 0 && x < w && y >= 0 && y < h) { grid[y][x] = ch; } diff --git a/test/rep.test.ts b/test/rep.test.ts new file mode 100644 index 0000000..3ea0c78 --- /dev/null +++ b/test/rep.test.ts @@ -0,0 +1,103 @@ +import { close, fixed, open, 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); +const trim = (s: string) => s.split("\n").map((l) => l.trimEnd()).join("\n"); + +// REP is "\x1b[b" — repeat the preceding graphic character n times. +function hasRep(ansi: string): boolean { + for ( + let i = ansi.indexOf("\x1b["); + i !== -1; + i = ansi.indexOf("\x1b[", i + 1) + ) { + let j = i + 2; + while (j < ansi.length && ansi[j] >= "0" && ansi[j] <= "9") j++; + if (j > i + 2 && ansi[j] === "b") return true; + } + return false; +} + +describe("REP (CSI b) coalescing", () => { + it("coalesces a horizontal run of identical cells into a REP sequence", async () => { + let term = await createTerm({ width: 40, height: 3 }); + + // A bordered box: the top edge is ┌ + 36×─ + ┐ — a long run of the + // identical box-drawing cell that REP can collapse. + let ansi = decode( + term.render([ + open("box", { + layout: { width: fixed(38), height: fixed(3), direction: "ttb" }, + border: { + color: rgba(255, 255, 255), + left: 1, + right: 1, + top: 1, + bottom: 1, + }, + }), + text("hi"), + close(), + ]).output, + ); + + expect(hasRep(ansi)).toBe(true); + }); + + it("renders the same grid the repeated cells would have produced", async () => { + let term = await createTerm({ width: 40, height: 3 }); + + let out = print( + decode( + term.render([ + open("box", { + layout: { width: fixed(38), height: fixed(3), direction: "ttb" }, + border: { + color: rgba(255, 255, 255), + left: 1, + right: 1, + top: 1, + bottom: 1, + }, + }), + text("hi"), + close(), + ]).output, + ), + 40, + 3, + ); + + expect(trim(out).split("\n")[0]).toBe("┌" + "─".repeat(36) + "┐"); + }); + + it("does not use REP for a short single-byte run that would not save bytes", async () => { + let term = await createTerm({ width: 40, height: 1 }); + + // Frame 1: a full row of 'a'. Frame 2: change the first 5 cells to 'b'. + // The diff is an isolated 5-cell run — REP (\x1b[4b) is break-even, so + // emitting "bbbbb" inline is preferred. + term.render([text("a".repeat(40))]); + let ansi = decode( + term.render([text("b".repeat(5) + "a".repeat(35))]).output, + ); + + expect(ansi).toContain("bbbbb"); + expect(hasRep(ansi)).toBe(false); + }); + + it("uses REP once a single-byte run is long enough to save bytes", async () => { + let term = await createTerm({ width: 40, height: 1 }); + + // Frame 2 changes the first 6 cells to 'b'. run*1=6 > 1+3+1=5, so the + // run collapses to one 'b' + \x1b[5b. + term.render([text("a".repeat(40))]); + let ansi = decode( + term.render([text("b".repeat(6) + "a".repeat(34))]).output, + ); + + expect(ansi).toContain("\x1b[5b"); + }); +});