From 1f514e0024eb297eb915de9faf2f147053478030 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Fri, 9 Oct 2026 10:30:45 -0500 Subject: [PATCH 1/3] feat(term): synchronized output (DEC mode 2026) driven by sync-output capability Full-screen frames wrap in BSU/ESU when RuntimeCapabilities.syncOutput is true; the flag rides reduce() per frame so a capability flip takes effect on the next rendered frame with no invalidation signal. Zero-diff frames emit zero bytes even when wrapped, and line mode is never wrapped. Renderer-spec 7.9 discharges the deferral recorded in 7.8. --- .changeset/sync-output-capability.md | 7 ++++ specs/renderer-spec.md | 38 ++++++++++++++++++++ src/clayterm.c | 39 +++++++++++++++++--- src/clayterm.h | 2 +- term-native.ts | 2 ++ term.ts | 10 +++++- test/term.test.ts | 53 ++++++++++++++++++++++++++++ test/width.test.ts | 2 +- 8 files changed, 146 insertions(+), 7 deletions(-) create mode 100644 .changeset/sync-output-capability.md diff --git a/.changeset/sync-output-capability.md b/.changeset/sync-output-capability.md new file mode 100644 index 0000000..5e7aeb1 --- /dev/null +++ b/.changeset/sync-output-capability.md @@ -0,0 +1,7 @@ +--- +"@bomb.sh/tty": minor +--- + +Adds synchronized output (DEC mode 2026) driven by the `sync-output` capability. + +When `RuntimeCapabilities.syncOutput` is true, every full-screen render is wrapped in Begin/End Synchronized Update (`CSI ? 2026 h` / `CSI ? 2026 l`) so the terminal presents the frame as one atomic repaint without tearing. There is no new API: the capability stays false until a DECRPM 2026 probe reply reports support (`detectTerminal()` probes `CSI ? 2026 $ p`, replies surface through `input.scan()` and fold in via `term.update()`), and frames render unwrapped when it is false or unknown. Zero-diff frames emit nothing whether or not wrapping is active, and line mode is never wrapped. diff --git a/specs/renderer-spec.md b/specs/renderer-spec.md index 0e1d6ff..fe14c6f 100644 --- a/specs/renderer-spec.md +++ b/specs/renderer-spec.md @@ -480,6 +480,44 @@ output, Kitty keyboard mode setup, and Kitty graphics emission are deferred to their respective follow-up PRs. The renderer continues to emit its existing hardcoded ANSI output until one of those specifications is adopted. +### 7.9 Synchronized output (DEC mode 2026) + +When the `syncOutput` runtime capability is true, each full-screen frame +(§8.2.1) is wrapped in Synchronized Output mode 2026 — Begin Synchronized +Update (`CSI ? 2026 h`) before the frame's first emitted byte and End +Synchronized Update (`CSI ? 2026 l`) after its last. The terminal buffers +everything between the pair and presents it as one atomic repaint, so a frame +never tears mid-update. + +**Capability evidence.** `syncOutput` originates exclusively from the +`sync-output` `CapabilityEvent` (input-spec §5), emitted when the input parser +reads a DECRPM 2026 reply to the probe (`CSI ? 2026 $ p`). Reply values 1, 2, +and 3 (set, reset, permanently set) report support; 0 and 4 report none. The +static `Capabilities` never seed this field: it is false until a probe reply +is folded in through `update()`. + +**Invalidation and timing.** The renderer reads the capability snapshot at +each render transaction, so a capability flip takes effect on the next +rendered frame. No invalidation signal exists, and none is required: a flip +between frames changes only whether the next frame is wrapped. + +**Normative rules:** + +- When `syncOutput` is false, the renderer MUST NOT emit `?2026h` or `?2026l`. +- When `syncOutput` is true, the renderer MUST wrap each full-screen frame in + the BSU/ESU pair. Line mode (§8.2.2) is never wrapped: inline output may be + interleaved with foreign content, and an unterminated synchronized block + would suppress it. +- A full-screen render whose diff produces no cell changes and no caret + transition MUST emit zero bytes, even when `syncOutput` is true. The + renderer decides after the diff, so no-op frames carry neither the wrap + prefix nor the suffix. +- Terminals that receive the sequences without supporting the mode ignore it + by design; the probe still refines behavior for terminals that report mode + 0 or 4. + +This section discharges the synchronized-output deferral recorded in §7.8. + --- ## 8. Public Rendering API diff --git a/src/clayterm.c b/src/clayterm.c index 7e4e62e..b9b395e 100644 --- a/src/clayterm.c +++ b/src/clayterm.c @@ -87,6 +87,9 @@ struct Clayterm { Clay_ErrorData errors[MAX_ERRORS]; int error_count; int animating_count; + /* wrap each full-screen frame in BSU/ESU (DEC mode 2026); driven by the + * RuntimeCapabilities.syncOutput capability, passed per render call */ + int sync; /* Caret state for hardware-cursor management. The renderer records the * first text node carrying a caret declaration per frame, precomputes the * caret's byte offset into that node's content, then places the cell as @@ -116,6 +119,12 @@ struct Clayterm { * to 4 (base SMP char) + 8×4 (combining marks) = 36 bytes of cluster text. */ #define OUT_BYTES_PER_CELL 128 +/* 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) { @@ -261,6 +270,18 @@ 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; + * whether we emit the wrap at all is decided by the sync-output + * capability, threaded through reduce() each frame. */ + int start = ct->out.length; + if (ct->sync) + buf_str(&ct->out, "\x1b[?2026h"); + /* Body start: the no-op check below compares against this so the BSU + * itself never counts as frame content. */ + int body = ct->out.length; + for (int y = 0; y < ct->h; y++) { for (int x = 0; x < ct->w;) { Cell *back = cell_at(ct, ct->back, x, y); @@ -310,6 +331,14 @@ static void present_cups(struct Clayterm *ct, int row) { buf_str(&ct->out, "\x1b[?25l"); } ct->had_caret_last_frame = ct->has_caret; + + if (ct->out.length == body) { + /* Nothing was emitted — no cell changes and no caret transition. + * Rewind the BSU so a no-op frame still produces zero bytes. */ + ct->out.length = start; + } else if (ct->sync) { + buf_str(&ct->out, "\x1b[?2026l"); /* ESU: end synchronized update */ + } } /** @@ -624,7 +653,7 @@ int clayterm_size(int w, int h) { set_clay_capacity(w, 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 */ @@ -686,7 +715,7 @@ struct Clayterm *init(void *mem, int w, int h) { 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; @@ -701,7 +730,8 @@ 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, @@ -717,9 +747,10 @@ struct Clayterm *init(void *mem, int w, int h) { } void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row, - float deltaTime) { + float deltaTime, int sync) { int i = 0; ct_active_context = ct; + ct->sync = sync; ct->error_count = 0; ct->animating_count = 0; ct->caret_text_chars = NULL; diff --git a/src/clayterm.h b/src/clayterm.h index 8a24db4..41a190b 100644 --- a/src/clayterm.h +++ b/src/clayterm.h @@ -13,7 +13,7 @@ struct Clayterm; int clayterm_size(int w, int h); struct Clayterm *init(void *mem, int w, int h); void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row, - float deltaTime); + float deltaTime, int sync); char *output(struct Clayterm *ct); int length(struct Clayterm *ct); int animating(struct Clayterm *ct); diff --git a/term-native.ts b/term-native.ts index b54549d..f9c4315 100644 --- a/term-native.ts +++ b/term-native.ts @@ -42,6 +42,7 @@ export interface Native { mode: number, row: number, deltaTime: number, + sync: number, ): void; output(ct: number): number; length(ct: number): number; @@ -99,6 +100,7 @@ export async function createTermNative( mode: number, row: number, deltaTime: number, + sync: number, ): void; output(ct: number): number; length(ct: number): number; diff --git a/term.ts b/term.ts index 5326daa..8b8c2b2 100644 --- a/term.ts +++ b/term.ts @@ -212,7 +212,15 @@ export async function createTerm(options: TermOptions): Promise { dt = now - lastRenderAt; } lastRenderAt = now; - native.reduce(native.statePtr, native.opsBuf, len, mode, row, dt); + native.reduce( + native.statePtr, + native.opsBuf, + len, + mode, + row, + dt, + currentCaps.syncOutput ? 1 : 0, + ); if (options?.pointer) { let { x, y, down } = options.pointer; diff --git a/test/term.test.ts b/test/term.test.ts index 7190fd6..14ebb5f 100644 --- a/test/term.test.ts +++ b/test/term.test.ts @@ -885,4 +885,57 @@ hi expect(trim(print(decode(large.output), 120, 40))).toContain("Hi"); }); }); + + describe("synchronized output", () => { + let frame: Op[] = [ + open("root", { layout: { width: grow(), height: grow() } }), + text("Hi"), + close(), + ]; + + let syncOn = { + type: "capability", + key: "sync-output", + value: true, + } as const; + let syncOff = { + type: "capability", + key: "sync-output", + value: false, + } as const; + + it("does not wrap frames by default", () => { + let out = decode(term.render(frame).output); + expect(out).not.toContain("\x1b[?2026"); + }); + + it("wraps full-screen frames when the capability is on", () => { + term.update([syncOn]); + let out = decode(term.render(frame).output); + expect(out.startsWith("\x1b[?2026h")).toBe(true); + expect(out.endsWith("\x1b[?2026l")).toBe(true); + }); + + it("emits zero bytes for a no-op frame even when wrapped", () => { + term.update([syncOn]); + term.render(frame); + expect(term.render(frame).output.length).toBe(0); + }); + + it("stops wrapping after the capability turns off", () => { + term.update([syncOn]); + term.render(frame); + term.update([syncOff]); + let out = decode(term.render(frame).output); + expect(out).not.toContain("\x1b[?2026"); + }); + + it("never wraps line mode output", () => { + term.update([syncOn]); + let out = decode( + term.render(frame, { mode: "line", row: 1 }).output, + ); + expect(out).not.toContain("\x1b[?2026"); + }); + }); }); diff --git a/test/width.test.ts b/test/width.test.ts index 1c54399..4475960 100644 --- a/test/width.test.ts +++ b/test/width.test.ts @@ -83,7 +83,7 @@ describe("width", () => { buf[at + 1] = 0xa0; buf[at + 2] = 0x80; - native.reduce(native.statePtr, native.opsBuf, len, 0, 1, 0); + native.reduce(native.statePtr, native.opsBuf, len, 0, 1, 0, 0); let out = new Uint8Array( native.memory.buffer, native.output(native.statePtr), From bad7e4288cf0c6b0af1d0f66baee52b0abe8bb33 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Sat, 10 Oct 2026 12:26:18 -0500 Subject: [PATCH 2/3] =?UTF-8?q?spec:=20hex=20color=20strings=20accepted=20?= =?UTF-8?q?at=20directive=20color=20fields=20(=C2=A78.5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Color fields (open bg, border color/bg, border side color/bg, text color/bg) accept a hex color string in addition to the packed ARGB number. Hex grammar: '#' + exactly 3/4/6/8 hex digits, case-insensitive; 3/4-digit forms double each digit; missing alpha defaults to 255. Conversion happens at the transfer-encoding boundary — hex strings never appear on the wire; packed values are bit-identical to the equivalent rgba() result. Invalid hex is invalid directive input: validate() reports it and the packer MUST throw a TypeError before producing bytes. Static types stay number | string; directive constructors check string literals against the grammar at compile time (the TS equivalent of #${string{3,4,6,8}}), reserving room for full CSS Color 4 syntax later. --- specs/renderer-spec.md | 48 +++++++++++++++++++++++++++++++++++++----- 1 file changed, 43 insertions(+), 5 deletions(-) diff --git a/specs/renderer-spec.md b/specs/renderer-spec.md index fe14c6f..84b5eb1 100644 --- a/specs/renderer-spec.md +++ b/specs/renderer-spec.md @@ -697,7 +697,7 @@ fit(min?: number, max?: number): SizingAxis The element sizes to fit its content, optionally constrained by minimum and maximum bounds. -### 8.5 Color helper +### 8.5 Color values ``` rgba(r: number, g: number, b: number, a?: number): number @@ -707,6 +707,41 @@ Packs color channel values (each 0–255) into a single 32-bit integer in ARGB format. Alpha defaults to 255 (fully opaque). The returned value is used wherever the directive model expects a color. +**Color values.** A color value is either a packed ARGB `number`, as +produced by `rgba()`, or a hexadecimal color `string`. Every directive field +described as a color — `open({ bg })`, `border.color`, `border.bg`, the +`color` and `bg` fields of structured border sides, and +`text({ color, bg })` — accepts both forms. The forms are exactly +equivalent: each hex string is converted to its packed ARGB value at the +transfer-encoding boundary (§9.2), so hex strings MUST NOT appear in the +transfer encoding. The wire format, snapshot encoding, and cell +representation are unchanged by this acceptance, and the numeric and string +forms of the same color produce bit-identical packed values. + +**Hex grammar.** A hex color string is `#` followed by exactly 3, 4, 6, or 8 +hexadecimal digits, case-insensitive. The `#` is required. The 3- and +4-digit forms expand by doubling each digit: `#f80` is `#ff8800`, and +`#f80a` is `#ff8800aa`. The 6- and 8-digit forms are taken literally. Alpha +defaults to 255 in the 3- and 6-digit forms; the 4- and 8-digit forms supply +alpha as their final digit pair (or digit, doubled). + +**Invalid hex.** A string that does not match the hex grammar is invalid +directive input (§9.1). `validate()` reports it as an invalid field value. +The packer MUST reject a directive array containing an invalid hex color +string by throwing a `TypeError` before producing any bytes, rather than +emitting a partially decoded or garbage color value. + +**Static checking.** In TypeScript, color fields are typed `number | string`. +The directive constructors check string literals against the hex grammar at +compile time — the type-level equivalent of `#${string{3,4,6,8}}` — so an +invalid hex literal is a type error at the call site. Strings whose value is +only known at runtime (colors read from a theme or configuration, for +example) pass the type check as `string` and are checked by `validate()` +and the packer at render time. The string form is deliberately restricted +to the hex grammar for now: a future version may accept the broader CSS +Color 4 syntax, which is why the static type remains the plain +`number | string` shape rather than a narrower prefix-pattern type. + ### 8.6 Term update ``` @@ -930,7 +965,8 @@ The `open()` constructor currently accepts the following property groups in its `bottom`, `left`) accepts either a scalar width or a structured object `{ width, color?, bg? }`. The shared `color` field is required and is the fallback foreground for every side; the optional shared `bg` field is the - fallback border-cell background for every side + fallback border-cell background for every side. Color fields accept the + color values of §8.5 (packed ARGB number or hex string) - **`cornerRadius`** — per-corner radius values, producing rounded box-drawing characters - **`clip`** — Declares the element as a clip region (see §7.5). Currently @@ -992,7 +1028,8 @@ input or lets it pass through, `clipTo` controls inherited clipping, and The `text()` constructor accepts: `color`, `bg`, `fontSize`, `letterSpacing`, `lineHeight`, and attribute flags (`bold`, `italic`, `underline`, -`strikethrough`). +`strikethrough`). The `color` and `bg` fields accept the color values of +§8.5 (packed ARGB number or hex string). These property groups represent the current implementation surface. New groups and fields have been added incrementally and more may follow. @@ -1151,8 +1188,9 @@ acknowledged gaps, and the approach may evolve. ### 12.5 Validation and packing **`validate(ops)`** — A public API function that checks a directive array for -structural errors (unbalanced open/close pairs, invalid field types). Exported -and used in tests. +structural errors (unbalanced open/close pairs, invalid field types — +including color fields, which must be a packed ARGB number or a §8.5 hex color string). +Exported and used in tests. **`pack(ops, mem, offset)`** — An internal function that serializes a directive array into the transfer encoding described in Section 12.1. Currently exported From 3e84962ef80d3cfa2d47daed04c2d1312cba8b73 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Sat, 10 Oct 2026 12:31:46 -0500 Subject: [PATCH 3/3] feat: hex color strings for directive color fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Color fields (open bg, border color/bg, border side color/bg, text color/bg) accept '#RGB', '#RGBA', '#RRGGBB', and '#RRGGBBAA' alongside packed ARGB numbers. colorValue() expands strings at the transfer- encoding boundary (RRGGBBAA digit order -> AARRGGBB word layout, 3/4- digit forms double each digit, missing alpha = 255); the wire format and the C renderer are untouched. Directive constructors type-check string literals against the grammar via per-character recursion — a flat template union is unrepresentable (TS2590) at 6/8 digits — while computed strings pass through and are checked normatively by validate() and the packer. Invalid hex throws TypeError before any bytes are produced. --- .changeset/hex-color-strings.md | 13 +++ ops.ts | 136 +++++++++++++++++++++++++++----- test/border.test.ts | 28 +++++++ test/color.test.ts | 85 ++++++++++++++++++++ test/pack.test.ts | 79 ++++++++++++++++++- test/validate.test.ts | 39 +++++++++ validate.ts | 23 ++++-- 7 files changed, 377 insertions(+), 26 deletions(-) create mode 100644 .changeset/hex-color-strings.md diff --git a/.changeset/hex-color-strings.md b/.changeset/hex-color-strings.md new file mode 100644 index 0000000..0f51c58 --- /dev/null +++ b/.changeset/hex-color-strings.md @@ -0,0 +1,13 @@ +--- +"@bomb.sh/tty": minor +--- + +Directive color fields accept CSS-style hex color strings. `open({ bg })`, +`border.color`/`border.bg` and per-side `color`/`bg`, and +`text({ color, bg })` take `#RGB`, `#RGBA`, `#RRGGBB`, or `#RRGGBBAA` +(case-insensitive, `#` required) in addition to packed ARGB numbers from +`rgba()`. Strings expand to the packed value at the transfer-encoding +boundary, so the wire format, snapshots, and the renderer are unchanged. +Invalid hex is a compile-time type error on literals, rejected by +`validate()`, and throws a `TypeError` from the packer before any bytes are +produced. diff --git a/ops.ts b/ops.ts index e89b291..0453aad 100644 --- a/ops.ts +++ b/ops.ts @@ -80,20 +80,20 @@ function sideWidth(side: BorderSide | undefined): number { return typeof side === "number" ? side : side?.width ?? 0; } -function sideFg(side: BorderSide | undefined, shared: number): number { +function sideFg(side: BorderSide | undefined, shared: Color): number { let color = typeof side === "object" && side.color !== undefined ? side.color : shared; - return color & 0x00FFFFFF; + return colorValue(color) & 0x00FFFFFF; } function sideBg( side: BorderSide | undefined, - shared: number | undefined, + shared: Color | undefined, ): number { let bg = typeof side === "object" && side.bg !== undefined ? side.bg : shared; // ATTR_DEFAULT sentinel (bit 31 set) means "keep the existing cell bg" - return bg === undefined ? 0x80000000 : bg & 0x00FFFFFF; + return bg === undefined ? 0x80000000 : colorValue(bg) & 0x00FFFFFF; } function packString( @@ -189,7 +189,7 @@ export function pack( } if (op.bg !== undefined) { - view.setUint32(o, op.bg, true); + view.setUint32(o, colorValue(op.bg), true); o += 4; } @@ -295,13 +295,18 @@ export function pack( // 0 and setting ATTR_DEFAULT (0x80 in the attrs byte). The C path ORs // it into fg and emit_attr skips the foreground SGR (mirrors unset bg). let textDefault = op.color === undefined; - view.setUint32(o, op.color ?? 0, true); + view.setUint32( + o, + op.color === undefined ? 0 : colorValue(op.color), + true, + ); o += 4; // No explicit bg: leave the terminal default bg by writing // 0 and setting ATTR_DEFAULT (0x80 in the attrs byte). The C path ORs // it into bg and emit_attr skips the background SGR - let bg = op.bg === undefined ? 0x80000000 : op.bg & 0x00FFFFFF; + let bg = op.bg === undefined ? 0x80000000 : colorValue(op.bg) & + 0x00FFFFFF; view.setUint32(o, bg, true); o += 4; @@ -341,6 +346,38 @@ export function rgba(r: number, g: number, b: number, a = 255): number { (b & 0xFF); } +/** + * A color value: a packed ARGB `number` as produced by `rgba()`, or a hex + * color `string` (§8.5). Strings are expanded to the packed value at the + * transfer-encoding boundary; only numbers reach the wire. + */ +export type Color = number | string; + +/* §8.5 hex grammar: "#" followed by exactly 3, 4, 6, or 8 hex digits. */ +const HEX_COLOR = + /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/; + +/** + * Resolves a color value to the packed ARGB number the transfer encoding + * carries (§8.5). Numbers pass through; hex strings expand — 3/4-digit + * forms double each digit, 6-digit forms gain alpha 255 — from the + * string's RRGGBBAA digit order into the word's AARRGGBB layout. Invalid + * hex throws rather than decoding to a garbage color. + */ +function colorValue(color: Color): number { + if (typeof color === "number") return color; + if (!HEX_COLOR.test(color)) { + throw new TypeError(`invalid hex color: "${color}"`); + } + let body = color.slice(1); + if (body.length === 3 || body.length === 4) { + body = body.replace(/./g, "$&$&"); + } + if (body.length === 6) body += "ff"; + let value = parseInt(body, 16); + return (value >>> 8) | ((value & 0xFF) << 24); +} + export type SizingAxis = | { type: "fit"; min?: number; max?: number } | { type: "grow"; min?: number; max?: number } @@ -371,8 +408,8 @@ export type BorderSide = | number | { width: number; - color?: number; - bg?: number; + color?: Color; + bg?: Color; }; export interface OpenElement { @@ -387,11 +424,11 @@ export interface OpenElement { alignX?: "left" | "center" | "right"; alignY?: "top" | "center" | "bottom"; }; - bg?: number; + bg?: Color; cornerRadius?: { tl?: number; tr?: number; bl?: number; br?: number }; border?: { - color: number; - bg?: number; + color: Color; + bg?: Color; left?: BorderSide; right?: BorderSide; top?: BorderSide; @@ -479,8 +516,8 @@ function encodeClipTo(value: ClipTo | undefined): number { export interface Text { directive: typeof OP_TEXT; content: string; - color?: number; - bg?: number; + color?: Color; + bg?: Color; fontSize?: number; fontId?: number; wrap?: number; @@ -495,16 +532,79 @@ interface Snapshot { export type Op = OpenElement | Text | CloseElement | Snapshot; -export function open( +/* + * Compile-time color checking (§8.5). A string literal assigned to a color + * field must match the hex grammar — the type-level equivalent of + * `#${string{3,4,6,8}}`. Strings that are not literals (computed colors) and + * values already narrowed to `#${string}` pass through; the runtime grammar + * check in validate()/pack() is normative for them. Plain unions like + * `HexRun` would need ~16^6 union members, which TypeScript cannot + * represent (TS2590), so checking runs per-character via recursion instead. + */ + +type HexDigit = + | "0" + | "1" + | "2" + | "3" + | "4" + | "5" + | "6" + | "7" + | "8" + | "9" + | "a" + | "b" + | "c" + | "d" + | "e" + | "f" + | "A" + | "B" + | "C" + | "D" + | "E" + | "F"; + +type HexRun = S extends "" + ? (C["length"] extends 3 | 4 | 6 | 8 ? true : false) + : S extends `${HexDigit}${infer Rest}` ? HexRun + : false; + +type CheckedColor = T extends number ? T + : T extends string ? string extends T ? T + : `#${string}` extends T ? T + : T extends `#${infer Body}` ? (HexRun extends true ? T : never) + : never + : T; + +/* Applies CheckedColor to the color fields only; other fields pass through. */ +type CheckedColors = { + [K in keyof T]: K extends "bg" | "color" ? CheckedColor + : T[K] extends object ? CheckedColors + : T[K]; +}; + +export function open< + P extends Omit = Omit< + OpenElement, + "directive" | "id" + >, +>( id: string, - props: Omit = {}, + props: P & NoInfer> = {} as P & NoInfer>, ): OpenElement { return { directive: OP_OPEN_ELEMENT, id, ...props }; } -export function text( +export function text< + P extends Omit = Omit< + Text, + "directive" | "content" + >, +>( content: string, - props: Omit = {}, + props: P & NoInfer> = {} as P & NoInfer>, ): Text { return { directive: OP_TEXT, content, ...props }; } diff --git a/test/border.test.ts b/test/border.test.ts index 2e62f13..aefa730 100644 --- a/test/border.test.ts +++ b/test/border.test.ts @@ -647,3 +647,31 @@ describe("box model", () => { expect(lines[2]).toBe("└────────────┘"); }); }); + +describe("hex colors", () => { + it("renders side colors from hex strings with shared fallback", async () => { + let parsed = await renderBox({ + border: { + color: "#ff0000", + bg: "#0000ff", + top: { width: 1, bg: "#00ff00" }, + right: 1, + bottom: { width: 1, color: "#ffffff" }, + left: 1, + }, + }); + + // top: own bg, shared fg + expect(at(parsed, 3, 0).fg).toBe(FG.red); + expect(at(parsed, 3, 0).bg).toBe(BG.green); + // right: shared fg, shared bg + expect(at(parsed, 7, 1).fg).toBe(FG.red); + expect(at(parsed, 7, 1).bg).toBe(BG.blue); + // bottom: own fg, shared bg + expect(at(parsed, 3, 3).fg).toBe(FG.white); + expect(at(parsed, 3, 3).bg).toBe(BG.blue); + // left: shared fg, shared bg + expect(at(parsed, 0, 1).fg).toBe(FG.red); + expect(at(parsed, 0, 1).bg).toBe(BG.blue); + }); +}); diff --git a/test/color.test.ts b/test/color.test.ts index 9741243..5bb086f 100644 --- a/test/color.test.ts +++ b/test/color.test.ts @@ -219,3 +219,88 @@ describe("background", () => { expect(afterHi.startsWith("\x1b[0m ")).toBe(true); }); }); + +describe("hex color strings", () => { + it("renders text bg from a 3-digit hex string", async () => { + let term = await createTerm({ width: 20, height: 1 }); + let ansi = decode( + term.render([ + open("root", { layout: { width: grow(), height: grow() } }), + text("Hi", { bg: "#f80" }), + close(), + ]).output, + ); + + expect(ansi).toContain("\x1b[48;2;255;136;0m"); + }); + + it("renders text fg from a 6-digit hex string", async () => { + let term = await createTerm({ width: 20, height: 1 }); + let ansi = decode( + term.render([ + open("root", { layout: { width: grow(), height: grow() } }), + text("Hi", { color: "#ff8800" }), + close(), + ]).output, + ); + + expect(ansi).toContain("\x1b[38;2;255;136;0m"); + }); + + it("matches hex case-insensitively", async () => { + let term = await createTerm({ width: 20, height: 1 }); + let ansi = decode( + term.render([ + open("root", { layout: { width: grow(), height: grow() } }), + text("Hi", { color: "#FF8800", bg: "#F80" }), + close(), + ]).output, + ); + + expect(ansi).toContain("\x1b[38;2;255;136;0m"); + expect(ansi).toContain("\x1b[48;2;255;136;0m"); + }); + + it("expands 4- and 8-digit hex with doubled digits", async () => { + let term = await createTerm({ width: 20, height: 1 }); + let ansi = decode( + term.render([ + open("root", { layout: { width: grow(), height: grow() } }), + text("Hi", { color: "#ff8800aa", bg: "#f80a" }), + close(), + ]).output, + ); + + expect(ansi).toContain("\x1b[38;2;255;136;0m"); + expect(ansi).toContain("\x1b[48;2;255;136;0m"); + }); + + it("fills element bg from a hex string", async () => { + let term = await createTerm({ width: 8, height: 2 }); + let ansi = decode( + term.render([ + open("root", { + layout: { width: fixed(8), height: fixed(2) }, + bg: "#ff8800", + }), + text("Hi"), + close(), + ]).output, + ); + + expect(ansi).toContain("\x1b[48;2;255;136;0m"); + }); + + it("rejects invalid hex at render time with a TypeError", async () => { + let term = await createTerm({ width: 20, height: 1 }); + let error: unknown; + try { + term.render([text("Hi", { bg: "#ff8800a" as string })]); + } catch (caught) { + error = caught; + } + + expect(error).toBeInstanceOf(TypeError); + expect((error as Error).message).toContain("#ff8800a"); + }); +}); diff --git a/test/pack.test.ts b/test/pack.test.ts index 4b4a03d..bc87ca2 100644 --- a/test/pack.test.ts +++ b/test/pack.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "./suite.ts"; -import { close, open, pack, snapshot, text } from "../ops.ts"; +import { close, open, pack, rgba, snapshot, text } from "../ops.ts"; describe("pack", () => { it("throws a descriptive RangeError when text exceeds the transfer buffer", () => { @@ -73,3 +73,80 @@ describe("pack", () => { ).not.toThrow(); }); }); + +describe("hex colors", () => { + it("packs hex strings to bytes identical to their rgba() equivalents", () => { + let hex = new ArrayBuffer(256); + let n = pack( + [text("Hi", { color: "#ff8800aa", bg: "#f80" })], + hex, + 0, + ); + let num = new ArrayBuffer(256); + let m = pack( + [text("Hi", { color: rgba(255, 136, 0, 170), bg: rgba(255, 136, 0) })], + num, + 0, + ); + + expect(n).toBe(m); + expect(new Uint8Array(hex, 0, n * 4)).toEqual( + new Uint8Array(num, 0, m * 4), + ); + }); + + it("packs open bg and border colors bit-identically to rgba()", () => { + let hex = new ArrayBuffer(256); + let n = pack( + [ + open("x", { + bg: "#ff8800aa", + border: { + color: "#f80", + bg: "#00ff00", + top: { width: 1, color: "#ff0000", bg: "#0000ff" }, + right: 1, + }, + }), + close(), + ], + hex, + 0, + ); + let num = new ArrayBuffer(256); + let m = pack( + [ + open("x", { + bg: rgba(255, 136, 0, 170), + border: { + color: rgba(255, 136, 0), + bg: rgba(0, 255, 0), + top: { width: 1, color: rgba(255, 0, 0), bg: rgba(0, 0, 255) }, + right: 1, + }, + }), + close(), + ], + num, + 0, + ); + + expect(n).toBe(m); + expect(new Uint8Array(hex, 0, n * 4)).toEqual( + new Uint8Array(num, 0, m * 4), + ); + }); + + it("throws a TypeError on an invalid hex string", () => { + let memory = new ArrayBuffer(256); + let error: unknown; + try { + pack([text("Hi", { bg: "#ggg" as string })], memory, 0); + } catch (caught) { + error = caught; + } + + expect(error).toBeInstanceOf(TypeError); + expect((error as Error).message).toContain("#ggg"); + }); +}); diff --git a/test/validate.test.ts b/test/validate.test.ts index 3cd8896..a0e4f56 100644 --- a/test/validate.test.ts +++ b/test/validate.test.ts @@ -321,3 +321,42 @@ describe("validated", () => { ).toThrow(TypeError); }); }); + +describe("color fields", () => { + it("accepts hex color strings at every color field", () => { + expect(validate([ + open("x", { + bg: "#ff8800", + border: { + color: "#f80", + bg: "#00ff00", + top: { width: 1, color: "#ff0000", bg: "#0000ff" }, + right: 1, + }, + }), + text("hi", { color: "#ff8800aa", bg: "#f80a" }), + close(), + ])).toBe(true); + }); + + it("rejects malformed hex color strings", () => { + for ( + let spec of [ + "#f8", + "#ff880", + "#ff8800a", + "#ff88000aa", + "#ggg", + "f80", + "#", + "", + ] + ) { + expect(validate([text("hi", { color: spec })])).toBe(false); + expect(validate([ + open("x", { bg: spec, border: { color: spec } }), + close(), + ])).toBe(false); + } + }); +}); diff --git a/validate.ts b/validate.ts index d98c94a..01438b8 100644 --- a/validate.ts +++ b/validate.ts @@ -14,6 +14,15 @@ const i16 = Type.Integer({ minimum: -32768, maximum: 32767 }); * Accept both signed and unsigned representations of the same bit pattern. */ const rgba = Type.Integer({ minimum: -0x80000000, maximum: 0xFFFFFFFF }); +/* §8.5 hex color string: "#" followed by exactly 3, 4, 6, or 8 hex digits. + * Mirrors the HEX_COLOR grammar in ops.ts. */ +const hexColor = Type.String({ + pattern: "^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$", +}); + +/* A §8.5 color value: packed ARGB number or a hex color string. */ +const color = Type.Union([rgba, hexColor]); + /* ── Sizing axis (discriminated union) ────────────────────────────── */ const Fit = Type.Object({ @@ -84,14 +93,14 @@ const BorderSide = Type.Union([ u8, Type.Object({ width: u8, - color: Type.Optional(rgba), - bg: Type.Optional(rgba), + color: Type.Optional(color), + bg: Type.Optional(color), }), ]); const Border = Type.Object({ - color: rgba, - bg: Type.Optional(rgba), + color, + bg: Type.Optional(color), left: Type.Optional(BorderSide), right: Type.Optional(BorderSide), top: Type.Optional(BorderSide), @@ -186,7 +195,7 @@ const OpenElement = Type.Object({ directive: Type.Literal(0x02), id: Type.String(), layout: Type.Optional(Layout), - bg: Type.Optional(rgba), + bg: Type.Optional(color), cornerRadius: Type.Optional(CornerRadius), border: Type.Optional(Border), clip: Type.Optional(Clip), @@ -197,8 +206,8 @@ const OpenElement = Type.Object({ const TextOp = Type.Object({ directive: Type.Literal(0x03), content: Type.String(), - color: Type.Optional(rgba), - bg: Type.Optional(rgba), + color: Type.Optional(color), + bg: Type.Optional(color), fontSize: Type.Optional(u8), fontId: Type.Optional(u8), wrap: Type.Optional(u8),