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
6 changes: 5 additions & 1 deletion examples/basic/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,13 @@
"private": true,
"type": "module",
"scripts": {
"start": "node src/index.ts"
"start": "tsx src/index.ts"
},
"dependencies": {
"@clack/ui": "workspace:*"
},
"devDependencies": {
"@types/node": "^22.20.0",
"tsx": "^4.19.0"
}
}
22 changes: 9 additions & 13 deletions examples/basic/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
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';
import { type BoxProps, createUI, rgba, type TextProps, type HostElement } from '@clack/ui';

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);

const host = createHost();
useBoxElement(host);
useTextElement(host);
await using ui = await createUI({
height: 7,
inline: true,
input: process.stdin,
output: process.stdout,
});

const { host } = ui;

const app = box(
{
Expand All @@ -31,10 +31,6 @@ const app = box(

host.insertBefore(host.element, app);

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);
Expand Down
6 changes: 5 additions & 1 deletion examples/basic/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
{
"extends": "@bomb.sh/tools/tsconfig.json",
"compilerOptions": {
"noEmit": true
"composite": false,
"declaration": false,
"declarationMap": false,
"noEmit": true,
"types": ["node"]
}
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"devDependencies": {
"@bomb.sh/tools": "^0.6.1",
"@types/node": "^22.20.0",
"@typescript/native-preview": "7.0.0-dev.20260623.1",
"vitest": "^4.1.9"
},
"devEngines": {
Expand Down
49 changes: 0 additions & 49 deletions packages/ui/src/children.test.ts

This file was deleted.

45 changes: 0 additions & 45 deletions packages/ui/src/children.ts

This file was deleted.

15 changes: 0 additions & 15 deletions packages/ui/src/components/box.ts

This file was deleted.

15 changes: 0 additions & 15 deletions packages/ui/src/components/text.ts

This file was deleted.

10 changes: 7 additions & 3 deletions packages/ui/src/event-listener.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@ import { describe, expect, expectTypeOf, test } from 'vitest';
import type { Node } from './core.ts';
import type { HostElement } from './elements.ts';
import { createHost, type Host, HostApi } from './host.ts';
import type { AnyHostEvent } from './events.ts';
import type { HostEventData } from './events.ts';

interface TestInputEvent extends HostEvent<'@test/input'> {
readonly value?: string;
}

interface ProbeEvent extends HostEvent<'@test/probe'> {
readonly value: number;
Expand All @@ -12,7 +16,7 @@ interface ProbeEvent extends HostEvent<'@test/probe'> {
declare module '@clack/ui/events' {
interface HostEvents {
'@test/focus': HostEvent<'@test/focus'>;
'@test/input': HostEvent<'@test/input'>;
'@test/input': TestInputEvent;
'@test/probe': ProbeEvent;
}
}
Expand Down Expand Up @@ -176,6 +180,6 @@ function snapshot(event: HostEvent, target: HostElement): EventSnapshot {
};
}

function emit(node: Node, event: AnyHostEvent): void {
function emit(node: Node, event: HostEventData): void {
HostApi.at(node).emit(event);
}
10 changes: 6 additions & 4 deletions packages/ui/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
export { rgba } from '@bomb.sh/tty';
export { DispatchApi } from './dispatch.ts';
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';
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 { RenderApi } from './render.ts';
export { createUI } from './ui.ts';
150 changes: 150 additions & 0 deletions packages/ui/src/input-loop.ts
Original file line number Diff line number Diff line change
@@ -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<InputEvent[]> {
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<IteratorResult<any[]>>,
pending: Pending,
): Promise<IteratorResult<ReadEvent>> {
let timeoutId: NodeJS.Timeout | undefined = undefined;
const timeout = pending
? new Promise<IteratorResult<ReadEvent>>((resolve) => {
timeoutId = setTimeout(
() => resolve({ done: false, value: { type: 'timeout' } }),
pending.delay,
);
})
: new Promise<IteratorResult<ReadEvent>>(() => {});

const data: Promise<IteratorResult<ReadEvent>> = read.then((item) => {
if (item.done) {
return { done: true } as IteratorResult<ReadEvent>;
} else {
const [data] = item.value;
return {
done: false,
value: { type: 'data', data },
} as IteratorResult<ReadEvent>;
}
});

try {
return await Promise.race([data, timeout]);
} finally {
clearTimeout(timeoutId);
}
}

type Pending = ScanResult['pending'];

type ReadEvent = DataEvent | TimeoutEvent;

type DataEvent = {
type: 'data';
data: Buffer<ArrayBuffer>;
};

type TimeoutEvent = {
type: 'timeout';
};

type Abortable<T> =
| {
type: 'aborted';
}
| {
type: 'resolved';
value: T;
};

async function abortable<T>(signal: AbortSignal, op: Promise<T>): Promise<Abortable<T>> {
if (signal.aborted) {
return { type: 'aborted' };
}
let listener = () => {};
try {
return await Promise.race([
op.then((value: T) => ({ type: 'resolved', value }) as Abortable<T>),
new Promise((resolve) => {
signal.addEventListener('abort', (listener = () => resolve({ type: 'aborted' })));
}),
] as Promise<Abortable<T>>[]);
} finally {
if (listener) {
signal.removeEventListener('abort', listener);
}
}
}
Loading
Loading