Skip to content
Closed
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
42 changes: 37 additions & 5 deletions examples/basic/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
import { box, text, createRoot, rgba } from '@clack/ui';
import { type BoxProps, type TextProps, rgba } from '@clack/ui';
import { useBoxElement } from '../../../packages/ui/src/elements/box.ts';
import { useTextElement } from '../../../packages/ui/src/elements/text.ts';
import { createHost, type HostElement } from '../../../packages/ui/src/host.ts';
import { layout } from '../../../packages/ui/src/layout.ts';
import { RenderRoot } from '../../../packages/ui/src/render/createRoot.ts';

const cyan = rgba(0, 205, 205);
const green = rgba(0, 205, 0);
const white = rgba(229, 229, 229);
const gray = rgba(127, 127, 127);
const blue = rgba(0, 0, 238);

// Framework-free: build a tree with `box` / `text`, render it once.
const root = await createRoot();
const host = createHost();
useBoxElement(host);
useTextElement(host);

const app = box(
{
Expand All @@ -23,8 +29,34 @@ const app = box(
),
);

root.render(app);
host.insertBefore(host.element, app);

function row(label: string, value: ReturnType<typeof text>) {
const renderer = new RenderRoot();
await renderer.ready();
renderer.render(...layout(app.node!));

function box(properties: Omit<BoxProps, 'key'>, ...children: HostElement[]): HostElement {
const element = host.createElement('box');
setProperties(element, properties);
for (const child of children) {
host.insertBefore(element, child);
}
return element;
}

function text(properties: TextProps, content: string): HostElement {
const element = host.createElement('text');
setProperties(element, properties);
host.insertBefore(element, host.createLiteral(content));
return element;
}

function row(label: string, value: HostElement): HostElement {
return box({ layout: { direction: 'ltr', gap: 1 } }, text({ color: gray }, label), value);
}

function setProperties(element: HostElement, properties: object): void {
for (const [name, value] of Object.entries(properties)) {
host.setProperty(element, name, value);
}
}
42 changes: 42 additions & 0 deletions packages/ui/src/elements/box.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { open, close, text, type OpenElement } from '@bomb.sh/tty';
import { lifecycle } from '../core.ts';
import { type Host } from '../host.ts';
import { LayoutApi } from '../layout.ts';
import { getElement } from '../elements.ts';

export type BoxProps = Omit<OpenElement, 'directive' | 'id'>;

declare module '@clack/ui/elements' {
interface HostElements {
box: BoxProps;
}
}

export function useBoxElement(host: Host) {
LayoutApi.decorate(host.root, () => ({
*layout() {
const element = getElement(this.node);
if (element.name === 'box') {
let content = '';
yield open(lifecycle.id(this.node), element.properties);
for (const child of element.children) {
if (child.type === 'element') {
if (content !== '') {
yield text(content);
content = '';
}
yield* LayoutApi.at(child.node!).layout();
} else {
content += child.content;
}
}
if (content !== '') {
yield text(content);
}
yield close();
} else {
return yield* super.layout();
}
},
}));
}
37 changes: 37 additions & 0 deletions packages/ui/src/elements/text.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { text, type Text } from '@bomb.sh/tty';
import { type Host } from '../host.ts';
import { getElement } from '../elements.ts';
import { LayoutApi } from '../layout.ts';

export type TextProps = Omit<Text, 'directive' | 'content'>;

declare module '@clack/ui/elements' {
interface HostElements {
text: TextProps;
}
}

/**
* Install "text" element behavior into a host.
*
* Text elements ignore non-textual children like "box" or "input" and will
* always return an iteration of tty `text()` directives
*/
export function useTextElement(host: Host) {
LayoutApi.decorate(host.root, () => ({
*layout() {
const element = getElement(this.node);
if (element.name === 'text') {
let content = '';
for (const child of element.children) {
if (child.type === 'literal') {
content += child.content;
}
}
yield text(content, element.properties);
} else {
return yield* super.layout();
}
},
}));
}
27 changes: 27 additions & 0 deletions packages/ui/src/host.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
// oxlint-disable max-params
import { text } from '@bomb.sh/tty';
import { type Node, lifecycle, LifecycleApi } from './core.ts';
import { Api } from './core/api.ts';
import {
Expand All @@ -8,6 +9,7 @@ import {
type HostElement,
type HostLiteral,
} from './elements.ts';
import { LayoutApi } from './layout.ts';

export interface Host {
root: Node;
Expand Down Expand Up @@ -36,6 +38,8 @@ export class HostApi extends Api implements Host {
children: [],
};

const { element } = this;

setElement(root, this.element);

LifecycleApi.decorate(root, () => ({
Expand All @@ -48,6 +52,29 @@ export class HostApi extends Api implements Host {
return super.destroy(node);
},
}));

LayoutApi.decorate(root, () => ({
*layout() {
if (this.node !== root) {
return yield* super.layout();
}
let content = '';
for (const child of element.children) {
if (child.type === 'element') {
if (content !== '') {
yield text(content);
content = '';
}
yield* LayoutApi.at(child.node!).layout();
} else {
content += child.content;
}
}
if (content !== '') {
yield text(content);
}
},
}));
}

createElement(name: string): HostElement {
Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export { rgba } from '@bomb.sh/tty';
export { box, type BoxProps } from './components/box.ts';
export { text, type TextProps } from './components/text.ts';
export { LayoutApi } from './layout.ts';
export { createRoot, type RenderRoot, type RenderRootOptions } from './render/createRoot.ts';
91 changes: 91 additions & 0 deletions packages/ui/src/layout.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { lifecycle, type Node } from './core.ts';
import { close, open, text as ttyText } from '@bomb.sh/tty';
import { describe, expect, test } from 'vitest';
import { useBoxElement } from './elements/box.ts';
import { useTextElement } from './elements/text.ts';
import { createHost } from './host.ts';
import { LayoutApi } from './layout.ts';

describe('layout', () => {
describe('box', () => {
test('lays out host children in element order', () => {
const host = createHost();
LayoutApi.init(host.root);
useBoxElement(host);

const parent = host.createElement('box');
const first = host.createElement('box');
const second = host.createElement('box');

host.insertBefore(parent, first);
host.insertBefore(parent, second);
host.insertBefore(host.element, parent);
host.insertBefore(parent, second, first);

expect([...LayoutApi.at(host.root).layout()]).toEqual([
open(id(parent.node!)),
open(id(second.node!)),
close(),
open(id(first.node!)),
close(),
close(),
]);
});

test('coalesces runs of text literals around child elements', () => {
const host = createHost();
LayoutApi.init(host.root);
useBoxElement(host);

const parent = host.createElement('box');
const child = host.createElement('box');
host.insertBefore(parent, host.createLiteral('Hello'));
host.insertBefore(parent, host.createLiteral(', '));
host.insertBefore(parent, child);
host.insertBefore(parent, host.createLiteral('World'));
host.insertBefore(parent, host.createLiteral('!'));
host.insertBefore(host.element, parent);

expect([...LayoutApi.at(host.root).layout()]).toEqual([
open(id(parent.node!)),
ttyText('Hello, '),
open(id(child.node!)),
close(),
ttyText('World!'),
close(),
]);
});
});

describe('text', () => {
test('coalesces literals with the enclosing properties', () => {
const host = createHost();
LayoutApi.init(host.root);
useTextElement(host);

const element = host.createElement('text');
host.setProperty(element, 'color', 1);
host.insertBefore(element, host.createLiteral('Hello'));
host.insertBefore(element, host.createLiteral(', '));
host.insertBefore(element, host.createLiteral('World!'));
host.insertBefore(host.element, element);

expect([...LayoutApi.at(element.node!).layout()]).toEqual([
ttyText('Hello, World!', { color: 1 }),
]);
});

test('coalesces text literals at the host root', () => {
const host = createHost();
LayoutApi.init(host.root);
host.insertBefore(host.element, host.createLiteral('Hello'));
host.insertBefore(host.element, host.createLiteral(', world!'));

expect([...LayoutApi.at(host.root).layout()]).toEqual([ttyText('Hello, world!')]);
});
});
});

function id(node: Node): string {
return lifecycle.id(node);
}
6 changes: 6 additions & 0 deletions packages/ui/src/layout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { type Op } from '@bomb.sh/tty';
import { Api } from './core/api.ts';

export class LayoutApi extends Api {
*layout(): Iterable<Op> {}
}
Loading