diff --git a/package.json b/package.json index ddb03e6..3e7d9da 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,11 @@ "publishConfig": { "access": "public" }, + "pnpm": { + "overrides": { + "@bomb.sh/tty": "https://pkg.pr.new/@bomb.sh/tty@103" + } + }, "scripts": { "playground": "NODE_NO_WARNINGS=1 node --experimental-transform-types ./scripts/playground.ts", "format": "bsh format", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 61f58ea..4e4a53f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,9 +9,6 @@ catalogs: '@bomb.sh/args': specifier: ^0.3.1 version: 0.3.1 - '@bomb.sh/tty': - specifier: ^0.8.0 - version: 0.8.0 '@clack/prompts': specifier: ^1.7.0 version: 1.7.0 @@ -22,6 +19,9 @@ catalogs: specifier: ^2.0.0 version: 2.0.8 +overrides: + '@bomb.sh/tty': https://pkg.pr.new/@bomb.sh/tty@103 + importers: .: @@ -42,14 +42,14 @@ importers: examples/demo: dependencies: '@bomb.sh/tty': - specifier: 'catalog:' - version: 0.8.0 + specifier: https://pkg.pr.new/@bomb.sh/tty@103 + version: https://pkg.pr.new/@bomb.sh/tty@103 examples/input: dependencies: '@bomb.sh/tty': - specifier: 'catalog:' - version: 0.8.0 + specifier: https://pkg.pr.new/@bomb.sh/tty@103 + version: https://pkg.pr.new/@bomb.sh/tty@103 '@clack/ui': specifier: 'catalog:' version: https://pkg.pr.new/bombshell-dev/ui/@clack/ui@a60ac12 @@ -66,11 +66,30 @@ importers: specifier: ^4.0.2 version: 4.0.3 + vendor/clack-ui: + dependencies: + '@bomb.sh/tty': + specifier: https://pkg.pr.new/@bomb.sh/tty@103 + version: https://pkg.pr.new/@bomb.sh/tty@103 + '@types/node': + specifier: ^22.20.0 + version: 22.20.1 + + vendor/clack-ui-preact: + dependencies: + '@clack/ui': + specifier: workspace:* + version: link:../clack-ui + devDependencies: + preact: + specifier: 11.0.0-beta.2 + version: 11.0.0-beta.2 + vendor/ui: dependencies: '@bomb.sh/tty': - specifier: ^0.8.0 - version: 0.8.0 + specifier: https://pkg.pr.new/@bomb.sh/tty@103 + version: https://pkg.pr.new/@bomb.sh/tty@103 '@types/node': specifier: ^22.20.0 version: 22.20.1 @@ -95,12 +114,9 @@ packages: resolution: {integrity: sha512-p3dLXau2JygQ9DTErUvHSn7GwqpRraIjiXdj/xeAU0+7opUZrtiHe18FLrU5opCSr16WZep0p0/OBX1HgMjgJQ==} hasBin: true - '@bomb.sh/tty@0.7.0': - resolution: {integrity: sha512-htTavc3o84vR6uSoyF+Y9u81GG8NoTJkwOovahcNOY4nVeiM86vodCHdPvAXoj2ucmx04frpwMrOJ4ipLSSJfw==} - engines: {node: '>= 22'} - - '@bomb.sh/tty@0.8.0': - resolution: {integrity: sha512-tEE0mtqnEp2uJt4kQS5AjdghCD5KN4BobqHOlfhaiNyrigxFBKk61XkDy//4D7V/AejrTvubh44B4NV35lsnxw==} + '@bomb.sh/tty@https://pkg.pr.new/@bomb.sh/tty@103': + resolution: {tarball: https://pkg.pr.new/@bomb.sh/tty@103} + version: 0.8.0-pr engines: {node: '>= 22'} '@clack/core@1.4.3': @@ -1251,6 +1267,14 @@ packages: resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} engines: {node: ^10 || ^12 || >=14} + preact@11.0.0-beta.2: + resolution: {integrity: sha512-7nIgtr4wtls48+fuazknYNuZ3tOuJoo9ZrT+dzXrMBWkcQH5xmOJiibJpmbU8sFmo2IKLFe3eMp84n0Lq0dCHg==} + peerDependencies: + preact-render-to-string: '>=5' + peerDependenciesMeta: + preact-render-to-string: + optional: true + publint@0.3.21: resolution: {integrity: sha512-OqejcnMV6E9zel2oCrUOJEiiFkGiAAni0A6ibfQNh1k9Gu5z4F+Yso8lllam7AzmV6Do0vp7u3UpZNRBwuXaHQ==} engines: {node: '>=18'} @@ -1612,9 +1636,7 @@ snapshots: - vite-plus - vue-tsc - '@bomb.sh/tty@0.7.0': {} - - '@bomb.sh/tty@0.8.0': {} + '@bomb.sh/tty@https://pkg.pr.new/@bomb.sh/tty@103': {} '@clack/core@1.4.3': dependencies: @@ -1630,7 +1652,7 @@ snapshots: '@clack/ui@https://pkg.pr.new/bombshell-dev/ui/@clack/ui@a60ac12': dependencies: - '@bomb.sh/tty': 0.7.0 + '@bomb.sh/tty': https://pkg.pr.new/@bomb.sh/tty@103 '@types/node': 22.20.1 '@emnapi/core@1.10.0': @@ -2483,6 +2505,8 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + preact@11.0.0-beta.2: {} + publint@0.3.21: dependencies: '@publint/pack': 0.1.5 diff --git a/vendor/clack-ui-preact/package.json b/vendor/clack-ui-preact/package.json new file mode 100644 index 0000000..cc94f8f --- /dev/null +++ b/vendor/clack-ui-preact/package.json @@ -0,0 +1,32 @@ +{ + "name": "@clack/ui-preact", + "version": "0.0.0", + "description": "Vendored Preact adapter for @clack/ui", + "private": true, + "license": "MIT", + "type": "module", + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + }, + "./jsx-runtime": { + "types": "./src/jsx-runtime.ts", + "import": "./src/jsx-runtime.ts" + }, + "./jsx-dev-runtime": { + "types": "./src/jsx-dev-runtime.ts", + "import": "./src/jsx-dev-runtime.ts" + }, + "./package.json": "./package.json" + }, + "dependencies": { + "@clack/ui": "workspace:*" + }, + "devDependencies": { + "preact": "11.0.0-beta.2" + }, + "peerDependencies": { + "preact": ">=11.0.0-beta.2 <12" + } +} diff --git a/vendor/clack-ui-preact/src/components.ts b/vendor/clack-ui-preact/src/components.ts new file mode 100644 index 0000000..6ac8a3e --- /dev/null +++ b/vendor/clack-ui-preact/src/components.ts @@ -0,0 +1,10 @@ +import { type FunctionComponent, createElement } from 'preact'; +import type { BoxProps, TextProps } from './elements.ts'; + +export type { BoxProps, TextProps } from './elements.ts'; + +/** A layout container backed by the Host's `box` element. */ +export const Box: FunctionComponent = (props) => createElement('box', props); + +/** A styled text scope backed by the Host's `text` element. */ +export const Text: FunctionComponent = (props) => createElement('text', props); diff --git a/vendor/clack-ui-preact/src/elements.ts b/vendor/clack-ui-preact/src/elements.ts new file mode 100644 index 0000000..46ab385 --- /dev/null +++ b/vendor/clack-ui-preact/src/elements.ts @@ -0,0 +1,23 @@ +import type { HostElements as CoreHostElements } from '@clack/ui'; +import type { ComponentChildren, Key, Ref } from 'preact'; +import type { ElementHandle } from './facade.ts'; + +type PreactProperties = { + [Name in keyof Properties as Name extends string + ? Name extends `on${infer Type}` + ? `on${Capitalize}` + : Name + : Name]: Properties[Name]; +} & { + children?: ComponentChildren; + key?: Key | null; + ref?: Ref; +}; + +export type HostElements = { + [Name in keyof CoreHostElements]: PreactProperties; +}; + +export type BoxProps = HostElements['box']; +export type InputProps = HostElements['input']; +export type TextProps = HostElements['text']; diff --git a/vendor/clack-ui-preact/src/facade.ts b/vendor/clack-ui-preact/src/facade.ts new file mode 100644 index 0000000..a6da7a7 --- /dev/null +++ b/vendor/clack-ui-preact/src/facade.ts @@ -0,0 +1,161 @@ +// oxlint-disable bombshell-dev/exported-function-async -- This is an internal synchronous adapter factory. +import type { Host } from '@clack/ui'; +import type { HostElement, HostElementChild, HostLiteral } from '@clack/ui/elements'; +import type { HostEventListener, HostEventType } from '@clack/ui/events'; + +type PreactNode = PreactElementNode | PreactTextNode; + +interface HostAttribute { + readonly name: string; + readonly value: unknown; +} + +export interface ElementHandle { + /** The framework-neutral element represented by this Preact host instance. */ + readonly element: HostElement; +} + +/** Create the DOM-shaped container Preact uses to mutate a Host. */ +export function createContainer(host: Host, element: HostElement): ElementHandle { + const document = new HostDocument(host); + return document.wrap(element) as PreactElementNode; +} + +const XHTML_NAMESPACE = 'http://www.w3.org/1999/xhtml'; +const hostChild = Symbol('host child'); + +class HostDocument { + readonly #instances = new WeakMap(); + readonly host: Host; + + constructor(host: Host) { + this.host = host; + } + + createElementNS(_namespace: string | null, name: string): PreactElementNode { + return this.wrap(this.host.createElement(name)) as PreactElementNode; + } + + createTextNode(content: string): PreactTextNode { + return this.wrap(this.host.createLiteral(String(content))) as PreactTextNode; + } + + wrap(child: HostElementChild): PreactNode { + const existing = this.#instances.get(child); + if (existing) return existing; + + const instance = + child.type === 'element' + ? new PreactElementNode(this, child) + : new PreactTextNode(this, child); + this.#instances.set(child, instance); + return instance; + } +} + +abstract class PreactNodeBase { + abstract readonly nodeType: number; + readonly ownerDocument: HostDocument; + readonly [hostChild]: Child; + + constructor(ownerDocument: HostDocument, child: Child) { + this.ownerDocument = ownerDocument; + this[hostChild] = child; + } + + get parentNode(): PreactElementNode | null { + const parent = this[hostChild].parent; + return parent ? (this.ownerDocument.wrap(parent) as PreactElementNode) : null; + } + + get nextSibling(): PreactNode | null { + const parent = this[hostChild].parent; + if (!parent) return null; + + const index = parent.children.indexOf(this[hostChild]); + const sibling = parent.children[index + 1]; + return sibling ? this.ownerDocument.wrap(sibling) : null; + } + + remove(): void { + const parent = this[hostChild].parent; + if (parent) this.ownerDocument.host.removeChild(parent, this[hostChild]); + } +} + +class PreactElementNode extends PreactNodeBase implements ElementHandle { + readonly #listeners = new WeakMap<(event: unknown) => void, HostEventListener>(); + readonly nodeType = 1; + readonly namespaceURI = XHTML_NAMESPACE; + _children?: unknown; + + get element(): HostElement { + return this[hostChild]; + } + + get localName(): string { + return this.element.name; + } + + get attributes(): readonly HostAttribute[] { + return Object.entries(this.element.properties).map(([name, value]) => ({ name, value })); + } + + get childNodes(): PreactNode[] { + return this.element.children.map((child) => this.ownerDocument.wrap(child)); + } + + get firstChild(): PreactNode | null { + const child = this.element.children[0]; + return child ? this.ownerDocument.wrap(child) : null; + } + + setAttribute(name: string, value: unknown): void { + this.ownerDocument.host.setProperty(this.element, name, value); + } + + removeAttribute(name: string): void { + this.ownerDocument.host.setProperty(this.element, name, undefined); + } + + insertBefore(child: PreactNode, anchor: PreactNode | null): PreactNode { + this.ownerDocument.host.insertBefore(this.element, child[hostChild], anchor?.[hostChild]); + return child; + } + + appendChild(child: PreactNode): PreactNode { + return this.insertBefore(child, null); + } + + removeChild(child: PreactNode): PreactNode { + this.ownerDocument.host.removeChild(this.element, child[hostChild]); + return child; + } + + addEventListener(type: string, listener: (event: unknown) => void): void { + let wrapped = this.#listeners.get(listener); + if (!wrapped) { + wrapped = (event) => listener.call(this, event); + this.#listeners.set(listener, wrapped); + } + this.ownerDocument.host.addEventListener(this.element, type as HostEventType, wrapped); + } + + removeEventListener(type: string, listener: (event: unknown) => void): void { + const wrapped = this.#listeners.get(listener); + if (!wrapped) return; + this.ownerDocument.host.removeEventListener(this.element, type as HostEventType, wrapped); + } +} + +class PreactTextNode extends PreactNodeBase { + readonly nodeType = 3; + + get data(): string { + return this[hostChild].content; + } + + set data(content: string) { + this.ownerDocument.host.setText(this[hostChild], String(content)); + } +} diff --git a/vendor/clack-ui-preact/src/index.ts b/vendor/clack-ui-preact/src/index.ts new file mode 100644 index 0000000..ed85099 --- /dev/null +++ b/vendor/clack-ui-preact/src/index.ts @@ -0,0 +1,5 @@ +export { rgba } from '@clack/ui'; +export { Box, Text } from './components.ts'; +export type { BoxProps, HostElements, InputProps, TextProps } from './elements.ts'; +export type { ElementHandle } from './facade.ts'; +export { createRoot, type Root } from './root.ts'; diff --git a/vendor/clack-ui-preact/src/jsx-dev-runtime.ts b/vendor/clack-ui-preact/src/jsx-dev-runtime.ts new file mode 100644 index 0000000..edb1528 --- /dev/null +++ b/vendor/clack-ui-preact/src/jsx-dev-runtime.ts @@ -0,0 +1,23 @@ +import type { Component, ComponentType, FunctionComponent, VNode } from 'preact'; +import type { JSX as PreactJSX } from 'preact/jsx-dev-runtime'; +import type { HostElements } from './index.ts'; + +export { Fragment, jsxDEV } from 'preact/jsx-dev-runtime'; + +export declare namespace JSX { + type LibraryManagedAttributes = PreactJSX.LibraryManagedAttributes< + ManagedComponent, + Props + >; + interface IntrinsicAttributes extends PreactJSX.IntrinsicAttributes {} + type ElementType = keyof HostElements | ComponentType; + interface Element extends VNode {} + type ElementClass = Component | FunctionComponent; + interface ElementAttributesProperty { + props: unknown; + } + interface ElementChildrenAttribute { + children: unknown; + } + interface IntrinsicElements extends HostElements {} +} diff --git a/vendor/clack-ui-preact/src/jsx-runtime.ts b/vendor/clack-ui-preact/src/jsx-runtime.ts new file mode 100644 index 0000000..0d13c08 --- /dev/null +++ b/vendor/clack-ui-preact/src/jsx-runtime.ts @@ -0,0 +1,23 @@ +import type { Component, ComponentType, FunctionComponent, VNode } from 'preact'; +import type { JSX as PreactJSX } from 'preact/jsx-runtime'; +import type { HostElements } from './index.ts'; + +export { Fragment, jsx, jsxs } from 'preact/jsx-runtime'; + +export declare namespace JSX { + type LibraryManagedAttributes = PreactJSX.LibraryManagedAttributes< + ManagedComponent, + Props + >; + interface IntrinsicAttributes extends PreactJSX.IntrinsicAttributes {} + type ElementType = keyof HostElements | ComponentType; + interface Element extends VNode {} + type ElementClass = Component | FunctionComponent; + interface ElementAttributesProperty { + props: unknown; + } + interface ElementChildrenAttribute { + children: unknown; + } + type IntrinsicElements = HostElements; +} diff --git a/vendor/clack-ui-preact/src/root.ts b/vendor/clack-ui-preact/src/root.ts new file mode 100644 index 0000000..36006ca --- /dev/null +++ b/vendor/clack-ui-preact/src/root.ts @@ -0,0 +1,31 @@ +// oxlint-disable bombshell-dev/exported-function-async -- Preact root creation is synchronous. +import { HostApi, type Host } from '@clack/ui'; +import type { HostElement } from '@clack/ui/elements'; +import { type ComponentChild, render } from 'preact'; +import { createContainer } from './facade.ts'; + +export interface Root { + readonly element: HostElement; + readonly host: Host; + /** Reconcile `node` into the Host synchronously. */ + render(node: ComponentChild): void; + /** Remove the Preact tree from the Host synchronously. */ + unmount(): void; +} + +/** Create a Preact root which reconciles into an attached Host element. */ +export function createRoot(element: HostElement): Root { + const host = HostApi.methods.getHost(element.node!); + const container = createContainer(host, element) as unknown as Element; + + return { + element, + host, + render(node) { + render(node, container); + }, + unmount() { + render(null, container); + }, + }; +} diff --git a/vendor/clack-ui/package.json b/vendor/clack-ui/package.json new file mode 100644 index 0000000..540ead7 --- /dev/null +++ b/vendor/clack-ui/package.json @@ -0,0 +1,82 @@ +{ + "name": "@clack/ui", + "version": "0.0.0", + "keywords": [ + "bombshell", + "cli", + "vendored" + ], + "homepage": "https://bomb.sh/docs/ui", + "license": "MIT", + "author": { + "name": "Bombshell", + "email": "oss@bomb.sh", + "url": "https://bomb.sh" + }, + "repository": { + "type": "git+https://github.com/bombshell-dev/ui.git", + "directory": "packages/ui" + }, + "type": "module", + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + }, + "./elements": { + "types": "./src/elements.ts", + "import": "./src/elements.ts" + }, + "./events": { + "types": "./src/events.ts", + "import": "./src/events.ts" + }, + "./focus": { + "types": "./src/focus.ts", + "import": "./src/focus.ts" + }, + "./keyboard": { + "types": "./src/keyboard.ts", + "import": "./src/keyboard.ts" + }, + "./render": { + "types": "./src/render.ts", + "import": "./src/render.ts" + }, + "./extensions": { + "types": "./src/extensions.ts", + "import": "./src/extensions.ts" + }, + "./core": { + "types": "./src/core.ts", + "import": "./src/core.ts" + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public" + }, + "scripts": { + "dev": "bsh dev", + "build": "bsh build --dts", + "format": "bsh format", + "lint": "bsh lint", + "test": "bsh test" + }, + "dependencies": { + "@bomb.sh/tty": "^0.8.0", + "@types/node": "^22.20.0" + }, + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "10.7.0", + "onFail": "error" + }, + "runtime": { + "name": "node", + "version": "22.14.0", + "onFail": "error" + } + } +} diff --git a/vendor/clack-ui/src/core.ts b/vendor/clack-ui/src/core.ts new file mode 100644 index 0000000..8ce36a4 --- /dev/null +++ b/vendor/clack-ui/src/core.ts @@ -0,0 +1,4 @@ +export * from './core/api.ts'; +export * from './core/context.ts'; +export * from './core/lifecycle.ts'; +export * from './core/node.ts'; diff --git a/vendor/clack-ui/src/core/api.ts b/vendor/clack-ui/src/core/api.ts new file mode 100644 index 0000000..85edd5e --- /dev/null +++ b/vendor/clack-ui/src/core/api.ts @@ -0,0 +1,167 @@ +// oxlint-disable no-explicit-any +import { createContext } from './context.ts'; +import type { Node } from './node.ts'; + +/** + * The shape every api core must satisfy: each member is a function whose first + * parameter is the {@link Node} it operates on. + */ +type Core = Record any>; + +/** + * A function that surrounds a core member, optionally delegating to the next + * link in the stack. + * + * @param args - the member's full parameter tuple, with the node as `args[0]`. + * @param next - the next link in the chain; accepts the same arguments. + * @returns a value of the same shape as `next()`'s return value. + */ +export interface Middleware { + (args: TArgs, next: (...args: TArgs) => TReturn): TReturn; +} + +/** + * The set of middlewares that can surround a core `A`. Each member is wrapped + * by a {@link Middleware} over that member's own signature — node included. + */ +export type Around = { + [K in keyof A]: A[K] extends (...args: infer TArgs) => infer TReturn + ? Middleware + : never; +}; + +export interface Api { + /** + * Every core member, node-first, with the middleware installed on a node + * (and inherited from its ancestors) woven in. `methods.foo(node, ...args)` + * is the ergonomic way to call member `foo` on `node`. + */ + methods: A; + + /** + * Call member `key` as it exists on `args[0]`, threading `args` through every + * middleware installed on that node and its ancestors. The first argument is + * both the node used to resolve middleware and the node handed to the core. + */ + invoke(key: K, args: Parameters): ReturnType; + + /** + * Install middleware around this api within `node`. Descendants of `node` + * inherit it; ancestors compose outermost. + */ + around(node: Node, middlewares: Partial>): void; +} + +export function createApi(name: string, core: A): Api { + const fields = Object.keys(core) as (keyof A)[]; + const context = createContext>(`api::${name}`); + + // Merge `inner`'s middlewares into `outer`, combining any key both define so + // that `outer` wraps `inner`. + function append(outer: Partial>, inner: Partial>): Partial> { + const result: Partial> = { ...outer }; + for (const key of Object.keys(inner) as (keyof A)[]) { + const current = outer[key]; + const decoration = inner[key]; + if (!current) { + result[key] = decoration; + } else { + result[key] = combine([current as any, decoration as any]) as Around[keyof A]; + } + } + return result; + } + + // Eagerly reify a set of middlewares into a concrete handle: every core member + // is either wrapped by its middleware or copied through untouched. + function createHandle(around: Partial>): A { + // No middlewares at all + if (Object.keys(around).length === 0) { + return core; + } else { + const handle = {} as A; + for (const key of fields) { + const middleware = around[key] as Middleware | undefined; + if (!middleware) { + handle[key] = core[key]; + } else { + handle[key] = ((...args: any[]) => middleware(args, core[key] as any)) as A[keyof A]; + } + } + return handle; + } + } + + // Recompute `node`'s handle from the total inherited from its parent, then + // push the new total down to its children. + function install(node: Node, total: Partial>): void { + if (context.hasOwn(node)) { + const installed = context.expect(node); + installed.total = total; + total = append(total, installed.local); + installed.handle = createHandle(total); + } + for (const child of node.children) { + install(child, total); + } + } + + const api: Api = { + methods: fields.reduce((methods, key) => { + return Object.assign(methods, { + [key]: (node: Node, ...args: any[]) => api.invoke(key, [node, ...args] as any), + }); + }, {} as A), + + invoke(key, args) { + const node = args[0] as Node; + const handle = context.get(node)?.handle ?? core; + const member = handle[key] as (...args: any[]) => any; + return member(...args); + }, + + around(node, middlewares) { + let current: Installed; + if (!context.hasOwn(node)) { + const parent = context.get(node); + current = { + total: parent ? append(parent.total, parent.local) : {}, + local: middlewares, + handle: core, + }; + context.set(node, current); + } else { + current = context.expect(node); + current.local = append(current.local, middlewares); + } + install(node, current.total); + }, + }; + + return api; +} + +/** + * What one node remembers about this api's middleware. + * + * - `local`: middleware added to this node directly. + * - `total`: middleware inherited from its ancestors. + * - `handle`: the core methods with `total` + `local` already wrapped around + * them, so calling a method does no extra work. + */ +interface Installed { + local: Partial>; + total: Partial>; + handle: A; +} + +/** Fold a stack of middlewares into one; the first is outermost. */ +function combine(middlewares: Middleware[]): Middleware { + if (middlewares.length === 0) { + return (args, next) => next(...args); + } else { + return middlewares.reduceRight( + (next, middleware) => (args, base) => middleware(args, (...args) => next(args, base)), + ); + } +} diff --git a/vendor/clack-ui/src/core/context.ts b/vendor/clack-ui/src/core/context.ts new file mode 100644 index 0000000..536764b --- /dev/null +++ b/vendor/clack-ui/src/core/context.ts @@ -0,0 +1,67 @@ +import type { Node } from './node.ts'; + +/** + * A `Context` defines a value which is in effect for a given {@link Node}. + * + * Unless a context value is set for a particular node, it will inherit its + * value from that node's parent. + */ +export class Context { + /** A unique identifier for this context. */ + readonly name: string; + + /** The value returned when this context is not present on a node. */ + readonly defaultValue?: T; + + constructor(name: string, defaultValue?: T) { + this.name = name; + this.defaultValue = defaultValue; + } + + get(node: Node): T | undefined { + for (let n: Node | undefined = node; n; n = n.parent) { + const contexts = n.contexts; + if (contexts?.has(this.name)) { + const value = contexts.get(this.name) as T | undefined; + return value === undefined ? this.defaultValue : value; + } + } + return this.defaultValue; + } + + set(node: Node, value: T): void { + const contexts = (node.contexts ??= new Map()); + contexts.set(this.name, value); + } + + expect(node: Node): T { + const value = this.get(node); + if (value === undefined) { + throw new MissingContextError(this.name); + } else { + return value; + } + } + + hasOwn(node: Node): boolean { + return node.contexts?.has(this.name) ?? false; + } +} + +/** + * Create a new {@link Context}. + * + * @param name - the unique name to give this context. + * @param defaultValue - the value used when the context is not present on a node. + * @returns the new context + */ +export function createContext(name: string, defaultValue?: T): Context { + return new Context(name, defaultValue); +} + +export class MissingContextError extends Error { + constructor(name: string) { + super(`no value set for context "${name}"`); + this.name = 'MissingContextError'; + } +} diff --git a/vendor/clack-ui/src/core/lifecycle.ts b/vendor/clack-ui/src/core/lifecycle.ts new file mode 100644 index 0000000..83dc3cc --- /dev/null +++ b/vendor/clack-ui/src/core/lifecycle.ts @@ -0,0 +1,36 @@ +import { Node } from './node.ts'; +import { createApi } from './api.ts'; +import { createContext } from './context.ts'; + +export const LifecycleApi = createApi('lifecycle', { + create(parent) { + const ids = Ids.expect(parent); + const node = new Node(parent); + parent.children.add(node); + IdContext.set(node, `${ids.latest++}`); + return node; + }, + destroy(node) { + for (const child of node.children) { + destroy(child); + } + node.parent?.children.delete(node); + }, + id(node) { + return IdContext.expect(node); + }, +}); + +export const { destroy, id } = LifecycleApi.methods; + +export function create(parent: Node = global) { + return LifecycleApi.methods.create(parent); +} + +const IdContext = createContext('id'); +const Ids = createContext<{ latest: number }>('@ids'); + +const global = new Node(); + +IdContext.set(global, 'root'); +Ids.set(global, { latest: 0 }); diff --git a/vendor/clack-ui/src/core/node.ts b/vendor/clack-ui/src/core/node.ts new file mode 100644 index 0000000..2049b94 --- /dev/null +++ b/vendor/clack-ui/src/core/node.ts @@ -0,0 +1,11 @@ +export class Node { + readonly parent?: Node; + readonly children: Set; + + contexts?: Map; + + constructor(parent?: Node) { + this.parent = parent; + this.children = new Set(); + } +} diff --git a/vendor/clack-ui/src/dispatch.ts b/vendor/clack-ui/src/dispatch.ts new file mode 100644 index 0000000..416a4c2 --- /dev/null +++ b/vendor/clack-ui/src/dispatch.ts @@ -0,0 +1,29 @@ +import { createApi } from './core.ts'; +import type { Host } from './host.ts'; + +export const DispatchApi = createApi('dispatch', { + dispatch(_node, _event: unknown): Result { + return Ok; + }, +}); + +export function useDispatch(host: Host): void { + DispatchApi.around(host.root, { + dispatch(args, next) { + try { + return next(...args); + } catch (reason) { + return { ok: false, reason }; + } + }, + }); +} + +export type Result = + | typeof Ok + | { + ok: false; + reason: unknown; + }; + +const Ok = { ok: true } as const; diff --git a/vendor/clack-ui/src/elements.ts b/vendor/clack-ui/src/elements.ts new file mode 100644 index 0000000..2a61270 --- /dev/null +++ b/vendor/clack-ui/src/elements.ts @@ -0,0 +1,40 @@ +import { createApi, createContext, type Node } from './core.ts'; + +/** + * Type container for elements and their properties + */ +export interface HostElements {} + +export interface HostElement { + parent: HostElement | null; + readonly type: 'element'; + readonly name: string; + readonly properties: Record; + readonly children: HostElementChild[]; + node: Node | null; + attach?: (node: Node) => void; + detach?: (node: Node) => void; +} + +export interface HostLiteral { + parent?: HostElement; + type: 'literal'; + content: string; +} + +export type HostElementChild = HostElement | HostLiteral; + +export const ElementApi = createApi('element', { + getElement(node): HostElement { + return ElementContext.expect(node); + }, + + setElement(node, element: HostElement): void { + element.node = node; + ElementContext.set(node, element); + }, +}); + +export const { getElement, setElement } = ElementApi.methods; + +const ElementContext = createContext('element'); diff --git a/vendor/clack-ui/src/elements/box.ts b/vendor/clack-ui/src/elements/box.ts new file mode 100644 index 0000000..9e9fa28 --- /dev/null +++ b/vendor/clack-ui/src/elements/box.ts @@ -0,0 +1,50 @@ +import { open, close, text, type OpenElement, type Op } from '@bomb.sh/tty'; +import { id, type Node } from '../core.ts'; +import { type Host } from '../host.ts'; +import { LayoutApi, layout } from '../layout.ts'; +import { getElement, type HostElement } from '../elements.ts'; + +export type BoxProps = Omit; + +declare module '@clack/ui/elements' { + interface HostElements { + box: BoxProps; + } +} + +export function useBoxElement(host: Host) { + LayoutApi.around(host.root, { + *layout([node], next) { + const element = getElement(node); + if (element.name === 'box') { + yield* containerLayout(node, element); + } else { + return yield* next(node); + } + }, + }); +} + +/** Container layout: box framing with literal children folded into text runs. */ +export function* containerLayout( + node: Node, + element: HostElement, +): Generator { + let content = ''; + yield open(id(node), element.properties); + for (const child of element.children) { + if (child.type === 'element') { + if (content !== '') { + yield text(content); + content = ''; + } + yield* layout(child.node!); + } else { + content += child.content; + } + } + if (content !== '') { + yield text(content); + } + yield close(); +} diff --git a/vendor/clack-ui/src/elements/button.ts b/vendor/clack-ui/src/elements/button.ts new file mode 100644 index 0000000..9f35476 --- /dev/null +++ b/vendor/clack-ui/src/elements/button.ts @@ -0,0 +1,98 @@ +import { open, close, text, rgba, type OpenElement } from '@bomb.sh/tty'; +import { id, type Node } from '../core.ts'; +import { emit } from '../emit.ts'; +import { getElement, type HostElement } from '../elements.ts'; +import { type Host, HostApi } from '../host.ts'; +import { LayoutApi } from '../layout.ts'; +import { getFocus, setFocusable } from '../focus.ts'; +import { KeyboardApi } from '../keyboard.ts'; +import type { KeyDown } from '@bomb.sh/tty'; +import { collectValues, nearestForm } from './form.ts'; + +export interface ButtonPressEvent { + type: 'press'; + target: HostElement; +} + +declare module '@clack/ui/events' { + interface HostEvents { + press: ButtonPressEvent; + } +} + +export interface ButtonProps extends Omit { + role?: string; + label?: string; + type?: 'button' | 'submit'; + color?: number; + onpress?: (event: ButtonPressEvent) => void; +} + +declare module '@clack/ui/elements' { + interface HostElements { + button: ButtonProps; + } +} + +/** + * A button is a focusable inline control that paints its label and fires + * `press` when activated with Enter or Space. A button with `type: 'submit'` + * inside a form also emits `submit` on the enclosing form with the collected + * field values — the DOM implicit-submission contract, terminal edition. + */ +export function useButtonElement(host: Host): void { + HostApi.around(host.root, { + createElement([_node, name], next) { + const element = next(_node, name); + if (element.name === 'button') { + element.attach = (node) => { + setFocusable(node); + }; + } + return element; + }, + }); + + LayoutApi.around(host.root, { + *layout([node], next) { + const element = getElement(node); + if (element.name === 'button') { + const focused = node === getFocus(node); + const { color: configuredColor, ...properties } = element.properties; + const color = focused + ? rgba(255, 255, 255) + : typeof configuredColor === 'number' + ? configuredColor + : rgba(100, 100, 100); + yield open(id(node), properties); + let content = ''; + for (const child of element.children) { + if (child.type === 'literal') content += child.content; + } + yield text(content || ' ', { color }); + yield close(); + } else { + return yield* next(node); + } + }, + }); + + KeyboardApi.around(host.root, { + keydown([node, event], next) { + if (activateIfButton(node, event as KeyDown)) return; + return next(node, event); + }, + }); + + function activateIfButton(node: Node, event: KeyDown): boolean { + if (event.code !== 'Enter' && event.code !== ' ') return false; + const element = getElement(node); + if (element.name !== 'button' || node !== getFocus(node)) return false; + emit(node, { type: 'press' }); + if (element.properties.type === 'submit') { + const form = nearestForm(element); + if (form) emit(form.node!, { type: 'submit', values: collectValues(form) }); + } + return true; + } +} diff --git a/vendor/clack-ui/src/elements/dialog.ts b/vendor/clack-ui/src/elements/dialog.ts new file mode 100644 index 0000000..9c36373 --- /dev/null +++ b/vendor/clack-ui/src/elements/dialog.ts @@ -0,0 +1,50 @@ +import { LayoutApi } from '../layout.ts'; +import { getElement, type HostElement, type HostElementChild } from '../elements.ts'; +import { type Host, HostApi } from '../host.ts'; +import { activateFocusScope } from '../focus.ts'; +import { containerLayout, type BoxProps } from './box.ts'; + +export interface DialogProps extends BoxProps { + role?: string; + label?: string; + modal?: boolean; +} + +declare module '@clack/ui/elements' { + interface HostElements { + dialog: DialogProps; + } +} + +/** + * A dialog is a container. A dialog attached with `modal: true` owns the active + * focus scope until its subtree is removed. The focus API chooses its first + * focusable descendant and restores the prior focus when the dialog closes. + */ +export function useDialogElement(host: Host): void { + LayoutApi.around(host.root, { + *layout([node], next) { + const element = getElement(node); + if (element.name === 'dialog') { + yield* containerLayout(node, element); + } else { + return yield* next(node); + } + }, + }); + + HostApi.around(host.root, { + insertBefore([node, parent, child, anchor], next) { + const wasAttached = child.type === 'element' && child.node !== null; + next(node, parent, child, anchor); + if (child.type !== 'element' || wasAttached || child.node === null) return; + for (const dialog of modalDialogs(child)) activateFocusScope(dialog.node!); + }, + }); +} + +function* modalDialogs(child: HostElementChild): Iterable { + if (child.type !== 'element') return; + if (child.name === 'dialog' && child.properties.modal === true) yield child; + for (const descendant of child.children) yield* modalDialogs(descendant); +} diff --git a/vendor/clack-ui/src/elements/form.ts b/vendor/clack-ui/src/elements/form.ts new file mode 100644 index 0000000..0a9cea0 --- /dev/null +++ b/vendor/clack-ui/src/elements/form.ts @@ -0,0 +1,86 @@ +import { id } from '../core.ts'; +import { KeyboardApi } from '../keyboard.ts'; +import { LayoutApi } from '../layout.ts'; +import { emit } from '../emit.ts'; +import { getElement, type HostElement } from '../elements.ts'; +import { type Host } from '../host.ts'; +import { containerLayout, type BoxProps } from './box.ts'; + +export interface FormSubmitEvent { + type: 'submit'; + /** Field values collected from descendant inputs, keyed by label (or node id). */ + values: Record; + target: HostElement; +} + +declare module '@clack/ui/events' { + interface HostEvents { + submit: FormSubmitEvent; + } +} + +export interface FormProps extends BoxProps { + role?: string; + label?: string; + onsubmit?: (event: FormSubmitEvent) => void; +} + +declare module '@clack/ui/elements' { + interface HostElements { + form: FormProps; + } +} + +export function nearestForm(element: HostElement): HostElement | undefined { + for (let current: HostElement | null | undefined = element; current; current = current.parent) { + if (current.name === 'form') return current; + } + return undefined; +} + +/** Field values from descendant inputs, keyed by label (falling back to node id). */ +export function collectValues(form: HostElement): Record { + const values: Record = {}; + function visit(element: HostElement): void { + for (const child of element.children) { + if (child.type !== 'element') continue; + if (child.name === 'input') { + const key = typeof child.properties.label === 'string' + ? child.properties.label + : String(child.properties.key ?? id(child.node!)); + values[key] = String(child.properties.value ?? ''); + } + visit(child); + } + } + visit(form); + return values; +} + +/** + * A form is a container (box layout) that owns implicit submission: pressing + * Enter inside any of its fields emits a `submit` event on the form with the + * collected field values. Enter is consumed at the form boundary and never + * reaches elements mounted outside it. + */ +export function useFormElement(host: Host): void { + LayoutApi.around(host.root, { + *layout([node], next) { + const element = getElement(node); + if (element.name === 'form') { + yield* containerLayout(node, element); + } else { + return yield* next(node); + } + }, + }); + + KeyboardApi.around(host.root, { + keydown([node, event], next) { + if (event.code !== 'Enter') return next(node, event); + const form = nearestForm(getElement(node)); + if (!form) return next(node, event); + emit(form.node!, { type: 'submit', values: collectValues(form) }); + }, + }); +} diff --git a/vendor/clack-ui/src/elements/input.ts b/vendor/clack-ui/src/elements/input.ts new file mode 100644 index 0000000..fe2958b --- /dev/null +++ b/vendor/clack-ui/src/elements/input.ts @@ -0,0 +1,236 @@ +import { createApi, createContext, id, type Node } from '../core.ts'; +import { open, close, text, fit, percent, rgba, type KeyDown, type KeyRepeat } from '@bomb.sh/tty'; +import type { HostEvent } from '@clack/ui/events'; +import { emit } from '../emit.ts'; +import { getElement } from '../elements.ts'; +import { type Host, HostApi } from '../host.ts'; +import { LayoutApi } from '../layout.ts'; +import { getFocus, setFocusable } from '../focus.ts'; +import { requestRender } from '../render.ts'; +import { KeyboardApi } from '../keyboard.ts'; + +export interface InputEvent extends HostEvent<'input'> { + value: string; +} + +declare module '@clack/ui/events' { + interface HostEvents { + input: InputEvent; + } +} + +export interface InputProps { + role?: string; + label?: string; + oninput?: (event: InputEvent) => void; +} + +declare module '@clack/ui/elements' { + interface HostElements { + input: InputProps; + } +} + +export function useInputElement(host: Host): void { + const { root } = host; + + HostApi.around(root, { + createElement([root, name], next) { + const element = next(root, name); + if (element.name === 'input') { + const model = { content: '', caret: 0 }; + element.attach = (node) => { + setFocusable(node); + InputContext.set(node, model); + }; + + // make value and caret accessible to the public + Object.defineProperties(element.properties, { + value: { + get() { + return model.content; + }, + }, + caret: { + get() { + return model.caret; + }, + }, + }); + } + return element; + }, + }); + + LayoutApi.around(root, { + *layout([node], next) { + const element = getElement(node); + if (element.name !== 'input') { + return yield* next(node); + } + + const focused = node === getFocus(node); + const model = InputContext.expect(node); + const value = model.content; + const caret = Math.min(model.caret, [...value].length); + const color = focused ? rgba(255, 255, 255) : rgba(100, 100, 100); + const border = { color, top: 1, right: 1, bottom: 1, left: 1 }; + yield open(id(node), { + border, + layout: { + height: fit(3), + width: percent(0.3), + padding: { top: 1, right: 1, bottom: 1, left: 1 }, + }, + }); + yield text(value, { color, ...(focused ? { caret } : {}) }); + yield close(); + }, + }); + + KeyboardApi.around(root, { + keydown([node, event], next) { + if (!handleKeyEvent(node, event)) { + return next(node, event); + } + }, + keyrepeat([node, event], next) { + if (!handleKeyEvent(node, event)) { + return next(node, event); + } + }, + }); +} + +export const InputApi = createApi('input', { + insert(node, text: string): void { + withModel(node, (model) => { + // Inputs are single-line, so discard CR/LF. Spreading the string produces + // Unicode code points rather than UTF-16 code units. + const inserted = [...text.replace(/[\r\n]/g, '')]; + const codepoints = [...model.content]; + const caret = model.caret; + + // The caret is a code-point index. Insert each new code point independently, + // then advance by exactly the number that survived normalization. + codepoints.splice(caret, 0, ...inserted); + model.content = codepoints.join(''); + model.caret = caret + inserted.length; + }); + }, + deleteBackward(node): void { + withModel(node, (model) => { + const codepoints = [...model.content]; + let caret = model.caret; + const length = codepoints.length; + if (caret > length) { + console.warn('caret mismatch: ${caret}, value length: ${length}'); + caret = length; + } + if (caret > 0) { + codepoints.splice(caret - 1, 1); + model.content = codepoints.join(''); + model.caret = caret - 1; + } + }); + }, + deleteForward(node): void { + withModel(node, (model) => { + const codepoints = [...model.content]; + let caret = model.caret; + const length = codepoints.length; + if (caret > length) { + console.warn('caret mismatch: ${caret}, value length: ${length}'); + caret = length; + } + if (caret < length) { + codepoints.splice(caret, 1); + model.content = codepoints.join(''); + } + }); + }, + moveBack(node): void { + withModel(node, (model) => { + const caret = model.caret; + model.caret = Math.max(0, caret - 1); + }); + }, + moveForward(node): void { + withModel(node, (model) => { + const caret = model.caret; + const codepoints = [...model.content]; + model.caret = Math.min(codepoints.length, caret + 1); + }); + }, + moveMin(node): void { + withModel(node, (model) => { + model.caret = 0; + }); + }, + moveMax(node): void { + withModel(node, (model) => { + model.caret = [...model.content].length; + }); + }, +}); + +export const { insert, deleteForward, deleteBackward, moveForward, moveBack, moveMin, moveMax } = + InputApi.methods; + +interface InputModel { + content: string; + caret: number; +} + +const InputContext = createContext('input'); + +function isInput(node: Node): boolean { + return InputContext.hasOwn(node); +} + +function withModel(node: Node, fn: (model: InputModel) => void): void { + if (isInput(node)) { + const model = InputContext.expect(node); + const originalContent = model.content; + const originalCaret = model.caret; + fn(model); + const contentChanged = originalContent !== model.content; + const caretChanged = originalCaret !== model.caret; + if (contentChanged) { + emit(node, { + type: 'input', + value: model.content, + }); + } + // Caret-only movement must repaint without emitting a value-change event. + if (contentChanged || caretChanged) requestRender(node); + } +} + +function handleKeyEvent(node: Node, event: KeyDown | KeyRepeat): boolean { + if (isInput(node)) { + if (event.text && event.text?.length > 0) { + insert(node, event.text); + return true; + } else if (event.code === 'Backspace') { + deleteBackward(node); + return true; + } else if (event.code === 'Delete') { + deleteForward(node); + return true; + } else if (event.code === 'ArrowLeft') { + moveBack(node); + return true; + } else if (event.code === 'ArrowRight') { + moveForward(node); + return true; + } else if (event.code === 'Home') { + moveMin(node); + return true; + } else if (event.code === 'End') { + moveMax(node); + return true; + } + } + return false; +} diff --git a/vendor/clack-ui/src/elements/text.ts b/vendor/clack-ui/src/elements/text.ts new file mode 100644 index 0000000..0aef3e5 --- /dev/null +++ b/vendor/clack-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.around(host.root, { + *layout([node], next) { + const element = getElement(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* next(node); + } + }, + }); +} diff --git a/vendor/clack-ui/src/emit.ts b/vendor/clack-ui/src/emit.ts new file mode 100644 index 0000000..ea051cd --- /dev/null +++ b/vendor/clack-ui/src/emit.ts @@ -0,0 +1,32 @@ +// oxlint-disable no-unused-vars +import { createApi, type Node } from './core.ts'; +import { getElement, type HostElement } from './elements.ts'; +import type { AnyHostEvent, HostEvent, HostEvents } from './events.ts'; + +export const EmitApi = createApi('emit', { + emit(node, event: AnyHostEvent): void { + if (event.target !== getElement(node)) { + throw new InvalidEventTargetError(node, event.target); + } + }, +}); + +export function emit>(node: Node, data: E): void { + return EmitApi.methods.emit(node, { + ...data, + target: getElement(node), + } as AnyHostEvent); +} + +class InvalidEventTargetError extends TypeError { + readonly code = 'CLACK_UI_INVALID_EVENT_TARGET'; + readonly node: Node; + readonly target: HostElement; + + constructor(node: Node, target: HostElement) { + super('event target does not match emitting node'); + this.name = 'InvalidEventTargetError'; + this.node = node; + this.target = target; + } +} diff --git a/vendor/clack-ui/src/events.ts b/vendor/clack-ui/src/events.ts new file mode 100644 index 0000000..3ed3379 --- /dev/null +++ b/vendor/clack-ui/src/events.ts @@ -0,0 +1,19 @@ +import type { HostElement } from './elements.ts'; + +/** + * Open interface that is extended with new event types + */ +export interface HostEvents {} + +export interface HostEvent { + type: T; + target: HostElement; +} + +export type HostEventType = keyof HostEvents; + +export type AnyHostEvent = HostEvents[HostEventType]; + +export interface HostEventListener { + (event: HostEvents[T]): void; +} diff --git a/vendor/clack-ui/src/extensions.ts b/vendor/clack-ui/src/extensions.ts new file mode 100644 index 0000000..e984bb7 --- /dev/null +++ b/vendor/clack-ui/src/extensions.ts @@ -0,0 +1,70 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { dirname, join, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import type { ReadStream, WriteStream } from 'node:tty'; +import type { Host } from './host.ts'; + +export interface UIExtensionContext { + host: Host; + input: ReadStream; + output: WriteStream; + width: number; + height: number; + inline: boolean; +} + +export type UIExtension = (context: UIExtensionContext) => void; + +const REGISTRY = Symbol.for('@clack/ui/extensions'); +const globals = globalThis as typeof globalThis & Record; + +/** Out-of-band registration: callable before clack/ui loads, cross-copy safe. */ +export function registerUIExtension(extension: UIExtension): void { + const registry = ((globals[REGISTRY] as UIExtension[] | undefined) ??= []); + registry.push(extension); +} + +export function registeredUIExtensions(): UIExtension[] { + return ((globals[REGISTRY] as UIExtension[] | undefined) ?? []).slice(); +} + +function findPackageJson(from: string): string | undefined { + let current = resolve(from); + for (;;) { + const candidate = join(current, 'package.json'); + if (existsSync(candidate)) return candidate; + const parent = dirname(current); + if (parent === current) return undefined; + current = parent; + } +} + +/** + * Load extensions declared in the nearest package.json: + * + * "@clack/ui": { "extensions": ["some-extension-package"] } + * + * Each specifier resolves from the declaring package (so workspace and + * installed dependencies both work) and must default-export a UIExtension. + */ +export async function loadDeclaredExtensions(from: string): Promise { + const pkgPath = findPackageJson(from); + if (!pkgPath) return []; + const declared = JSON.parse(readFileSync(pkgPath, 'utf8'))['@clack/ui']?.extensions; + if (!Array.isArray(declared)) return []; + const require = createRequire(pkgPath); + return Promise.all( + declared.map(async (specifier: string) => { + const resolved = require.resolve(specifier); + const module = await import(pathToFileURL(resolved).href); + const extension = module.default; + if (typeof extension !== 'function') { + throw new Error( + `@clack/ui extension "${specifier}" must default-export a UIExtension function`, + ); + } + return extension as UIExtension; + }), + ); +} diff --git a/vendor/clack-ui/src/focus-navigation.ts b/vendor/clack-ui/src/focus-navigation.ts new file mode 100644 index 0000000..6b7bda0 --- /dev/null +++ b/vendor/clack-ui/src/focus-navigation.ts @@ -0,0 +1,26 @@ +import { advanceFocus, retreatFocus } from './focus.ts'; +import type { Host } from './host.ts'; +import { KeyboardApi } from './keyboard.ts'; + +export function useFocusNavigation(host: Host): void { + KeyboardApi.around(host.root, { + keydown([node, event], next) { + if (event.code === 'Tab') { + advanceFocus(node); + } else if (event.code === 'Backtab') { + retreatFocus(node); + } else { + next(node, event); + } + }, + keyrepeat([node, event], next) { + if (event.code === 'Tab') { + advanceFocus(node); + } else if (event.code === 'Backtab') { + retreatFocus(node); + } else { + next(node, event); + } + }, + }); +} diff --git a/vendor/clack-ui/src/focus.ts b/vendor/clack-ui/src/focus.ts new file mode 100644 index 0000000..6072bcc --- /dev/null +++ b/vendor/clack-ui/src/focus.ts @@ -0,0 +1,264 @@ +import { createApi, createContext, type Node } from './core.ts'; +import { getElement, type HostElement, type HostElementChild } from './elements.ts'; +import { HostApi, type Host } from './host.ts'; + +export const FocusApi = createApi('focus', { + setFocusable(node): void { + FocusableContext.set(node, true); + + const host = getHost(node); + const focus = getFocus(node); + + if (focus === host.root && FocusApi.methods.isFocusable(node)) { + FocusContext.expect(node).current = node; + } + }, + setFocus(node): void { + if (!FocusApi.methods.isFocusable(node) && node !== getHost(node).root) { + throw new UnfocusableElementError(node); + } + FocusContext.expect(node).current = node; + }, + getFocus(node): Node { + return FocusContext.expect(node).current; + }, + getFocusScope(node): Node { + const state = FocusContext.expect(node); + return state.scopes[state.scopes.length - 1]?.node ?? getHost(node).root; + }, + isFocusable(node): boolean { + if (!FocusableContext.hasOwn(node) || !FocusableContext.expect(node)) return false; + const scope = FocusApi.methods.getFocusScope(node); + return scope === getHost(node).root || contains(scope, node); + }, + activateFocusScope(node): void { + const state = FocusContext.expect(node); + if (state.scopes.some((scope) => scope.node === node)) { + throw new DuplicateFocusScopeError(node); + } + state.scopes.push({ node, restore: state.current }); + const first = focusableNodes(node)[0]; + if (!first) { + state.scopes.pop(); + throw new EmptyFocusScopeError(node); + } + FocusApi.methods.setFocus(first); + }, + deactivateFocusScope(node): void { + const state = FocusContext.expect(node); + const active = state.scopes[state.scopes.length - 1]; + if (!active || active.node !== node) { + throw new FocusScopeOrderError(node); + } + state.scopes.pop(); + const host = getHost(node); + const scope = FocusApi.methods.getFocusScope(node); + const canRestore = + contains(host.root, active.restore) && FocusApi.methods.isFocusable(active.restore); + const replacement = canRestore + ? active.restore + : (focusableNodes(scope)[0] ?? host.root); + FocusApi.methods.setFocus(replacement); + }, + advanceFocus(node): void { + const current = getFocus(node); + const candidates = focusableNodes(FocusApi.methods.getFocusScope(node)); + if (candidates.length === 0) return; + const index = candidates.indexOf(current); + setFocus(candidates[index < 0 ? 0 : (index + 1) % candidates.length]!); + }, + retreatFocus(node): void { + const current = getFocus(node); + const candidates = focusableNodes(FocusApi.methods.getFocusScope(node)); + if (candidates.length === 0) return; + const index = candidates.indexOf(current); + setFocus(candidates[index <= 0 ? candidates.length - 1 : index - 1]!); + }, +}); + +export const { + setFocusable, + getFocus, + setFocus, + getFocusScope, + isFocusable, + activateFocusScope, + deactivateFocusScope, + advanceFocus, + retreatFocus, +} = FocusApi.methods; + +export function useFocus(host: Host): void { + FocusContext.set(host.root, { current: host.root, scopes: [] }); + HostApi.around(host.root, { + removeChild([root, parent, removed], next) { + if (removed.type === 'element') { + let scope = getFocusScope(root); + while (scope !== root && contains(removed.node!, scope)) { + deactivateFocusScope(scope); + scope = getFocusScope(root); + } + + if (contains(removed.node!, getFocus(root))) { + const tail = lastElementOf(removed); + const after = forward({ start: tail }); + const before = forward({ + start: host.element, + limit: removed, + }); + const replacement = findFocus(concat(after, before)) ?? host.root; + setFocus(replacement); + } + } + + return next(root, parent, removed); + }, + }); +} + +const { getHost } = HostApi.methods; + +interface FocusState { + current: Node; + scopes: Array<{ node: Node; restore: Node }>; +} + +const FocusContext = createContext('focus'); +const FocusableContext = createContext('focusable', false); + + +interface Range { + start: HostElementChild; + limit?: HostElement; +} + +function* forward(range: Range): Iterable { + type Tier = { + siblings: HostElementChild[]; + index: number; + }; + const tiers: Tier[] = [ + { + siblings: range.start.type === 'element' ? range.start.children : [], + index: 0, + }, + ]; + + let current = range.start; + while (current.parent) { + const siblings = current.parent.children; + tiers.unshift({ siblings, index: siblings.indexOf(current) + 1 }); + current = current.parent; + } + + while (tiers.length > 0) { + const tier = tiers[tiers.length - 1]!; + const child = tier.siblings[tier.index++]; + if (!child) { + tiers.pop(); + continue; + } + if (child === range.limit) { + break; + } + if (child.type === 'element') { + if (child !== range.start) { + yield child; + } + tiers.push({ siblings: child.children, index: 0 }); + } + } +} + +function* concat(...iterables: Iterable[]): Generator { + for (const iterable of iterables) { + yield* iterable; + } +} + +function focusableNodes(scope: Node): Node[] { + const nodes: Node[] = []; + function visit(element: HostElement): void { + if (element.node && FocusApi.methods.isFocusable(element.node)) nodes.push(element.node); + for (const child of element.children) { + if (child.type === 'element') visit(child); + } + } + visit(getElement(scope)); + return nodes; +} + +function findFocus(elements: Iterable): Node | undefined { + for (const element of elements) { + if (FocusApi.methods.isFocusable(element.node!)) { + return element.node!; + } + } +} + +function lastElementOf(element: HostElement): HostElement { + const { children } = element; + let last: HostElement | undefined = void 0; + for (let i = children.length - 1; i >= 0; i--) { + const child = children[i]; + if (child && child.type === 'element') { + last = child; + break; + } + } + return last ? lastElementOf(last) : element; +} + +function contains(node: Node, descendant: Node): boolean { + if (node === descendant) { + return true; + } else { + for (const child of node.children) { + if (contains(child, descendant)) { + return true; + } + } + } + return false; +} + +class DuplicateFocusScopeError extends TypeError { + readonly code = 'CLACK_UI_DUPLICATE_FOCUS_SCOPE'; + readonly node: Node; + constructor(node: Node) { + super('attempted to activate an active focus scope'); + this.name = 'DuplicateFocusScopeError'; + this.node = node; + } +} + +class EmptyFocusScopeError extends TypeError { + readonly code = 'CLACK_UI_EMPTY_FOCUS_SCOPE'; + readonly node: Node; + constructor(node: Node) { + super('focus scope has no focusable descendants'); + this.name = 'EmptyFocusScopeError'; + this.node = node; + } +} + +class FocusScopeOrderError extends TypeError { + readonly code = 'CLACK_UI_FOCUS_SCOPE_ORDER'; + readonly node: Node; + constructor(node: Node) { + super('focus scopes must be deactivated in stack order'); + this.name = 'FocusScopeOrderError'; + this.node = node; + } +} + +class UnfocusableElementError extends TypeError { + readonly code = 'CLACK_UI_UNFOCUSABLE_ELEMENT'; + readonly node: Node; + + constructor(node: Node) { + super('attempted to focus an unfocusable element'); + this.name = 'UnfocusableElementError'; + this.node = node; + } +} diff --git a/vendor/clack-ui/src/host-render.ts b/vendor/clack-ui/src/host-render.ts new file mode 100644 index 0000000..3200379 --- /dev/null +++ b/vendor/clack-ui/src/host-render.ts @@ -0,0 +1,52 @@ +import { requestRender } from './render.ts'; +import { type Host, HostApi } from './host.ts'; +import { FocusApi, getFocus } from './focus.ts'; + +export function useHostRenderer(host: Host): void { + HostApi.around(host.root, { + insertBefore([node, parent, child, anchor], next) { + const originalParentNode = child.parent?.node ?? null; + + next(node, parent, child, anchor); + + if (originalParentNode) { + requestRender(originalParentNode); + } + + if (parent.node && parent.node !== originalParentNode) { + requestRender(parent.node); + } + }, + removeChild([root, parent, child], next) { + next(root, parent, child); + + if (parent.node) { + requestRender(parent.node); + } + }, + setProperty([root, element, name, value], next) { + next(root, element, name, value); + + if (element.node) { + requestRender(element.node); + } + }, + setText([root, text, content], next) { + next(root, text, content); + + if (text.parent && text.parent.node) { + requestRender(text.parent.node); + } + }, + }); + + FocusApi.around(host.root, { + setFocus([node], next) { + const original = getFocus(node); + next(node); + if (original !== node) { + requestRender(node); + } + }, + }); +} diff --git a/vendor/clack-ui/src/host.ts b/vendor/clack-ui/src/host.ts new file mode 100644 index 0000000..8b89ac7 --- /dev/null +++ b/vendor/clack-ui/src/host.ts @@ -0,0 +1,259 @@ +// oxlint-disable max-params +import { text } from '@bomb.sh/tty'; +import { type Node, create, createApi, createContext, destroy, LifecycleApi } from './core.ts'; +import { + getElement, + setElement, + type HostElementChild, + type HostElement, + type HostLiteral, +} from './elements.ts'; +import { LayoutApi, layout } from './layout.ts'; +import { EmitApi } from './emit.ts'; +import type { HostEventType, HostEvents, HostEventListener } from './events.ts'; + +export interface Host { + root: Node; + element: HostElement; + createElement(type: string): HostElement; + createLiteral(content: string): HostLiteral; + insertBefore(parent: HostElement, child: HostElementChild, anchor?: HostElementChild): void; + removeChild(parent: HostElement, child: HostElementChild): void; + setProperty(element: HostElement, name: string, value: unknown): void; + setText(text: HostLiteral, content: string): void; + addEventListener( + element: HostElement, + type: T, + listener: HostEventListener, + ): void; + removeEventListener( + element: HostElement, + type: T, + listener: HostEventListener, + ): void; +} + +export function createHost(): Host { + const root = create(); + const element: HostElement = { + type: 'element', + name: 'root', + node: root, + parent: null, + properties: {}, + children: [], + }; + + setElement(root, element); + useRootLayout(root, element); + + LifecycleApi.around(root, { + destroy([node], next) { + const element = getElement(node); + if (element.detach) { + element.detach(node); + } + element.node = null; + return next(node); + }, + }); + + EmitApi.around(root, { + emit([node, event], next): void { + next(node, event); + const target = getElement(node); + const map = ListenerContext.expect(node); + const types = map.get(target); + if (types) { + const listeners = types.get(event.type); + if (listeners) { + const active = [...listeners]; + for (const listener of active) { + listener(event); + } + } + } + }, + }); + + const host: Host = { + root, + element, + createElement(type) { + return HostApi.methods.createElement(root, type); + }, + createLiteral(content) { + return HostApi.methods.createLiteral(root, content); + }, + insertBefore(parent, child, anchor) { + return HostApi.methods.insertBefore(root, parent, child, anchor); + }, + removeChild(parent, child) { + return HostApi.methods.removeChild(root, parent, child); + }, + setProperty(element, name, value) { + return HostApi.methods.setProperty(root, element, name, value); + }, + setText(text, value) { + return HostApi.methods.setText(root, text, value); + }, + addEventListener(element, type, listener): void { + return HostApi.methods.addEventListener(root, element, type, listener); + }, + removeEventListener(element, type, listener): void { + return HostApi.methods.removeEventListener(root, element, type, listener); + }, + }; + + HostContext.set(root, host); + ListenerContext.set(root, new WeakMap()); + return host; +} + +export const HostApi = createApi('host', { + createElement(node, name: string): HostElement { + return { + type: 'element', + name, + node: null, + parent: null, + properties: {}, + children: [], + }; + }, + createLiteral(node, content: string): HostLiteral { + return { type: 'literal', content }; + }, + insertBefore( + node, + parent: HostElement, + child: HostElementChild, + anchor?: HostElementChild, + ): void { + if (child.parent !== parent) { + if (child.type === 'element') { + detach(child); + + if (isAttached(parent)) { + attach(parent, child); + } + } + } + if (child.parent) { + const currentIndex = child.parent.children.indexOf(child); + child.parent.children.splice(currentIndex, 1); + } + const index = anchor ? parent.children.indexOf(anchor) : parent.children.length; + parent.children.splice(index, 0, child); + child.parent = parent; + }, + removeChild(root, parent: HostElement, child: HostElementChild): void { + if (child.parent === parent) { + child.parent = null; + parent.children.splice(parent.children.indexOf(child), 1); + if (child.type === 'element') { + detach(child); + } + } + }, + setProperty(node, element: HostElement, name: string, value: unknown): void { + if (typeof value === 'undefined') { + delete element.properties[name]; + } else { + element.properties[name] = value; + } + }, + + setText(node, text: HostLiteral, content: string): void { + text.content = content; + }, + + getHost(node): Host { + return HostContext.expect(node); + }, + + addEventListener(node, element, type, listener): void { + const map = ListenerContext.expect(node); + let types = map.get(element); + if (!types) { + map.set(element, (types = new Map())); + } + let listeners = types.get(type); + if (!listeners) { + types.set(type, (listeners = new Set())); + } + listeners.add(listener); + }, + + removeEventListener(node, element, type, listener): void { + const map = ListenerContext.expect(node); + const types = map.get(element); + if (types) { + const listeners = types.get(type); + if (listeners) { + listeners.delete(listener); + if (listeners.size === 0) { + types.delete(type); + } + } + if (types.size === 0) { + map.delete(element); + } + } + }, +}); + +const HostContext = createContext('host'); +const ListenerContext = + createContext>>>>( + 'listeners', + ); + +function isAttached(element: HostElement): boolean { + return !!element.node; +} + +function attach(parent: HostElement, child: HostElement): void { + child.node = create(parent.node!); + setElement(child.node, child); + + if (child.attach) { + child.attach(child.node); + } + for (const grandchild of child.children) { + if (grandchild.type === 'element') { + attach(child, grandchild); + } + } +} + +function detach(child: HostElement): void { + if (child.node) { + destroy(child.node); + } +} + +function useRootLayout(root: Node, element: HostElement): void { + LayoutApi.around(root, { + *layout([node], next) { + if (node !== root) { + return yield* next(node); + } + let content = ''; + for (const child of element.children) { + if (child.type === 'element') { + if (content !== '') { + yield text(content); + content = ''; + } + yield* layout(child.node!); + } else { + content += child.content; + } + } + if (content !== '') { + yield text(content); + } + }, + }); +} diff --git a/vendor/clack-ui/src/index.ts b/vendor/clack-ui/src/index.ts new file mode 100644 index 0000000..cfc1001 --- /dev/null +++ b/vendor/clack-ui/src/index.ts @@ -0,0 +1,8 @@ +export { rgba } from '@bomb.sh/tty'; +export { DispatchApi } from './dispatch.ts'; +export * from './elements.ts'; +export type { BoxProps } from './elements/box.ts'; +export type { InputEvent, InputProps } from './elements/input.ts'; +export type { TextProps } from './elements/text.ts'; +export * from './host.ts'; +export { createUI } from './ui.ts'; diff --git a/vendor/clack-ui/src/input-loop.ts b/vendor/clack-ui/src/input-loop.ts new file mode 100644 index 0000000..6d823aa --- /dev/null +++ b/vendor/clack-ui/src/input-loop.ts @@ -0,0 +1,150 @@ +import { createInput, type InputEvent, type ScanResult } from '@bomb.sh/tty'; +import type { ReadStream } from 'node:tty'; +import { on } from 'node:events'; + +export interface InputLoopOptions { + stdin: ReadStream; + signal: AbortSignal; +} + +export type Outcome = + | { + type: 'end'; + } + | { + type: 'error'; + error: Error; + }; + +export function createInputLoop(options: InputLoopOptions): AsyncIterable { + const { stdin, signal } = options; + + return { + async *[Symbol.asyncIterator]() { + const scanner = await abortable(signal, createInput()); + if (scanner.type === 'aborted') { + return; + } + + const { scan } = scanner.value; + + const data = on(stdin, 'data', { + signal, + close: ['end'], + }); + + let read = data.next(); + let pending: Pending; + + try { + while (true) { + const winner = await race(read, pending); + if (winner.done) { + break; + } + const { value: event } = winner; + + if (event.type === 'data') { + // move to the next chunk if this chunk wins. + read = data.next(); + const result = scan(event.data); + pending = result.pending; + + if (result.events.length > 0) { + yield result.events; + } + } else { + // `read` is still pending and must race again + const result = scan(); + pending = result.pending; + + if (result.events.length > 0) { + yield result.events; + } + } + } + } catch (error) { + if (signal.aborted && error instanceof Error && error.name === 'AbortError') { + return; + } + throw error; + } finally { + data.return?.(); + } + }, + }; +} + +async function race( + read: Promise>, + pending: Pending, +): Promise> { + let timeoutId: NodeJS.Timeout | undefined = undefined; + const timeout = pending + ? new Promise>((resolve) => { + timeoutId = setTimeout( + () => resolve({ done: false, value: { type: 'timeout' } }), + pending.delay, + ); + }) + : new Promise>(() => {}); + + const data: Promise> = read.then((item) => { + if (item.done) { + return { done: true } as IteratorResult; + } else { + const [data] = item.value; + return { + done: false, + value: { type: 'data', data }, + } as IteratorResult; + } + }); + + try { + return await Promise.race([data, timeout]); + } finally { + clearTimeout(timeoutId); + } +} + +type Pending = ScanResult['pending']; + +type ReadEvent = DataEvent | TimeoutEvent; + +type DataEvent = { + type: 'data'; + data: Buffer; +}; + +type TimeoutEvent = { + type: 'timeout'; +}; + +type Abortable = + | { + type: 'aborted'; + } + | { + type: 'resolved'; + value: T; + }; + +async function abortable(signal: AbortSignal, op: Promise): Promise> { + if (signal.aborted) { + return { type: 'aborted' }; + } + let listener = () => {}; + try { + return await Promise.race([ + op.then((value: T) => ({ type: 'resolved', value }) as Abortable), + new Promise((resolve) => { + signal.addEventListener('abort', (listener = () => resolve({ type: 'aborted' }))); + }), + ] as Promise>[]); + } finally { + if (listener) { + signal.removeEventListener('abort', listener); + } + } +} diff --git a/vendor/clack-ui/src/keyboard.ts b/vendor/clack-ui/src/keyboard.ts new file mode 100644 index 0000000..af7c35c --- /dev/null +++ b/vendor/clack-ui/src/keyboard.ts @@ -0,0 +1,37 @@ +// oxlint-disable no-unused-vars +import type { KeyDown, KeyEvent, KeyRepeat, KeyUp } from '@bomb.sh/tty'; +import { createApi } from './core.ts'; +import type { Host } from './host.ts'; +import { DispatchApi } from './dispatch.ts'; +import { getFocus } from './focus.ts'; + +export function useKeyboard(host: Host): void { + DispatchApi.around(host.root, { + dispatch([node, event], next) { + if (isKeyEvent(event)) { + const focus = getFocus(node); + KeyboardApi.invoke(event.type, [focus, event as KeyDown & KeyUp & KeyRepeat]); + return { ok: true }; + } else { + return next(node, event); + } + }, + }); +} + +export const KeyboardApi = createApi('keyboard', { + keydown(node, event: KeyDown): void {}, + keyup(node, event: KeyUp): void {}, + keyrepeat(node, event: KeyRepeat): void {}, +}); + +function isKeyEvent(value: unknown): value is KeyEvent { + const x = value as KeyEvent; + + return ( + !!x && + typeof x.key === 'string' && + typeof x.code === 'string' && + ['keyup', 'keydown', 'keyrepeat'].includes(x.type) + ); +} diff --git a/vendor/clack-ui/src/layout.ts b/vendor/clack-ui/src/layout.ts new file mode 100644 index 0000000..2324328 --- /dev/null +++ b/vendor/clack-ui/src/layout.ts @@ -0,0 +1,10 @@ +import { createApi } from './core.ts'; +import { type Op } from '@bomb.sh/tty'; + +export const LayoutApi = createApi('layout', { + layout(_): Iterable { + return []; + }, +}); + +export const { layout } = LayoutApi.methods; diff --git a/vendor/clack-ui/src/render.ts b/vendor/clack-ui/src/render.ts new file mode 100644 index 0000000..aaa09e5 --- /dev/null +++ b/vendor/clack-ui/src/render.ts @@ -0,0 +1,14 @@ +import type { WriteStream } from 'node:tty'; +import type { Op, RenderResult, Term } from '@bomb.sh/tty'; +import { createApi } from './core.ts'; + +export const RenderApi = createApi('render', { + requestRender(_): void {}, + render(_node, output: WriteStream, term: Term, ops: Op[]): RenderResult { + const result = term.render(ops); + output.write(result.output); + return result; + }, +}); + +export const { requestRender } = RenderApi.methods; diff --git a/vendor/clack-ui/src/ui.ts b/vendor/clack-ui/src/ui.ts new file mode 100644 index 0000000..2053ddb --- /dev/null +++ b/vendor/clack-ui/src/ui.ts @@ -0,0 +1,181 @@ +import type { ReadStream, WriteStream } from 'node:tty'; +import { + alternateBuffer, + createTerm, + CSI, + cursor, + settings, + type Setting, + type Term, +} from '@bomb.sh/tty'; +import { DispatchApi, useDispatch } from './dispatch.ts'; +import { useBoxElement } from './elements/box.ts'; +import { useButtonElement } from './elements/button.ts'; +import { useDialogElement } from './elements/dialog.ts'; +import { useFormElement } from './elements/form.ts'; +import { useInputElement } from './elements/input.ts'; +import { useTextElement } from './elements/text.ts'; +import { useFocusNavigation } from './focus-navigation.ts'; +import { useFocus } from './focus.ts'; +import { useHostRenderer } from './host-render.ts'; +import { createHost, type Host } from './host.ts'; +import { useKeyboard } from './keyboard.ts'; +import { loadDeclaredExtensions, registeredUIExtensions } from './extensions.ts'; +import { layout } from './layout.ts'; +import { RenderApi } from './render.ts'; +import type { UIExtension } from './extensions.ts'; +import { createInputLoop } from './input-loop.ts'; + +export interface UIOptions { + height?: number; + inline?: boolean; + width?: number; + input: ReadStream; + output: WriteStream; + extensions?: UIExtension[]; +} + +export interface UI extends AsyncDisposable { + host: Host; + main(): Promise; +} + +export async function createUI(options: UIOptions): Promise { + const { output } = options; + const { inline = false } = options; + const surfaceAt = () => ({ + width: options.width || output.columns || 80, + height: options.height || output.rows || 24, + }); + // Live dimensions: tracked with `let` so the resize handler can update them. + let { width, height } = surfaceAt(); + let term = await createTerm({ width, height }); + + const host = createHost(); + + useDispatch(host); + useFocus(host); + useKeyboard(host); + useFocusNavigation(host); + useBoxElement(host); + useDialogElement(host); + useButtonElement(host); + useFormElement(host); + useTextElement(host); + useInputElement(host); + useHostRenderer(host); + + const nextLine: Setting = { + apply: new Uint8Array(), + revert: new TextEncoder().encode('\r\n'), + }; + const setup = inline + ? settings(cursor(false), nextLine) + : settings(cursor(false), alternateBuffer()); + + let stopped = false; + let firstRender = true; + + // Deliberately naive: every request immediately lays out and renders the entire Host tree. + RenderApi.around(host.root, { + requestRender([node], next) { + if (!stopped) { + next(node); + RenderApi.methods.render(node, output, term, [...layout(host.root)]); + } + }, + // Renderer strategy as middleware. Inline mode repositions the cursor and + // paints in line mode via a term facade; fullscreen delegates to the core. + render([_node, innerOutput, innerTerm, ops], next) { + if (inline && !firstRender) { + if (height > 1) { + innerOutput.write(CSI(`${height - 1}A`)); + } + innerOutput.write(CSI('1G')); + } + const lineMode: Term = { render: (o) => innerTerm.render(o, { mode: 'line' }) }; + const result = next(_node, innerOutput, inline ? lineMode : innerTerm, ops); + firstRender = false; + return result; + }, + }); + + // Extensions run after createUI's own middleware, so they resolve inner in + // the chain. Explicit first, then package.json declarations, then the + // out-of-band registry (later-installed = deeper). + const extensions: UIExtension[] = [ + ...(options.extensions ?? []), + ...(await loadDeclaredExtensions(process.cwd())), + ...registeredUIExtensions(), + ]; + const extensionContext = { + host, + input: options.input, + output, + get width() { + return width; + }, + get height() { + return height; + }, + inline, + }; + for (const extension of extensions) { + extension(extensionContext); + } + + output.write(setup.apply); + + // Resize: the terminal owns the truth about its size. Re-create the term at + // the new dimensions and re-render the whole tree. createTerm is async, so + // rapid resizes race; only the newest term may win the swap. + let resizeToken = 0; + const onResize = () => { + ({ width, height } = surfaceAt()); + const token = ++resizeToken; + void createTerm({ width, height }).then((next) => { + if (token !== resizeToken) return; + term = next; + RenderApi.methods.requestRender(host.root); + }); + }; + if (typeof output.on === 'function') { + output.on('resize', onResize); + } + + return { + host, + async [Symbol.asyncDispose]() { + stopped = true; + if (typeof output.off === 'function') output.off('resize', onResize); + output.write(setup.revert); + }, + + async main(): Promise { + const controller = new AbortController(); + const { input: stdin } = options; + try { + stdin.setRawMode(true); + stdin.resume(); + + input: for await (const events of createInputLoop({ + stdin, + signal: controller.signal, + })) { + for (const event of events) { + if (event.type === 'keydown' && event.ctrl === true && event.code === 'c') { + break input; + } + + const dispatched = DispatchApi.methods.dispatch(host.root, event); + if (!dispatched.ok) throw dispatched.reason; + } + } + } finally { + controller.abort(); + stdin.setRawMode(false); + stdin.pause(); + } + }, + }; +} diff --git a/vendor/clack-ui/tsconfig.json b/vendor/clack-ui/tsconfig.json new file mode 100644 index 0000000..4dc9d44 --- /dev/null +++ b/vendor/clack-ui/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "@bomb.sh/tools/tsconfig.json", + "compilerOptions": { + "paths": { + "@clack/ui/elements": ["./src/elements.ts"], + "@clack/ui/events": ["./src/events.ts"] + }, + "types": ["node"] + } +}