diff --git a/input-native.ts b/input-native.ts index 32dda2c..d0a644f 100644 --- a/input-native.ts +++ b/input-native.ts @@ -176,6 +176,7 @@ export interface InputNative { */ export interface InputAttach { memory: WebAssembly.Memory; + exports: Record; structPtr: number; bytesPtr: number; bytesLen: number; @@ -190,19 +191,28 @@ export async function createInputNative( ): Promise { let memory = attach?.memory ?? new WebAssembly.Memory({ initial: 4 }); - let instance = await WebAssembly.instantiate(compiled, { - env: { memory }, - clay: { - measureTextFunction() {}, - queryScrollOffsetFunction(ret: number) { - let v = new DataView(memory.buffer); - v.setFloat32(ret, 0, true); - v.setFloat32(ret + 4, 0, true); + let raw: unknown; + if (attach) { + // Reuse the handle's instance: instantiating the module again over + // the shared memory would rewrite its data segments and clobber + // static state already initialized there. + raw = attach.exports; + } else { + let instance = await WebAssembly.instantiate(compiled, { + env: { memory }, + clay: { + measureTextFunction() {}, + queryScrollOffsetFunction(ret: number) { + let v = new DataView(memory.buffer); + v.setFloat32(ret, 0, true); + v.setFloat32(ret + 4, 0, true); + }, }, - }, - }); + }); + raw = instance.exports; + } - let exports = instance.exports as unknown as { + let exports = raw as { __heap_base: WebAssembly.Global; input_size(): number; input_init( diff --git a/src/clayterm.c b/src/clayterm.c index 9464669..74e76ff 100644 --- a/src/clayterm.c +++ b/src/clayterm.c @@ -84,6 +84,13 @@ struct Clayterm { Clay_ErrorData errors[MAX_ERRORS]; int error_count; int animating_count; + /* capability struct (shared or private) and the generation of the + * last emitted frame; a mismatch forces a full redraw */ + struct TermInfo *ti; + struct TermInfo ti_private; + uint32_t ti_generation; + /* color encoding for the current frame: 0 truecolor, 1 = 256, 2 = 16 */ + int depth; }; /* Memory layout inside the arena provided by the host: @@ -93,6 +100,8 @@ struct Clayterm { * full-screen redraws with truecolor SGR sequences on every cell. */ #define OUT_BYTES_PER_CELL 64 +/* headroom for the frame-scoped synchronized-output wrap */ +#define OUT_WRAP_BYTES 32 /* ── Cell buffer ops ──────────────────────────────────────────────── */ @@ -120,6 +129,80 @@ static void setcell(struct Clayterm *ct, int x, int y, uint32_t ch, uint32_t fg, /* ── Escape sequence generation ───────────────────────────────────── */ +static int dist2(int r1, int g1, int b1, int r2, int g2, int b2) { + int dr = r1 - r2, dg = g1 - g2, db = b1 - b2; + return dr * dr + dg * dg + db * db; +} + +/* Nearest xterm 256-color palette entry: 6x6x6 cube (levels 0, 95, 135, + * 175, 215, 255) vs 24-step grayscale ramp (8, 18, … 238), whichever is + * closer. Matches the tmux/xterm colour_find_rgb approach. */ +static int xterm256(int r, int g, int b) { + static const int q2c[6] = {0, 95, 135, 175, 215, 255}; + int qr = r < 48 ? 0 : r < 115 ? 1 : (r - 35) / 40; + int qg = g < 48 ? 0 : g < 115 ? 1 : (g - 35) / 40; + int qb = b < 48 ? 0 : b < 115 ? 1 : (b - 35) / 40; + + int avg = (r + g + b) / 3; + int gi = avg > 238 ? 23 : (avg - 3) / 10; + if (gi < 0) + gi = 0; + int gray = 8 + 10 * gi; + + if (dist2(r, g, b, gray, gray, gray) < + dist2(r, g, b, q2c[qr], q2c[qg], q2c[qb])) + return 232 + gi; + return 16 + 36 * qr + 6 * qg + qb; +} + +/* Nearest of the 16 ANSI colors (xterm default palette). */ +static int ansi16(int r, int g, int b) { + static const uint8_t palette[16][3] = { + {0, 0, 0}, {205, 0, 0}, {0, 205, 0}, {205, 205, 0}, + {0, 0, 238}, {205, 0, 205}, {0, 205, 205}, {229, 229, 229}, + {127, 127, 127}, {255, 0, 0}, {0, 255, 0}, {255, 255, 0}, + {92, 92, 255}, {255, 0, 255}, {0, 255, 255}, {255, 255, 255}, + }; + int best = 0; + int best_d = 0x7fffffff; + for (int i = 0; i < 16; i++) { + int d = dist2(r, g, b, palette[i][0], palette[i][1], palette[i][2]); + if (d < best_d) { + best_d = d; + best = i; + } + } + return best; +} + +/* Emit one color under the frame's encoding ladder (renderer-spec 7.6): + * truecolor, 256-color, or 16-color SGR. */ +static void emit_color(struct Clayterm *ct, uint32_t c, int is_bg) { + int r = (int)((c >> 16) & 0xff); + int g = (int)((c >> 8) & 0xff); + int b = (int)(c & 0xff); + + if (ct->depth == 0) { + buf_str(&ct->out, is_bg ? "\x1b[48;2;" : "\x1b[38;2;"); + buf_num(&ct->out, r); + buf_put(&ct->out, ";", 1); + buf_num(&ct->out, g); + buf_put(&ct->out, ";", 1); + buf_num(&ct->out, b); + buf_put(&ct->out, "m", 1); + } else if (ct->depth == 1) { + buf_str(&ct->out, is_bg ? "\x1b[48;5;" : "\x1b[38;5;"); + buf_num(&ct->out, xterm256(r, g, b)); + buf_put(&ct->out, "m", 1); + } else { + int i = ansi16(r, g, b); + int code = i < 8 ? (is_bg ? 40 : 30) + i : (is_bg ? 100 : 90) + (i - 8); + buf_str(&ct->out, "\x1b["); + buf_num(&ct->out, code); + buf_put(&ct->out, "m", 1); + } +} + static void emit_attr(struct Clayterm *ct, uint32_t fg, uint32_t bg) { if (fg == ct->lastfg && bg == ct->lastbg) return; @@ -143,27 +226,11 @@ static void emit_attr(struct Clayterm *ct, uint32_t fg, uint32_t bg) { if (fg & ATTR_STRIKEOUT) buf_str(&ct->out, "\x1b[9m"); - /* foreground truecolor */ - if (!(fg & ATTR_DEFAULT)) { - buf_str(&ct->out, "\x1b[38;2;"); - buf_num(&ct->out, (fg >> 16) & 0xff); - buf_put(&ct->out, ";", 1); - buf_num(&ct->out, (fg >> 8) & 0xff); - buf_put(&ct->out, ";", 1); - buf_num(&ct->out, fg & 0xff); - buf_put(&ct->out, "m", 1); - } + if (!(fg & ATTR_DEFAULT)) + emit_color(ct, fg, 0); - /* background truecolor */ - if (!(bg & ATTR_DEFAULT)) { - buf_str(&ct->out, "\x1b[48;2;"); - buf_num(&ct->out, (bg >> 16) & 0xff); - buf_put(&ct->out, ";", 1); - buf_num(&ct->out, (bg >> 8) & 0xff); - buf_put(&ct->out, ";", 1); - buf_num(&ct->out, bg & 0xff); - buf_put(&ct->out, "m", 1); - } + if (!(bg & ATTR_DEFAULT)) + emit_color(ct, bg, 1); ct->lastfg = fg; ct->lastbg = bg; @@ -456,7 +523,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_WRAP_BYTES; int clay_bytes = (int)Clay_MinMemorySize(); return align8((int)sizeof(struct Clayterm)) + align8(cell_bytes) /* front */ + align8(cell_bytes) /* back */ @@ -513,11 +580,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, struct TermInfo *ti) { 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_WRAP_BYTES); char *base = (char *)mem + align8((int)sizeof(struct Clayterm)); char *clay_mem = base + cell_bytes * 2 + out_bytes; @@ -532,13 +599,17 @@ 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_WRAP_BYTES}, .lastfg = 0xffffffff, .lastbg = 0xffffffff, .lastx = -1, .lasty = -1, }; + ct->ti = ti ? ti : terminfo_init(&ct->ti_private); + ct->ti_generation = ct->ti->generation; + // initialize back buffer with spaces and default fg/bg cells_fill(ct->back, w, h, ' ', ATTR_DEFAULT, ATTR_DEFAULT); @@ -713,6 +784,21 @@ void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row, ct->clip_depth_exceeded = 0; ct->clipping = 0; + /* capability gating: pick the color encoding for this frame, and on a + * generation change invalidate the front buffer so the frame is + * emitted as a complete redraw under the new capabilities */ + if (ct->ti->flags & TERMINFO_TRUECOLOR) { + ct->depth = 0; + } else if (ct->ti->colors >= 256) { + ct->depth = 1; + } else { + ct->depth = 2; + } + if (ct->ti->generation != ct->ti_generation) { + ct->ti_generation = ct->ti->generation; + cells_fill(ct->front, ct->w, ct->h, 0, 0, 0); + } + cells_fill(ct->back, ct->w, ct->h, ' ', ATTR_DEFAULT, ATTR_DEFAULT); /* walk Clay render commands into back buffer */ @@ -799,7 +885,20 @@ void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row, if (mode == 1) { present_lines(ct); } else { + /* synchronized output (renderer-spec 7.6): frame-scoped wrap, only + * when confirmed and only around non-empty output */ + int sync = (ct->ti->flags & TERMINFO_SYNC) != 0; + if (sync) + buf_str(&ct->out, "\x1b[?2026h"); + int start = ct->out.length; present_cups(ct, row); + if (sync) { + if (ct->out.length == start) { + ct->out.length = 0; + } else { + buf_str(&ct->out, "\x1b[?2026l"); + } + } } ct_active_context = NULL; diff --git a/src/clayterm.h b/src/clayterm.h index 8a24db4..ae822b8 100644 --- a/src/clayterm.h +++ b/src/clayterm.h @@ -6,12 +6,13 @@ #include #include "cell.h" +#include "terminfo.h" 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, struct TermInfo *ti); 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..e84a6ea 100644 --- a/term-native.ts +++ b/term-native.ts @@ -50,42 +50,62 @@ export interface Native { import { compiled } from "./wasm.ts"; +/** + * Attachment surface provided by a TermInfo handle: the shared memory, + * its bump allocator, and the capability struct pointer. See + * terminfo.ts internals(). + */ +export interface TermAttach { + memory: WebAssembly.Memory; + exports: Record; + structPtr: number; + alloc(size: number, align?: number): number; +} + export async function createTermNative( w: number, h: number, + attach?: TermAttach, ): Promise { - let memory = new WebAssembly.Memory({ initial: 2 }); + let memory = attach?.memory ?? new WebAssembly.Memory({ initial: 2 }); let exports: Record = {}; - let instance = await WebAssembly.instantiate(compiled, { - env: { memory }, - clay: { - measureTextFunction( - ret: number, - text: number, - _config: number, - _userData: number, - ) { - exports.measure(ret, text); - }, - queryScrollOffsetFunction( - ret: number, - _elementId: number, - _userData: number, - ) { - let view = new DataView(memory.buffer); - view.setFloat32(ret, 0, true); - view.setFloat32(ret + 4, 0, true); + if (attach) { + // Reuse the handle's instance: instantiating the module again over + // the shared memory would rewrite its data segments and clobber + // static state already initialized there. + Object.assign(exports, attach.exports); + } else { + let instance = await WebAssembly.instantiate(compiled, { + env: { memory }, + clay: { + measureTextFunction( + ret: number, + text: number, + _config: number, + _userData: number, + ) { + exports.measure(ret, text); + }, + queryScrollOffsetFunction( + ret: number, + _elementId: number, + _userData: number, + ) { + let view = new DataView(memory.buffer); + view.setFloat32(ret, 0, true); + view.setFloat32(ret + 4, 0, true); + }, }, - }, - }); + }); - Object.assign(exports, instance.exports); + Object.assign(exports, instance.exports); + } 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, ti: number): number; reduce( ct: number, buf: number, @@ -108,24 +128,32 @@ export async function createTermNative( error_message_ptr(ct: number, index: number): number; }; - let heap = ct.__heap_base.value as number; let size = ct.clayterm_size(w, h); - // Grow memory once to fit heap + renderer state + fixed transfer buffer. // The transfer budget is intentionally fixed: text/id/snapshot payload bytes // get 1MB, and fixed op overhead gets one max-sized element per Clay element. // Do not grow this dynamically per render; improve the wire format instead. let transferBytes = TEXT_TRANSFER_BUFFER_BYTES + CLAY_DEFAULT_MAX_ELEMENT_COUNT * MAX_FIXED_ELEMENT_WIRE_BYTES; - let needed = heap + size + transferBytes; - let pages = Math.ceil(needed / WASM_PAGE_BYTES); - let current = memory.buffer.byteLength / WASM_PAGE_BYTES; - if (pages > current) { - memory.grow(pages - current); - } - let statePtr = ct.init(heap, w, h); - let opsBuf = (heap + size + 3) & ~3; + let statePtr: number; + let opsBuf: number; + if (attach) { + let arena = attach.alloc(size); + opsBuf = attach.alloc(transferBytes, 4); + statePtr = ct.init(arena, w, h, attach.structPtr); + } else { + // Grow memory once to fit heap + renderer state + fixed transfer buffer. + let heap = ct.__heap_base.value as number; + let needed = heap + size + transferBytes; + let pages = Math.ceil(needed / WASM_PAGE_BYTES); + let current = memory.buffer.byteLength / WASM_PAGE_BYTES; + if (pages > current) { + memory.grow(pages - current); + } + statePtr = ct.init(heap, w, h, 0); + opsBuf = (heap + size + 3) & ~3; + } return { memory, diff --git a/term.ts b/term.ts index 3b8ddef..b1f0bf2 100644 --- a/term.ts +++ b/term.ts @@ -1,9 +1,25 @@ import { type Op, pack } from "./ops.ts"; -import { type BoundingBox, createTermNative } from "./term-native.ts"; +import { + type BoundingBox, + createTermNative, + type TermAttach, +} from "./term-native.ts"; +import { internals, type TermInfo } from "./terminfo.ts"; export interface TermOptions { height: number; width: number; + + /** + * TermInfo handle from queryTermInfo(). Attaches the Term to the + * handle's shared capability struct, which gates emission (color + * encoding ladder, synchronized output, full redraw on capability + * change — see specs/renderer-spec.md section 7.6). + * + * If no handle is provided the Term uses the baseline capabilities + * (256-color emission). + */ + terminfo?: TermInfo; } export interface RenderOptions { @@ -74,8 +90,19 @@ export interface Term { } export async function createTerm(options: TermOptions): Promise { - let { width, height } = options; - let native = await createTermNative(width, height); + let { width, height, terminfo } = options; + + let attach: TermAttach | undefined; + if (terminfo) { + let ti = internals(terminfo); + if (ti.termAttached) { + throw new Error("TermInfo handle is already attached to a Term"); + } + ti.termAttached = true; + attach = ti; + } + + let native = await createTermNative(width, height, attach); let { memory, statePtr, opsBuf } = native; let prev = new Set(); diff --git a/terminfo.ts b/terminfo.ts index 54056b1..adc6027 100644 --- a/terminfo.ts +++ b/terminfo.ts @@ -145,6 +145,13 @@ const PROBE = encoder.encode( interface TermInfoNative { memory: WebAssembly.Memory; + /** + * The handle's WASM instance exports. Attached consumers reuse this + * instance rather than instantiating the module again: instantiation + * rewrites the module's data segments, which would clobber the static + * state of anything already initialized in the shared memory. + */ + exports: Record; structPtr: number; bytesPtr: number; bytesLen: number; @@ -228,10 +235,13 @@ export async function queryTermInfo( } let memory = new WebAssembly.Memory({ initial: 4 }); + let allExports: Record = {}; let instance = await WebAssembly.instantiate(compiled, { env: { memory }, clay: { - measureTextFunction() {}, + measureTextFunction(ret: number, text: number) { + allExports.measure(ret, text); + }, queryScrollOffsetFunction(ret: number) { let view = new DataView(memory.buffer); view.setFloat32(ret, 0, true); @@ -239,6 +249,7 @@ export async function queryTermInfo( }, }, }); + Object.assign(allExports, instance.exports); let exports = instance.exports as unknown as { __heap_base: WebAssembly.Global; @@ -278,7 +289,14 @@ export async function queryTermInfo( exports.terminfo_grant(structPtr, FLAG_TRUECOLOR); } - let native: TermInfoNative = { memory, structPtr, bytesPtr, bytesLen, alloc }; + let native: TermInfoNative = { + memory, + exports: allExports, + structPtr, + bytesPtr, + bytesLen, + alloc, + }; let handle: TermInfo = { get capabilities(): Capabilities { diff --git a/test/border.test.ts b/test/border.test.ts index 3b78100..222b819 100644 --- a/test/border.test.ts +++ b/test/border.test.ts @@ -1,5 +1,10 @@ import { close, fixed, open, type OpenElement, rgba } from "../ops.ts"; import { createTerm } from "../term.ts"; +import { trueColorTermInfo } from "./caps.ts"; + +async function trueColorTerm(options: { width: number; height: number }) { + return await createTerm({ ...options, terminfo: await trueColorTermInfo() }); +} import { describe, expect, it } from "./suite.ts"; const decode = (b: Uint8Array) => new TextDecoder().decode(b); @@ -107,7 +112,7 @@ type OpenProps = Omit; * 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 { - let term = await createTerm({ width: 12, height: 5 }); + let term = await trueColorTerm({ width: 12, height: 5 }); let ansi = decode( term.render([ open("box", { @@ -256,7 +261,7 @@ describe("structured sides", () => { }); it("does not retain a prior frame's side bg", async () => { - let term = await createTerm({ width: 12, height: 5 }); + let term = await trueColorTerm({ width: 12, height: 5 }); let frame = (bg?: number) => [ open("box", { layout: { width: fixed(8), height: fixed(4) }, @@ -442,8 +447,8 @@ describe("directive model", () => { 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 a = await trueColorTerm({ width: 12, height: 5 }); + let b = await trueColorTerm({ width: 12, height: 5 }); let frame = (top: number, bottom: number) => [ open("box", { diff --git a/test/caps.ts b/test/caps.ts new file mode 100644 index 0000000..3fb645e --- /dev/null +++ b/test/caps.ts @@ -0,0 +1,20 @@ +/** + * Test helpers for building TermInfo handles without a filesystem or a + * TTY: streams are non-TTY mocks so queryTermInfo never probes. + */ + +import { queryTermInfo, type QueryTermInfoOptions } from "../terminfo.ts"; + +export function offlineTermInfo(options: Partial = {}) { + return queryTermInfo({ + env: {}, + input: { isTTY: false, on() {}, off() {} }, + output: { isTTY: false, write() {} }, + ...options, + }); +} + +/** A handle with truecolor granted via COLORTERM evidence. */ +export function trueColorTermInfo() { + return offlineTermInfo({ env: { COLORTERM: "truecolor" } }); +} diff --git a/test/color.test.ts b/test/color.test.ts index 9741243..983787d 100644 --- a/test/color.test.ts +++ b/test/color.test.ts @@ -1,6 +1,8 @@ import { close, fixed, grow, open, rgba, text } from "../ops.ts"; import { createTerm } from "../term.ts"; import { describe, expect, it } from "./suite.ts"; +import { offlineTermInfo, trueColorTermInfo } from "./caps.ts"; +import { CLAYTERM_16 } from "./fixtures.ts"; const decode = (b: Uint8Array) => new TextDecoder().decode(b); @@ -80,9 +82,13 @@ describe("foreground", () => { }); }); +async function trueColorTerm(options: { width: number; height: number }) { + return await createTerm({ ...options, terminfo: await trueColorTermInfo() }); +} + describe("background", () => { it("fills border cells with the requested border-level bg", async () => { - let term = await createTerm({ width: 12, height: 4 }); + let term = await trueColorTerm({ width: 12, height: 4 }); let bg = randomBgColor(); let ansi = decode( term.render([ @@ -112,7 +118,7 @@ describe("background", () => { }); it("leaves existing border-cell bg unchanged when border bg is omitted", async () => { - let term = await createTerm({ width: 12, height: 4 }); + let term = await trueColorTerm({ width: 12, height: 4 }); let bg = randomBgColor(); let ansi = decode( term.render([ @@ -140,7 +146,7 @@ describe("background", () => { }); it("fills glyph cells with the requested text-level bg", async () => { - let term = await createTerm({ width: 20, height: 1 }); + let term = await trueColorTerm({ width: 20, height: 1 }); let bg = randomBgColor(); let ansi = decode( term.render([ @@ -155,7 +161,7 @@ describe("background", () => { }); it("resets border bg on subsequent frames without border bg", async () => { - let term = await createTerm({ width: 12, height: 4 }); + let term = await trueColorTerm({ width: 12, height: 4 }); let bg = randomBgColor(); // Frame 1: border with bg @@ -198,7 +204,7 @@ describe("background", () => { }); it("resets the background before writing trailing cells", async () => { - let term = await createTerm({ width: 20, height: 1 }); + let term = await trueColorTerm({ width: 20, height: 1 }); let bg = randomBgColor(); let ansi = decode( term.render([ @@ -219,3 +225,55 @@ describe("background", () => { expect(afterHi.startsWith("\x1b[0m ")).toBe(true); }); }); + +describe("capability-gated color encoding", () => { + let OPS = [ + open("root", { layout: { width: grow(), height: grow() } }), + text("hi", { color: rgba(255, 0, 0), bg: rgba(0, 0, 255) }), + close(), + ]; + + it("emits 256-color SGR with no terminfo handle (baseline)", async () => { + let term = await createTerm({ width: 12, height: 1 }); + let ansi = decode(term.render(OPS).output); + + expect(ansi).toContain("\x1b[38;5;196m"); + expect(ansi).toContain("\x1b[48;5;21m"); + expect(ansi).not.toContain("38;2"); + expect(ansi).not.toContain("48;2"); + }); + + it("emits truecolor SGR when truecolor evidence is present", async () => { + let terminfo = await trueColorTermInfo(); + let term = await createTerm({ width: 12, height: 1, terminfo }); + let ansi = decode(term.render(OPS).output); + + expect(ansi).toContain("\x1b[38;2;255;0;0m"); + expect(ansi).toContain("\x1b[48;2;0;0;255m"); + }); + + it("emits 16-color SGR when the terminfo entry reports 16 colors", async () => { + let terminfo = await offlineTermInfo({ terminfo: CLAYTERM_16 }); + let term = await createTerm({ width: 12, height: 1, terminfo }); + let ansi = decode(term.render(OPS).output); + + expect(ansi).toContain("\x1b[91m"); + expect(ansi).toContain("\x1b[44m"); + expect(ansi).not.toContain("38;5"); + expect(ansi).not.toContain("38;2"); + }); + + it("maps grays onto the 256-color grayscale ramp", async () => { + let term = await createTerm({ width: 12, height: 1 }); + let ansi = decode( + term.render([ + open("root", { layout: { width: grow(), height: grow() } }), + text("hi", { color: rgba(128, 128, 128) }), + close(), + ]).output, + ); + + // (128,128,128) sits on the grayscale ramp: 232 + (128-8)/10 = 244 + expect(ansi).toContain("\x1b[38;5;244m"); + }); +}); diff --git a/test/term.test.ts b/test/term.test.ts index 121ece2..6d12a91 100644 --- a/test/term.test.ts +++ b/test/term.test.ts @@ -1,5 +1,7 @@ import { beforeEach, describe, expect, it } from "./suite.ts"; import { createTerm, type Term } from "../term.ts"; +import { createInput } from "../input.ts"; +import { offlineTermInfo } from "./caps.ts"; import { close, fixed, @@ -53,9 +55,10 @@ describe("term", () => { ); // the SGR active when "h" is emitted should include the - // parent's red background (48;2;255;0;0), not terminal default + // parent's red background (48;5;196 under baseline 256-color + // capabilities), not terminal default let before = ansi.slice(0, ansi.indexOf("h")); - expect(before).toContain("\x1b[48;2;255;0;0"); + expect(before).toContain("\x1b[48;5;196"); }); it("renders borders and padding", () => { @@ -395,3 +398,79 @@ describe("term", () => { }); }); }); + +describe("capability generation", () => { + let OPS = [ + open("root", { layout: { width: grow(), height: grow() } }), + text("hi", { color: rgba(255, 0, 0) }), + close(), + ]; + + it("emits nothing for an unchanged frame", async () => { + let terminfo = await offlineTermInfo(); + let term = await createTerm({ width: 12, height: 2, terminfo }); + term.render(OPS); + expect(term.render(OPS).output.length).toBe(0); + }); + + it("forces a full redraw when capabilities change between frames", async () => { + let terminfo = await offlineTermInfo(); + let term = await createTerm({ width: 12, height: 2, terminfo }); + let input = await createInput({ terminfo }); + + term.render(OPS); + expect(term.render(OPS).output.length).toBe(0); + + // an XTGETTCAP reply confirms truecolor mid-session + input.scan(new TextEncoder().encode("\x1bP1+r524742\x1b\\")); + + let ansi = new TextDecoder().decode(term.render(OPS).output); + expect(ansi).toContain("hi"); + expect(ansi).toContain("\x1b[38;2;255;0;0m"); + }); +}); + +describe("synchronized output", () => { + let OPS = [ + open("root", { layout: { width: grow(), height: grow() } }), + text("hi", { color: rgba(255, 0, 0) }), + close(), + ]; + + async function syncTerm() { + let terminfo = await offlineTermInfo(); + let term = await createTerm({ width: 12, height: 2, terminfo }); + let input = await createInput({ terminfo }); + input.scan(new TextEncoder().encode("\x1b[?2026;2$y")); + expect(terminfo.capabilities.syncOutput).toBe(true); + return term; + } + + it("wraps non-empty frames when syncOutput is confirmed", async () => { + let term = await syncTerm(); + let ansi = new TextDecoder().decode(term.render(OPS).output); + expect(ansi.startsWith("\x1b[?2026h")).toBe(true); + expect(ansi.endsWith("\x1b[?2026l")).toBe(true); + }); + + it("does not wrap when syncOutput is unconfirmed", async () => { + let terminfo = await offlineTermInfo(); + let term = await createTerm({ width: 12, height: 2, terminfo }); + let ansi = new TextDecoder().decode(term.render(OPS).output); + expect(ansi).not.toContain("2026"); + }); + + it("does not wrap empty frames", async () => { + let term = await syncTerm(); + term.render(OPS); + expect(term.render(OPS).output.length).toBe(0); + }); + + it("does not wrap line-mode output", async () => { + let term = await syncTerm(); + let ansi = new TextDecoder().decode( + term.render(OPS, { mode: "line" }).output, + ); + expect(ansi).not.toContain("2026"); + }); +});