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/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 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),