diff --git a/.gitignore b/.gitignore index f8285e5..31b6f2c 100644 --- a/.gitignore +++ b/.gitignore @@ -68,3 +68,4 @@ jspm_packages/ # Stores VSCode versions used for testing VSCode extensions .vscode-test +/.agent-shell/ diff --git a/package.json b/package.json index 7ac8795..2a7a3d2 100644 --- a/package.json +++ b/package.json @@ -24,10 +24,12 @@ "build": "bsh build", "format": "bsh format", "lint": "bsh lint", - "test": "bsh test" + "test": "bsh test", + "demo": "node scripts/demo.mjs" }, "devDependencies": { - "@bomb.sh/tools": "latest" + "@bomb.sh/tools": "latest", + "typescript": "^6.0.3" }, "publishConfig": { "access": "public" diff --git a/packages/demo/package.json b/packages/demo/package.json index eecc05f..0609031 100644 --- a/packages/demo/package.json +++ b/packages/demo/package.json @@ -23,8 +23,11 @@ "test": "bsh test" }, "dependencies": { - "@bomb.sh/tty": "latest", - "@types/node": "^26.0.0" + "@bomb.sh/freedom": "workspace:*", + "@bomb.sh/input": "workspace:^", + "@bomb.sh/tty": "https://pkg.pr.new/@bomb.sh/tty@103", + "@types/node": "^26.0.0", + "effection": "4.1.0-alpha.9" }, "devDependencies": { "@bomb.sh/tools": "latest" diff --git a/packages/demo/src/freedom-focus-text-input.ts b/packages/demo/src/freedom-focus-text-input.ts new file mode 100644 index 0000000..0ac14de --- /dev/null +++ b/packages/demo/src/freedom-focus-text-input.ts @@ -0,0 +1,231 @@ +// oxlint-disable bombshell-dev/no-generic-error +import { each, ensure, main, spawn, until } from "effection"; +import { + advance, + createNodeData, + createRoot, + type Node, + retreat, + useFocus, +} from "@bomb.sh/freedom"; +import { + initInput, + KeyboardApi, + useInput, + useReadlineKeymap, +} from "@bomb.sh/input"; +import { + alternateBuffer, + close, + createTerm, + cursor, + fit, + grow, + type Op, + open, + percent, + rgba, + settings, + text, +} from "@bomb.sh/tty"; +import { stdin, stdout } from "node:process"; +import { useInput as decodeBytes } from "./use-input.ts"; +import { useStdin } from "./use-stdin.ts"; + +const GRAY = rgba(100, 100, 100); + +interface LayoutOptions { + node: Node; + children: Iterable; +} + +const layoutKey = createNodeData<(options: LayoutOptions) => Op[]>( + "demo:layout", + () => [], +); + +function layout(node: Node, body: (options: LayoutOptions) => Op[]): void { + node.data.set(layoutKey, body); +} + +// A bordered text input. Editing, caret, and focus state come from +// `@bomb.sh/input` (`makeInput`); the demo owns only how it's drawn. The focused +// input passes its `caret` (a code-point offset) to `text()`, so tty positions +// the terminal's native cursor there rather than drawing a glyph. +function textInput(node: Node): void { + initInput(node); + layout(node, () => { + const focused = node.props.focused; + const value = String(node.props.value ?? ""); + const caret = Math.min(Number(node.props.caret ?? 0), [...value].length); + const color = focused ? rgba(255, 255, 255) : GRAY; + const border = { color, top: 1, right: 1, bottom: 1, left: 1 }; + return [ + open(node.id, { + border, + layout: { + height: fit(3), + width: percent(0.3), + padding: { top: 1, right: 1, bottom: 1, left: 1 }, + }, + }), + // An empty focused field has no cell for the native cursor, so render a + // single space to give the caret at 0 somewhere to sit. + focused ? text(value || " ", { caret }) : text(value), + close(), + ]; + }); +} + +function screenBody({ node, children }: LayoutOptions): Op[] { + return [ + open(node.id, { + layout: { + height: grow(), + width: grow(), + direction: "ttb", + padding: { top: 1, right: 1, bottom: 1, left: 1 }, + }, + border: { + color: rgba(255, 255, 255), + top: 1, + right: 1, + bottom: 1, + left: 1, + }, + }), + ...children, + close(), + ]; +} + +function containerBody({ node, children }: LayoutOptions): Op[] { + return [ + open(node.id, { + border: { color: 0xFFF, top: 1, right: 1, bottom: 1, left: 1 }, + layout: { + height: fit(), + width: grow(), + direction: "ttb", + padding: { top: 1, right: 1, bottom: 1, left: 1 }, + }, + }), + ...children, + close(), + ]; +} + +await main(function* () { + if (!stdin.isTTY) { + throw new Error("freedom demo requires an interactive TTY"); + } + + const root = createRoot(); + + // Route keyboard events to the focused input's editing behavior. + useInput(root); + useReadlineKeymap(root.node); + + // Tab/Backtab move focus between inputs. Installed at the root scope so it + // wraps the focused input's editing behavior (root is an ancestor of where + // `KeyboardApi` is invoked): Tab/Backtab are consumed here, every other key + // falls through via `next` to the input. + root.node.scope.around(KeyboardApi, { + keydown([node, event], next) { + if (event.code === "Tab") { + advance(root.node); + } else if (event.code === "Backtab") { + retreat(root.node); + } else { + next(node, event); + } + }, + keyrepeat([node, event], next) { + if (event.code === "Tab") { + advance(root.node); + } else if (event.code === "Backtab") { + retreat(root.node); + } else { + next(node, event); + } + }, + }); + + layout(root.node, screenBody); + + const container = root.node.createChild("input-1"); + layout(container, containerBody); + + textInput(container.createChild("input-1-1")); + textInput(container.createChild("input-1-2")); + textInput(root.node.createChild("input-2")); + + useFocus(root.node); // seed focus now that focusable inputs exist (input-1-1) + + const { columns, rows } = stdout.isTTY + ? { columns: stdout.columns, rows: stdout.rows } + : { columns: 80, rows: 24 }; + + stdin.setRawMode(true); + yield* ensure(() => { + stdin.setRawMode(false); + stdin.pause(); + }); + + const bytes = yield* useStdin(); + const stream = decodeBytes(bytes); + + let term = yield* until(createTerm({ height: rows, width: columns })); + + const events = yield* spawn(function* () { + for (const event of yield* each(stream)) { + if (event.type === "keydown" && event.ctrl && event.code === "c") { + break; + } + if (event.type === "resize") { + term = yield* until(createTerm({ + height: event.height, + width: event.width, + })); + render(); + } + + root.dispatch(event); + + yield* each.next(); + } + }); + + function render(): void { + const ops = walk(root.node); + const { output } = term.render(ops); + stdout.write(output); + } + + const tty = settings(cursor(true), alternateBuffer()); + + try { + stdout.write(tty.apply); + + render(); + yield* spawn(function* () { + for (const _ of yield* each(root)) { + render(); + yield* each.next(); + } + }); + + yield* events; + } finally { + stdout.write(tty.revert); + } +}); + +function walk(node: Node): Op[] { + const children: Op[] = []; + for (const child of node.children) { + children.push(...walk(child)); + } + const body = node.data.get(layoutKey); + return body ? body({ node, children }) : children; +} diff --git a/packages/demo/src/pizza.ts b/packages/demo/src/pizza.ts new file mode 100644 index 0000000..c0c3b9e --- /dev/null +++ b/packages/demo/src/pizza.ts @@ -0,0 +1,322 @@ +// oxlint-disable bombshell-dev/no-generic-error +import { each, ensure, main, spawn, until } from "effection"; +import { + advance, + createNodeData, + createRoot, + focusable, + focusPush, + type Node, + retreat, + type Root, + useFocus, +} from "@bomb.sh/freedom"; +import { + initInput, + KeyboardApi, + useInput as installInput, + useReadlineKeymap, +} from "@bomb.sh/input"; +import { + alternateBuffer, + close, + createTerm, + fit, + grow, + type KeyEvent, + type Op, + open, + rgba, + settings, + text, +} from "@bomb.sh/tty"; +import { stdin, stdout } from "node:process"; +import { pathToFileURL } from "node:url"; +import { useInput } from "./use-input.ts"; +import { useStdin } from "./use-stdin.ts"; + +const WHITE = rgba(255, 255, 255); // all text, and the focused border +const GRAY = rgba(100, 100, 100); // unfocused border only + +interface LayoutOptions { + node: Node; + children: Iterable; +} + +const layoutKey = createNodeData<(options: LayoutOptions) => Op[]>( + "demo:layout", + () => [], +); + +function layout(node: Node, body: (options: LayoutOptions) => Op[]): void { + node.data.set(layoutKey, body); +} + +// The centering screen: holds the form box in the middle of the terminal. +function screenBody({ node, children }: LayoutOptions): Op[] { + return [ + open(node.id, { + layout: { width: grow(), height: grow(), alignX: "center", alignY: "center" }, + }), + ...children, + close(), + ]; +} + +// The titled, bordered form panel. +function formBody({ node, children }: LayoutOptions): Op[] { + return [ + open(node.id, { + border: { color: WHITE, top: 1, right: 1, bottom: 1, left: 1 }, + layout: { + direction: "ttb", + width: fit(48), + height: fit(), + padding: { top: 1, right: 1, bottom: 1, left: 1 }, + gap: 1, + }, + }), + text("Pizza Delivery", { color: WHITE }), + ...children, + close(), + ]; +} + +// A labelled text input. Readline editing is provided by @bomb.sh/input; the +// focused field renders a native cursor via the value text's `caret`. +function makeField(node: Node, label: string): void { + initInput(node); // focusable + input:true + value:"" + caret:0 + layout(node, () => { + const focused = node.props.focused === true; + const border = focused ? WHITE : GRAY; + return [ + open(`${node.id}-field`, { + layout: { direction: "ttb", width: grow(), padding: { left: 1, right: 1 } }, + }), + text(label, { color: WHITE }), + open(node.id, { + border: { color: border, top: 1, right: 1, bottom: 1, left: 1 }, + layout: { + width: grow(), + height: fit(3), + padding: { top: 1, right: 1, bottom: 1, left: 1 }, + }, + }), + text(String(node.props.value ?? ""), { + color: WHITE, + caret: focused ? Number(node.props.caret ?? 0) : undefined, + }), + close(), + close(), + ]; + }); +} + +// A single-line activatable control (link/button). Focused shows `› text ‹` +// over a background that eases in. +function makeControl(node: Node, label: string): void { + focusable(node); + node.set("label", label); + layout(node, () => { + const focused = node.props.focused === true; + const caption = String(node.props.label ?? ""); + // Reserve the caret columns in both states so the label never shifts. + const content = focused ? `› ${caption} ‹` : ` ${caption} `; + return [ + open(node.id, { + layout: { width: grow(), padding: { left: 1, right: 1 } }, + }), + text(content, { color: WHITE }), + close(), + ]; + }); +} + +// Fire `onActivate` when the focused control receives Enter or Space; other +// keys bubble so Tab/Backtab still navigate. +function activatable(node: Node, onActivate: () => void): void { + node.scope.around(KeyboardApi, { + keydown([n, event], next) { + if (event.code === "Enter" || event.code === " ") { + onActivate(); + } else { + next(n, event); + } + }, + }); +} + +// The credit-card modal: a floating panel centered over (and occluding) the +// form, on top via zIndex. Its own focus root traps Tab/Backtab (§12). +function cardModalBody({ node, children }: LayoutOptions): Op[] { + return [ + open(node.id, { + border: { color: WHITE, top: 1, right: 1, bottom: 1, left: 1 }, + bg: rgba(0, 0, 0), + floating: { + attachTo: "root", + attachPoints: { element: "center-center", parent: "center-center" }, + zIndex: 10, + }, + layout: { + direction: "ttb", + width: fit(40), + height: fit(), + padding: { top: 1, right: 1, bottom: 1, left: 1 }, + gap: 1, + }, + }), + text("Card details", { color: WHITE }), + ...children, + close(), + ]; +} + +function isConfirmed(value: unknown): value is { last4: string } { + return !!value && typeof value === "object" && "last4" in value; +} + +// Open the card modal: build its subtree, push it as the focus root, and wire +// Cancel/Confirm to pop with a result the push callback acts on. +function openCardModal(root: Root, card: Node): void { + const modal = root.node.createChild("card-modal"); + layout(modal, cardModalBody); + const number = modal.createChild("card-number"); + makeField(number, "Card number"); + makeField(modal.createChild("expiry"), "Expiry"); + makeField(modal.createChild("cvc"), "CVC"); + const cancel = modal.createChild("cancel"); + makeControl(cancel, "Cancel"); + const confirm = modal.createChild("confirm"); + makeControl(confirm, "Confirm"); + + const pop = focusPush(modal, (value) => { + if (isConfirmed(value)) { + card.set("label", `Edit card •••• ${value.last4}`); + } + void modal.remove(); // focus already restored to the card link + }); + + activatable(cancel, () => pop({ cancelled: true })); + activatable(confirm, () => { + const digits = String(number.props.value ?? "").replace(/\D/g, ""); + pop({ last4: digits.slice(-4) || "????" }); + }); +} + +// Build the pizza form's node tree: a centered panel holding two text fields +// and two activatable controls, with Tab/Backtab focus navigation installed. +export function buildPizza(): Root { + const root = createRoot(); + + // Demux + readline editing (insert-at-caret, Backspace/Delete, arrows, + // Home/End) from @bomb.sh/input, plus its emacs Ctrl-A/E/F/B/D keymap. + installInput(root); + useReadlineKeymap(root.node); + + // Tab/Backtab navigation, bubbled up from nodes that don't consume the key. + function tab([node, event]: [Node, KeyEvent], next: (node: Node, event: KeyEvent) => void): void { + if (event.code === "Tab") { + advance(root.node); + } else if (event.code === "Backtab") { + retreat(root.node); + } else { + next(node, event); + } + } + root.node.scope.around(KeyboardApi, { keydown: tab }); + + layout(root.node, screenBody); + + const panel = root.node.createChild("form"); + layout(panel, formBody); + + makeField(panel.createChild("name"), "Name"); + makeField(panel.createChild("address"), "Address"); + const card = panel.createChild("card"); + makeControl(card, "Add card"); + activatable(card, () => openCardModal(root, card)); + makeControl(panel.createChild("submit"), "Submit"); + + useFocus(root.node); // seed focus now that focusable controls exist (name) + + return root; +} + +export function walk(node: Node): Op[] { + const children: Op[] = []; + for (const child of node.children) { + children.push(...walk(child)); + } + const body = node.data.get(layoutKey); + return body ? body({ node, children }) : children; +} + +function* run() { + if (!stdin.isTTY) { + throw new Error("pizza demo requires an interactive TTY"); + } + + const root = buildPizza(); + + const { columns, rows } = stdout.isTTY + ? { columns: stdout.columns, rows: stdout.rows } + : { columns: 80, rows: 24 }; + + stdin.setRawMode(true); + yield* ensure(() => { + stdin.setRawMode(false); + stdin.pause(); + }); + + const bytes = yield* useStdin(); + const input = useInput(bytes); + + let term = yield* until(createTerm({ height: rows, width: columns })); + + function render(): void { + const { output } = term.render(walk(root.node), { deltaTime: 0 }); + if (output.length > 0) { + stdout.write(output); + } + } + + const tty = settings(alternateBuffer()); // native cursor shown via text carets + + try { + stdout.write(tty.apply); + + // Event-driven: paint once, then only when the tree changes. Rendering + // every frame would re-emit the cursor position each tick and reset the + // terminal's blink timer, so an idle cursor would never blink. + render(); + yield* spawn(function* () { + for (const _ of yield* each(root)) { + render(); + yield* each.next(); + } + }); + + for (const event of yield* each(input)) { + if (event.type === "keydown" && event.ctrl && event.code === "c") { + break; + } + if (event.type === "resize") { + term = yield* until(createTerm({ + height: event.height, + width: event.width, + })); + render(); + } + root.dispatch(event); + yield* each.next(); + } + } finally { + stdout.write(tty.revert); + } +} + +// Run the IO loop only when executed directly, not when imported for testing. +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + await main(run); +} diff --git a/packages/demo/src/use-input.ts b/packages/demo/src/use-input.ts new file mode 100644 index 0000000..2606c56 --- /dev/null +++ b/packages/demo/src/use-input.ts @@ -0,0 +1,70 @@ +import { + call, + createChannel, + each, + type Operation, + race, + resource, + sleep, + spawn, + type Stream, + suspend, + until, +} from "effection"; +import { + createInput, + type InputEvent, + type InputOptions, +} from "@bomb.sh/tty"; + +function nothing() { + return suspend() as unknown as Operation< + IteratorResult + >; +} + +// Parse a raw byte Stream into a Stream of decoded terminal InputEvents. +export function useInput( + stream: Stream, + options?: InputOptions, +): Stream { + return resource(function* (provide) { + const input = yield* until(createInput(options)); + const subscription = yield* stream; + + let pending = nothing(); + + const events = createChannel(); + + yield* spawn(function* () { + let next = yield* subscription.next(); + while (!next.done) { + const result = input.scan(next.value); + pending = result.pending ? rescan(result.pending.delay) : nothing(); + for (const event of result.events) { + yield* events.send(event); + } + next = yield* race([subscription.next(), pending]); + } + yield* events.close(); + }); + + yield* race([provide(yield* events), drain(events)]); + }); +} + +function rescan(delay: number): ReturnType { + return call(function* (): Operation> { + yield* sleep(delay); + return { + done: false, + value: new Uint8Array(), + }; + }); +} + +function* drain(stream: Stream): Operation { + for (const _ of yield* each(stream)) { + yield* each.next(); + } +} diff --git a/packages/demo/src/use-stdin.ts b/packages/demo/src/use-stdin.ts new file mode 100644 index 0000000..eb2c9ca --- /dev/null +++ b/packages/demo/src/use-stdin.ts @@ -0,0 +1,37 @@ +import { + createChannel, + each, + type Operation, + race, + resource, + spawn, + type Stream, + until, +} from "effection"; +import { stdin } from "node:process"; + +// Bridge Node's process.stdin (raw bytes) into an Effection Stream. +export function useStdin(): Operation> { + return resource(function* (provide) { + const channel = createChannel(); + + const iterator = stdin[Symbol.asyncIterator](); + + yield* spawn(function* () { + let next = yield* until(iterator.next()); + while (!next.done) { + yield* channel.send(next.value); + next = yield* until(iterator.next()); + } + yield* channel.close(); + }); + + yield* race([provide(channel), drain(channel)]); + }); +} + +function* drain(stream: Stream): Operation { + for (const _ of yield* each(stream)) { + yield* each.next(); + } +} diff --git a/packages/freedom-react/examples/counter.tsx b/packages/freedom-react/examples/counter.tsx new file mode 100644 index 0000000..0a85807 --- /dev/null +++ b/packages/freedom-react/examples/counter.tsx @@ -0,0 +1,247 @@ +// oxlint-disable bombshell-dev/no-generic-error +// A counter rendered through the freedom-react reconciler to @bomb.sh/tty. +// +// Run it: pnpm --filter @bomb.sh/freedom-react counter +// +// JSX (//