From 36c03fc7c1fd43fef06ce4114cf73e3e97dca770 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Fri, 9 Oct 2026 15:07:35 -0500 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20img=20element=20=E2=80=94=20public?= =?UTF-8?q?=20surface,=20validation,=20and=20test=20suite?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Graphics Specification's public TypeScript API: - ops.ts: the void img() directive (image registry id, required alt, sizing axes, evidence-gated variant override, element bg) and its transfer encoding, mirroring the C decode. - term.ts: Term.setImage/removeImage - the two-phase native surface (begin validates and reserves at an absolute address, the host copies pixels, commit bumps the version) with closed RangeErrors; update() forwards the kitty-graphics capability into the renderer's mirror and surfaces the TINV-5 cleanup bytes the engine emits on denial and resize; createTerm takes imagePoolBytes. - validate.ts: the Img op shape (registry id range, required alt, variant union). - test/graphics.test.ts: the §14 suite - tier resolution, kitty conformance (chunking, replace cycles, steady-state silence, move re-places), cleanup/removal, capability invalidation, resize, ascii art determinism and alpha, alt tier, registry validation, input-stream cleanliness. --- .changeset/img-element.md | 15 ++ ops.ts | 94 ++++++++- term.ts | 128 +++++++++++- test/graphics.test.ts | 424 ++++++++++++++++++++++++++++++++++++++ validate.ts | 31 ++- 5 files changed, 685 insertions(+), 7 deletions(-) create mode 100644 .changeset/img-element.md create mode 100644 test/graphics.test.ts diff --git a/.changeset/img-element.md b/.changeset/img-element.md new file mode 100644 index 0000000..4db01f3 --- /dev/null +++ b/.changeset/img-element.md @@ -0,0 +1,15 @@ +--- +"@bomb.sh/tty": minor +--- + +Adds the `img()` directive — declarative images rendered through a fidelity ladder: kitty graphics protocol pixels, shape-aware ASCII character art, or alt text, resolved from capability evidence (`RuntimeCapabilities.kittyGraphics`). + +Surfaces are caller-owned decoded RGBA buffers managed by the Term: + +```ts +term.setImage(1, { width: 128, height: 64, pixels: rgba8 }); +term.removeImage(1); // returns deletion bytes to write now +term.render([img("cover", { image: 1, alt: "cover art" })]); +``` + +`createTerm` takes an `imagePoolBytes` option (default 4 MiB) sizing the fixed pixel pool. Transmission is lazy; re-transmission only on data change; steady-state renders are byte-silent. `update()` emits cleanup bytes immediately when a capability denial or resize kills live placements (TINV-5). Line mode caps the ladder at ascii. Errors surface as `IMAGE_NOT_FOUND`, `IMAGE_PLACEMENTS_EXCEEDED`, and `IMAGE_PLACEMENT_COLLISION`. diff --git a/ops.ts b/ops.ts index e89b291..4e1a705 100644 --- a/ops.ts +++ b/ops.ts @@ -25,6 +25,8 @@ const OP_OPEN_ELEMENT = 0x02; const OP_TEXT = 0x03; const OP_CLOSE_ELEMENT = 0x04; const OP_SNAPSHOT = 0x05; +/* the void image directive (Graphics Specification §6.1) */ +const OP_IMG = 0x06; /* Property group masks for OPEN_ELEMENT */ const PROP_LAYOUT = 0x01; @@ -323,6 +325,37 @@ export function pack( o = packString(view, str, o, end, "text content"); break; } + + case OP_IMG: { + view.setUint32(o, OP_IMG, true); + o += 4; + + o = packString(view, encoder.encode(op.id), o, end, "element id"); + + // image registry id; 0 = omitted (alt-only element) + view.setUint32(o, op.image ?? 0, true); + o += 4; + + // variant (low byte) | bg_flag (byte 1) | pad + view.setUint32( + o, + IMG_VARIANT_CODE[op.variant ?? "auto"] | + ((op.bg !== undefined ? 1 : 0) << 8), + true, + ); + o += 4; + + o = packAxis(view, o, op.width ?? fit()); + o = packAxis(view, o, op.height ?? fit()); + + o = packString(view, encoder.encode(op.alt), o, end, "alt text"); + + if (op.bg !== undefined) { + view.setUint32(o, op.bg, true); + o += 4; + } + break; + } } if (o > end) { throw new RangeError( @@ -488,12 +521,61 @@ export interface Text { caret?: number; } +export type ImgVariant = "auto" | "kitty" | "ascii" | "alt"; + +export interface ImgProps { + /** Registry id of the image to render. Omit for an alt-only element. */ + image?: number; + /** Required. Text fallback and description. "" = decorative. */ + alt: string; + width?: SizingAxis; + height?: SizingAxis; + /** Tier override; evidence-gated — falls through the auto ladder when the + * tier's evidence is missing (Graphics Specification §9.1). */ + variant?: ImgVariant; + /** Element background, same value space as open({ bg }). */ + bg?: number; +} + +/** The void image element: one self-closing directive, no children, not + * paired with close() (Graphics Specification §6.1). */ +export interface Img { + directive: typeof OP_IMG; + id: string; + image?: number; + alt: string; + width?: SizingAxis; + height?: SizingAxis; + variant?: ImgVariant; + bg?: number; +} + +const IMG_VARIANT_CODE: Record = { + auto: 0, + kitty: 1, + ascii: 2, + alt: 3, +}; + +export function img(id: string, props: ImgProps): Img { + return { + directive: OP_IMG, + id, + image: props.image, + alt: props.alt, + width: props.width ?? fit(), + height: props.height ?? fit(), + variant: props.variant ?? "auto", + bg: props.bg, + }; +} + interface Snapshot { directive: typeof OP_SNAPSHOT; data: Uint8Array; } -export type Op = OpenElement | Text | CloseElement | Snapshot; +export type Op = OpenElement | Text | CloseElement | Snapshot | Img; export function open( id: string, @@ -542,6 +624,16 @@ function packSize(ops: Op[]): number { n += 4 + Math.ceil(encoder.encode(op.content).length / 4) * 4; // string break; } + case OP_IMG: { + n += 4; // opcode + n += 4 + Math.ceil(encoder.encode(op.id).length / 4) * 4; // id string + n += 4; // image registry id + n += 4; // variant | bg_flag | pad + n += 6 * 4; // width + height axes + n += 4 + Math.ceil(encoder.encode(op.alt).length / 4) * 4; // alt string + if (op.bg !== undefined) n += 4; // element background + break; + } } } return n; diff --git a/term.ts b/term.ts index 5326daa..8ce61b3 100644 --- a/term.ts +++ b/term.ts @@ -14,6 +14,21 @@ export interface TermOptions { * baseline. */ terminfo?: TerminalInfo; + /** + * Sizes the Term's carved image pixel pool (Graphics Specification §5.3). + * Default 4 MiB. The registry never grows at runtime (INV-I8). + */ + imagePoolBytes?: number; +} + +/** + * Decoded raster pixels for the image registry (Graphics Specification + * §6.2): straight (non-premultiplied) RGBA8, top-to-bottom rows. + */ +export interface ImageData { + width: number; // positive integer + height: number; // positive integer + pixels: Uint8Array; // exactly width * height * 4 bytes } /** @@ -126,6 +141,9 @@ const ERROR_TYPES = [ "UNBALANCED_OPEN_CLOSE", "CLIP_DEPTH_EXCEEDED", "COMBINING_MARKS_EXCEEDED", + "IMAGE_NOT_FOUND", + "IMAGE_PLACEMENTS_EXCEEDED", + "IMAGE_PLACEMENT_COLLISION", ] as const; export interface ClayError { @@ -157,17 +175,45 @@ export interface Term { */ update(events: readonly InputEvent[]): Uint8Array; + /** + * Put a decoded image into the Term's pixel-surface registry (Graphics + * Specification §6.2). Synchronous, byte-free: transmission is lazy, on + * the first render that places the image. Throws RangeError on + * validation failure or pool/registry exhaustion. Re-setting a live id + * replaces the image and re-transmits on the next placing render. + */ + setImage(id: number, data: ImageData): void; + + /** + * Remove a registry image. Returns bytes to write now: the terminal's + * data-freeing deletion block when the id was live, empty otherwise. + * Idempotent; the covered cells are reconciled so the next render + * repaints them. + */ + removeImage(id: number): Uint8Array; + /** Frozen snapshot of the current merged capability state. */ readonly capabilities: RuntimeCapabilities; } +const IMAGE_ERROR_MESSAGES: Record = { + [-1]: "registry id outside [1, 4294967295]", + [-2]: "width and height must be positive integers", + [-4]: "image registry table full", + [-5]: "image pixel pool exhausted (raise the imagePoolBytes option)", + [-6]: "unknown registry id", +}; + +function imageRangeError(n: number): RangeError { + return new RangeError( + `setImage failed: ${IMAGE_ERROR_MESSAGES[n] ?? `native error ${n}`}`, + ); +} + export async function createTerm(options: TermOptions): Promise { - let { width, height, terminfo } = options; + let { width, height, terminfo, imagePoolBytes } = options; - let native = await createTermNative( - width, - height, - ); + let native = await createTermNative(width, height, imagePoolBytes); let { memory } = native; let currentCaps: RuntimeCapabilities = runtimeFromStatic( @@ -283,6 +329,50 @@ export async function createTerm(options: TermOptions): Promise { return { output, events, info, errors, animating }; }, + setImage(id: number, data: ImageData): void { + if (!Number.isInteger(id) || id < 1 || id > 4294967295) { + throw new RangeError(`invalid image registry id ${id}`); + } + if ( + !Number.isInteger(data.width) || data.width < 1 || + !Number.isInteger(data.height) || data.height < 1 + ) { + throw new RangeError( + `invalid image dimensions ${data.width}x${data.height}`, + ); + } + if (data.pixels.length !== data.width * data.height * 4) { + throw new RangeError( + `image pixels length ${data.pixels.length} !== ${data.width} * ${data.height} * 4`, + ); + } + let addr = native.imageBegin( + native.statePtr, + id, + data.width, + data.height, + ); + if (addr < 0) { + throw imageRangeError(addr); + } + new Uint8Array(memory.buffer).set(data.pixels, addr); + let committed = native.imageCommit(native.statePtr, id); + if (committed < 0) { + throw imageRangeError(committed); + } + }, + + removeImage(id: number): Uint8Array { + let n = native.imageRemove(native.statePtr, id); + if (n <= 0) { + return new Uint8Array(0); + } + // copy: the native output buffer is reused by the next call; these + // bytes are the caller's to write now (TINV-5) + return new Uint8Array(memory.buffer, native.output(native.statePtr), n) + .slice(); + }, + update(events: readonly InputEvent[]): Uint8Array { let out: Uint8Array[] = []; @@ -298,6 +388,18 @@ export async function createTerm(options: TermOptions): Promise { throw new RangeError(`invalid terminal dimensions ${w}x${h}`); } if (w !== width || h !== height) { + // Graphics Specification §10.5: deletion bytes for live + // placements precede the placements record's discard + let prepared = native.graphicsResizePrepare(native.statePtr); + if (prepared > 0) { + out.push( + new Uint8Array( + memory.buffer, + native.output(native.statePtr), + prepared, + ).slice(), + ); + } width = w; height = h; native.update(width, height); @@ -307,6 +409,22 @@ export async function createTerm(options: TermOptions): Promise { lastRenderAt = undefined; wasAnimating = false; } + } else if ( + c.type === "capability" && c.key === "kitty-graphics" + ) { + // the renderer's capability mirror (terminfo-spec §4.2); the + // denial path emits §11.2's deletion bytes immediately (TINV-5) + let kitty = c.value ? 1 : 0; + let wrote = native.graphicsCapability(native.statePtr, kitty); + if (wrote > 0) { + out.push( + new Uint8Array( + memory.buffer, + native.output(native.statePtr), + wrote, + ).slice(), + ); + } } currentCaps = next; diff --git a/test/graphics.test.ts b/test/graphics.test.ts new file mode 100644 index 0000000..4e0a36f --- /dev/null +++ b/test/graphics.test.ts @@ -0,0 +1,424 @@ +/* test/graphics.test.ts — the Graphics Specification's test suite (§14). + * One spec, one test file. */ + +import { createInput } from "../input.ts"; +import { + close, + img, + type ImgProps, + type Op, + open, + type SizingAxis, +} from "../ops.ts"; +import { createTerm } from "../term.ts"; +import { describe, expect, it } from "./suite.ts"; +import { print } from "./print.ts"; + +const KITTY_GRANT = { + type: "capability", + key: "kitty-graphics", + value: true, +} as const; +const KITTY_DENY = { + type: "capability", + key: "kitty-graphics", + value: false, +} as const; + +/** Solid-color W×H RGBA image. */ +function solid(w: number, h: number, rgb: [number, number, number]): { + width: number; + height: number; + pixels: Uint8Array; +} { + const pixels = new Uint8Array(w * h * 4); + for (let i = 0; i < pixels.length; i += 4) { + pixels[i] = rgb[0]; + pixels[i + 1] = rgb[1]; + pixels[i + 2] = rgb[2]; + pixels[i + 3] = 255; + } + return { width: w, height: h, pixels }; +} + +async function term(w = 40, h = 12) { + return await createTerm({ width: w, height: h }); +} + +/** Render a frame and return the output as a string. */ +function frame(t: Awaited>, ops: Op[]): string { + return new TextDecoder().decode(t.render(ops).output); +} + +function grid(t: Awaited>, ops: Op[]): string[] { + const text = print(frame(t, ops), 40, 12); + return text.split("\n"); +} + +function stripApc(s: string): string { + return s.replace(/\x1b_G[^\x1b]*\x1b\\/g, ""); +} + +describe("graphics: tier resolution", () => { + it("renders ascii art without evidence (auto ladder)", async () => { + let t = await term(); + t.setImage(1, solid(8, 8, [255, 0, 0])); + let out = frame(t, [img("a", { image: 1, alt: "x" })]); + if (out.includes("\x1b_G")) throw new Error("kitty bytes without evidence"); + if (!out.includes("\x1b[38;2;255;0;0m")) throw new Error("no art color"); + }); + + it("kitty tier after the capability grant", async () => { + let t = await term(); + t.setImage(1, solid(8, 8, [255, 0, 0])); + t.update([KITTY_GRANT]); + let out = frame(t, [img("a", { image: 1, alt: "x" })]); + if (!out.includes("\x1b_Ga=t,t=d,f=32,i=1,s=8,v=8,")) { + throw new Error("no transmission block"); + } + if (!out.includes("\x1b_Ga=p,i=1,p=")) throw new Error("no placement"); + if (!out.includes("q=2")) throw new Error("not quiet"); + }); + + it("variant override: alt force, kitty fall-through, ascii direct", async () => { + let t = await term(); + t.setImage(1, solid(8, 8, [255, 0, 0])); + let alt = frame(t, [img("a", { image: 1, alt: "hart", variant: "alt" })]); + if (alt.includes("\x1b_G") || !alt.includes("hart")) { + throw new Error("variant alt not honored"); + } + let kitty = frame(t, [ + img("a", { image: 1, alt: "x", variant: "kitty" }), + ]); + if (kitty.includes("\x1b_G")) { + throw new Error("variant kitty ignored missing evidence"); + } + // fresh color: the identical art frame would diff to silence + t.setImage(2, solid(8, 8, [0, 255, 0])); + let ascii = frame(t, [ + img("a", { image: 2, alt: "x", variant: "ascii" }), + ]); + if (ascii.includes("\x1b_G") || !ascii.includes("\x1b[38;2;0;255;0m")) { + throw new Error("variant ascii not honored"); + } + }); + + it("image omitted renders alt-only without error", async () => { + let t = await term(); + let out = frame(t, [img("a", { alt: "chart: revenue" })]); + if (!out.includes("chart: revenue")) throw new Error("alt not rendered"); + let errs = t.render([img("a", { alt: "chart: revenue" })]).errors; + if (errs.length !== 0) throw new Error("unexpected error"); + }); + + it("missing registry id renders alt tier + IMAGE_NOT_FOUND", async () => { + let t = await term(); + let r = t.render([img("a", { image: 99, alt: "fallback" })]); + if (!new TextDecoder().decode(r.output).includes("fallback")) { + throw new Error("no alt fallback"); + } + let found = r.errors.some((e) => e.type === "IMAGE_NOT_FOUND"); + if (!found) throw new Error("IMAGE_NOT_FOUND not surfaced"); + }); + + it("line mode caps the ladder at ascii", async () => { + let t = await term(); + t.setImage(1, solid(8, 8, [255, 0, 0])); + t.update([KITTY_GRANT]); + let out = new TextDecoder().decode( + t.render([img("a", { image: 1, alt: "x" })], { mode: "line" }).output, + ); + if (out.includes("\x1b_G")) throw new Error("APC bytes in line mode"); + }); +}); + +describe("graphics: kitty conformance", () => { + it("steady state is byte-silent", async () => { + let t = await term(); + t.setImage(1, solid(8, 8, [255, 0, 0])); + t.update([KITTY_GRANT]); + frame(t, [img("a", { image: 1, alt: "x" })]); + let out = frame(t, [img("a", { image: 1, alt: "x" })]); + if (out.includes("\x1b_G")) throw new Error("re-emitted graphics bytes"); + }); + + it("chunks large payloads m=1/m=0 at 4096 base64 chars", async () => { + let t = await term(); + t.setImage(1, solid(40, 40, [0, 255, 0])); // 6400 bytes → 3 chunks + t.update([KITTY_GRANT]); + let out = frame(t, [img("a", { image: 1, alt: "x" })]); + let blocks = out.split("\x1b_G").length - 1; + if (blocks !== 4) { + throw new Error(`expected tx 3 chunks + 1 placement, got ${blocks}`); + } + let m1 = out.match(/m=1/g)?.length ?? 0; + let m0 = out.match(/m=0/g)?.length ?? 0; + if (m1 !== 2 || m0 !== 1) throw new Error(`m flags ${m1}/${m0}`); + let first = out.indexOf("a=t,t=d,f=32,i=1,s=40,v=40"); + if (first < 0) throw new Error("tx header missing"); + }); + + it("replace re-transmits with delete-before-transmit, re-places", async () => { + let t = await term(); + t.setImage(1, solid(8, 8, [255, 0, 0])); + t.update([KITTY_GRANT]); + frame(t, [img("a", { image: 1, alt: "x" })]); + t.setImage(1, solid(8, 8, [0, 0, 255])); // version bump + let out = frame(t, [img("a", { image: 1, alt: "x" })]); + let dIdx = out.indexOf("\x1b_Ga=d,d=I,i=1"); + let txIdx = out.indexOf("\x1b_Ga=t,t=d,f=32,i=1"); + let pIdx = out.indexOf("\x1b_Ga=p,i=1,p="); + if (dIdx < 0 || txIdx < 0 || pIdx < 0) { + throw new Error("replace cycle incomplete"); + } + if (!(dIdx < txIdx && txIdx < pIdx)) { + throw new Error("ordering: delete, transmit, place"); + } + }); + + it("move re-places with the same placement id, no delete/re-transmit", async () => { + let t = await term(40, 12); + t.setImage(1, solid(4, 4, [255, 0, 0])); + t.update([KITTY_GRANT]); + let one = frame(t, [img("a", { image: 1, alt: "x" })]); + let pid = one.match(/a=p,i=1,p=(\d+)/)?.[1]; + if (!pid) throw new Error("no pid"); + frame(t, [img("a", { image: 1, alt: "x" })]); // steady: silent + // move: the same directive inside a padded container shifts the + // element's box (Clay's root is the window, so an img-as-root cannot + // move: its footprint is window-derived) + let three = frame(t, [ + open("wrap", { layout: { padding: { left: 2, top: 2 } } }), + img("a", { image: 1, alt: "x" }), + close(), + ]); + if (!three.includes(`a=p,i=1,p=${pid},c=`)) { + throw new Error("move lost the placement id"); + } + if (three.includes("a=d,")) throw new Error("move emitted a deletion"); + if (three.includes("a=t,")) throw new Error("move re-transmitted"); + }); + + it("unmount deletes the scoped placement and repaints", async () => { + let t = await term(40, 12); + t.setImage(1, solid(4, 4, [255, 0, 0])); + t.update([KITTY_GRANT]); + let one = frame(t, [img("a", { image: 1, alt: "x" })]); + let pid = one.match(/a=p,i=1,p=(\d+)/)?.[1]; + let out = frame(t, []); // element gone + if (!out.includes(`a=d,d=i,i=1,p=${pid}`)) { + throw new Error("no scoped deletion on unmount"); + } + let g = print(out, 40, 12).split("\n"); + // the old footprint repaints as blank content (space), not graphics + if (g[0].slice(0, 4) !== " ") throw new Error("footprint not repainted"); + }); + + it("removeImage returns the d=I block and is idempotent", async () => { + let t = await term(); + t.setImage(1, solid(4, 4, [255, 0, 0])); + t.update([KITTY_GRANT]); + frame(t, [img("a", { image: 1, alt: "x" })]); + let bytes = new TextDecoder().decode(t.removeImage(1)); + if (!bytes.includes("\x1b_Ga=d,d=I,i=1")) throw new Error("no d=I"); + let again = t.removeImage(1); + if (again.length !== 0) throw new Error("not idempotent"); + }); + + it("removeImage without kitty evidence still deletes (earlier evidence)", async () => { + let t = await term(); + t.setImage(1, solid(4, 4, [255, 0, 0])); + t.update([KITTY_GRANT]); + frame(t, [img("a", { image: 1, alt: "x" })]); + t.update([KITTY_DENY]); // denial itself cleans + falls back + // after denial, re-remove: the entry is still live, so d=I flows again + let bytes = new TextDecoder().decode(t.removeImage(1)); + if (!bytes.includes("a=d,d=I,i=1")) throw new Error("denial ate removal"); + }); +}); + +describe("graphics: capability invalidation", () => { + it("denial returns immediate deletion bytes (TINV-5)", async () => { + let t = await term(); + t.setImage(1, solid(4, 4, [255, 0, 0])); + t.update([KITTY_GRANT]); + frame(t, [img("a", { image: 1, alt: "x" })]); + let bytes = new TextDecoder().decode(t.update([KITTY_DENY])); + if (!bytes.includes("\x1b_Ga=d,d=I,i=1")) { + throw new Error("no immediate denial bytes"); + } + }); + + it("grant then render emits kitty; denial render falls back to ascii", async () => { + let t = await term(); + t.setImage(1, solid(8, 8, [255, 0, 0])); + t.update([KITTY_GRANT]); + let kitty = frame(t, [img("a", { image: 1, alt: "x" })]); + if (!kitty.includes("\x1b_Ga=p")) throw new Error("grant not honored"); + t.update([KITTY_DENY]); + let ascii = frame(t, [img("a", { image: 1, alt: "x" })]); + if (ascii.includes("\x1b_G")) throw new Error("kitty after denial"); + if (!ascii.includes("\x1b[38;2;255;0;0m")) throw new Error("no ascii art"); + }); + + it("re-arming re-transmits (stored data is never trusted)", async () => { + let t = await term(); + t.setImage(1, solid(8, 8, [255, 0, 0])); + t.update([KITTY_GRANT]); + frame(t, [img("a", { image: 1, alt: "x" })]); + t.update([KITTY_DENY]); + t.update([KITTY_GRANT]); + let out = frame(t, [img("a", { image: 1, alt: "x" })]); + if (!out.includes("\x1b_Ga=t,")) { + throw new Error("re-arm skipped re-transmission"); + } + }); +}); + +describe("graphics: resize", () => { + it("resize returns deletion bytes; next render re-transmits + re-places", async () => { + let t = await term(40, 12); + t.setImage(1, solid(8, 8, [255, 0, 0])); + t.update([KITTY_GRANT]); + frame(t, [img("a", { image: 1, alt: "x" })]); + let bytes = new TextDecoder().decode( + t.update([{ type: "resize", width: 30, height: 10 }]), + ); + if (!bytes.includes("\x1b_Ga=d,d=I,i=1")) { + throw new Error("no resize deletion bytes"); + } + let out = frame(t, [img("a", { image: 1, alt: "x" })]); + if (!out.includes("\x1b_Ga=t,")) throw new Error("no re-transmission"); + if (!out.includes("\x1b_Ga=p,i=1,p=")) throw new Error("no re-place"); + }); +}); + +describe("graphics: ascii art", () => { + it("solid red square paints uniform density glyphs in red", async () => { + let t = await term(); + t.setImage(1, solid(8, 8, [255, 0, 0])); + let g = grid(t, [img("a", { image: 1, alt: "x" })]); + for (let y = 0; y < 4; y++) { + if (g[y].slice(0, 8) !== "::::::::") { + throw new Error(`row ${y}: ${JSON.stringify(g[y].slice(0, 8))}`); + } + } + // intrinsic size under the half-row convention: 8 cols × ceil(8/2)=4 rows + for (let y = 4; y < 12; y++) { + if (g[y].slice(0, 8) !== " ") { + throw new Error(`row ${y} painted outside the footprint`); + } + } + }); + + it("luminance ordering: dark square paints lower-density glyphs", async () => { + let t = await term(); + t.setImage(1, solid(8, 8, [40, 40, 40])); + let dark = grid(t, [img("a", { image: 1, alt: "x" })])[0].slice(0, 8); + t.setImage(2, solid(8, 8, [220, 220, 220])); + let light = grid(t, [img("b", { image: 2, alt: "x" })])[0].slice(0, 8); + if (dark === light) throw new Error("luminance did not change density"); + }); + + it("determinism: identical inputs, identical art bytes", async () => { + let a = await term(); + let b = await term(); + a.setImage(1, solid(8, 8, [90, 140, 200])); + b.setImage(1, solid(8, 8, [90, 140, 200])); + if ( + frame(a, [img("a", { image: 1, alt: "x" })]) !== + frame(b, [img("a", { image: 1, alt: "x" })]) + ) { + throw new Error("nondeterministic art"); + } + }); + + it("alpha below the threshold is transparent and keeps the background", async () => { + let t = await term(); + // element bg declared; image fully transparent + let px = new Uint8Array(8 * 8 * 4); // alpha 0 everywhere + t.setImage(1, { width: 8, height: 8, pixels: px }); + let bg = 0xff102030; // rgba(): alpha 255 = opaque declared bg + let out = frame(t, [img("a", { image: 1, alt: "x", bg })]); + let g = print(out, 40, 12).split("\n"); + if (g[0].slice(0, 8) !== " ") throw new Error("not blank"); + // the bg is element-declared; the blank cell carries it — asserted via + // the raw output's bg SGR + if (!out.includes("\x1b[48;2;16;32;48m")) { + throw new Error("element bg missing under transparent art"); + } + }); +}); + +describe("graphics: alt tier", () => { + it("empty alt renders an empty box (decorative)", async () => { + let t = await term(); + let g = print(frame(t, [img("a", { alt: "" })]), 40, 12).split("\n"); + for (let y = 0; y < 12; y++) { + if (g[y].trim() !== "") throw new Error("decorative img painted"); + } + }); +}); + +describe("graphics: registry API", () => { + it("setImage validation throws RangeError", async () => { + let t = await term(); + let bad = [-1, 0, 4294967296, 1.5] as number[]; + for (let id of bad) { + let threw = false; + try { + t.setImage(id, solid(2, 2, [0, 0, 0])); + } catch (e) { + threw = e instanceof RangeError; + } + if (!threw) throw new Error(`id ${id} accepted`); + } + let threw = false; + try { + t.setImage(1, { width: 2, height: 2, pixels: new Uint8Array(3) }); + } catch (e) { + threw = e instanceof RangeError; + } + if (!threw) throw new Error("length mismatch accepted"); + }); + + it("input.scan() sees zero events from the APC frames alone", async () => { + let t = await term(); + t.setImage(1, solid(8, 8, [255, 0, 0])); + t.update([KITTY_GRANT]); + let out = frame(t, [img("a", { image: 1, alt: "x" })]); + // INV-I5's claim is about the graphics frames themselves: fed to the + // input parser, an APC frame generates no responses of any kind + let apc = out.match(/\x1b_G[^\x1b]*\x1b\\/g) ?? []; + if (apc.length === 0) throw new Error("no APC frames to test"); + let input = await createInput(); + let { events } = input.scan(new TextEncoder().encode(apc.join(""))); + if (events.length !== 0) { + throw new Error(`input parser saw ${events.length} events`); + } + }); +}); + +describe("graphics: validation", () => { + it("img ops pass validate()", async () => { + let { validate } = await import("../validate.ts"); + let sizing: SizingAxis = { type: "fixed", value: 8 }; + if ( + !validate([ + img("a", { image: 1, alt: "x" }), + img("b", { alt: "", width: sizing, height: sizing, variant: "ascii" }), + ]) + ) { + throw new Error("valid img ops rejected"); + } + if (validate([img("c", { image: 0, alt: "x" })])) { + throw new Error("registry id 0 accepted"); + } + if (validate([img("d", { image: 1, alt: undefined as never })])) { + throw new Error("missing alt accepted"); + } + if (validate([img("e", { alt: "x", variant: "sixel" as never })])) { + throw new Error("bad variant accepted"); + } + }); +}); diff --git a/validate.ts b/validate.ts index d98c94a..978211d 100644 --- a/validate.ts +++ b/validate.ts @@ -205,7 +205,30 @@ const TextOp = Type.Object({ attrs: Type.Optional(u8), }); -const Ops = Type.Array(Type.Union([OpenElement, TextOp, CloseElement])); +/* The void image directive (Graphics Specification §6.1/§6.3): no children, + * not part of open/close balance. `image` is a pixel-surface registry id in + * the kitty protocol's positive-id range. */ +const ImgOp = Type.Object({ + directive: Type.Literal(0x06), + id: Type.String(), + image: Type.Optional( + Type.Integer({ minimum: 1, maximum: 4294967295 }), + ), + alt: Type.String(), + width: Type.Optional(SizingAxis), + height: Type.Optional(SizingAxis), + variant: Type.Optional( + Type.Union([ + Type.Literal("auto"), + Type.Literal("kitty"), + Type.Literal("ascii"), + Type.Literal("alt"), + ]), + ), + bg: Type.Optional(rgba), +}); + +const Ops = Type.Array(Type.Union([OpenElement, TextOp, CloseElement, ImgOp])); /* ── Compiled validator ───────────────────────────────────────────── */ @@ -240,5 +263,11 @@ export function validated(term: Term): Term { update(events) { return term.update(events); }, + setImage(id, data) { + return term.setImage(id, data); + }, + removeImage(id) { + return term.removeImage(id); + }, }; } From 033ef6d852e01f4d75fcf448e2cde60cdac64881 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Fri, 9 Oct 2026 15:21:50 -0500 Subject: [PATCH 2/2] test: prefer-let and lint-clean graphics suite --- test/graphics.test.ts | 20 +++++--------------- 1 file changed, 5 insertions(+), 15 deletions(-) diff --git a/test/graphics.test.ts b/test/graphics.test.ts index 4e0a36f..6b15b1c 100644 --- a/test/graphics.test.ts +++ b/test/graphics.test.ts @@ -2,16 +2,9 @@ * One spec, one test file. */ import { createInput } from "../input.ts"; -import { - close, - img, - type ImgProps, - type Op, - open, - type SizingAxis, -} from "../ops.ts"; +import { close, img, type Op, open, type SizingAxis } from "../ops.ts"; import { createTerm } from "../term.ts"; -import { describe, expect, it } from "./suite.ts"; +import { describe, it } from "./suite.ts"; import { print } from "./print.ts"; const KITTY_GRANT = { @@ -31,7 +24,7 @@ function solid(w: number, h: number, rgb: [number, number, number]): { height: number; pixels: Uint8Array; } { - const pixels = new Uint8Array(w * h * 4); + let pixels = new Uint8Array(w * h * 4); for (let i = 0; i < pixels.length; i += 4) { pixels[i] = rgb[0]; pixels[i + 1] = rgb[1]; @@ -51,14 +44,10 @@ function frame(t: Awaited>, ops: Op[]): string { } function grid(t: Awaited>, ops: Op[]): string[] { - const text = print(frame(t, ops), 40, 12); + let text = print(frame(t, ops), 40, 12); return text.split("\n"); } -function stripApc(s: string): string { - return s.replace(/\x1b_G[^\x1b]*\x1b\\/g, ""); -} - describe("graphics: tier resolution", () => { it("renders ascii art without evidence (auto ladder)", async () => { let t = await term(); @@ -389,6 +378,7 @@ describe("graphics: registry API", () => { let out = frame(t, [img("a", { image: 1, alt: "x" })]); // INV-I5's claim is about the graphics frames themselves: fed to the // input parser, an APC frame generates no responses of any kind + // deno-lint-ignore no-control-regex let apc = out.match(/\x1b_G[^\x1b]*\x1b\\/g) ?? []; if (apc.length === 0) throw new Error("no APC frames to test"); let input = await createInput();