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/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 e34e79d..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 }; @@ -85,20 +87,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( @@ -196,7 +198,7 @@ export function pack( } if (op.bg !== undefined) { - view.setUint32(o, op.bg, true); + view.setUint32(o, colorValue(op.bg), true); o += 4; } @@ -307,13 +309,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; @@ -353,6 +360,26 @@ 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 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; + +/** + * Resolves a color value to the packed ARGB number the transfer encoding + * 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; + let value = resolveColor(color); + if (value === undefined) throw new TypeError(`invalid color: "${color}"`); + return value; +} + export type SizingAxis = | { type: "fit"; min?: number; max?: number } | { type: "grow"; min?: number; max?: number } @@ -383,8 +410,8 @@ export type BorderSide = | number | { width: number; - color?: number; - bg?: number; + color?: Color; + bg?: Color; }; export interface OpenElement { @@ -399,11 +426,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; @@ -492,8 +519,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; @@ -508,16 +535,96 @@ 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 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 hex checking runs per-character via recursion. + */ + +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 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; + +/* 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/specs/renderer-spec.md b/specs/renderer-spec.md index 2b70afd..7b7ff8c 100644 --- a/specs/renderer-spec.md +++ b/specs/renderer-spec.md @@ -570,6 +570,44 @@ To restore before exit, the caller renders a frame without `pointer`, or passes `{ type: "capability", key: "pointer-shape", value: false }` to `update()`, and writes the result. +### 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 @@ -749,7 +787,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 @@ -759,6 +797,67 @@ 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 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 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 ``` @@ -983,7 +1082,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 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 @@ -1046,7 +1146,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 CSS color string). These property groups represent the current implementation surface. New groups and fields have been added incrementally and more may follow. @@ -1205,8 +1306,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 CSS 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 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..3d0ff96 100644 --- a/test/color.test.ts +++ b/test/color.test.ts @@ -219,3 +219,124 @@ 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"); + }); +}); + +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 4b4a03d..e6d938c 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,147 @@ 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"); + }); +}); + +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 0095584..858467c 100644 --- a/test/validate.test.ts +++ b/test/validate.test.ts @@ -335,3 +335,78 @@ 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); + } + }); +}); + +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 b49ee88..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,6 +16,13 @@ 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 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 CSS color string. */ +const color = Type.Union([rgba, cssColor]); + /* ── Sizing axis (discriminated union) ────────────────────────────── */ const Fit = Type.Object({ @@ -85,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), @@ -189,7 +197,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), @@ -201,8 +209,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),