From 8d6a999106d4b2f950189d2828e3d92e14282df2 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Sun, 2 Aug 2026 19:46:55 -0500 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20layout:=20render=20box=20and=20text?= =?UTF-8?q?=20element=20trees?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- examples/basic/src/index.ts | 42 +++++++++++++-- packages/ui/src/elements/box.ts | 42 +++++++++++++++ packages/ui/src/elements/text.ts | 37 +++++++++++++ packages/ui/src/host.ts | 27 ++++++++++ packages/ui/src/index.ts | 1 + packages/ui/src/layout.test.ts | 91 ++++++++++++++++++++++++++++++++ packages/ui/src/layout.ts | 6 +++ 7 files changed, 241 insertions(+), 5 deletions(-) create mode 100644 packages/ui/src/elements/box.ts create mode 100644 packages/ui/src/elements/text.ts create mode 100644 packages/ui/src/layout.test.ts create mode 100644 packages/ui/src/layout.ts diff --git a/examples/basic/src/index.ts b/examples/basic/src/index.ts index 3a43d04..7b701bf 100644 --- a/examples/basic/src/index.ts +++ b/examples/basic/src/index.ts @@ -1,4 +1,9 @@ -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); @@ -6,8 +11,9 @@ 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( { @@ -23,8 +29,34 @@ const app = box( ), ); -root.render(app); +host.insertBefore(host.element, app); -function row(label: string, value: ReturnType) { +const renderer = new RenderRoot(); +await renderer.ready(); +renderer.render(...layout(app.node!)); + +function box(properties: Omit, ...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); + } +} diff --git a/packages/ui/src/elements/box.ts b/packages/ui/src/elements/box.ts new file mode 100644 index 0000000..68b165e --- /dev/null +++ b/packages/ui/src/elements/box.ts @@ -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; + +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(); + } + }, + })); +} diff --git a/packages/ui/src/elements/text.ts b/packages/ui/src/elements/text.ts new file mode 100644 index 0000000..dc9c186 --- /dev/null +++ b/packages/ui/src/elements/text.ts @@ -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; + +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(); + } + }, + })); +} diff --git a/packages/ui/src/host.ts b/packages/ui/src/host.ts index cc75ea8..d337ccd 100644 --- a/packages/ui/src/host.ts +++ b/packages/ui/src/host.ts @@ -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 { @@ -8,6 +9,7 @@ import { type HostElement, type HostLiteral, } from './elements.ts'; +import { LayoutApi } from './layout.ts'; export interface Host { root: Node; @@ -36,6 +38,8 @@ export class HostApi extends Api implements Host { children: [], }; + const { element } = this; + setElement(root, this.element); LifecycleApi.decorate(root, () => ({ @@ -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 { diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 5ffcc04..4b7d950 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -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'; diff --git a/packages/ui/src/layout.test.ts b/packages/ui/src/layout.test.ts new file mode 100644 index 0000000..e816e97 --- /dev/null +++ b/packages/ui/src/layout.test.ts @@ -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); +} diff --git a/packages/ui/src/layout.ts b/packages/ui/src/layout.ts new file mode 100644 index 0000000..712c52c --- /dev/null +++ b/packages/ui/src/layout.ts @@ -0,0 +1,6 @@ +import { type Op } from '@bomb.sh/tty'; +import { Api } from './core/api.ts'; + +export class LayoutApi extends Api { + *layout(): Iterable {} +}