diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml index 4330045..c7e833d 100644 --- a/.github/workflows/preview.yml +++ b/.github/workflows/preview.yml @@ -11,3 +11,4 @@ jobs: secrets: inherit with: publish: './packages/*' + template: './examples/*' diff --git a/examples/basic/README.md b/examples/basic/README.md new file mode 100644 index 0000000..0ef305e --- /dev/null +++ b/examples/basic/README.md @@ -0,0 +1,40 @@ +# Basic example + +A DOM version of the original basic panel, using `div`, `span`, text nodes, and +direct `element.style` assignments. + +From the repository root: + +```sh +pnpm install +pnpm build +pnpm --filter @examples/basic start +``` + +The panel appears in the terminal's alternate screen while the example runs. +Press Enter or Ctrl+C to finish and restore the previous screen: + +```text +┌──────────────────────┐ +│ @clack/ui │ +│ │ +│ status ● ready │ +│ version 0.0.0 │ +│ render @bomb.sh/tty │ +└──────────────────────┘ +``` + +The title is cyan, labels are gray, status is green, remaining values inherit +white from the panel, and borders are blue. Each label and value is a separate +`span` in a flex row; this does not need mixed-color runs inside one text flow. + +Borders occupy their own cells in the CSS box model, so the panel has one cell +of horizontal padding and zero vertical padding. Together with its one-cell +border, this preserves the original example's 24-column, 7-row appearance. + +The tests render the same `createApp()` tree through the UI source, checking +layout and colors. Run them with: + +```sh +pnpm test examples/basic/src/app.test.ts +``` diff --git a/examples/basic/package.json b/examples/basic/package.json new file mode 100644 index 0000000..661e6aa --- /dev/null +++ b/examples/basic/package.json @@ -0,0 +1,11 @@ +{ + "name": "@examples/basic", + "private": true, + "type": "module", + "scripts": { + "start": "node --experimental-strip-types src/index.ts" + }, + "dependencies": { + "@clack/ui": "workspace:*" + } +} diff --git a/examples/basic/src/app.test.ts b/examples/basic/src/app.test.ts new file mode 100644 index 0000000..904d08b --- /dev/null +++ b/examples/basic/src/app.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; +import { createTestUI } from '../../../packages/ui/test/ui.ts'; +import { createApp } from './app.ts'; + +const panel = [ + '┌──────────────────────┐', + '│ @clack/ui │', + '│ │', + '│ status ● ready │', + '│ version 0.0.0 │', + '│ render @bomb.sh/tty │', + '└──────────────────────┘', +]; + +describe('basic example', () => { + it('renders the original panel with styled labels and inherited value colors', async () => { + await using ui = await createTestUI({ cols: 32, rows: 9 }); + const { document, terminal } = ui; + document.documentElement.appendChild(createApp(document)); + + await expect + .poll(() => terminal.screen.getLines().map((line) => line.trimEnd()), { timeout: 2000 }) + .toEqual([...panel, '', '']); + + const runs = [ + { row: 1, col: 2, content: '@clack/ui', color: { r: 0, g: 255, b: 255 } }, + { row: 3, col: 2, content: 'status ', color: { r: 128, g: 128, b: 128 } }, + { row: 3, col: 10, content: '● ready', color: { r: 0, g: 128, b: 0 } }, + { row: 4, col: 2, content: 'version', color: { r: 128, g: 128, b: 128 } }, + { row: 4, col: 10, content: '0.0.0', color: { r: 255, g: 255, b: 255 } }, + { row: 5, col: 2, content: 'render ', color: { r: 128, g: 128, b: 128 } }, + { row: 5, col: 10, content: '@bomb.sh/tty', color: { r: 255, g: 255, b: 255 } }, + ]; + for (const run of runs) { + for (const [offset, char] of [...run.content].entries()) { + const cell = terminal.getCell(run.row, run.col + offset); + expect(cell.char).toBe(char); + expect(cell.fg).toMatchObject(run.color); + } + } + + for (const [row, line] of panel.entries()) { + for (const [col, char] of [...line].entries()) { + if ('┌┐└┘─│'.includes(char)) { + expect(terminal.getCell(row, col).fg).toMatchObject({ r: 0, g: 0, b: 255 }); + } + } + } + }); +}); diff --git a/examples/basic/src/app.ts b/examples/basic/src/app.ts new file mode 100644 index 0000000..b293d00 --- /dev/null +++ b/examples/basic/src/app.ts @@ -0,0 +1,45 @@ +import type { Document, Element, ColorValue } from '@clack/ui'; + +/* oxlint-disable bombshell-dev/exported-function-async -- Builds a detached DOM tree synchronously. */ +/** Build the basic panel using elements, text nodes, and inline declarations. */ +export function createApp(document: Document): Element { + const panel = document.createElement('div'); + panel.style.flexDirection = 'column'; + panel.style.gap = '1px'; + panel.style.paddingLeft = '1px'; + panel.style.paddingRight = '1px'; + panel.style.color = 'white'; + panel.style.borderStyle = 'solid'; + panel.style.borderColor = 'blue'; + panel.style.borderTopWidth = '1px'; + panel.style.borderRightWidth = '1px'; + panel.style.borderBottomWidth = '1px'; + panel.style.borderLeftWidth = '1px'; + panel.appendChild(span('@clack/ui', 'cyan')); + + const details = document.createElement('div'); + details.style.flexDirection = 'column'; + details.appendChild(row('status ', span('● ready', 'green'))); + details.appendChild(row('version', span('0.0.0'))); + details.appendChild(row('render ', span('@bomb.sh/tty'))); + panel.appendChild(details); + + return panel; + + function span(content: string, color?: ColorValue): Element { + const element = document.createElement('span'); + if (color !== undefined) { + element.style.color = color; + } + element.appendChild(document.createTextNode(content)); + return element; + } + + function row(label: string, value: Element): Element { + const element = document.createElement('div'); + element.style.gap = '1px'; + element.appendChild(span(label, 'gray')); + element.appendChild(value); + return element; + } +} diff --git a/examples/basic/src/index.ts b/examples/basic/src/index.ts new file mode 100644 index 0000000..f01fb57 --- /dev/null +++ b/examples/basic/src/index.ts @@ -0,0 +1,25 @@ +import { createUI } from '@clack/ui'; +import { createApp } from './app.ts'; + +const ui = await createUI({ + height: 7, + input: process.stdin, + output: process.stdout, +}); + +const { promise: stopped, resolve: stop } = Promise.withResolvers(); + +try { + ui.document.documentElement.appendChild(createApp(ui.document)); + process.stdin.once('data', stop); + process.stdin.once('end', stop); + process.once('SIGINT', stop); + process.stdin.resume(); + await Promise.race([ui.main(), stopped]); +} finally { + process.stdin.off('data', stop); + process.stdin.off('end', stop); + process.off('SIGINT', stop); + process.stdin.pause(); + await ui[Symbol.asyncDispose](); +} diff --git a/examples/basic/tsconfig.json b/examples/basic/tsconfig.json new file mode 100644 index 0000000..817128b --- /dev/null +++ b/examples/basic/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "composite": false, + "declaration": false, + "declarationMap": false, + "noEmit": true, + "types": ["node"] + } +} diff --git a/package.json b/package.json index fd745ed..db16f8a 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "format": "bsh format", "format:check": "bsh format --check", "lint": "bsh lint .", - "test": "bsh test" + "test": "bsh test --exclude '**/node_modules/**' --exclude '**/dist/**'" }, "devDependencies": { "@bomb.sh/tools": "^0.8.0", diff --git a/packages/ui/package.json b/packages/ui/package.json index 0b1b0bf..68fafef 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -36,7 +36,9 @@ "test": "bsh test" }, "dependencies": { - "@bomb.sh/tty": "catalog:" + "@bomb.sh/tty": "catalog:", + "@standard-schema/spec": "^1.1.0", + "hueful": "^0.8.1" }, "devEngines": { "packageManager": { diff --git a/packages/ui/src/dom.ts b/packages/ui/src/dom.ts index e56606e..0f43331 100644 --- a/packages/ui/src/dom.ts +++ b/packages/ui/src/dom.ts @@ -1,8 +1,12 @@ +import { StyleDeclaration } from './style.ts'; + /** * Container type for valid names of createElement() */ export interface Elements { document: Element; + div: Element; + span: Element; } export class Document { @@ -44,10 +48,12 @@ export class Node { export class Element extends Node { readonly tagName: string; + readonly style: StyleDeclaration; constructor(tagName: keyof Elements, notify: Node['notify']) { super(tagName.toUpperCase(), notify); this.tagName = tagName; + this.style = new StyleDeclaration(this.notify); } get children(): Element[] { diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index ca4a46a..50d9847 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -1 +1,10 @@ export { createUI } from './ui.ts'; +export { Document, Element, Text } from './dom.ts'; +export type { + ColorValue, + BorderWidthValue, + FlexDirectionValue, + DisplayValue, + LengthValue, + BorderStyleValue, +} from './style.ts'; diff --git a/packages/ui/src/layout.test.ts b/packages/ui/src/layout.test.ts new file mode 100644 index 0000000..01b05ba --- /dev/null +++ b/packages/ui/src/layout.test.ts @@ -0,0 +1,93 @@ +import { close, open, rgba, snapshot, text, type Op } from '@bomb.sh/tty'; +import { assert, describe, expect, it } from 'vitest'; +import { Document } from './dom.ts'; +import { Layout } from './layout.ts'; + +describe('sparse layout options', () => { + it('omits default layout options and saves the whole packed layout block', () => { + const document = new Document(() => {}); + const { documentElement } = document; + // Explicit defaults have the same packing requirements as undeclared defaults. + documentElement.style.display = 'flex'; + documentElement.style.flexDirection = 'row'; + documentElement.style.gap = '0px'; + documentElement.style.paddingLeft = '0px'; + documentElement.style.borderColor = 'blue'; + const ops = [...new Layout(documentElement).layout()]; + const id = rootId(ops); + const dense = [ + open(id, { + layout: { + direction: 'ltr', + gap: 0, + padding: { top: 0, right: 0, bottom: 0, left: 0 }, + }, + }), + close(), + ]; + expect(ops).toEqual([open(id), close()]); + expect(packed(dense).byteLength - packed(ops).byteLength).toBe(36); + }); + + it('packs nondefault layout and border options identically to explicit zero sides', () => { + const document = new Document(() => {}); + const { documentElement } = document; + documentElement.style.flexDirection = 'column'; + documentElement.style.gap = '2px'; + documentElement.style.paddingLeft = '2px'; + documentElement.style.borderStyle = 'solid'; + documentElement.style.borderColor = 'blue'; + documentElement.style.borderTopWidth = '0px'; + documentElement.style.borderRightWidth = '0px'; + documentElement.style.borderBottomWidth = '0px'; + const ops = [...new Layout(documentElement).layout()]; + const id = rootId(ops); + expect(ops).toEqual([ + open(id, { + layout: { direction: 'ttb', gap: 2, padding: { left: 2 } }, + border: { color: rgba(0, 0, 255), left: 1 }, + }), + close(), + ]); + expect(packed(ops)).toEqual( + packed([ + open(id, { + layout: { direction: 'ttb', gap: 2, padding: { top: 0, right: 0, bottom: 0, left: 2 } }, + border: { color: rgba(0, 0, 255), top: 0, right: 0, bottom: 0, left: 1 }, + }), + close(), + ]), + ); + }); + + it('omits terminal-default text color but preserves inherited packed zero', () => { + const document = new Document(() => {}); + const { documentElement } = document; + const span = document.createElement('span'); + span.appendChild(document.createTextNode('inline')); + documentElement.appendChild(document.createTextNode('direct')); + documentElement.appendChild(span); + const layout = new Layout(documentElement); + const ops = [...layout.layout()]; + const id = rootId(ops); + expect(ops).toEqual([open(id), text('direct'), text('inline'), close()]); + documentElement.style.color = 'transparent'; + expect([...layout.layout()]).toEqual([ + open(id), + text('direct', { color: 0 }), + text('inline', { color: 0 }), + close(), + ]); + }); +}); + +function rootId(ops: Op[]): string { + const root = ops[0]; + assert(root && 'id' in root); + return root.id; +} + +function packed(ops: Op[]): Uint8Array { + const result = snapshot(ops) as { data: Uint8Array }; + return result.data; +} diff --git a/packages/ui/src/layout.ts b/packages/ui/src/layout.ts index 05bce7c..3c793f8 100644 --- a/packages/ui/src/layout.ts +++ b/packages/ui/src/layout.ts @@ -1,5 +1,11 @@ -import { close, open, text, type Op } from '@bomb.sh/tty'; +/* oxlint-disable bombshell-dev/max-params -- Layout helpers pass the element and rendering context explicitly. */ +import { close, open, rgba, text, type Op } from '@bomb.sh/tty'; +import { convert } from 'hueful'; import { Element, Text } from './dom.ts'; +import type { StyleValues } from './style.ts'; + +type TTYContainerOptions = NonNullable[1]>; +type TTYTextOptions = NonNullable[1]>; export class Layout { #lastIdNumber = 0; @@ -10,14 +16,30 @@ export class Layout { this.#root = root; } - /** Serialize an element to TTY Ops. */ - *layout(element: Element = this.#root): IterableIterator { + /** + * Serialize an element to TTY Ops. + */ + *layout(element: Element = this.#root, inheritedColor?: number): IterableIterator { + const style = computeStyle(element); + const color = resolveColorValue(style.color.value, inheritedColor); + + if (style.display.value === 'inline') { + yield* this.#layoutInline(element, color); + } else { + yield* this.#layoutContainer(element, style, color); + } + } + + /** + * Layout an element with `display: flex` + */ + *#layoutContainer(element: Element, style: StyleValues, color?: number): IterableIterator { let stableId = this.#stableIds.get(element); if (!stableId) { stableId = `${element.nodeName}: ${this.#lastIdNumber++}`; this.#stableIds.set(element, stableId); } - yield open(stableId); + yield open(stableId, ttyContainerOptions(style, color)); try { let content = ''; @@ -26,17 +48,164 @@ export class Layout { content += child.data; } else if (child instanceof Element) { if (content !== '') { - yield text(content); + yield text(content, ttyTextOptions(color)); content = ''; } - yield* this.layout(child); + yield* this.layout(child, color); } } if (content !== '') { - yield text(content); + yield text(content, ttyTextOptions(color)); } } finally { yield close(); } } + + /** + * Layout an element with `display: inline`. Any containers contained within + * are ignored, and we just collect text. E.g.
Hello World + * ignores the layout of the span + */ + *#layoutInline(element: Element, color?: number): IterableIterator { + let content = ''; + function collectTextContent(element: Element): void { + for (const child of element.childNodes) { + if (child instanceof Text) { + content += child.data; + } else if (child instanceof Element) { + collectTextContent(child); + } + } + } + collectTextContent(element); + if (content !== '') { + yield text(content, ttyTextOptions(color)); + } + } +} + +/** + * Convert a color which is stored internally as a hueful `ParseResult` into + * an RGB numeric value which is what TTY speaks natively. + */ +function resolveColorValue( + value: StyleValues['color']['value'], + inheritedColor?: number, +): number | undefined { + if (value === 'currentColor') { + return inheritedColor; + } + const [r, g, b, alpha = 1] = convert(value.space, 'rgb', value.value); + const channel = (value: number) => Math.round(Math.max(0, Math.min(255, value))); + return rgba(channel(r), channel(g), channel(b), channel(alpha * 255)); +} + +/** + * Map a layout container element's styles to TTY options. The mapping is + * sparse so that even though we have a completely known computed style, + * we only send through to WASM enough information to render. + */ +function ttyContainerOptions(style: StyleValues, color?: number): TTYContainerOptions { + const props: TTYContainerOptions = {}; + const layout: NonNullable = {}; + if (style.flexDirection.value === 'column') { + layout.direction = 'ttb'; + } + if (style.gap.value !== 0) { + layout.gap = style.gap.value; + } + + let top = style.paddingTop.value; + let right = style.paddingRight.value; + let bottom = style.paddingBottom.value; + let left = style.paddingLeft.value; + const borderTop = style.borderTopWidth.value; + const borderRight = style.borderRightWidth.value; + const borderBottom = style.borderBottomWidth.value; + const borderLeft = style.borderLeftWidth.value; + if ( + style.borderStyle.value === 'solid' && + (borderTop || borderRight || borderBottom || borderLeft) + ) { + const borderColor = resolveColorValue(style.borderColor.value, color); + // TTY requires an explicit border color. Keep the space when it cannot draw the border. + if (borderColor !== undefined) { + props.border = { color: borderColor }; + if (borderTop !== 0) { + props.border.top = borderTop; + } + if (borderRight !== 0) { + props.border.right = borderRight; + } + if (borderBottom !== 0) { + props.border.bottom = borderBottom; + } + if (borderLeft !== 0) { + props.border.left = borderLeft; + } + } else { + // TTY cannot draw borders with the terminal-default foreground yet. + // Reserve their space with padding until https://github.com/bombshell-dev/tty/issues/155 + // is implemented; then emit the border and remove this fallback. + top += borderTop; + right += borderRight; + bottom += borderBottom; + left += borderLeft; + } + } + + if (top || right || bottom || left) { + layout.padding = {}; + if (top !== 0) { + layout.padding.top = top; + } + if (right !== 0) { + layout.padding.right = right; + } + if (bottom !== 0) { + layout.padding.bottom = bottom; + } + if (left !== 0) { + layout.padding.left = left; + } + } + // Even an empty layout object makes TTY pack the entire layout block. + if (Object.keys(layout).length > 0) { + props.layout = layout; + } + return props; +} + +function ttyTextOptions(color?: number): TTYTextOptions { + return color === undefined ? {} : { color }; +} + +function computeStyle(element: Element): StyleValues { + const defaults = computeDefaultStyles(element); + return applyInlineStyles(element, defaults); +} + +function computeDefaultStyles(element: Element): StyleValues { + const display = element.tagName === 'span' ? 'inline' : 'flex'; + return { + color: { value: 'currentColor', representation: 'currentColor' }, + display: { value: display, representation: display }, + flexDirection: { value: 'row', representation: 'row' }, + gap: { value: 0, representation: '0px' }, + paddingTop: { value: 0, representation: '0px' }, + paddingRight: { value: 0, representation: '0px' }, + paddingBottom: { value: 0, representation: '0px' }, + paddingLeft: { value: 0, representation: '0px' }, + borderStyle: { value: 'none', representation: 'none' }, + borderTopWidth: { value: 1, representation: '1px' }, + borderRightWidth: { value: 1, representation: '1px' }, + borderBottomWidth: { value: 1, representation: '1px' }, + borderLeftWidth: { value: 1, representation: '1px' }, + borderColor: { value: 'currentColor', representation: 'currentColor' }, + }; +} + +function applyInlineStyles(element: Element, defaults: StyleValues): StyleValues { + return { ...defaults, ...element.style.values }; } diff --git a/packages/ui/src/style-rendering.test.ts b/packages/ui/src/style-rendering.test.ts new file mode 100644 index 0000000..7562087 --- /dev/null +++ b/packages/ui/src/style-rendering.test.ts @@ -0,0 +1,329 @@ +import { describe, expect, it } from 'vitest'; +import { createTestUI } from '../test/ui.ts'; +import type { UI } from './ui.ts'; + +const green = { r: 0, g: 205, b: 0 }; +const blue = { r: 0, g: 0, b: 238 }; + +describe('style rendering', () => { + it('defaults div to a row with no gap or padding, and repaints when direction changes', async () => { + await using ui = await createTestUI({ cols: 12, rows: 4 }); + const { document, terminal } = ui; + const row = document.createElement('div'); + row.appendChild(label(document, 'A')); + row.appendChild(label(document, 'B')); + document.documentElement.appendChild(row); + + await expect.poll(() => terminal.screen.getLines()).toEqual(['AB', '', '', '']); + + row.style.flexDirection = 'column'; + await expect.poll(() => terminal.screen.getLines()).toEqual(['A', 'B', '', '']); + + row.style.flexDirection = ''; + await expect.poll(() => terminal.screen.getLines()).toEqual(['AB', '', '', '']); + }); + + it('lets a span become a flex container', async () => { + await using ui = await createTestUI({ cols: 12, rows: 4 }); + const { document, terminal } = ui; + const column = document.createElement('span'); + column.style.display = 'flex'; + column.style.flexDirection = 'column'; + column.style.gap = '1px'; + column.appendChild(label(document, 'A')); + column.appendChild(label(document, 'B')); + document.documentElement.appendChild(column); + document.documentElement.appendChild(label(document, 'C')); + + await expect.poll(() => terminal.screen.getLines()).toEqual(['AC', '', 'B', '']); + }); + + it('lets a div become one inline text item', async () => { + await using ui = await createTestUI({ cols: 12, rows: 4 }); + const { document, terminal } = ui; + document.documentElement.style.flexDirection = 'column'; + document.documentElement.style.gap = '1px'; + const flow = document.createElement('div'); + flow.style.display = 'inline'; + // These declarations apply to flex containers, not inline text. + flow.style.flexDirection = 'column'; + flow.style.gap = '3px'; + flow.appendChild(document.createTextNode('A')); + flow.appendChild(label(document, 'B')); + document.documentElement.appendChild(flow); + document.documentElement.appendChild(label(document, 'C')); + + await expect.poll(() => terminal.screen.getLines()).toEqual(['AB', '', 'C', '']); + }); + + it('uses gap and individual padding as whole terminal cells without inheriting them', async () => { + await using ui = await createTestUI({ cols: 12, rows: 5 }); + const { document, terminal } = ui; + document.documentElement.style.flexDirection = 'column'; + const row = document.createElement('div'); + const padded = document.createElement('div'); + padded.style.gap = '2px'; + padded.style.paddingTop = '1px'; + padded.style.paddingRight = '3px'; + padded.style.paddingBottom = '1px'; + padded.style.paddingLeft = '2px'; + const inner = document.createElement('div'); + inner.appendChild(label(document, 'A')); + inner.appendChild(label(document, 'B')); + padded.appendChild(inner); + padded.appendChild(label(document, 'C')); + row.appendChild(padded); + row.appendChild(label(document, 'R')); + document.documentElement.appendChild(row); + document.documentElement.appendChild(label(document, 'D')); + + // R observes right padding; D observes bottom padding and the row's height. + await expect + .poll(() => terminal.screen.getLines()) + .toEqual([' R', ' AB C', '', 'D', '']); + }); + + it.each([ + ['borderTopWidth', ['─', 'X', '']], + ['borderRightWidth', ['X│', '', '']], + ['borderBottomWidth', ['X', '─', '']], + ['borderLeftWidth', ['│X', '', '']], + ] as const)('draws only the side selected by %s', async (side, lines) => { + await using ui = await createTestUI({ cols: 8, rows: 3 }); + const { document, terminal } = ui; + const box = document.createElement('div'); + box.style.borderStyle = 'solid'; + box.style.borderColor = 'rgb(0 0 238)'; + box.style.borderTopWidth = '0px'; + box.style.borderRightWidth = '0px'; + box.style.borderBottomWidth = '0px'; + box.style.borderLeftWidth = '0px'; + box.style[side] = '1px'; + box.appendChild(document.createTextNode('X')); + document.documentElement.appendChild(box); + + await expect.poll(() => terminal.screen.getLines()).toEqual(lines); + }); + + it('requires solid to show a border and retains its widths while hidden', async () => { + await using ui = await createTestUI({ cols: 8, rows: 4 }); + const { document, terminal } = ui; + const box = document.createElement('div'); + box.style.borderColor = 'rgb(0 0 238)'; + box.style.borderTopWidth = '1px'; + box.style.borderRightWidth = '1px'; + box.style.borderBottomWidth = '1px'; + box.style.borderLeftWidth = '1px'; + box.appendChild(document.createTextNode('X')); + document.documentElement.appendChild(box); + + await expect.poll(() => terminal.screen.getLines()).toEqual(['X', '', '', '']); + + box.style.borderStyle = 'solid'; + await expect.poll(() => terminal.screen.getLines()).toEqual(['┌─┐', '│X│', '└─┘', '']); + + box.style.borderStyle = 'none'; + await expect.poll(() => terminal.screen.getLines()).toEqual(['X', '', '', '']); + expect(box.style.borderTopWidth).toBe('1px'); + expect(box.style.borderRightWidth).toBe('1px'); + expect(box.style.borderBottomWidth).toBe('1px'); + expect(box.style.borderLeftWidth).toBe('1px'); + + box.style.borderStyle = 'solid'; + await expect.poll(() => terminal.screen.getLines()).toEqual(['┌─┐', '│X│', '└─┘', '']); + }); + + it('resolves the default border width to one cell outside the padding', async () => { + await using ui = await createTestUI({ cols: 8, rows: 6 }); + const { document, terminal } = ui; + const box = document.createElement('div'); + box.style.borderStyle = 'solid'; + box.style.borderColor = 'rgb(0 0 238)'; + box.style.paddingTop = '1px'; + box.style.paddingRight = '1px'; + box.style.paddingBottom = '1px'; + box.style.paddingLeft = '1px'; + box.appendChild(document.createTextNode('X')); + document.documentElement.appendChild(box); + + await expect + .poll(() => terminal.screen.getLines()) + .toEqual(['┌───┐', '│ │', '│ X │', '│ │', '└───┘', '']); + // Resolved defaults do not become authored inline declarations. + expect(box.style.borderTopWidth).toBe(''); + }); + + it('inherits color, preserves overrides, and repaints descendants when declarations change or clear', async () => { + await using ui = await createTestUI({ cols: 8, rows: 2 }); + const { document, terminal } = ui; + document.documentElement.style.color = 'rgb(229 229 229)'; + const parent = document.createElement('div'); + parent.style.color = 'rgb(0 205 0)'; + const inherited = label(document, 'A'); + const overridden = label(document, 'B'); + overridden.style.color = 'rgb(0 0 0)'; + parent.appendChild(inherited); + parent.appendChild(overridden); + document.documentElement.appendChild(parent); + + await expect.poll(() => terminal.screen.getLines()).toEqual(['AB', '']); + await expect.poll(() => terminal.getCell(0, 0).fg).toMatchObject(green); + expect(terminal.getCell(0, 1).fg).toMatchObject({ r: 0, g: 0, b: 0 }); + expect(inherited.style.color).toBe(''); + + parent.style.color = 'rgb(0 0 238)'; + await expect.poll(() => terminal.getCell(0, 0).fg).toMatchObject(blue); + expect(terminal.getCell(0, 1).fg).toMatchObject({ r: 0, g: 0, b: 0 }); + + overridden.style.color = ''; + await expect.poll(() => terminal.getCell(0, 1).fg).toMatchObject(blue); + + parent.style.color = ''; + await expect.poll(() => terminal.getCell(0, 0).fg).toMatchObject({ r: 229, g: 229, b: 229 }); + expect(terminal.getCell(0, 1).fg).toMatchObject({ r: 229, g: 229, b: 229 }); + + document.documentElement.style.color = ''; + await expect.poll(() => terminal.getCell(0, 0).fg).toBeNull(); + expect(terminal.getCell(0, 1).fg).toBeNull(); + }); + + it.each([ + ['green', { r: 0, g: 128, b: 0 }], + ['#00cd00', green], + ['hsl(240 100% 50%)', { r: 0, g: 0, b: 255 }], + ] as const)('converts %s to a terminal text color', async (color, expected) => { + await using ui = await createTestUI({ cols: 8, rows: 2 }); + const { document, terminal } = ui; + const text = label(document, 'X'); + text.style.color = color; + document.documentElement.appendChild(text); + + await expect.poll(() => terminal.screen.getLines()).toEqual(['X', '']); + expect(terminal.getCell(0, 0).fg).toMatchObject(expected); + }); + + it('resolves default and explicit currentColor borders from inherited text color', async () => { + await using ui = await createTestUI({ cols: 8, rows: 4 }); + const { document, terminal } = ui; + document.documentElement.style.color = 'rgb(0 205 0)'; + const box = document.createElement('div'); + box.style.borderStyle = 'solid'; + box.appendChild(document.createTextNode('X')); + document.documentElement.appendChild(box); + + await expect.poll(() => terminal.screen.getLines()).toEqual(['┌─┐', '│X│', '└─┘', '']); + expect(terminal.getCell(0, 0).fg).toMatchObject(green); + expect(terminal.getCell(1, 1).fg).toMatchObject(green); + + box.style.borderColor = 'rgb(0 0 238)'; + await expect.poll(() => terminal.getCell(0, 0).fg).toMatchObject(blue); + expect(terminal.getCell(1, 1).fg).toMatchObject(green); + + box.style.borderColor = 'currentColor'; + await expect.poll(() => terminal.getCell(0, 0).fg).toMatchObject(green); + + document.documentElement.style.color = 'rgb(0 0 238)'; + await expect.poll(() => terminal.getCell(0, 0).fg).toMatchObject(blue); + expect(terminal.getCell(1, 1).fg).toMatchObject(blue); + }); + + it('concatenates adjacent text nodes verbatim without inserting gaps inside a span', async () => { + await using ui = await createTestUI({ cols: 32, rows: 2 }); + const { document, terminal } = ui; + document.documentElement.style.gap = '2px'; + const flow = document.createElement('span'); + flow.appendChild(document.createTextNode(' Hello ')); + flow.appendChild(document.createTextNode(' world ')); + flow.appendChild(document.createTextNode('!')); + document.documentElement.appendChild(flow); + document.documentElement.appendChild(label(document, '|')); + + await expect.poll(() => terminal.screen.getLines()).toEqual([' Hello world ! |', '']); + }); + + it('wraps nested spans with equal resolved styles as one text flow', async () => { + await using ui = await createTestUI({ cols: 10, rows: 5 }); + const { document, terminal } = ui; + document.documentElement.style.flexDirection = 'column'; + document.documentElement.style.gap = '1px'; + document.documentElement.style.color = 'rgb(0 205 0)'; + const flow = document.createElement('span'); + flow.appendChild(document.createTextNode('one ')); + const nested = label(document, 'two three'); + // Different declaration spelling, same resolved color as its parent. + nested.style.color = 'rgb(0, 205, 0)'; + flow.appendChild(nested); + flow.appendChild(document.createTextNode(' four')); + document.documentElement.appendChild(flow); + document.documentElement.appendChild(label(document, '!')); + + await expect + .poll(() => terminal.screen.getLines()) + .toEqual(['one two', 'three four', '', '!', '']); + expect(terminal.getCell(1, 0).fg).toMatchObject(green); + }); + + it.each(['span', 'div'] as const)( + 'collects text from a nested %s using the outer inline color', + async (tagName) => { + await using ui = await createTestUI({ cols: 20, rows: 2 }); + const { document, terminal } = ui; + const flow = document.createElement('span'); + flow.style.color = 'rgb(0 205 0)'; + flow.appendChild(document.createTextNode('Hello ')); + const nested = document.createElement(tagName); + nested.style.color = 'rgb(0 0 238)'; + nested.appendChild(document.createTextNode('world')); + flow.appendChild(nested); + flow.appendChild(document.createTextNode('!')); + document.documentElement.appendChild(flow); + + await expect.poll(() => terminal.screen.getLines()).toEqual(['Hello world!', '']); + for (let column = 0; column < 'Hello world!'.length; column++) { + expect(terminal.getCell(0, column).fg).toMatchObject(green); + } + }, + ); + + it('preserves border space and keeps rendering when the border color is terminal-default', async () => { + await using ui = await createTestUI({ cols: 8, rows: 4 }); + const { document, terminal } = ui; + const box = document.createElement('div'); + box.style.borderStyle = 'solid'; + box.appendChild(document.createTextNode('X')); + document.documentElement.appendChild(box); + + await expect.poll(() => terminal.screen.getLines()).toEqual(['', ' X', '', '']); + expect(terminal.getCell(1, 1).fg).toBeNull(); + + box.style.borderColor = 'rgb(0 0 238)'; + await expect.poll(() => terminal.screen.getLines()).toEqual(['┌─┐', '│X│', '└─┘', '']); + expect(terminal.getCell(0, 0).fg).toMatchObject(blue); + + box.style.borderColor = ''; + document.documentElement.appendChild(label(document, '!')); + await expect.poll(() => terminal.screen.getLines()).toEqual([' !', ' X', '', '']); + }); + + it('allows terminal-default foreground when every border side has zero width', async () => { + await using ui = await createTestUI({ cols: 8, rows: 2 }); + const { document, terminal } = ui; + const box = document.createElement('div'); + box.style.borderStyle = 'solid'; + box.style.borderTopWidth = '0px'; + box.style.borderRightWidth = '0px'; + box.style.borderBottomWidth = '0px'; + box.style.borderLeftWidth = '0px'; + box.appendChild(document.createTextNode('X')); + document.documentElement.appendChild(box); + + await expect.poll(() => terminal.screen.getLines()).toEqual(['X', '']); + expect(terminal.getCell(0, 0).fg).toBeNull(); + }); +}); + +function label(document: UI['document'], content: string): UI['document']['documentElement'] { + const element = document.createElement('span'); + element.appendChild(document.createTextNode(content)); + return element; +} diff --git a/packages/ui/src/style.test.ts b/packages/ui/src/style.test.ts new file mode 100644 index 0000000..9ba5173 --- /dev/null +++ b/packages/ui/src/style.test.ts @@ -0,0 +1,287 @@ +import { describe, expect, expectTypeOf, it } from 'vitest'; +import { Document } from './dom.ts'; +import type { ColorValue } from './style.ts'; + +describe('inline style declarations', () => { + it('keeps a stable style object for each element', () => { + const document = new Document(() => {}); + const first = document.createElement('div'); + const second = document.createElement('span'); + const style = first.style; + + expect(style).toBeDefined(); + expect(first.style).toBe(style); + expect(second.style).not.toBe(style); + + style.color = 'green'; + + expect(first.style).toBe(style); + expect(second.style.color).toBe(''); + }); + + it('reads undeclared properties as empty strings, including element defaults', () => { + const document = new Document(() => {}); + const { documentElement } = document; + + expect(documentElement.style.display, `${documentElement.tagName}.style.display`).toBe(''); + expect( + documentElement.style.flexDirection, + `${documentElement.tagName}.style.flexDirection`, + ).toBe(''); + expect(documentElement.style.gap, `${documentElement.tagName}.style.gap`).toBe(''); + expect(documentElement.style.paddingTop, `${documentElement.tagName}.style.paddingTop`).toBe( + '', + ); + expect( + documentElement.style.paddingRight, + `${documentElement.tagName}.style.paddingRight`, + ).toBe(''); + expect( + documentElement.style.paddingBottom, + `${documentElement.tagName}.style.paddingBottom`, + ).toBe(''); + expect(documentElement.style.paddingLeft, `${documentElement.tagName}.style.paddingLeft`).toBe( + '', + ); + expect(documentElement.style.borderStyle, `${documentElement.tagName}.style.borderStyle`).toBe( + '', + ); + expect( + documentElement.style.borderTopWidth, + `${documentElement.tagName}.style.borderTopWidth`, + ).toBe(''); + expect( + documentElement.style.borderRightWidth, + `${documentElement.tagName}.style.borderRightWidth`, + ).toBe(''); + expect( + documentElement.style.borderBottomWidth, + `${documentElement.tagName}.style.borderBottomWidth`, + ).toBe(''); + expect( + documentElement.style.borderLeftWidth, + `${documentElement.tagName}.style.borderLeftWidth`, + ).toBe(''); + expect(documentElement.style.borderColor, `${documentElement.tagName}.style.borderColor`).toBe( + '', + ); + expect(documentElement.style.color, `${documentElement.tagName}.style.color`).toBe(''); + + const div = document.createElement('div'); + + expect(div.style.display, `${div.tagName}.style.display`).toBe(''); + expect(div.style.flexDirection, `${div.tagName}.style.flexDirection`).toBe(''); + expect(div.style.gap, `${div.tagName}.style.gap`).toBe(''); + expect(div.style.paddingTop, `${div.tagName}.style.paddingTop`).toBe(''); + expect(div.style.paddingRight, `${div.tagName}.style.paddingRight`).toBe(''); + expect(div.style.paddingBottom, `${div.tagName}.style.paddingBottom`).toBe(''); + expect(div.style.paddingLeft, `${div.tagName}.style.paddingLeft`).toBe(''); + expect(div.style.borderStyle, `${div.tagName}.style.borderStyle`).toBe(''); + expect(div.style.borderTopWidth, `${div.tagName}.style.borderTopWidth`).toBe(''); + expect(div.style.borderRightWidth, `${div.tagName}.style.borderRightWidth`).toBe(''); + expect(div.style.borderBottomWidth, `${div.tagName}.style.borderBottomWidth`).toBe(''); + expect(div.style.borderLeftWidth, `${div.tagName}.style.borderLeftWidth`).toBe(''); + expect(div.style.borderColor, `${div.tagName}.style.borderColor`).toBe(''); + expect(div.style.color, `${div.tagName}.style.color`).toBe(''); + + const span = document.createElement('span'); + + expect(span.style.display, `${span.tagName}.style.display`).toBe(''); + expect(span.style.flexDirection, `${span.tagName}.style.flexDirection`).toBe(''); + expect(span.style.gap, `${span.tagName}.style.gap`).toBe(''); + expect(span.style.paddingTop, `${span.tagName}.style.paddingTop`).toBe(''); + expect(span.style.paddingRight, `${span.tagName}.style.paddingRight`).toBe(''); + expect(span.style.paddingBottom, `${span.tagName}.style.paddingBottom`).toBe(''); + expect(span.style.paddingLeft, `${span.tagName}.style.paddingLeft`).toBe(''); + expect(span.style.borderStyle, `${span.tagName}.style.borderStyle`).toBe(''); + expect(span.style.borderTopWidth, `${span.tagName}.style.borderTopWidth`).toBe(''); + expect(span.style.borderRightWidth, `${span.tagName}.style.borderRightWidth`).toBe(''); + expect(span.style.borderBottomWidth, `${span.tagName}.style.borderBottomWidth`).toBe(''); + expect(span.style.borderLeftWidth, `${span.tagName}.style.borderLeftWidth`).toBe(''); + expect(span.style.borderColor, `${span.tagName}.style.borderColor`).toBe(''); + expect(span.style.color, `${span.tagName}.style.color`).toBe(''); + }); + + it('accepts direct display and flexDirection assignments', () => { + const style = new Document(() => {}).createElement('div').style; + + style.display = 'inline'; + style.flexDirection = 'column'; + expect(style.display).toBe('inline'); + expect(style.flexDirection).toBe('column'); + + style.display = 'flex'; + style.flexDirection = 'row'; + expect(style.display).toBe('flex'); + expect(style.flexDirection).toBe('row'); + }); + + it('ignores unsupported display and flexDirection values', () => { + const style = new Document(() => {}).createElement('div').style; + style.display = 'inline'; + style.flexDirection = 'column'; + + Reflect.set(style, 'display', 'grid'); + Reflect.set(style, 'flexDirection', 'row-reverse'); + + expect(style.display).toBe('inline'); + expect(style.flexDirection).toBe('column'); + }); + + it('accepts nonnegative whole-cell px lengths for gap and each padding side', () => { + const style = new Document(() => {}).createElement('div').style; + style.gap = '0px'; + style.paddingTop = '1px'; + style.paddingRight = '2px'; + style.paddingBottom = '3px'; + style.paddingLeft = '4px'; + + expect(style.gap).toBe('0px'); + expect(style.paddingTop).toBe('1px'); + expect(style.paddingRight).toBe('2px'); + expect(style.paddingBottom).toBe('3px'); + expect(style.paddingLeft).toBe('4px'); + }); + + it('types gap and padding as px strings or empty declarations', () => { + const style = new Document(() => {}).createElement('div').style; + + expectTypeOf(style.gap).toEqualTypeOf<`${number}px` | ''>(); + expectTypeOf(style.paddingTop).toEqualTypeOf<`${number}px` | ''>(); + expectTypeOf(style.paddingRight).toEqualTypeOf<`${number}px` | ''>(); + expectTypeOf(style.paddingBottom).toEqualTypeOf<`${number}px` | ''>(); + expectTypeOf(style.paddingLeft).toEqualTypeOf<`${number}px` | ''>(); + }); + + it('preserves previous lengths after invalid assignments', () => { + const style = new Document(() => {}).createElement('div').style; + + for (const property of [ + 'gap', + 'paddingTop', + 'paddingRight', + 'paddingBottom', + 'paddingLeft', + ] as const) { + style[property] = '2px'; + + for (const value of ['-1px', '1.5px', '1ch', '2', 'auto', 'NaNpx', 2]) { + Reflect.set(style, property, value); + expect(style[property], `${property} after ${String(value)}`).toBe('2px'); + } + } + }); + + it('accepts zero or one cell for each border width and preserves other sides', () => { + const style = new Document(() => {}).createElement('div').style; + style.borderTopWidth = '0px'; + style.borderRightWidth = '1px'; + style.borderBottomWidth = '0px'; + style.borderLeftWidth = '1px'; + + expect(style.borderTopWidth).toBe('0px'); + expect(style.borderRightWidth).toBe('1px'); + expect(style.borderBottomWidth).toBe('0px'); + expect(style.borderLeftWidth).toBe('1px'); + }); + + it('preserves previous border widths after unsupported assignments', () => { + const style = new Document(() => {}).createElement('div').style; + + for (const property of [ + 'borderTopWidth', + 'borderRightWidth', + 'borderBottomWidth', + 'borderLeftWidth', + ] as const) { + style[property] = '1px'; + + for (const value of ['2px', '-1px', '0.5px', '1ch', 'thick', 1]) { + Reflect.set(style, property, value); + expect(style[property], `${property} after ${String(value)}`).toBe('1px'); + } + } + }); + + it('supports solid and none without erasing border width or color declarations', () => { + const style = new Document(() => {}).createElement('div').style; + style.borderTopWidth = '1px'; + style.borderColor = 'blue'; + const color = style.borderColor; + style.borderStyle = 'solid'; + expect(style.borderStyle).toBe('solid'); + + style.borderStyle = 'none'; + expect(style.borderStyle).toBe('none'); + expect(style.borderTopWidth).toBe('1px'); + expect(style.borderColor).toBe(color); + + Reflect.set(style, 'borderStyle', 'dashed'); + expect(style.borderStyle).toBe('none'); + }); + + it.each(['rgb(0 205 0)', 'rgba(0, 205, 0, 0.5)', '#00cd00', 'green', 'hsl(120 100% 40%)'])( + 'accepts %s for text and border colors', + (value) => { + const style = new Document(() => {}).createElement('span').style; + const color = value as ColorValue; + style.color = color; + style.borderColor = color; + + // Valid declarations may serialize into a normalized CSS color string. + expect(style.color).toEqual(expect.any(String)); + expect(style.color).not.toBe(''); + expect(style.borderColor).toEqual(expect.any(String)); + expect(style.borderColor).not.toBe(''); + }, + ); + + it('accepts currentColor as an explicit border color declaration', () => { + const style = new Document(() => {}).createElement('div').style; + style.borderColor = 'currentColor'; + + expect(style.borderColor.toLowerCase()).toBe('currentcolor'); + expect(style.color).toBe(''); + }); + + it('preserves previous color declarations after invalid assignments', () => { + const style = new Document(() => {}).createElement('span').style; + style.color = 'green'; + style.borderColor = 'blue'; + + for (const property of ['color', 'borderColor'] as const) { + const previous = style[property]; + + for (const value of ['not-a-color', '#xyz', 'rgb(0 205)', 42]) { + Reflect.set(style, property, value); + expect(style[property], `${property} after ${String(value)}`).toBe(previous); + } + } + }); + + it('removes inline declarations when assigned an empty string', () => { + const style = new Document(() => {}).createElement('div').style; + style.display = 'inline'; + style.flexDirection = 'column'; + style.gap = '1px'; + style.paddingLeft = '2px'; + style.borderStyle = 'solid'; + style.borderLeftWidth = '1px'; + style.borderColor = 'blue'; + style.color = 'green'; + + for (const property of [ + 'display', + 'flexDirection', + 'gap', + 'paddingLeft', + 'borderStyle', + 'borderLeftWidth', + 'borderColor', + 'color', + ] as const) { + style[property] = ''; + expect(style[property], property).toBe(''); + } + }); +}); diff --git a/packages/ui/src/style.ts b/packages/ui/src/style.ts new file mode 100644 index 0000000..f5fb5f5 --- /dev/null +++ b/packages/ui/src/style.ts @@ -0,0 +1,276 @@ +/* oxlint-disable bombshell-dev/max-params -- Property setters take a key, value, and schema. */ +import { parse, type names, type ParseResult } from 'hueful'; +import type { StandardSchemaV1 } from '@standard-schema/spec'; + +/** Approximate CSS color syntax; Hueful validates the contents at runtime. */ +export type ColorValue = + | keyof typeof names + | 'transparent' + | 'currentColor' + | `#${string}` + | `${'rgb' | 'rgba' | 'hsl' | 'hsla' | 'hwb' | 'lab' | 'lch' | 'oklab' | 'oklch' | 'device-cmyk'}(${string})`; + +export type DisplayValue = 'inline' | 'flex'; + +export type FlexDirectionValue = 'row' | 'column'; + +export type LengthValue = `${number}px`; +export type BorderWidthValue = '0px' | '1px'; +export type BorderStyleValue = 'solid' | 'none'; + +export interface StyleValues { + color: StyleValue; + display: StyleValue; + flexDirection: StyleValue; + gap: StyleValue; + paddingTop: StyleValue; + paddingRight: StyleValue; + paddingBottom: StyleValue; + paddingLeft: StyleValue; + borderStyle: StyleValue; + borderTopWidth: StyleValue; + borderRightWidth: StyleValue; + borderBottomWidth: StyleValue; + borderLeftWidth: StyleValue; + borderColor: StyleValue; +} + +export interface StyleValue { + value: T; + representation: R; +} + +export class StyleDeclaration { + #values: Partial; + #changed: () => void; + constructor(changed: () => void = () => {}) { + this.#values = {}; + this.#changed = changed; + } + + get values(): Readonly> { + return { ...this.#values }; + } + + set color(value: ColorValue | '') { + this.#setProperty('color', value, schema.color); + } + + get color() { + return this.#getProperty('color'); + } + + set display(value: DisplayValue | '') { + this.#setProperty('display', value, schema.enum('inline', 'flex')); + } + + get display(): DisplayValue | '' { + return this.#getProperty('display'); + } + + set flexDirection(value: FlexDirectionValue | '') { + this.#setProperty('flexDirection', value, schema.enum('row', 'column')); + } + + get flexDirection(): FlexDirectionValue | '' { + return this.#getProperty('flexDirection'); + } + + set gap(value: LengthValue | '') { + this.#setProperty('gap', value, schema.length); + } + + get gap(): LengthValue | '' { + return this.#getProperty('gap'); + } + + set paddingTop(value: LengthValue | '') { + this.#setProperty('paddingTop', value, schema.length); + } + + get paddingTop(): LengthValue | '' { + return this.#getProperty('paddingTop'); + } + + set paddingRight(value: LengthValue | '') { + this.#setProperty('paddingRight', value, schema.length); + } + + get paddingRight(): LengthValue | '' { + return this.#getProperty('paddingRight'); + } + + set paddingBottom(value: LengthValue | '') { + this.#setProperty('paddingBottom', value, schema.length); + } + + get paddingBottom(): LengthValue | '' { + return this.#getProperty('paddingBottom'); + } + + set paddingLeft(value: LengthValue | '') { + this.#setProperty('paddingLeft', value, schema.length); + } + + get paddingLeft(): LengthValue | '' { + return this.#getProperty('paddingLeft'); + } + + set borderTopWidth(value: BorderWidthValue | '') { + this.#setProperty('borderTopWidth', value, schema.borderWidth); + } + + get borderTopWidth(): BorderWidthValue | '' { + return this.#getProperty('borderTopWidth'); + } + + set borderRightWidth(value: BorderWidthValue | '') { + this.#setProperty('borderRightWidth', value, schema.borderWidth); + } + + get borderRightWidth(): BorderWidthValue | '' { + return this.#getProperty('borderRightWidth'); + } + + set borderBottomWidth(value: BorderWidthValue | '') { + this.#setProperty('borderBottomWidth', value, schema.borderWidth); + } + + get borderBottomWidth(): BorderWidthValue | '' { + return this.#getProperty('borderBottomWidth'); + } + + set borderLeftWidth(value: BorderWidthValue | '') { + this.#setProperty('borderLeftWidth', value, schema.borderWidth); + } + + get borderLeftWidth(): BorderWidthValue | '' { + return this.#getProperty('borderLeftWidth'); + } + + set borderStyle(value: BorderStyleValue | '') { + this.#setProperty('borderStyle', value, schema.enum('solid', 'none')); + } + + get borderStyle(): BorderStyleValue | '' { + return this.#getProperty('borderStyle'); + } + + set borderColor(value: ColorValue | '') { + this.#setProperty('borderColor', value, schema.borderColor); + } + + get borderColor(): ColorValue | '' { + return this.#getProperty('borderColor'); + } + + #setProperty( + key: K, + input: StyleValues[K]['representation'] | '', + schema: StandardSchemaV1, + ): void { + if (typeof input !== 'string') return; + const trimmed = input.trim(); + if (trimmed === '') { + if (this.#values[key] !== undefined) { + delete this.#values[key]; + this.#changed(); + } + return; + } + const representation = trimmed as StyleValues[K]['representation']; + const result = schema['~standard'].validate(representation) as StandardSchemaV1.Result< + StyleValues[K]['value'] + >; + + // ignore styles that can't be validated and converted; + if (result.issues) { + return; //todo: issue warning. + } + + this.#values[key] = { value: result.value, representation } as StyleValues[K]; + this.#changed(); + } + + #getProperty(property: K): StyleValues[K]['representation'] | '' { + return this.#values[property]?.representation ?? ''; + } +} + +function parseLength(representation: LengthValue): number | null { + const value = Number(representation.slice(0, -2)); + return /^\d+px$/.test(representation) && Number.isSafeInteger(value) ? value : null; +} + +function parseBorderWidth(representation: BorderWidthValue): number | null { + const value = parseLength(representation); + return value !== null && value <= 1 ? value : null; +} + +const schema = { + enum(...values: T[]) { + return { + '~standard': { + version: 1, + vendor: '@clack/ui', + validate(input: unknown): StandardSchemaV1.Result { + const value = values.find((value) => value === input); + return value === undefined + ? { issues: [{ message: `Expected one of: ${values.join(', ')}` }] } + : { value }; + }, + }, + } satisfies StandardSchemaV1; + }, + length: { + '~standard': { + version: 1, + vendor: '@clack/ui', + validate(input: unknown): StandardSchemaV1.Result { + const value = typeof input === 'string' ? parseLength(input as LengthValue) : null; + return value === null + ? { issues: [{ message: 'Expected a nonnegative whole-cell px length' }] } + : { value }; + }, + }, + } satisfies StandardSchemaV1, + borderWidth: { + '~standard': { + version: 1, + vendor: '@clack/ui', + validate(input: unknown): StandardSchemaV1.Result { + const value = + typeof input === 'string' ? parseBorderWidth(input as BorderWidthValue) : null; + return value === null + ? { issues: [{ message: 'Expected a border width of 0px or 1px' }] } + : { value }; + }, + }, + } satisfies StandardSchemaV1, + borderColor: { + '~standard': { + version: 1, + vendor: '@clack/ui', + validate(input: unknown): StandardSchemaV1.Result { + if (input === 'currentColor') return { value: input }; + return schema.color['~standard'].validate(input); + }, + }, + } satisfies StandardSchemaV1, + + color: { + '~standard': { + version: 1, + vendor: '@clack/ui', + validate(input: unknown): StandardSchemaV1.Result { + if (typeof input !== 'string') { + return { issues: [{ message: 'Expected a CSS color string' }] }; + } + const representation = input.trim(); + if (representation === 'currentColor') return { value: representation }; + const value = parse(representation); + return value === null ? { issues: [{ message: 'Invalid CSS color' }] } : { value }; + }, + }, + } satisfies StandardSchemaV1, +}; diff --git a/packages/ui/test/terminal.ts b/packages/ui/test/terminal.ts index a6cf845..fc680d3 100644 --- a/packages/ui/test/terminal.ts +++ b/packages/ui/test/terminal.ts @@ -1,5 +1,5 @@ import { PassThrough, Writable } from 'node:stream'; -import { keyToAnsi, parseKey, type Region } from '@termless/core'; +import { keyToAnsi, parseKey, type Cell, type Region } from '@termless/core'; import { createTestTerminal as createTermlessTerminal, type SyncTestTerminalOptions, @@ -9,6 +9,7 @@ export interface TestTerminal extends AsyncDisposable { input: PassThrough; output: Writable; readonly screen: Region; + getCell(row: number, col: number): Cell; type(text: string): void; press(key: string): void; } @@ -36,6 +37,9 @@ export function createTestTerminal(options: SyncTestTerminalOptions = {}): TestT get screen() { return terminal.screen; }, + getCell(row, col) { + return terminal.backend.getCell(row, col); + }, type(text) { input.write(text); }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 68f3a5c..6069ba5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -30,11 +30,23 @@ importers: specifier: 1.2.0-beta.15 version: 1.2.0-beta.15 + examples/basic: + dependencies: + '@clack/ui': + specifier: workspace:* + version: link:../../packages/ui + packages/ui: dependencies: '@bomb.sh/tty': specifier: 'catalog:' version: 0.10.0 + '@standard-schema/spec': + specifier: ^1.1.0 + version: 1.1.0 + hueful: + specifier: ^0.8.1 + version: 0.8.1 packages: @@ -968,6 +980,9 @@ packages: '@rolldown/pluginutils@1.0.1': resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@termless/core@0.10.2': resolution: {integrity: sha512-xr/TvnhzB0R7g24W3EcwP8UQ+ztYd5ybk4oxR/PkcEh92O5NRjl9mCkqz/wvTcGX3u77AcmA8w5Ox16T22DOCA==} engines: {bun: '>=1.0.0', node: '>=23.6.0'} @@ -1286,6 +1301,9 @@ packages: hookable@6.1.2: resolution: {integrity: sha512-+abwxtiEA52GCVIsQqut3S/uKTbUwYIp4Pe/vv+6py5XiXBCqMZHg6pA6Y5qhgLSEys0/cYuPbO0z1QFj5ZCmg==} + hueful@0.8.1: + resolution: {integrity: sha512-5JmU/74TAKM9BCRcwY046VImUb6dhvrsB2kvj5eEJ1eaCKu6EPPxtONF/kueY9PdzYgUz3g5zrrj+YENz2xy3A==} + import-without-cache@0.4.1: resolution: {integrity: sha512-vXoV9PjKHEednCUu01e98TkImxy67e3BJXbTIOmIq4Hyzw3IAwYRcuPkfeTzJzwyyts4kjuA73x+pWJLc4f86A==} engines: {node: ^22.18.0 || >=24.0.0} @@ -2330,6 +2348,8 @@ snapshots: '@rolldown/pluginutils@1.0.1': {} + '@standard-schema/spec@1.1.0': {} + '@termless/core@0.10.2(node-pty@1.2.0-beta.15)': optionalDependencies: '@napi-rs/canvas': 1.0.11 @@ -2535,6 +2555,8 @@ snapshots: hookable@6.1.2: {} + hueful@0.8.1: {} + import-without-cache@0.4.1: {} jiti@2.7.0: {} diff --git a/tsconfig.json b/tsconfig.json index 276d375..71f5c45 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,3 +1,8 @@ { - "extends": "@bomb.sh/tools/tsconfig.json" + "extends": "@bomb.sh/tools/tsconfig.json", + "compilerOptions": { + "paths": { + "@clack/ui": ["./packages/ui/src/index.ts"] + } + } }