From 1f514e0024eb297eb915de9faf2f147053478030 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Fri, 9 Oct 2026 10:30:45 -0500 Subject: [PATCH 1/5] 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/5] =?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/5] 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), From ebac3ffb3f023a1ab3d986b015e783b71b7d0cc5 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Sun, 18 Oct 2026 15:38:12 -0400 Subject: [PATCH 4/5] =?UTF-8?q?spec:=20CSS=20Color=204=20strings=20accepte?= =?UTF-8?q?d=20at=20directive=20color=20fields=20(=C2=A78.5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Widens the §8.5 string form from hex-only to CSS Color 4: hex, named colors and transparent, rgb()/rgba() and hsl()/hsla() (modern and legacy), hwb(), lab(), lch(), oklab(), oklch(). Strings resolve to sRGB at the transfer-encoding boundary; channels clamp to [0, 255] and round, alpha clamps to [0, 1] and scales to 255. Out-of-gamut colors clip per channel with no perceptual gamut mapping. Hex results stay bit-identical to the previous grammar, and the wire format is unchanged. color(), color-mix(), relative color syntax, and device-cmyk() are invalid. System colors and currentcolor are reserved: system colors will map to the themable ANSI 16-color palette, which packed sRGB cannot express, so both are specified separately. Static checking extends to named-color literals and function-name prefixes; function arguments are checked at runtime only. --- specs/renderer-spec.md | 93 +++++++++++++++++++++++++++--------------- 1 file changed, 60 insertions(+), 33 deletions(-) diff --git a/specs/renderer-spec.md b/specs/renderer-spec.md index 7e97406..7b7ff8c 100644 --- a/specs/renderer-spec.md +++ b/specs/renderer-spec.md @@ -798,38 +798,65 @@ 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. +`rgba()`, or a CSS 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. Each +color string is resolved to a packed ARGB value at the transfer-encoding +boundary (§9.2), so color strings MUST NOT appear in the transfer encoding. The +wire format, snapshot encoding, and cell representation are unchanged by this +acceptance: packed sRGB ARGB remains the only color representation the renderer +sees. + +**Color string grammar.** A color string is one of the following CSS Color +Module Level 4 forms. Function names and keywords are case-insensitive. Leading +or trailing whitespace is not permitted. + +- **Hex.** `#` followed by exactly 3, 4, 6, or 8 hexadecimal digits. The 3- and + 4-digit forms expand by doubling each digit: `#f80` is `#ff8800`, and `#f80a` + is `#ff8800aa`. The 4- and 8-digit forms supply alpha as their final digit + pair (or digit, doubled). +- **Named colors.** The CSS named color keywords (`red`, `rebeccapurple`, ...), + and `transparent`, which is black with alpha 0. +- **`rgb()` / `rgba()`.** Modern space-separated syntax with optional `/ alpha`, + and the legacy comma-separated syntax. +- **`hsl()` / `hsla()`.** Modern and legacy syntax. +- **`hwb()`, `lab()`, `lch()`, `oklab()`, `oklch()`.** Modern syntax. + +Channel and alpha arguments follow CSS Color 4: numbers, percentages where the +channel permits them, angles (`deg`, `rad`, `grad`, `turn`) for hues, and the +`none` keyword, which resolves to 0. The `color()` function, `color-mix()`, +relative color syntax, and `device-cmyk()` are not supported and are invalid +color strings. + +CSS system colors (`Canvas`, `CanvasText`, `LinkText`, ...) and `currentcolor` +are reserved. They are invalid color strings in this version. System colors are +reserved for mapping to the terminal's themable ANSI 16-color palette, which a +packed sRGB value cannot express; that mapping, and `currentcolor`, will be +specified separately. + +**Resolution.** A color string resolves to sRGB, then to a packed ARGB value: +each of R, G, B is clamped to [0, 255] and rounded to the nearest integer; alpha +is clamped to [0, 1], multiplied by 255, and rounded. Colors outside the sRGB +gamut (common for `lab()`, `lch()`, `oklab()`, and `oklch()`) are therefore +clipped per channel; no perceptual gamut mapping is performed. Alpha defaults to +255 when a form does not supply it. A hex string and the equivalent `rgba()` +call produce bit-identical packed values. + +**Invalid color strings.** A string that does not match the color string 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 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. +The directive constructors check string literals at compile time: a literal +beginning with `#` must match the hex form (the type-level equivalent of +`#${string{3,4,6,8}}`); a literal containing `(` must begin with a supported +function name; any other literal must be a named color keyword or `transparent`. +Function arguments are not checked statically. 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. ### 8.6 Term update @@ -1056,7 +1083,7 @@ The `open()` constructor currently accepts the following property groups in its `{ 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. Color fields accept the color - values of §8.5 (packed ARGB number or hex string) + values of §8.5 (packed ARGB number or CSS color string) - **`cornerRadius`** — per-corner radius values, producing rounded box-drawing characters - **`clip`** — Declares the element as a clip region (see §7.5). Currently @@ -1120,7 +1147,7 @@ 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`). The `color` and `bg` fields accept the color values of §8.5 -(packed ARGB number or hex string). +(packed ARGB number or CSS color string). These property groups represent the current implementation surface. New groups and fields have been added incrementally and more may follow. @@ -1280,7 +1307,7 @@ acknowledged gaps, and the approach may evolve. **`validate(ops)`** — A public API function that checks a directive array for 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). +color fields, which must be a packed ARGB number or a §8.5 CSS color string). Exported and used in tests. **`pack(ops, mem, offset)`** — An internal function that serializes a directive From 20558532c101ab190f3db10c234799b9cabd7ffc Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Sun, 18 Oct 2026 15:40:47 -0400 Subject: [PATCH 5/5] feat: CSS Color 4 strings for directive color fields via hueful MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Color strings now resolve through hueful's parse() + convert() instead of the hand-rolled hex expander. A new internal color.ts owns resolveColor(), shared by the packer (colorValue in ops.ts) and validate(), which swaps its hex regex for a TypeBox "css-color" format backed by the same resolver, so the two can't drift. hueful returns unclamped sRGB for wide-gamut spaces, so each channel and alpha clamps to [0, 1] before scaling to a byte; this is the §8.5 per-channel clip. hueful also parses device-cmyk(), which §8.5 excludes, so the cmyk space is rejected explicitly. System colors and currentcolor aren't in hueful's name table and fall out as invalid with no extra code. Literal checking keeps the per-character hex recursion and adds two branches: literals containing "(" must lowercase to a supported function prefix, and anything else must lowercase to a key of hueful's names export (or "transparent"). hueful becomes a runtime dependency of the npm package; dnt maps the npm: import into package.json. --- .changeset/css-color-strings.md | 14 +++++++ .changeset/hex-color-strings.md | 13 ------- color.ts | 18 +++++++++ deno.json | 3 +- deno.lock | 5 +++ ops.ts | 57 ++++++++++++++++------------ test/color.test.ts | 36 ++++++++++++++++++ test/pack.test.ts | 67 +++++++++++++++++++++++++++++++++ test/validate.test.ts | 36 ++++++++++++++++++ validate.ts | 15 ++++---- 10 files changed, 217 insertions(+), 47 deletions(-) create mode 100644 .changeset/css-color-strings.md delete mode 100644 .changeset/hex-color-strings.md create mode 100644 color.ts diff --git a/.changeset/css-color-strings.md b/.changeset/css-color-strings.md new file mode 100644 index 0000000..c3a799c --- /dev/null +++ b/.changeset/css-color-strings.md @@ -0,0 +1,14 @@ +--- +"@bomb.sh/tty": minor +--- + +Directive color fields accept CSS color strings. `open({ bg })`, +`border.color`/`border.bg` and per-side `color`/`bg`, and +`text({ color, bg })` take hex (`#f80`, `#ff8800aa`), named colors +(`rebeccapurple`, `transparent`), and `rgb()`, `hsl()`, `hwb()`, `lab()`, +`lch()`, `oklab()`, and `oklch()` in addition to packed ARGB numbers from +`rgba()`. Strings resolve to sRGB at the transfer-encoding boundary, so the +wire format, snapshots, and the renderer are unchanged; out-of-gamut colors +clip per channel. Invalid hex, unknown names, and unsupported functions are +compile-time type errors on literals, are rejected by `validate()`, and throw +a `TypeError` from the packer before any bytes are produced. diff --git a/.changeset/hex-color-strings.md b/.changeset/hex-color-strings.md deleted file mode 100644 index 0f51c58..0000000 --- a/.changeset/hex-color-strings.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@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/color.ts b/color.ts new file mode 100644 index 0000000..63d4b0c --- /dev/null +++ b/color.ts @@ -0,0 +1,18 @@ +import { convert, parse } from "hueful"; + +function byte(unit: number): number { + return Math.round(Math.min(Math.max(unit, 0), 1) * 255); +} + +/** + * Resolves a §8.5 CSS color string to the packed ARGB number the transfer + * encoding carries, or `undefined` when the string is not a supported color. + * Channels clamp to the sRGB range, so out-of-gamut colors clip per channel. + */ +export function resolveColor(input: string): number | undefined { + let parsed = parse(input); + if (parsed === null || parsed.space === "cmyk") return undefined; + let [r, g, b, alpha = 1] = convert(parsed.space, "rgb", parsed.value); + return (byte(alpha) << 24) | (byte(r / 255) << 16) | (byte(g / 255) << 8) | + byte(b / 255); +} diff --git a/deno.json b/deno.json index 0448304..064a6a3 100644 --- a/deno.json +++ b/deno.json @@ -19,7 +19,8 @@ "effection": "npm:effection@^4.0.2", "@std/encoding": "jsr:@std/encoding@1", "@codspeed/tinybench-plugin": "npm:@codspeed/tinybench-plugin@^5.4.0", - "tinybench": "npm:tinybench@^5.0.0" + "tinybench": "npm:tinybench@^5.0.0", + "hueful": "npm:hueful@^0.8.1" }, "exports": { ".": "./mod.ts", diff --git a/deno.lock b/deno.lock index 4218806..916021f 100644 --- a/deno.lock +++ b/deno.lock @@ -26,6 +26,7 @@ "npm:@sinclair/typebox@*": "0.34.48", "npm:@sinclair/typebox@0.34": "0.34.48", "npm:effection@^4.0.2": "4.0.2", + "npm:hueful@~0.8.1": "0.8.1", "npm:tinybench@5": "5.1.0", "npm:valrs@*": "0.1.0" }, @@ -491,6 +492,9 @@ "debug" ] }, + "hueful@0.8.1": { + "integrity": "sha512-5JmU/74TAKM9BCRcwY046VImUb6dhvrsB2kvj5eEJ1eaCKu6EPPxtONF/kueY9PdzYgUz3g5zrrj+YENz2xy3A==" + }, "human-id@4.2.1": { "integrity": "sha512-zPGsiS+dWoTZtZ4AtpA9Y+BdSFSNWvnouNlWNoUFyAM6xHOHmdCvqO3k8AIbdamCOv4gUFUVNPf6rJFfc4UiJw==", "bin": true @@ -609,6 +613,7 @@ "npm:@codspeed/tinybench-plugin@^5.4.0", "npm:@sinclair/typebox@0.34", "npm:effection@^4.0.2", + "npm:hueful@~0.8.1", "npm:tinybench@5" ], "packageJson": { diff --git a/ops.ts b/ops.ts index fdf0f2c..9206f73 100644 --- a/ops.ts +++ b/ops.ts @@ -1,3 +1,5 @@ +import type { names } from "hueful"; +import { resolveColor } from "./color.ts"; import { type PointerShape, pointerShapeIndex } from "./pointer-shape.ts"; export type { PointerShape }; @@ -359,35 +361,23 @@ export function rgba(r: number, g: number, b: number, a = 255): number { } /** - * 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 + * A color value: a packed ARGB `number` as produced by `rgba()`, or a CSS + * color `string` (§8.5). Strings are resolved 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. + * carries (§8.5). Numbers pass through; strings resolve through the CSS + * color grammar. Invalid strings throw 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); + let value = resolveColor(color); + if (value === undefined) throw new TypeError(`invalid color: "${color}"`); + return value; } export type SizingAxis = @@ -547,12 +537,13 @@ export type Op = OpenElement | Text | CloseElement | Snapshot; /* * 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 + * field must be a hex color (the type-level equivalent of + * `#${string{3,4,6,8}}`), start with a supported color function, or be a + * named color. 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. + * represent (TS2590), so hex checking runs per-character via recursion. */ type HexDigit = @@ -584,10 +575,26 @@ type HexRun = S extends "" : S extends `${HexDigit}${infer Rest}` ? HexRun : false; +type ColorFunction = + | "rgb" + | "rgba" + | "hsl" + | "hsla" + | "hwb" + | "lab" + | "lch" + | "oklab" + | "oklch"; + +type NamedColor = keyof typeof names | "transparent"; + 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) + : T extends `${string}(${string}` + ? (Lowercase extends `${ColorFunction}(${string})` ? T : never) + : Lowercase extends NamedColor ? T : never : T; diff --git a/test/color.test.ts b/test/color.test.ts index 5bb086f..3d0ff96 100644 --- a/test/color.test.ts +++ b/test/color.test.ts @@ -304,3 +304,39 @@ describe("hex color strings", () => { expect((error as Error).message).toContain("#ff8800a"); }); }); + +describe("CSS color strings", () => { + it("renders fg and bg from CSS color functions and names", async () => { + let term = await createTerm({ width: 20, height: 1 }); + let ansi = decode( + term.render([ + open("root", { layout: { width: grow(), height: grow() } }), + text("Hi", { color: "rebeccapurple", bg: "hsl(120 100% 50%)" }), + close(), + ]).output, + ); + + expect(ansi).toContain("\x1b[38;2;102;51;153m"); + expect(ansi).toContain("\x1b[48;2;0;255;0m"); + }); + + it("checks color string literals at compile time", () => { + let valid = [ + text("a", { color: "#f80" }), + text("a", { color: "RebeccaPurple" }), + text("a", { color: "transparent" }), + text("a", { color: "oklch(0.7 0.1 30)" }), + text("a", { color: "RGB(255 0 0)" }), + open("a", { bg: "hsl(0 0% 0%)", border: { color: "red" } }), + ]; + // @ts-expect-error invalid hex digit count + text("a", { color: "#ff8800a" }); + // @ts-expect-error misspelled named color + text("a", { color: "rebeccapurpel" }); + // @ts-expect-error unsupported color function + text("a", { color: "color(srgb 1 0 0)" }); + // @ts-expect-error reserved system color + open("a", { bg: "Canvas" }); + expect(valid).toHaveLength(6); + }); +}); diff --git a/test/pack.test.ts b/test/pack.test.ts index bc87ca2..e6d938c 100644 --- a/test/pack.test.ts +++ b/test/pack.test.ts @@ -150,3 +150,70 @@ describe("hex colors", () => { expect((error as Error).message).toContain("#ggg"); }); }); + +describe("CSS color strings", () => { + function packed(color: string | number): Uint8Array { + let memory = new ArrayBuffer(256); + let n = pack([text("Hi", { color })], memory, 0); + return new Uint8Array(memory, 0, n * 4); + } + + it("packs each CSS Color 4 form identically to its rgba() equivalent", () => { + let cases: [string, number][] = [ + ["rgb(255 136 0)", rgba(255, 136, 0)], + ["rgb(255 136 0 / 50%)", rgba(255, 136, 0, 128)], + ["rgba(255, 136, 0, 0.5)", rgba(255, 136, 0, 128)], + ["rgb(100% 0% 0%)", rgba(255, 0, 0)], + ["hsl(120 100% 50%)", rgba(0, 255, 0)], + ["hsla(240, 100%, 50%, 0.25)", rgba(0, 0, 255, 64)], + ["hsl(0.5turn 100% 50%)", rgba(0, 255, 255)], + ["hwb(0 0% 0%)", rgba(255, 0, 0)], + ["lab(100 0 0)", rgba(255, 255, 255)], + ["lch(0 0 0)", rgba(0, 0, 0)], + ["oklab(1 0 0)", rgba(255, 255, 255)], + ["oklch(0 0 none)", rgba(0, 0, 0)], + ["rebeccapurple", rgba(102, 51, 153)], + ["RebeccaPurple", rgba(102, 51, 153)], + ["RGB(255 136 0)", rgba(255, 136, 0)], + ["transparent", rgba(0, 0, 0, 0)], + ]; + for (let [color, value] of cases) { + expect([color, packed(color)]).toEqual([color, packed(value)]); + } + }); + + it("clamps channels and alpha to the packable range", () => { + expect(packed("rgb(300 -20 0 / 2)")).toEqual(packed(rgba(255, 0, 0))); + }); + + it("clips out-of-gamut colors per channel", () => { + expect(packed("oklch(0.6 0.4 30)")).toEqual(packed(rgba(255, 0, 0))); + }); + + it("throws a TypeError on unsupported and reserved forms", () => { + for ( + let color of [ + "color(srgb 1 0 0)", + "color-mix(in srgb, red, blue)", + "rgb(from red r g b)", + "device-cmyk(0 0 0 1)", + "Canvas", + "CanvasText", + "currentcolor", + " red", + "red ", + "rgb(1 2)", + "notacolor", + ] + ) { + let error: unknown; + try { + packed(color); + } catch (caught) { + error = caught; + } + expect([color, error instanceof TypeError]).toEqual([color, true]); + expect((error as Error).message).toContain(color); + } + }); +}); diff --git a/test/validate.test.ts b/test/validate.test.ts index c460b58..858467c 100644 --- a/test/validate.test.ts +++ b/test/validate.test.ts @@ -374,3 +374,39 @@ describe("color fields", () => { } }); }); + +describe("CSS color fields", () => { + it("accepts CSS color strings at every color field", () => { + expect(validate([ + open("x", { + bg: "oklch(0.7 0.1 30)", + border: { + color: "rebeccapurple", + bg: "hsl(120 100% 50%)", + top: { width: 1, color: "rgb(255 0 0)", bg: "transparent" }, + right: 1, + }, + }), + text("hi", { color: "lab(50 20 -30)", bg: "rgba(0, 0, 255, 0.5)" }), + close(), + ])).toBe(true); + }); + + it("rejects unsupported and reserved color strings", () => { + for ( + let spec of [ + "color(srgb 1 0 0)", + "device-cmyk(0 0 0 1)", + "Canvas", + "currentcolor", + " red", + "rgb(1 2)", + ] + ) { + expect([spec, validate([text("hi", { color: spec })])]).toEqual([ + spec, + false, + ]); + } + }); +}); diff --git a/validate.ts b/validate.ts index a7905be..ec6b6fd 100644 --- a/validate.ts +++ b/validate.ts @@ -1,5 +1,6 @@ -import { Type } from "@sinclair/typebox"; +import { FormatRegistry, Type } from "@sinclair/typebox"; import { TypeCompiler } from "@sinclair/typebox/compiler"; +import { resolveColor } from "./color.ts"; import type { Op } from "./ops.ts"; import { POINTER_SHAPES } from "./pointer-shape.ts"; import type { RenderOptions, RenderResult, Term } from "./term.ts"; @@ -15,14 +16,12 @@ 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})$", -}); +/* §8.5 CSS color string, checked by the same resolver the packer uses. */ +FormatRegistry.Set("css-color", (value) => resolveColor(value) !== undefined); +const cssColor = Type.String({ format: "css-color" }); -/* A §8.5 color value: packed ARGB number or a hex color string. */ -const color = Type.Union([rgba, hexColor]); +/* A §8.5 color value: packed ARGB number or a CSS color string. */ +const color = Type.Union([rgba, cssColor]); /* ── Sizing axis (discriminated union) ────────────────────────────── */