Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/preview.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ jobs:
secrets: inherit
with:
publish: './packages/*'
template: './examples/*'
40 changes: 40 additions & 0 deletions examples/basic/README.md
Original file line number Diff line number Diff line change
@@ -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
```
11 changes: 11 additions & 0 deletions examples/basic/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"name": "@examples/basic",
"private": true,
"type": "module",
"scripts": {
"start": "node --experimental-strip-types src/index.ts"
},
"dependencies": {
"@clack/ui": "workspace:*"
}
}
50 changes: 50 additions & 0 deletions examples/basic/src/app.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
}
}
});
});
45 changes: 45 additions & 0 deletions examples/basic/src/app.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
25 changes: 25 additions & 0 deletions examples/basic/src/index.ts
Original file line number Diff line number Diff line change
@@ -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<void>();

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]();
}
10 changes: 10 additions & 0 deletions examples/basic/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"composite": false,
"declaration": false,
"declarationMap": false,
"noEmit": true,
"types": ["node"]
}
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 3 additions & 1 deletion packages/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
6 changes: 6 additions & 0 deletions packages/ui/src/dom.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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[] {
Expand Down
9 changes: 9 additions & 0 deletions packages/ui/src/index.ts
Original file line number Diff line number Diff line change
@@ -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';
93 changes: 93 additions & 0 deletions packages/ui/src/layout.test.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading
Loading