This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

TUI framework: widgets tier, focus manager, mouse support, playground demo

Nathan Herald (Apr 21, 2026, 5:47 PM +0200) d357897a 429df30a

+7150 -33
+13
CHANGELOG.md
··· 8 8 - Add integration coverage for both installers: a real `systemctl --user` install/uninstall test and a runit install test that boots the generated service under a private `runsvdir`. 9 9 10 10 ### TUI framework 11 + - **Focus manager.** `createFocusManager()` + `ctx.focus` on every `ScreenContext`. Stack-based: `push(scope)` returns a disposer, `dispatchKey` / `dispatchMouse` walk innermost → outermost with bubbling (return `false` to let the parent scope try). Scopes can be conditionally active via an `active: () => boolean` predicate — useful for keeping sibling scopes alive (like one per pane) and switching which one dispatches without pushing/popping. Solves the "nested panes / modals / overlays" routing problem that every app was re-implementing by hand. The playground now uses it: global pane-switch chords via `AppConfig.onKey` (they intercept before any scope so modals can't swallow them); pane scopes in the focus stack with `active: () => pane.get() === ...`. Modal overlays just push a scope on top and dispose it when closed. `FocusManager`, `FocusScope` exported from `@myobie/pty/tui`. 12 + - **Mouse support.** Set `AppConfig.mouse: true` to enable SGR mouse reporting. Screens get an optional `handleMouse(event, ctx)` alongside `handleKey`. `MouseEvent` carries `action` (`press` / `release` / `drag` / `move` / `scrollUp` / `scrollDown`), `button` (left/middle/right/none), 0-based `x`/`y`, and modifier flags. `hitTest(roots, x, y)` walks the rendered tree by `_rect` to find the deepest node under a point; `findInPath` locates a typed ancestor. Widgets that benefit from mouse — `virtual-list` (click + scroll), `stream-view` (scroll), `tabs` (click to select) — gained `handleVirtualMouse` / `handleStreamMouse` / `handleTabsMouse` helpers. `parseInput(buf)` returns the unified `InputEvent` stream; the legacy `parseKey(buf)` filters it to keyboard-only so existing consumers are unaffected. 13 + - **Cursor rendering via `inverse`.** `text()` gained optional `inverse` and `background` style flags. Text widgets (`renderFieldText` / `renderTextArea`) render the cursor by painting the character under it with fg/bg swapped instead of inserting a block glyph that shifts neighbors sideways. New `renderFieldNodes(text, cursor, active, opts)` returns the three text nodes (before / inverse / after) for composing custom fields. 14 + - **Multi-line prompt bar.** `promptBar({ kind: "multi", state })` now renders each line of the `TextAreaState` as its own row within its column, so newlines actually show. Prompt glyph appears only on the first line; continuation rows are indented. 15 + - **Word motion in text fields.** `applyTextKey` / `applyTextAreaKey` respect `alt+left`/`alt+right` and `alt+b`/`alt+f` for word-boundary navigation. The input parser learned modified-arrow sequences (`ESC[1;mods<letter>`) so `option+arrow` on macOS terminals routes correctly. 16 + - **Separator bg preserved inside panels.** `hSepBuf` used to zero-out the cell bg, showing the terminal default through as a grey band when separators sat inside a panel. Now it preserves the underlying cell bg. 17 + - **Breaking: `handleKey` return value no longer quits the app.** Screens that want to quit now call `ctx.quit()` explicitly (`ScreenContext.quit`). The old "return `false` from `handleKey` → app exits" behavior was a footgun: any screen that forgot to return `true` on an unhandled key would accidentally terminate. The return value is now a hint for nested routing only; the framework ignores it. Also: `app()` now has a default ctrl+c handler that calls quit unless `AppConfig.onKey` intercepts the key first — most consumers duplicated this themselves and no longer need to. The interactive pty TUI and the playground demo have been migrated to the new pattern. 18 + - **New widgets tier.** `src/tui/widgets/` exposes higher-level building blocks on top of the core primitives: tree view (`tree.ts`), date picker (`date-picker.ts`), multi-field form with focus ring (`form.ts`), markdown renderer (`markdown.ts`), multi-line text composer (`text-area.ts`), virtualized list for large datasets (`virtual-list.ts`), sticky-bottom stream view for chat/logs (`stream-view.ts`), horizontal tabs (`tabs.ts`), confirm modal (`confirm.ts`), toast queue (`toast.ts`), fuzzy command palette (`command-palette.ts`), sortable table (`table.ts`), a help-overlay helper (`help-overlay.ts`), Claude-Code-style prompt bar (`prompt-bar.ts`), toolbar (`toolbar.ts`), sparkline (`sparkline.ts`), and bar chart (`bar-chart.ts`). All state-first: the consumer owns the signals, widgets are pure key-dispatch + pure render. Re-exported from `@myobie/pty/tui`; existing primitives (text, row, column, panel, selectable, etc.) are unchanged. Full tests per widget. 19 + - **Signal-backed command registry** (`command-registry.ts`): `registerGlobalCommand` for app-lifetime commands and `useCommandScope(scopeId, commands)` for screen/focus-scoped commands. The existing `command-palette.ts` widget takes a `Command[]` — pass `allCommands.get()` to get the aggregated live view. Scopes compose (global + screen + focus), replace in one call (useful for contextual focused-item commands), and auto-unregister via returned disposers. Enables the "command palette represents every action across the app" pattern without every call site knowing about every command. 20 + - **Panel footer caption.** `panel(title, children, { footerTitle: "x" })` renders an optional caption on the bottom border with the same chrome as the top title. Mirrors mactop's "4/17 layout (skyblue) · -/+ 1000ms" strip. Back-compat: the third arg can still be a plain `BoxStyle` string. 11 21 - **Shift+Tab (backtab) now fires as a proper key event.** The raw-stdin parser in `src/tui/input.ts` previously dropped the legacy `ESC[Z` encoding as "unknown CSI" and ignored the shift modifier in the kitty keyboard protocol for code 9 (tab). Both paths now produce `{ name: "backtab", shift: true, ctrl: false, alt: false }`, so form widgets across apps can bind shift-tab for backward field navigation. Added `shift: boolean` to `KeyEvent` as a required field — existing consumers that only read ctrl/alt are unaffected. Surfaced while building the `reminders` app where the form widget needed backtab to move between fields. 22 + 23 + ### Demos 24 + - Add `demos/playground/` — an interactive catalog of every widget the TUI framework ships, grouped by category (atoms, layout, inputs, lists, data, overlays, patterns, kitchen-sink). Each demo has its own state, live interaction, and a source snippet. Run via `./demos/run playground`. Intended as reference docs + a shareable "here's what the framework does." 12 25 13 26 ### Fixes 14 27 - **Shift+Enter / kitty keyboard protocol inside supervised (launchd-started) sessions.** Children of a daemon started from a minimal env (launchd, systemd, sparse CI runners) previously had no `TERM` at all, which caused modern TUIs — Claude Code, vim, nvim — to fall back to legacy key encoding where Shift+Enter is indistinguishable from Enter. Two related fixes:
+1
README.md
··· 326 326 - **file-browser** — two-pane directory tree + file preview with soft-wrap and markdown highlighting 327 327 - **reminders** — full CRUD backed by `.md` files, three views (list, board, calendar), overlays 328 328 - **agent-teams** — live dashboard of a simulated AI agent hierarchy with real-time updates 329 + - **playground** — interactive catalog of every TUI widget — atoms, layout, inputs, lists, data, overlays, and composition patterns, each with a live example and source snippet. A reference for anyone building on the TUI framework. 329 330 330 331 Run them with `node --experimental-strip-types demos/{name}/main.ts` (or `./demos/run <name>`). Each demo includes unit tests and PTY integration tests that exercise the testing library. 331 332
+53
demos/playground/demos/atoms.ts
··· 1 + // "Atoms" — the smallest display primitives. 2 + 3 + import { 4 + text, row, column, dot, checkbox, progressBar, spinner, icon, 5 + separator, gap, indent, 6 + signal, 7 + } from "../../../src/tui/index.ts"; 8 + import type { Demo } from "../types.ts"; 9 + 10 + const progress = signal(30); 11 + const checked = signal(false); 12 + 13 + function atomsDemo(): Demo { 14 + return { 15 + id: "atoms", 16 + category: "atoms", 17 + name: "text / dot / checkbox / progress", 18 + blurb: "The smallest display primitives. Press +/- to change progress, space to toggle.", 19 + render() { 20 + return [ 21 + row(text(" plain ", "primary")), 22 + row(text(" accent ", "accent"), text(" bold ", "accent", { bold: true }), text(" dim", "muted", { dim: true })), 23 + row(text(" error ", "error"), text(" warn ", "warn"), text(" ok", "ok")), 24 + separator(), 25 + row(text(" dot: ", "muted"), dot(true, "accent"), dot(false, "muted")), 26 + row(text(" checkbox: ", "muted"), checkbox(checked.get(), "accent"), 27 + text(` ${checked.get() ? "checked" : "unchecked"}`, "primary")), 28 + row(text(" spinner: ", "muted"), spinner("accent"), 29 + text(" (animates when plugged into startSpinnerTimer)", "muted", { dim: true })), 30 + row(text(" icon: ", "muted"), icon("\u2605", "accent"), text(" star", "primary")), 31 + separator(), 32 + row(text(" progress ", "muted"), progressBar(progress.get() / 100, 30, "accent")), 33 + row(text(` value = ${progress.get()}%`, "muted", { dim: true })), 34 + ]; 35 + }, 36 + handleKey(key) { 37 + if (key.char === "+") { progress.set(Math.min(100, progress.peek() + 5)); return true; } 38 + if (key.char === "-") { progress.set(Math.max(0, progress.peek() - 5)); return true; } 39 + if (key.char === " ") { checked.set(!checked.peek()); return true; } 40 + return false; 41 + }, 42 + }; 43 + } 44 + 45 + const source = String.raw`import { text, dot, checkbox, progressBar, spinner } from "@myobie/pty/tui"; 46 + 47 + row(text("accent", "accent", { bold: true })) 48 + row(dot(true, "accent")) 49 + row(checkbox(true, "accent")) 50 + row(progressBar(0.3, 30, "accent")) 51 + row(spinner("accent"))`; 52 + 53 + export const atoms: Demo = { ...atomsDemo(), source };
+157
demos/playground/demos/bars.ts
··· 1 + // Prompt bar (Claude-Code style input) + toolbar demos. 2 + 3 + import { 4 + text, row, column, separator, 5 + signal, 6 + applyTextKey, type TextFieldState, 7 + createTextArea, applyTextAreaKey, type TextAreaState, 8 + promptBar, toolbar, toolbarItemFor, 9 + type ToolbarItem, 10 + } from "../../../src/tui/index.ts"; 11 + import type { Demo } from "../types.ts"; 12 + 13 + // --- promptBar single-line --- 14 + const single = signal<TextFieldState>({ text: "", cursor: 0 }); 15 + 16 + export const promptBarDemo: Demo = { 17 + id: "prompt-bar", 18 + category: "patterns", 19 + name: "prompt bar (single-line)", 20 + blurb: "Claude Code style: top/bottom rules, title overlay, optional status strip, prompt glyph.", 21 + render() { 22 + return [ 23 + row(text(" Full-width input with left-aligned title:", "muted", { dim: true })), 24 + promptBar( 25 + { kind: "single", state: single.get() }, 26 + { 27 + title: { text: "ask me anything", align: "left" }, 28 + status: { left: " ctrl+c to cancel", right: "\u23ce submit " }, 29 + }, 30 + ), 31 + row(text(" Center-aligned title, no status:", "muted", { dim: true })), 32 + promptBar( 33 + { kind: "single", state: single.get() }, 34 + { title: { text: "compose", align: "center" } }, 35 + ), 36 + row(text(" Right-aligned title, custom glyph ($):", "muted", { dim: true })), 37 + promptBar( 38 + { kind: "single", state: single.get() }, 39 + { 40 + glyph: "$", 41 + title: { text: "shell", align: "right" }, 42 + status: { right: "zsh · ~/code " }, 43 + }, 44 + ), 45 + ]; 46 + }, 47 + handleKey(key) { 48 + const next = applyTextKey(single.peek(), key); 49 + if (next) { single.set(next); return true; } 50 + return false; 51 + }, 52 + source: String.raw`const field = signal({ text: "", cursor: 0 }); 53 + // in render: 54 + promptBar( 55 + { kind: "single", state: field.get() }, 56 + { 57 + title: { text: "ask me anything", align: "left" }, 58 + status: { left: "ctrl+c to cancel", right: "⏎ submit" }, 59 + }, 60 + ) 61 + // in keys: 62 + const next = applyTextKey(field.peek(), key); 63 + if (next) field.set(next);`, 64 + }; 65 + 66 + // --- promptBar multi-line --- 67 + const multi = signal<TextAreaState>(createTextArea("type here — return for newline, ctrl+return to submit")); 68 + 69 + export const promptBarMultiDemo: Demo = { 70 + id: "prompt-bar-multi", 71 + category: "patterns", 72 + name: "prompt bar (multi-line)", 73 + blurb: "Same widget with a TextAreaState — shift+enter inserts a newline, ctrl+enter bubbles up.", 74 + render() { 75 + return [ 76 + promptBar( 77 + { kind: "multi", state: multi.get() }, 78 + { 79 + title: { text: "chat", align: "left" }, 80 + status: { left: " shift+\u23ce newline", right: "ctrl+\u23ce send " }, 81 + }, 82 + ), 83 + row(text(` lines: ${multi.get().lines.length} cursor: (${multi.get().row}, ${multi.get().col})`, "muted", { dim: true })), 84 + ]; 85 + }, 86 + handleKey(key) { 87 + const next = applyTextAreaKey(multi.peek(), key); 88 + if (next) { multi.set(next); return true; } 89 + return false; 90 + }, 91 + source: String.raw`const state = signal(createTextArea()); 92 + // in render: 93 + promptBar( 94 + { kind: "multi", state: state.get() }, 95 + { title: { text: "chat" }, status: { left: "shift+⏎ newline", right: "ctrl+⏎ send" } }, 96 + ) 97 + // in keys: 98 + const next = applyTextAreaKey(state.peek(), key); 99 + if (next) state.set(next);`, 100 + }; 101 + 102 + // --- toolbar --- 103 + const toolbarState = signal<string>("n"); 104 + 105 + const items: ToolbarItem[] = [ 106 + { key: "n", label: "ew" }, 107 + { key: "s", label: "ave" }, 108 + { key: "/", label: "Search", hint: "fuzzy" }, 109 + { key: "g", label: "it", hint: "sync" }, 110 + { key: "q", label: "uit" }, 111 + ]; 112 + 113 + export const toolbarDemo: Demo = { 114 + id: "toolbar", 115 + category: "patterns", 116 + name: "toolbar", 117 + blurb: "Horizontal action bar with hotkey highlights. Press a letter to activate; [?] marks disabled.", 118 + render() { 119 + const withActive = items.map(i => ({ ...i, active: i.key === toolbarState.get() })); 120 + return [ 121 + row(text(" bracket format (default):", "muted", { dim: true })), 122 + toolbar(withActive), 123 + row(text(" ", "muted")), 124 + row(text(" inline format (key highlighted inside the label):", "muted", { dim: true })), 125 + toolbar([ 126 + { key: "n", label: "new", active: toolbarState.get() === "n" }, 127 + { key: "s", label: "save", active: toolbarState.get() === "s" }, 128 + { key: "o", label: "open", active: toolbarState.get() === "o" }, 129 + { key: "q", label: "quit", active: toolbarState.get() === "q" }, 130 + ], { format: "inline" }), 131 + row(text(" ", "muted")), 132 + row(text(` active: ${toolbarState.get()}`, "muted")), 133 + ]; 134 + }, 135 + handleKey(key) { 136 + const match = toolbarItemFor(items, key.char); 137 + if (match) { toolbarState.set(match.key); return true; } 138 + const inlineKeys = ["n", "s", "o", "q"]; 139 + if (key.char && inlineKeys.includes(key.char)) { 140 + toolbarState.set(key.char); 141 + return true; 142 + } 143 + return false; 144 + }, 145 + source: String.raw`const items: ToolbarItem[] = [ 146 + { key: "n", label: "ew" }, 147 + { key: "s", label: "ave", active: true }, 148 + { key: "/", label: "Search", hint: "fuzzy" }, 149 + ]; 150 + // render: 151 + toolbar(items) // bracket format: [N]ew [S]ave [/]Search 152 + toolbar(items, { format: "inline" }) // for natural-language labels 153 + 154 + // in handleKey: 155 + const match = toolbarItemFor(items, key.char); 156 + if (match) /* run action */;`, 157 + };
+62
demos/playground/demos/data.ts
··· 1 + // Data: sortable table. 2 + 3 + import { 4 + text, row, separator, 5 + signal, 6 + createTableState, sortRows, handleTableKey, renderTable, 7 + type TableColumn, type TableState, 8 + } from "../../../src/tui/index.ts"; 9 + import type { Demo } from "../types.ts"; 10 + 11 + interface Person { name: string; role: string; joined: string; age: number } 12 + 13 + const people: Person[] = [ 14 + { name: "Alex", role: "eng", joined: "2022-03-01", age: 28 }, 15 + { name: "Bea", role: "design", joined: "2021-05-14", age: 34 }, 16 + { name: "Cam", role: "eng", joined: "2024-01-09", age: 25 }, 17 + { name: "Dan", role: "product", joined: "2019-11-20", age: 41 }, 18 + { name: "Erin", role: "eng", joined: "2023-07-03", age: 29 }, 19 + ]; 20 + 21 + const cols: TableColumn<Person>[] = [ 22 + { id: "name", header: "Name", render: p => p.name }, 23 + { id: "role", header: "Role", render: p => p.role }, 24 + { id: "joined", header: "Joined", render: p => p.joined }, 25 + { id: "age", header: "Age", render: p => String(p.age), getSortValue: p => p.age, align: "right" }, 26 + ]; 27 + 28 + const tableState = signal<TableState>(createTableState(cols, "name")); 29 + 30 + export const tableDemo: Demo = { 31 + id: "table", 32 + category: "data", 33 + name: "sortable table", 34 + blurb: "Press 1-4 to sort by column; press the same number again to flip direction.", 35 + render() { 36 + const s = tableState.get(); 37 + const sorted = sortRows(people, cols, s); 38 + return [ 39 + renderTable(sorted, cols, s), 40 + separator(), 41 + row(text(` sort: ${s.sortColumnId} ${s.sortDirection} selected: ${sorted[s.selectedIndex]?.name ?? ""}`, "muted", { dim: true })), 42 + ]; 43 + }, 44 + handleKey(key) { 45 + const s = tableState.peek(); 46 + const sorted = sortRows(people, cols, s); 47 + const r = handleTableKey(s, sorted, cols, key); 48 + tableState.set(r.state); 49 + return r.action !== "none"; 50 + }, 51 + source: String.raw`const cols: TableColumn<Row>[] = [ 52 + { id: "name", header: "Name", render: r => r.name }, 53 + { id: "age", header: "Age", render: r => String(r.age), getSortValue: r => r.age, align: "right" }, 54 + ]; 55 + const state = signal(createTableState(cols, "name")); 56 + // render: 57 + const sorted = sortRows(rows, cols, state.get()); 58 + renderTable(sorted, cols, state.get()); 59 + // keys: 60 + const r = handleTableKey(state.peek(), sorted, cols, key); 61 + state.set(r.state);`, 62 + };
+154
demos/playground/demos/inputs.ts
··· 1 + // Inputs: text-input (single line), text-area (multi line), form (focus ring), 2 + // date-picker. 3 + 4 + import { 5 + text, row, column, panel, separator, canvas, 6 + signal, 7 + applyTextKey, renderFieldText, 8 + createTextArea, applyTextAreaKey, renderTextArea, textAreaToString, 9 + createFormState, handleFormKey, renderFieldText as rft, 10 + datePickerFromDate, handleDatePickerKey, datePickerBody, 11 + type TextFieldState, type FormState, type DatePickerState, type TextAreaState, 12 + } from "../../../src/tui/index.ts"; 13 + import type { Demo } from "../types.ts"; 14 + 15 + // --- single-line text input --- 16 + const singleField = signal<TextFieldState>({ text: "", cursor: 0 }); 17 + 18 + export const textInputDemo: Demo = { 19 + id: "text-input", 20 + category: "inputs", 21 + name: "single-line input", 22 + blurb: "applyTextKey handles printable chars + arrows + home/end + ctrl+u.", 23 + render() { 24 + const s = singleField.get(); 25 + return [ 26 + row(text(" Type something: ", "muted")), 27 + row(text(" [ " + renderFieldText(s.text, s.cursor, true) + " ]", "primary")), 28 + separator(), 29 + row(text(` text = ${JSON.stringify(s.text)}`, "muted", { dim: true })), 30 + row(text(` cursor = ${s.cursor}`, "muted", { dim: true })), 31 + ]; 32 + }, 33 + handleKey(key) { 34 + const next = applyTextKey(singleField.peek(), key); 35 + if (next) { singleField.set(next); return true; } 36 + return false; 37 + }, 38 + source: String.raw`const field = signal({ text: "", cursor: 0 }); 39 + // in handleKey: 40 + const next = applyTextKey(field.peek(), key); 41 + if (next) field.set(next); 42 + // in render: 43 + row(text(renderFieldText(field.get().text, field.get().cursor, true)))`, 44 + }; 45 + 46 + // --- multi-line text area --- 47 + const composer = signal<TextAreaState>(createTextArea( 48 + "Write notes, logs, emails, chat messages...\n\nreturn for newline, ctrl+return to submit, esc to cancel." 49 + )); 50 + 51 + export const textAreaDemo: Demo = { 52 + id: "text-area", 53 + category: "inputs", 54 + name: "multi-line composer", 55 + blurb: "createTextArea + applyTextAreaKey. Return for newline; ctrl+return and esc return null so the caller can submit/cancel.", 56 + render() { 57 + return [ 58 + panel("compose", [renderTextArea(composer.get(), true)]), 59 + row(text(` ${composer.get().lines.length} lines, cursor (${composer.get().row}, ${composer.get().col})`, "muted", { dim: true })), 60 + ]; 61 + }, 62 + handleKey(key) { 63 + const next = applyTextAreaKey(composer.peek(), key); 64 + if (next) { composer.set(next); return true; } 65 + return false; 66 + }, 67 + source: String.raw`const state = signal(createTextArea()); 68 + // in render: 69 + panel("compose", [renderTextArea(state.get(), true)]) 70 + // in handleKey: 71 + const next = applyTextAreaKey(state.peek(), key); 72 + if (next) state.set(next); // null = ctrl+return / esc / tab — handle externally`, 73 + }; 74 + 75 + // --- form with focus ring --- 76 + type Field = "title" | "notes" | "due"; 77 + const formState = signal<FormState<Field>>(createFormState( 78 + ["title", "notes", "due"] as const, 79 + { title: "Buy milk", notes: "on the way home", due: "tomorrow 5pm" }, 80 + )); 81 + const formStatus = signal<string>("edit me — tab / shift-tab to change fields"); 82 + 83 + export const formDemo: Demo = { 84 + id: "form", 85 + category: "inputs", 86 + name: "form with focus ring", 87 + blurb: "Tab/backtab walk the fields; enter on the last is 'submit'; escape is 'cancel'.", 88 + render() { 89 + const f = formState.get(); 90 + const renderField = (id: Field, label: string) => { 91 + const field = f.values[id]; 92 + const active = f.focused === id; 93 + return row( 94 + text(` ${label.padEnd(8)}`, active ? "accent" : "muted", { bold: active }), 95 + text(rft(field.text, field.cursor, active), active ? "primary" : "secondary"), 96 + ); 97 + }; 98 + return [ 99 + renderField("title", "title"), 100 + renderField("notes", "notes"), 101 + renderField("due", "due"), 102 + separator(), 103 + row(text(` ${formStatus.get()}`, "muted", { dim: true })), 104 + ]; 105 + }, 106 + handleKey(key) { 107 + const r = handleFormKey(formState.peek(), key); 108 + formState.set(r.state); 109 + if (r.action === "submit") formStatus.set(`submitted: ${JSON.stringify(Object.fromEntries( 110 + Object.entries(r.state.values).map(([k, v]) => [k, v.text]) 111 + ))}`); 112 + else if (r.action === "cancel") formStatus.set("cancelled"); 113 + else if (r.action === "moved") formStatus.set(`focused: ${r.state.focused}`); 114 + else if (r.action === "edited") formStatus.set("editing..."); 115 + else if (r.action === "activate") formStatus.set(`enter on ${r.state.focused} — trigger a picker overlay here`); 116 + return r.action !== "none"; 117 + }, 118 + source: String.raw`const state = signal(createFormState( 119 + ["title", "notes", "due"] as const, 120 + { title: "", notes: "", due: "" }, 121 + )); 122 + // in handleKey: 123 + const r = handleFormKey(state.peek(), key); 124 + state.set(r.state); 125 + switch (r.action) { 126 + case "submit": /* save the form */ break; 127 + case "cancel": /* close the overlay */ break; 128 + case "activate": /* open a picker for this field */ break; 129 + }`, 130 + }; 131 + 132 + // --- date picker --- 133 + const picker = signal<DatePickerState>(datePickerFromDate(new Date())); 134 + 135 + export const datePickerDemo: Demo = { 136 + id: "date-picker", 137 + category: "inputs", 138 + name: "date picker", 139 + blurb: "Arrow keys navigate days; [ ] change month; h/H hour; m/M minute; selection highlighted.", 140 + render() { 141 + return datePickerBody(picker.get()); 142 + }, 143 + handleKey(key) { 144 + const next = handleDatePickerKey(picker.peek(), key); 145 + if (next) { picker.set(next); return true; } 146 + return false; 147 + }, 148 + source: String.raw`const picker = signal(datePickerFromDate(new Date())); 149 + // in render: 150 + return datePickerBody(picker.get()); 151 + // in handleKey: 152 + const next = handleDatePickerKey(picker.peek(), key); 153 + if (next) picker.set(next);`, 154 + };
+136
demos/playground/demos/kitchen-sink.ts
··· 1 + // Kitchen sink: a mini "reminders-ish" screen using many widgets at once. 2 + // Proves the library composes. 3 + 4 + import { 5 + text, row, column, panel, separator, spacer, checkbox, 6 + signal, 7 + createTabsState, handleTabsKey, renderTabs, 8 + createTreeState, flattenTree, handleTreeKey, treeGlyph, 9 + createTableState, sortRows, renderTable, handleTableKey, 10 + renderToasts, pushToast, pruneExpired, createToastQueue, 11 + type TabsState, type TabDef, type TreeState, type TreeNode, 12 + type TableColumn, type TableState, type ToastQueue, 13 + } from "../../../src/tui/index.ts"; 14 + import type { Demo } from "../types.ts"; 15 + 16 + type Item = { id: string; title: string; tag: string; due: string; done: boolean }; 17 + 18 + const items: Item[] = [ 19 + { id: "a", title: "Buy milk", tag: "home", due: "today 17:00", done: false }, 20 + { id: "b", title: "Call Mom", tag: "home", due: "tomorrow 10am", done: false }, 21 + { id: "c", title: "Refactor auth", tag: "work", due: "Friday", done: false }, 22 + { id: "d", title: "Write release notes", tag: "work", due: "today", done: true }, 23 + ]; 24 + 25 + const tabs: TabDef[] = [ 26 + { id: "all", label: "All" }, 27 + { id: "home", label: "#home" }, 28 + { id: "work", label: "#work" }, 29 + ]; 30 + const tabsState = signal<TabsState>(createTabsState(tabs)); 31 + 32 + const tree: TreeNode<string>[] = [ 33 + { id: "inbox", label: "Inbox", data: "root" }, 34 + { id: "work", label: "Work", data: "root", children: [ 35 + { id: "work/backlog", label: "Backlog", data: "list" }, 36 + { id: "work/done", label: "Done", data: "list" }, 37 + ] }, 38 + { id: "home", label: "Home", data: "root", children: [ 39 + { id: "home/errands", label: "Errands", data: "list" }, 40 + ] }, 41 + ]; 42 + const treeState = signal<TreeState>({ expanded: new Set(["work", "home"]), selectedId: "work" }); 43 + 44 + const cols: TableColumn<Item>[] = [ 45 + { id: "done", header: " ", render: r => r.done ? "\u2713" : "\u25cb" }, 46 + { id: "title", header: "Title", render: r => r.title }, 47 + { id: "tag", header: "Tag", render: r => `#${r.tag}` }, 48 + { id: "due", header: "Due", render: r => r.due }, 49 + ]; 50 + const tableState = signal<TableState>(createTableState(cols, "title")); 51 + 52 + const toasts = signal<ToastQueue>(createToastQueue()); 53 + 54 + export const kitchenSink: Demo = { 55 + id: "kitchen-sink", 56 + category: "sink", 57 + name: "kitchen sink", 58 + blurb: "Tabs across the top, a folder tree on the left, a sortable table on the right. Compose the primitives.", 59 + render() { 60 + const q = pruneExpired(toasts.peek()); 61 + if (q !== toasts.peek()) toasts.set(q); 62 + 63 + const filtered = items.filter(it => { 64 + const tab = tabsState.get().activeId; 65 + if (tab === "home") return it.tag === "home"; 66 + if (tab === "work") return it.tag === "work"; 67 + return true; 68 + }); 69 + const sorted = sortRows(filtered, cols, tableState.get()); 70 + 71 + const sidebar = column({ width: 20 }, [ 72 + row(text(" Lists", "muted", { bold: true })), 73 + ...flattenTree(tree, treeState.get().expanded).map(r => { 74 + const selected = r.node.id === treeState.get().selectedId; 75 + return row( 76 + text(" ".repeat(r.depth) + treeGlyph(r) + r.node.label, 77 + selected ? "accent" : "primary", 78 + { bold: selected }), 79 + ); 80 + }), 81 + ]); 82 + 83 + const main = column({ flex: true }, [ 84 + renderTabs(tabsState.get(), tabs), 85 + separator(), 86 + renderTable(sorted, cols, tableState.get()), 87 + separator(), 88 + row(text(" n: new toast t / space-tab: switch tab ↑↓: move 1..4: sort", "muted", { dim: true })), 89 + renderToasts(toasts.get()), 90 + ]); 91 + 92 + return [ 93 + row(sidebar, text(" ", "muted"), main), 94 + ]; 95 + }, 96 + handleKey(key) { 97 + // Tabs (ctrl+tab or number keys 1..3) 98 + const tabResult = handleTabsKey(tabsState.peek(), tabs, key); 99 + if (tabResult) { tabsState.set(tabResult); return true; } 100 + 101 + // Table (arrows, numbers to sort) 102 + const sorted = sortRows(items, cols, tableState.peek()); 103 + const tResult = handleTableKey(tableState.peek(), sorted, cols, key); 104 + if (tResult.action !== "none") { 105 + tableState.set(tResult.state); 106 + if (tResult.action === "activate" && tResult.activated) { 107 + toasts.set(pushToast(toasts.peek(), `opened: ${tResult.activated.title}`, { kind: "info" })); 108 + } 109 + return true; 110 + } 111 + 112 + // Tree (left/right to expand/collapse — up/down already eaten above) 113 + if (key.name === "left" || key.name === "right") { 114 + const rows = flattenTree(tree, treeState.peek().expanded); 115 + const r = handleTreeKey(treeState.peek(), rows, key); 116 + treeState.set(r.state); 117 + return r.action !== "none"; 118 + } 119 + 120 + // Toast hotkey 121 + if (key.char === "n") { 122 + toasts.set(pushToast(toasts.peek(), `saved at ${new Date().toLocaleTimeString()}`, { kind: "success" })); 123 + return true; 124 + } 125 + 126 + return false; 127 + }, 128 + source: String.raw`// Layout: 129 + // [ sidebar: tree ][ main: tabs + table ] 130 + // 131 + // State comes from a handful of signals (tabsState, treeState, tableState). 132 + // handleKey is a short cascade: try tabs, then table, then tree, then app 133 + // hotkeys. renderToasts is layered on last so notifications float above. 134 + // 135 + // This is 120 lines end-to-end including imports, state, render, and keys.`, 136 + };
+46
demos/playground/demos/layout.ts
··· 1 + // "Layout" — row / column / panel / separator / gap. 2 + 3 + import { 4 + text, row, column, panel, separator, gap, spacer, 5 + } from "../../../src/tui/index.ts"; 6 + import type { Demo } from "../types.ts"; 7 + 8 + export const layout: Demo = { 9 + id: "layout", 10 + category: "layout", 11 + name: "row / column / panel", 12 + blurb: "Containers compose into the shape you want. Rows flow horizontally, columns stack vertically.", 13 + render() { 14 + return [ 15 + panel("rounded panel", [ 16 + row(text(" first row", "primary")), 17 + row(text(" second row", "primary")), 18 + separator(), 19 + row(text(" below separator", "muted", { dim: true })), 20 + ]), 21 + gap(1), 22 + row( 23 + text(" left ", "accent", { bold: true }), 24 + spacer(), 25 + text(" pushed right ", "accent", { bold: true }), 26 + ), 27 + gap(1), 28 + row( 29 + text(" A ", "accent"), 30 + text(" | ", "muted", { dim: true }), 31 + text(" B ", "accent"), 32 + text(" | ", "muted", { dim: true }), 33 + text(" C ", "accent"), 34 + ), 35 + ]; 36 + }, 37 + handleKey() { return false; }, 38 + source: String.raw`panel("rounded panel", [ 39 + row(text("first row")), 40 + row(text("second row")), 41 + separator(), 42 + row(text("below separator", "muted", { dim: true })), 43 + ]) 44 + 45 + row(text("left"), spacer(), text("right")) // spacer pushes to the right`, 46 + };
+106
demos/playground/demos/lists.ts
··· 1 + // Lists: selectable, virtual-list, tree. 2 + 3 + import { 4 + text, row, column, panel, separator, 5 + signal, 6 + createVirtualListState, handleVirtualKey, renderVirtualList, virtualWindow, 7 + createTreeState, flattenTree, handleTreeKey, treeGlyph, 8 + type VirtualListState, type TreeState, type TreeNode, 9 + } from "../../../src/tui/index.ts"; 10 + import type { Demo } from "../types.ts"; 11 + 12 + // --- virtualized list --- 13 + const big = Array.from({ length: 5000 }, (_, i) => `item ${i.toString().padStart(5, "0")}`); 14 + const virtState = signal<VirtualListState>(createVirtualListState(big.length, 10)); 15 + 16 + export const virtualListDemo: Demo = { 17 + id: "virtual-list", 18 + category: "lists", 19 + name: "virtualized list (5000 items)", 20 + blurb: "renderVirtualList only invokes the item renderer for the visible slice. Arrow keys, page up/down, home/end all work.", 21 + render() { 22 + const s = virtState.get(); 23 + return [ 24 + row(text(` window: ${JSON.stringify(virtualWindow(s))} selected: ${s.selectedIndex}`, "muted", { dim: true })), 25 + panel("5000 items", [ 26 + renderVirtualList(s, (i, sel) => 27 + row(text((sel ? " \u25b8 " : " ") + big[i], 28 + sel ? "accent" : "primary", { bold: sel })) 29 + ), 30 + ]), 31 + ]; 32 + }, 33 + handleKey(key) { 34 + const r = handleVirtualKey(virtState.peek(), key); 35 + virtState.set(r.state); 36 + return r.action !== "none"; 37 + }, 38 + source: String.raw`const big = Array.from({ length: 5000 }, (_, i) => 'item ' + i); 39 + const state = signal(createVirtualListState(big.length, 10)); 40 + 41 + // render: 42 + renderVirtualList(state.get(), (i, sel) => 43 + row(text((sel ? '> ' : ' ') + big[i])), 44 + ) 45 + 46 + // keys: 47 + const r = handleVirtualKey(state.peek(), key); 48 + state.set(r.state);`, 49 + }; 50 + 51 + // --- tree --- 52 + const sampleTree: TreeNode<string>[] = [ 53 + { id: "work", label: "Work", data: "folder", children: [ 54 + { id: "work/backlog", label: "Backlog", data: "folder", children: [ 55 + { id: "work/backlog/a", label: "refactor auth", data: "file" }, 56 + { id: "work/backlog/b", label: "ship feature X", data: "file" }, 57 + ] }, 58 + { id: "work/in-progress", label: "In progress", data: "folder", children: [ 59 + { id: "work/in-progress/a", label: "fix CSI-Z", data: "file" }, 60 + ] }, 61 + ] }, 62 + { id: "home", label: "Home", data: "folder", children: [ 63 + { id: "home/groceries", label: "groceries", data: "file" }, 64 + { id: "home/plants", label: "water plants", data: "file" }, 65 + ] }, 66 + ]; 67 + 68 + const treeState = signal<TreeState>({ expanded: new Set(["work"]), selectedId: "work" }); 69 + 70 + export const treeDemo: Demo = { 71 + id: "tree", 72 + category: "lists", 73 + name: "tree view", 74 + blurb: "arrow keys move; right/left expand/collapse; enter activates a leaf.", 75 + render() { 76 + const s = treeState.get(); 77 + const rows = flattenTree(sampleTree, s.expanded); 78 + return rows.map(r => { 79 + const selected = r.node.id === s.selectedId; 80 + const prefix = " ".repeat(r.depth) + treeGlyph(r); 81 + return row( 82 + text(prefix, "muted"), 83 + text(r.node.label + (r.node.data === "file" ? "" : ""), 84 + selected ? "accent" : "primary", 85 + { bold: selected }), 86 + ); 87 + }); 88 + }, 89 + handleKey(key) { 90 + const s = treeState.peek(); 91 + const rows = flattenTree(sampleTree, s.expanded); 92 + const r = handleTreeKey(s, rows, key); 93 + treeState.set(r.state); 94 + return r.action !== "none"; 95 + }, 96 + source: String.raw`const state = signal({ expanded: new Set(['work']), selectedId: 'work' }); 97 + // render: 98 + const rows = flattenTree(tree, state.get().expanded); 99 + rows.map(r => row( 100 + text(' '.repeat(r.depth) + treeGlyph(r), 'muted'), 101 + text(r.node.label), 102 + )) 103 + // keys: 104 + const r = handleTreeKey(state.peek(), rows, key); 105 + state.set(r.state);`, 106 + };
+180
demos/playground/demos/meters.ts
··· 1 + // Meters: sparkline + bar chart + panel footer-title. 2 + // Everything you need to rebuild a mactop-style UI. 3 + 4 + import { 5 + text, row, column, panel, separator, hstack, progressBar, 6 + signal, 7 + sparkline, sparklineString, barChart, 8 + } from "../../../src/tui/index.ts"; 9 + import type { Demo } from "../types.ts"; 10 + 11 + // --- sparkline --- 12 + const cpuSeries = signal<number[]>( 13 + Array.from({ length: 60 }, (_, i) => 30 + 20 * Math.sin(i / 3) + Math.random() * 5), 14 + ); 15 + const memSeries = signal<number[]>( 16 + Array.from({ length: 60 }, (_, i) => 60 + 10 * Math.cos(i / 5) + Math.random() * 2), 17 + ); 18 + 19 + let tickerId: ReturnType<typeof setInterval> | null = null; 20 + export function startMetersTicker(): void { 21 + if (tickerId) return; 22 + tickerId = setInterval(() => { 23 + const c = cpuSeries.peek(); 24 + const m = memSeries.peek(); 25 + cpuSeries.set([...c.slice(1), 20 + Math.random() * 60]); 26 + memSeries.set([...m.slice(1), 55 + Math.random() * 15]); 27 + }, 500); 28 + } 29 + export function stopMetersTicker(): void { 30 + if (tickerId) { clearInterval(tickerId); tickerId = null; } 31 + } 32 + 33 + export const sparklineDemo: Demo = { 34 + id: "sparkline", 35 + category: "data", 36 + name: "sparkline", 37 + blurb: "Compact inline chart. One cell per sample, height via unicode blocks. Updates live.", 38 + render() { 39 + const cpu = cpuSeries.get(); 40 + const mem = memSeries.get(); 41 + return [ 42 + row( 43 + text(" CPU ", "muted"), 44 + sparkline(cpu, { width: 40, min: 0, max: 100, color: "accent" }), 45 + text(` ${cpu[cpu.length - 1].toFixed(0)}%`, "primary"), 46 + ), 47 + row( 48 + text(" MEM ", "muted"), 49 + sparkline(mem, { width: 40, min: 0, max: 100, color: "info" }), 50 + text(` ${mem[mem.length - 1].toFixed(0)}%`, "primary"), 51 + ), 52 + separator(), 53 + row(text(" stacked in a panel with bottom caption:", "muted", { dim: true })), 54 + panel("live CPU", [ 55 + row(sparkline(cpu, { width: 50, min: 0, max: 100, color: "accent" })), 56 + ], { footerTitle: "last 60 samples · 2Hz" }), 57 + ]; 58 + }, 59 + handleKey() { return false; }, 60 + source: String.raw`const series = signal<number[]>([]); 61 + // push new samples as they arrive: 62 + series.set([...series.peek().slice(1), latestValue]); 63 + 64 + // render: 65 + sparkline(series.get(), { width: 40, min: 0, max: 100, color: "accent" }) 66 + 67 + // or get the string directly: 68 + const glyphs = sparklineString(series.get(), { width: 40 });`, 69 + }; 70 + 71 + // --- bar chart --- 72 + const cores = signal<number[]>(Array.from({ length: 10 }, () => Math.random() * 100)); 73 + 74 + export const barChartDemo: Demo = { 75 + id: "bar-chart", 76 + category: "data", 77 + name: "bar chart", 78 + blurb: "Vertical histogram — CPU per core, power by component, any small-cardinality metric.", 79 + render() { 80 + const values = cores.get(); 81 + const items = values.map((v, i) => ({ 82 + label: String(i), 83 + value: v, 84 + color: v > 70 ? ("warn" as const) : v > 90 ? ("error" as const) : ("accent" as const), 85 + })); 86 + return [ 87 + row(text(" CPU per core (0-9), height = 6 rows:", "muted", { dim: true })), 88 + barChart(items, { height: 6, min: 0, max: 100, barWidth: 3, gap: 1, showLabels: true }), 89 + separator(), 90 + row(text(" thinner bars, no labels:", "muted", { dim: true })), 91 + barChart(items, { height: 4, min: 0, max: 100, barWidth: 1, gap: 1 }), 92 + ]; 93 + }, 94 + handleKey(key) { 95 + if (key.char === "r") { 96 + cores.set(Array.from({ length: 10 }, () => Math.random() * 100)); 97 + return true; 98 + } 99 + return false; 100 + }, 101 + source: String.raw`const items: BarChartItem[] = [ 102 + { label: "0", value: 23, color: "accent" }, 103 + { label: "1", value: 87, color: "warn" }, 104 + ... 105 + ]; 106 + // render: 107 + barChart(items, { height: 6, min: 0, max: 100, barWidth: 3, showLabels: true })`, 108 + }; 109 + 110 + // --- panel footer-title / kitchen-style meter grid (mactop-ish) --- 111 + const meterCpu = signal<number>(42); 112 + const meterMem = signal<number>(68); 113 + const meterGpu = signal<number>(15); 114 + 115 + let meterId: ReturnType<typeof setInterval> | null = null; 116 + export function startMeterGridTicker(): void { 117 + if (meterId) return; 118 + meterId = setInterval(() => { 119 + meterCpu.set(Math.max(0, Math.min(100, meterCpu.peek() + (Math.random() - 0.5) * 10))); 120 + meterMem.set(Math.max(0, Math.min(100, meterMem.peek() + (Math.random() - 0.5) * 4))); 121 + meterGpu.set(Math.max(0, Math.min(100, meterGpu.peek() + (Math.random() - 0.5) * 20))); 122 + }, 400); 123 + } 124 + export function stopMeterGridTicker(): void { 125 + if (meterId) { clearInterval(meterId); meterId = null; } 126 + } 127 + 128 + export const meterGridDemo: Demo = { 129 + id: "meter-grid", 130 + category: "patterns", 131 + name: "mactop-style meter grid", 132 + blurb: "Grid of panels with progress bars + sparklines + a bottom-border caption. Basically the shape of mactop or htop.", 133 + render() { 134 + const cpu = meterCpu.get(); 135 + const mem = meterMem.get(); 136 + const gpu = meterGpu.get(); 137 + return [ 138 + panel("system", [ 139 + hstack({ gap: 1 }, [ 140 + column({ flex: true }, [ 141 + panel("CPU", [ 142 + row(text(` ${cpu.toFixed(0)}% `, "primary"), progressBar(cpu / 100, 20, "accent")), 143 + row(sparkline(cpuSeries.get(), { width: 26, min: 0, max: 100, color: "accent" })), 144 + ]), 145 + panel("Memory", [ 146 + row(text(` ${mem.toFixed(0)}% `, "primary"), progressBar(mem / 100, 20, "info")), 147 + row(sparkline(memSeries.get(), { width: 26, min: 0, max: 100, color: "info" })), 148 + ]), 149 + ]), 150 + column({ flex: true }, [ 151 + panel("GPU", [ 152 + row(text(` ${gpu.toFixed(0)}% `, "primary"), progressBar(gpu / 100, 20, "warn")), 153 + ]), 154 + panel("per-core", [ 155 + barChart( 156 + cores.get().map((v, i) => ({ label: String(i), value: v, 157 + color: v > 80 ? ("warn" as const) : ("accent" as const), 158 + })), 159 + { height: 4, min: 0, max: 100, barWidth: 2, gap: 1 }, 160 + ), 161 + ]), 162 + ]), 163 + ]), 164 + ], { footerTitle: "4/17 layout (skyblue) · -/+ 400ms · live" }), 165 + ]; 166 + }, 167 + handleKey() { return false; }, 168 + source: String.raw`panel("system", [ 169 + hstack({ gap: 1 }, [ 170 + column({ flex: true }, [ 171 + panel("CPU", [row(progressBar(cpu/100, 20)), row(sparkline(series))]), 172 + panel("Memory", [/* ... */]), 173 + ]), 174 + column({ flex: true }, [ 175 + panel("GPU", [/* ... */]), 176 + panel("per-core", [barChart(cores, { height: 4 })]), 177 + ]), 178 + ]), 179 + ], { footerTitle: "4/17 layout (skyblue) · -/+ 400ms" })`, 180 + };
+213
demos/playground/demos/overlays.ts
··· 1 + // Overlays: confirm, toast, command palette, help. Confirm and command 2 + // palette are shown inline here (not as real overlays on the whole app) so 3 + // the playground can demo them without nesting overlays — the pattern is 4 + // identical when wired through `AppConfig.overlay`. 5 + 6 + import { 7 + text, row, column, panel, separator, 8 + signal, 9 + createConfirm, handleConfirmKey, confirmPanel, 10 + createToastQueue, pushToast, pruneExpired, renderToasts, 11 + createCommandPaletteState, handleCommandPaletteKey, renderCommandPalette, 12 + helpPanel, 13 + type ConfirmState, type ToastQueue, type CommandPaletteState, type Command, type HelpSection, 14 + } from "../../../src/tui/index.ts"; 15 + import type { Demo } from "../types.ts"; 16 + 17 + // --- confirm --- 18 + const confirmState = signal<ConfirmState>(createConfirm({ 19 + title: "Delete this reminder?", 20 + message: "This cannot be undone.", 21 + yesLabel: "Delete", 22 + noLabel: "Keep", 23 + })); 24 + const confirmResult = signal<string>("(press y / n / tab / return / esc)"); 25 + 26 + export const confirmDemo: Demo = { 27 + id: "confirm", 28 + category: "overlays", 29 + name: "confirm modal", 30 + blurb: "Yes/no dialog with safe default on 'no'. Shortcut keys work.", 31 + render() { 32 + return [ 33 + confirmPanel(confirmState.get()), 34 + row(text(` last action: ${confirmResult.get()}`, "muted", { dim: true })), 35 + ]; 36 + }, 37 + handleKey(key) { 38 + const r = handleConfirmKey(confirmState.peek(), key); 39 + confirmState.set(r.state); 40 + if (r.action !== "pending") confirmResult.set(r.action); 41 + return true; 42 + }, 43 + source: String.raw`const state = signal(createConfirm({ 44 + title: "Delete?", message: "cannot be undone", yesLabel: "Delete", noLabel: "Keep", 45 + })); 46 + // in app overlay: 47 + overlay: () => confirmState.get() ? confirmScreen : null 48 + // in keys: 49 + const r = handleConfirmKey(state.peek(), key); 50 + state.set(r.state); 51 + if (r.action === "yes") /* do the thing */`, 52 + }; 53 + 54 + // --- toast --- 55 + const toastQueue = signal<ToastQueue>(createToastQueue()); 56 + 57 + export const toastDemo: Demo = { 58 + id: "toast", 59 + category: "overlays", 60 + name: "toast notifications", 61 + blurb: "s: success toast i: info w: warning e: error. They auto-expire after 3s.", 62 + render() { 63 + const q = pruneExpired(toastQueue.peek()); 64 + if (q !== toastQueue.peek()) toastQueue.set(q); 65 + return [ 66 + row(text(" press s / i / w / e to emit a toast. Queue:", "muted")), 67 + renderToasts(toastQueue.get()), 68 + separator(), 69 + row(text(` live: ${toastQueue.get().toasts.length}`, "muted", { dim: true })), 70 + ]; 71 + }, 72 + handleKey(key) { 73 + const kinds: Record<string, "success" | "info" | "warn" | "error"> = { 74 + s: "success", i: "info", w: "warn", e: "error", 75 + }; 76 + if (key.char && kinds[key.char]) { 77 + const kind = kinds[key.char]; 78 + const msg = `${kind} at ${new Date().toLocaleTimeString()}`; 79 + toastQueue.set(pushToast(toastQueue.peek(), msg, { kind })); 80 + return true; 81 + } 82 + return false; 83 + }, 84 + source: String.raw`const queue = signal(createToastQueue()); 85 + // push a toast anywhere in your app: 86 + queue.set(pushToast(queue.peek(), "saved", { kind: "success" })); 87 + // overlay: render the queue in a corner — corner-anchor layout is up to you 88 + // periodically prune expired ones (every 500ms): 89 + setInterval(() => queue.set(pruneExpired(queue.peek())), 500);`, 90 + }; 91 + 92 + // --- command palette (dogfooding the registry) --- 93 + import { 94 + registerGlobalCommand, useCommandScope, allCommands, findCommand, 95 + } from "../../../src/tui/widgets/command-registry.ts"; 96 + 97 + // "Global" commands live for the app's lifetime. Register once at module 98 + // load. These are the "always available" commands. 99 + registerGlobalCommand({ id: "help", label: "Help", keywords: ["shortcuts"], run() {} }); 100 + registerGlobalCommand({ id: "quit", label: "Quit", keywords: ["exit"], run() {} }); 101 + 102 + // "Screen" commands — scoped to this demo screen. In a real app you'd 103 + // register these inside onMount / onUnmount hooks. For the demo we just 104 + // leave them registered; the scope isolation story is covered by the 105 + // widgets-command-registry tests. 106 + useCommandScope("screen:palette-demo", [ 107 + { id: "new-reminder", label: "New reminder", hint: "create in the current list", run() {} }, 108 + { id: "switch-list", label: "Switch list", hint: "jump to another folder", run() {} }, 109 + ]); 110 + 111 + // "Focus" commands change with the currently-focused item. A selected-item 112 + // signal drives which focus scope is active — setting a new item REPLACES 113 + // the previous scope's commands in one call, which is exactly the semantic 114 + // contextual commands want. 115 + const focusedItem = signal<string | null>("Buy milk"); 116 + 117 + function registerFocusScope(title: string | null): void { 118 + if (!title) { 119 + useCommandScope("focus:item", []); 120 + return; 121 + } 122 + useCommandScope("focus:item", [ 123 + { id: "item.complete", label: `Complete "${title}"`, keywords: ["done"], run() {} }, 124 + { id: "item.delete", label: `Delete "${title}"`, run() {} }, 125 + { id: "item.move", label: `Move "${title}"`, run() {} }, 126 + ]); 127 + } 128 + registerFocusScope(focusedItem.peek()); 129 + 130 + const paletteState = signal<CommandPaletteState>(createCommandPaletteState()); 131 + const paletteMessage = signal<string>("type to filter; tab to switch focused item; enter to run"); 132 + 133 + export const commandPaletteDemo: Demo = { 134 + id: "command-palette", 135 + category: "overlays", 136 + name: "command palette (registry)", 137 + blurb: "Fuzzy-matched action runner, but commands come from a signal-backed registry: global + screen scope + focused-item scope. Tab to change the focused item — notice the matching commands swap in.", 138 + render() { 139 + return [ 140 + row(text(` focused item: ${focusedItem.get() ?? "(none)"} total commands: ${allCommands.get().length}`, "muted", { dim: true })), 141 + renderCommandPalette(paletteState.get(), allCommands.get(), { title: "what do you want to do?" }), 142 + row(text(` ${paletteMessage.get()}`, "muted", { dim: true })), 143 + ]; 144 + }, 145 + handleKey(key) { 146 + // Tab rotates the "focused item" to demonstrate scope swap. 147 + if (key.name === "tab") { 148 + const next = focusedItem.peek() === "Buy milk" ? "Refactor auth" 149 + : focusedItem.peek() === "Refactor auth" ? null 150 + : "Buy milk"; 151 + focusedItem.set(next); 152 + registerFocusScope(next); 153 + return true; 154 + } 155 + const r = handleCommandPaletteKey(paletteState.peek(), allCommands.get(), key); 156 + paletteState.set(r.state); 157 + if (r.action === "run" && r.command) paletteMessage.set(`ran: ${r.command.id}`); 158 + else if (r.action === "cancel") paletteMessage.set("cancelled"); 159 + return r.action !== "none"; 160 + }, 161 + source: String.raw`// Register commands from wherever they conceptually belong. 162 + registerGlobalCommand({ id: "quit", label: "Quit", run: () => { ... } }); 163 + useCommandScope("screen:list", [{ id: "list.new", label: "New", run: () => {...} }]); 164 + 165 + // When focus changes, replace the focus scope: 166 + useCommandScope("focus:item", [ 167 + { id: "item.complete", label: 'Complete "' + item.title + '"', run: () => {...} }, 168 + ]); 169 + 170 + // The palette just reads the aggregated view: 171 + renderCommandPalette(state.get(), allCommands.get());`, 172 + }; 173 + 174 + // --- help overlay --- 175 + const helpSections: HelpSection[] = [ 176 + { 177 + title: "Navigation", 178 + bindings: [ 179 + { key: "j / k or ↑ / ↓", desc: "move selection" }, 180 + { key: "g / G", desc: "top / bottom" }, 181 + { key: "enter", desc: "open selected" }, 182 + ], 183 + }, 184 + { 185 + title: "Actions", 186 + bindings: [ 187 + { key: "n", desc: "new" }, 188 + { key: "d", desc: "delete" }, 189 + { key: "/", desc: "search" }, 190 + { key: "ctrl+k", desc: "command palette" }, 191 + ], 192 + }, 193 + ]; 194 + 195 + export const helpDemo: Demo = { 196 + id: "help", 197 + category: "overlays", 198 + name: "help overlay", 199 + blurb: "A cheat sheet overlay bound to ?. Keys align visually across sections.", 200 + render() { 201 + return [helpPanel(helpSections, "help")]; 202 + }, 203 + handleKey() { return false; }, 204 + source: String.raw`const sections: HelpSection[] = [ 205 + { title: "Navigation", bindings: [{ key: "↑/↓", desc: "move" }, ...] }, 206 + { title: "Actions", bindings: [{ key: "n", desc: "new" }, ...] }, 207 + ]; 208 + const showHelp = signal(false); 209 + // in overlay: 210 + overlay: () => showHelp.get() ? helpScreen(sections) : null 211 + // in keys: 212 + if (key.char === "?") { showHelp.set(!showHelp.peek()); return true; }`, 213 + };
+164
demos/playground/demos/patterns.ts
··· 1 + // Patterns: tabs, markdown, stream-view. 2 + 3 + import { 4 + text, row, column, panel, separator, 5 + signal, 6 + createTabsState, nextTab, prevTab, handleTabsKey, renderTabs, 7 + renderMarkdown, 8 + createStreamView, streamScrollUp, streamScrollDown, streamPin, 9 + handleStreamKey, renderStreamView, streamIsPinned, 10 + type TabsState, type TabDef, type StreamViewState, 11 + } from "../../../src/tui/index.ts"; 12 + import type { Demo } from "../types.ts"; 13 + 14 + // --- tabs --- 15 + const tabs: TabDef[] = [ 16 + { id: "inbox", label: "Inbox" }, 17 + { id: "sent", label: "Sent" }, 18 + { id: "drafts", label: "Drafts" }, 19 + { id: "trash", label: "Trash" }, 20 + ]; 21 + const tabsState = signal<TabsState>(createTabsState(tabs)); 22 + 23 + const tabContent: Record<string, string> = { 24 + inbox: " 32 unread messages", 25 + sent: " 145 messages", 26 + drafts: " 2 drafts", 27 + trash: " empty", 28 + }; 29 + 30 + export const tabsDemo: Demo = { 31 + id: "tabs", 32 + category: "patterns", 33 + name: "tabs", 34 + blurb: "ctrl+tab / ctrl+shift+tab cycle. Numbers 1-9 jump directly.", 35 + render() { 36 + return [ 37 + renderTabs(tabsState.get(), tabs), 38 + separator(), 39 + row(text(tabContent[tabsState.get().activeId ?? ""] ?? "", "primary")), 40 + ]; 41 + }, 42 + handleKey(key) { 43 + const next = handleTabsKey(tabsState.peek(), tabs, key); 44 + if (next) { tabsState.set(next); return true; } 45 + return false; 46 + }, 47 + source: String.raw`const tabs = [ 48 + { id: "inbox", label: "Inbox" }, 49 + { id: "sent", label: "Sent" }, 50 + ]; 51 + const state = signal(createTabsState(tabs)); 52 + // render: 53 + renderTabs(state.get(), tabs) 54 + // keys: 55 + const next = handleTabsKey(state.peek(), tabs, key); 56 + if (next) state.set(next);`, 57 + }; 58 + 59 + // --- markdown --- 60 + const markdownSource = `# Markdown renderer 61 + 62 + Renders a subset of **CommonMark** straight to UINodes. Supports: 63 + 64 + - Headings (\`#\`, \`##\`, ...) 65 + - Inline **bold**, *italic*, \`code\`, [links](https://example.com) 66 + - Fenced code blocks 67 + - Task lists 68 + - Blockquotes 69 + - Horizontal rules 70 + 71 + > "Talk is cheap. Show me the code." 72 + > — Linus Torvalds 73 + 74 + - [ ] plumb TUI framework 75 + - [x] ship first app 76 + - [ ] polish the playground 77 + 78 + \`\`\` 79 + const out = renderMarkdown(src, { width: 80 }); 80 + for (const node of out) /* render node */; 81 + \`\`\` 82 + 83 + --- 84 + 85 + Paragraphs wrap at the width you pass. Omit \`width\` to keep them as-is.`; 86 + 87 + export const markdownDemo: Demo = { 88 + id: "markdown", 89 + category: "patterns", 90 + name: "markdown renderer", 91 + blurb: "Subset of CommonMark → UINodes. Suitable for notes, emails, articles, chat.", 92 + render(ctx) { 93 + return renderMarkdown(markdownSource, { width: Math.max(30, ctx.cols - 20) }); 94 + }, 95 + handleKey() { return false; }, 96 + source: String.raw`const src = \`# Hello 97 + Some **text** with \`inline\` and a [link](https://x). 98 + - [ ] task 99 + \`; 100 + renderMarkdown(src, { width: 80 }); // returns UINode[]`, 101 + }; 102 + 103 + // --- stream view --- 104 + const streamItems = signal<string[]>([ 105 + "10:00 system: connected", 106 + "10:01 ava: hi there", 107 + "10:01 me: hey", 108 + "10:02 ava: how's the playground going?", 109 + "10:03 me: tabs work, stream-view up next", 110 + "10:04 ava: show me", 111 + ]); 112 + const streamState = signal<StreamViewState>(createStreamView()); 113 + 114 + let streamTimer: ReturnType<typeof setInterval> | null = null; 115 + export function startStreamTicker(): void { 116 + if (streamTimer) return; 117 + const speakers = ["ava", "me", "ben", "claude"]; 118 + const phrases = ["cool", "what about this", "let me try", "ok works", "great", "next idea?", "hmm", "got it"]; 119 + streamTimer = setInterval(() => { 120 + const speaker = speakers[Math.floor(Math.random() * speakers.length)]; 121 + const phrase = phrases[Math.floor(Math.random() * phrases.length)]; 122 + const now = new Date(); 123 + const stamp = `${now.getHours().toString().padStart(2, "0")}:${now.getMinutes().toString().padStart(2, "0")}`; 124 + streamItems.set([...streamItems.peek(), `${stamp} ${speaker}: ${phrase}`]); 125 + }, 1500); 126 + } 127 + export function stopStreamTicker(): void { 128 + if (streamTimer) { clearInterval(streamTimer); streamTimer = null; } 129 + } 130 + 131 + export const streamViewDemo: Demo = { 132 + id: "stream-view", 133 + category: "patterns", 134 + name: "sticky-bottom stream", 135 + blurb: "Auto-scrolls to new messages. Arrow up scrolls back. End re-pins to bottom.", 136 + render() { 137 + const items = streamItems.get(); 138 + const pinned = streamIsPinned(streamState.get()); 139 + const viewport = 10; 140 + return [ 141 + panel(`stream (${pinned ? "pinned" : "scrolled back"} · ${items.length} messages)`, [ 142 + renderStreamView(items, streamState.get(), viewport, (it) => 143 + row(text(it, it.includes("me: ") ? "accent" : "primary"))), 144 + ]), 145 + ]; 146 + }, 147 + handleKey(key) { 148 + const items = streamItems.peek(); 149 + const next = handleStreamKey(streamState.peek(), key, items.length, 10); 150 + if (next) { streamState.set(next); return true; } 151 + return false; 152 + }, 153 + source: String.raw`const items = signal<string[]>([]); 154 + const state = signal(createStreamView()); 155 + // render: 156 + panel("chat", [ 157 + renderStreamView(items.get(), state.get(), viewport, (it) => row(text(it))), 158 + ]) 159 + // keys: 160 + const next = handleStreamKey(state.peek(), key, items.peek().length, viewport); 161 + if (next) state.set(next); 162 + // new messages arrive: 163 + items.set([...items.peek(), newMessage]); // auto-follows if pinned`, 164 + };
+226
demos/playground/main.ts
··· 1 + // TUI playground — a live catalog of every widget pty/tui ships. 2 + // 3 + // Run: node --experimental-strip-types demos/playground/main.ts 4 + // or ./demos/run playground 5 + // 6 + // Layout: 7 + // ┌─ playground ────────────────────────────────────────┐ 8 + // │ sidebar │ blurb │ 9 + // │ (category tree) │ (live demo) │ 10 + // │ │ ───────── │ 11 + // │ │ source snippet │ 12 + // └─────────────────────────────────────────────────────┘ 13 + // footer: ↑↓ navigate tab switch pane q quit 14 + 15 + import { 16 + app, screen, panel, row, column, hstack, text, separator, spacer, 17 + signal, computed, batch, 18 + type ScreenContext, type UINode, type KeyEvent, type MouseEvent, 19 + type Screen, 20 + themes, helpPanel, startSpinnerTimer, stopSpinnerTimer, 21 + type HelpSection, 22 + } from "../../src/tui/index.ts"; 23 + import { demos, demosByCategory } from "./registry.ts"; 24 + import { 25 + registerPlaygroundScopes, makeGlobalChords, 26 + handleMouse as routeMouse, type RouterDeps, 27 + } from "./router.ts"; 28 + import { startStreamTicker, stopStreamTicker } from "./demos/patterns.ts"; 29 + import { 30 + startMetersTicker, stopMetersTicker, 31 + startMeterGridTicker, stopMeterGridTicker, 32 + } from "./demos/meters.ts"; 33 + import type { Demo } from "./types.ts"; 34 + 35 + // --- global state --- 36 + 37 + const selectedDemoId = signal<string>(demos[0].id); 38 + const selectedPane = signal<"sidebar" | "main">("sidebar"); 39 + const showHelp = signal(false); 40 + const showSource = signal(true); 41 + 42 + const selectedDemo = computed<Demo>(() => 43 + demos.find(d => d.id === selectedDemoId.get()) ?? demos[0]); 44 + 45 + // Flat list of (category, demo) rows for sidebar navigation. 46 + interface SidebarEntry { 47 + kind: "header" | "demo"; 48 + label: string; 49 + demoId?: string; 50 + } 51 + const sidebarEntries = computed<SidebarEntry[]>(() => { 52 + const out: SidebarEntry[] = []; 53 + for (const group of demosByCategory()) { 54 + out.push({ kind: "header", label: group.category }); 55 + for (const d of group.demos) { 56 + out.push({ kind: "demo", label: d.name, demoId: d.id }); 57 + } 58 + } 59 + return out; 60 + }); 61 + 62 + function stepDemos(delta: number): void { 63 + const entries = sidebarEntries.get(); 64 + const demoOnly = entries.filter(e => e.kind === "demo"); 65 + const cur = demoOnly.findIndex(e => e.demoId === selectedDemoId.peek()); 66 + const next = Math.max(0, Math.min(demoOnly.length - 1, cur + delta)); 67 + selectedDemoId.set(demoOnly[next].demoId!); 68 + } 69 + 70 + function selectNextDemo(delta: 1 | -1): void { stepDemos(delta); } 71 + 72 + const routerDeps: RouterDeps = { 73 + selectedDemoId, 74 + selectedPane, 75 + showHelp, 76 + showSource, 77 + sidebarEntries: () => sidebarEntries.get(), 78 + selectedDemo: () => selectedDemo.get(), 79 + selectNextDemo: (d) => stepDemos(d), 80 + stepDemos, 81 + }; 82 + 83 + let scopesRegistered = false; 84 + 85 + // --- sidebar screen --- 86 + 87 + function renderSidebar(): UINode { 88 + const entries = sidebarEntries.get(); 89 + const cur = selectedDemoId.get(); 90 + const active = selectedPane.get() === "sidebar"; 91 + 92 + const children: UINode[] = []; 93 + for (const e of entries) { 94 + if (e.kind === "header") { 95 + children.push(row(text(` ${e.label.toUpperCase()}`, "muted", { bold: true, dim: true }))); 96 + } else { 97 + const isSel = e.demoId === cur; 98 + const marker = isSel ? "\u25b8 " : " "; 99 + const color = isSel ? "accent" : "primary"; 100 + children.push(row(text(` ${marker}${e.label}`, color, { bold: isSel }))); 101 + } 102 + } 103 + return panel(active ? "\u25ba demos" : "demos", children); 104 + } 105 + 106 + // --- main pane --- 107 + 108 + function renderMain(ctx: ScreenContext): UINode { 109 + const d = selectedDemo.get(); 110 + const active = selectedPane.get() === "main"; 111 + const body: UINode[] = [ 112 + row(text(` ${d.blurb}`, "muted", { dim: true })), 113 + separator(), 114 + ...d.render({ 115 + ...ctx, 116 + // Subtract sidebar width + padding so demos know they have less space. 117 + cols: Math.max(20, ctx.cols - 24), 118 + }), 119 + ]; 120 + 121 + if (showSource.get()) { 122 + body.push(separator()); 123 + body.push(row(text(" source", "muted", { bold: true, dim: true }))); 124 + for (const line of d.source.split("\n")) { 125 + body.push(row(text(" " + line, "muted", { dim: true }))); 126 + } 127 + } 128 + 129 + return panel(active ? `\u25ba ${d.name}` : d.name, body); 130 + } 131 + 132 + // --- help overlay --- 133 + 134 + const helpSections: HelpSection[] = [ 135 + { 136 + title: "Playground", 137 + bindings: [ 138 + { key: "\u2191/\u2193", desc: "move within the active pane" }, 139 + { key: "tab / \u21e5", desc: "switch between sidebar and main" }, 140 + { key: "s", desc: "toggle source panel" }, 141 + { key: "?", desc: "toggle this help" }, 142 + { key: "q / ctrl+c", desc: "quit" }, 143 + ], 144 + }, 145 + { 146 + title: "Inside a demo", 147 + bindings: [ 148 + { key: "arrows / enter / chars", desc: "each demo has its own keys — try them" }, 149 + ], 150 + }, 151 + ]; 152 + 153 + function helpOverlayScreen(): Screen { 154 + return screen({ 155 + id: "help-overlay", 156 + render() { 157 + return [helpPanel(helpSections, "playground help")]; 158 + }, 159 + handleKey(key) { 160 + if (key.char === "?" || key.name === "escape") { 161 + showHelp.set(false); 162 + return true; 163 + } 164 + return true; 165 + }, 166 + }); 167 + } 168 + 169 + // --- root screen --- 170 + 171 + function rootScreen(): Screen { 172 + return screen({ 173 + id: "playground", 174 + render(ctx) { 175 + const inSidebar = selectedPane.get() === "sidebar"; 176 + return [ 177 + hstack({ gap: 1 }, [ 178 + column({ width: 24 }, [renderSidebar()]), 179 + column({ flex: true }, [renderMain(ctx)]), 180 + ]), 181 + row(text( 182 + inSidebar 183 + ? " \u2191\u2193 nav \u2192/tab/\u23ce focus demo ctrl+l focus demo s source ? help q quit" 184 + : " keys go to the demo ctrl+h back to list ctrl+c quit", 185 + "muted", { dim: true }, 186 + )), 187 + ]; 188 + }, 189 + handleMouse(event) { return routeMouse(routerDeps, event); }, 190 + handleKey(key, ctx) { 191 + // Scopes are registered on first key — ctx.focus isn't available 192 + // until app() is running. One-time guard so re-renders don't 193 + // pile up scopes. 194 + if (!scopesRegistered) { 195 + registerPlaygroundScopes(ctx.focus, routerDeps); 196 + scopesRegistered = true; 197 + } 198 + return ctx.focus.dispatchKey(key, ctx); 199 + }, 200 + }); 201 + } 202 + 203 + // --- lifecycle --- 204 + 205 + startSpinnerTimer(); 206 + startStreamTicker(); 207 + startMetersTicker(); 208 + startMeterGridTicker(); 209 + 210 + const instance = app({ 211 + screen: rootScreen, 212 + overlay: () => (showHelp.get() ? helpOverlayScreen() : null), 213 + theme: () => themes.coolBlue, 214 + boxStyle: () => "rounded", 215 + mouse: true, 216 + onKey: makeGlobalChords(routerDeps), 217 + }); 218 + 219 + process.on("exit", () => { 220 + stopSpinnerTimer(); 221 + stopStreamTicker(); 222 + stopMetersTicker(); 223 + stopMeterGridTicker(); 224 + }); 225 + 226 + instance.start();
+62
demos/playground/registry.ts
··· 1 + // All demos bundled for the sidebar. Order within the array is the order 2 + // the sidebar renders them. 3 + 4 + import { atoms } from "./demos/atoms.ts"; 5 + import { layout } from "./demos/layout.ts"; 6 + import { 7 + textInputDemo, textAreaDemo, formDemo, datePickerDemo, 8 + } from "./demos/inputs.ts"; 9 + import { 10 + virtualListDemo, treeDemo, 11 + } from "./demos/lists.ts"; 12 + import { tableDemo } from "./demos/data.ts"; 13 + import { 14 + confirmDemo, toastDemo, commandPaletteDemo, helpDemo, 15 + } from "./demos/overlays.ts"; 16 + import { 17 + tabsDemo, markdownDemo, streamViewDemo, 18 + } from "./demos/patterns.ts"; 19 + import { 20 + promptBarDemo, promptBarMultiDemo, toolbarDemo, 21 + } from "./demos/bars.ts"; 22 + import { 23 + sparklineDemo, barChartDemo, meterGridDemo, 24 + } from "./demos/meters.ts"; 25 + import { kitchenSink } from "./demos/kitchen-sink.ts"; 26 + import type { Demo } from "./types.ts"; 27 + 28 + export const demos: Demo[] = [ 29 + atoms, 30 + layout, 31 + textInputDemo, 32 + textAreaDemo, 33 + formDemo, 34 + datePickerDemo, 35 + virtualListDemo, 36 + treeDemo, 37 + tableDemo, 38 + sparklineDemo, 39 + barChartDemo, 40 + tabsDemo, 41 + markdownDemo, 42 + streamViewDemo, 43 + promptBarDemo, 44 + promptBarMultiDemo, 45 + toolbarDemo, 46 + meterGridDemo, 47 + confirmDemo, 48 + toastDemo, 49 + commandPaletteDemo, 50 + helpDemo, 51 + kitchenSink, 52 + ]; 53 + 54 + export function demosByCategory(): { category: string; demos: Demo[] }[] { 55 + const order: string[] = []; 56 + const groups = new Map<string, Demo[]>(); 57 + for (const d of demos) { 58 + if (!groups.has(d.category)) { order.push(d.category); groups.set(d.category, []); } 59 + groups.get(d.category)!.push(d); 60 + } 61 + return order.map(cat => ({ category: cat, demos: groups.get(cat)! })); 62 + }
+131
demos/playground/router.ts
··· 1 + // Playground routing expressed as focus scopes + a top-level chord 2 + // interceptor. 3 + // 4 + // - ctrl+h / ctrl+l (pane-switch) are wired into AppConfig.onKey so 5 + // they intercept before ANY scope. Those chords must always win — 6 + // even a modal overlay shouldn't swallow them. 7 + // 8 + // - "sidebar" and "main" pane scopes sit in the focus stack with 9 + // `active: () => pane.get() === <id>`. Only one is ever dispatched 10 + // per key; the other is inert. 11 + // 12 + // - Modal overlays can push additional scopes on top at will; they'll 13 + // run before the pane scopes but after AppConfig.onKey, which is 14 + // the right ordering (modal can eat esc but not ctrl+c). 15 + // 16 + // Mouse is kept as a flat handler because the hit-test logic needs the 17 + // actual rendered rects, which scopes don't track. If we add focus-aware 18 + // hit-testing later we can revisit. 19 + 20 + import { batch } from "../../src/tui/index.ts"; 21 + import type { 22 + KeyEvent, MouseEvent, ScreenContext, FocusManager, 23 + } from "../../src/tui/index.ts"; 24 + // KeyEvent imported above — needed by makeGlobalChords's returned closure. 25 + import type { Signal } from "../../src/tui/signals.ts"; 26 + import type { Demo } from "./types.ts"; 27 + 28 + export interface RouterDeps { 29 + selectedDemoId: Signal<string>; 30 + selectedPane: Signal<"sidebar" | "main">; 31 + showHelp: Signal<boolean>; 32 + showSource: Signal<boolean>; 33 + sidebarEntries: () => { kind: "header" | "demo"; demoId?: string }[]; 34 + selectedDemo: () => Demo; 35 + selectNextDemo: (delta: number) => void; 36 + stepDemos: (delta: number) => void; 37 + } 38 + 39 + /** Top-level chord interceptor. Install via `AppConfig.onKey` so it runs 40 + * before the focus stack — even a modal overlay won't swallow pane 41 + * navigation or app-wide shortcuts. */ 42 + export function makeGlobalChords(deps: RouterDeps) { 43 + return (key: KeyEvent): boolean => { 44 + if (key.name === "h" && key.ctrl) { deps.selectedPane.set("sidebar"); return true; } 45 + if (key.name === "l" && key.ctrl) { deps.selectedPane.set("main"); return true; } 46 + return false; 47 + }; 48 + } 49 + 50 + /** Push the playground's pane scopes onto `focus`. Returns a disposer. */ 51 + export function registerPlaygroundScopes( 52 + focus: FocusManager, 53 + deps: RouterDeps, 54 + ): () => void { 55 + const disposeSidebar = focus.push({ 56 + id: "sidebar", 57 + active: () => deps.selectedPane.get() === "sidebar", 58 + onKey(key, ctx) { 59 + if (key.char === "q") { ctx.quit(); return true; } 60 + if (key.char === "?") { deps.showHelp.set(!deps.showHelp.peek()); return true; } 61 + if (key.char === "s") { deps.showSource.set(!deps.showSource.peek()); return true; } 62 + if (key.name === "up") { deps.selectNextDemo(-1); return true; } 63 + if (key.name === "down") { deps.selectNextDemo(1); return true; } 64 + if (key.name === "pageup") { deps.stepDemos(-5); return true; } 65 + if (key.name === "pagedown") { deps.stepDemos(5); return true; } 66 + if (key.name === "right" || key.name === "tab" || key.name === "return") { 67 + deps.selectedPane.set("main"); 68 + return true; 69 + } 70 + // Unhandled — bubble up to the "global" scope so chords like ctrl+h 71 + // still work. The main scope is inactive while we are, so nothing 72 + // else can intercept en route. 73 + return false; 74 + }, 75 + }); 76 + 77 + const disposeMain = focus.push({ 78 + id: "main", 79 + active: () => deps.selectedPane.get() === "main", 80 + onKey(key, ctx) { 81 + // Every key the demo sees, regardless of whether the demo reports 82 + // it as consumed — nothing should ever leak to the sidebar while 83 + // main has focus. 84 + deps.selectedDemo().handleKey(key, ctx); 85 + return true; 86 + }, 87 + }); 88 + 89 + let disposed = false; 90 + return () => { 91 + if (disposed) return; 92 + disposed = true; 93 + disposeMain(); 94 + disposeSidebar(); 95 + }; 96 + } 97 + 98 + /** Mouse is simpler: a single flat handler that decides sidebar vs main 99 + * by x-coordinate. Call from the screen's handleMouse. */ 100 + export function handleMouse(deps: RouterDeps, event: MouseEvent): boolean { 101 + const sidebarWidth = 24; 102 + const inSidebar = event.x < sidebarWidth; 103 + 104 + if (inSidebar) { 105 + if (event.action === "scrollUp") { deps.selectNextDemo(-1); return true; } 106 + if (event.action === "scrollDown") { deps.selectNextDemo(1); return true; } 107 + if (event.action === "press" && event.button === "left") { 108 + const entries = deps.sidebarEntries(); 109 + const entryIdx = event.y - 1; 110 + if (entryIdx >= 0 && entryIdx < entries.length) { 111 + const entry = entries[entryIdx]; 112 + if (entry.kind === "demo" && entry.demoId) { 113 + batch(() => { 114 + deps.selectedDemoId.set(entry.demoId!); 115 + deps.selectedPane.set("main"); 116 + }); 117 + return true; 118 + } 119 + } 120 + } 121 + return false; 122 + } 123 + 124 + if (event.action === "press" && event.button === "left") { 125 + deps.selectedPane.set("main"); 126 + return true; 127 + } 128 + if (event.action === "scrollUp") { deps.selectNextDemo(-1); return true; } 129 + if (event.action === "scrollDown") { deps.selectNextDemo(1); return true; } 130 + return false; 131 + }
+261
demos/playground/routing.test.ts
··· 1 + // Integration tests for the playground's pane-focus routing. 2 + // 3 + // Exercises the REAL router in router.ts (not a mirror), wiring up the 4 + // minimum deps it needs. Catches regressions like "down arrow leaked 5 + // from main-pane demo into sidebar selection." 6 + // 7 + // Rules the router enforces: 8 + // 9 + // SIDEBAR FOCUSED: 10 + // up/down -> prev/next demo 11 + // pageup/pagedown -> jump by 5 12 + // right/tab/enter -> focus main 13 + // q / ? / s -> quit / help / source (playground-owned shortcuts) 14 + // anything else -> swallowed (does NOT leak to a demo) 15 + // 16 + // MAIN FOCUSED: 17 + // every key -> the demo's handleKey 18 + // sidebar never changes from a key typed here 19 + // ctrl+h -> focus sidebar (always works) 20 + // 21 + // UNIVERSAL: 22 + // ctrl+h / ctrl+l -> focus sidebar / main 23 + 24 + import { describe, it, expect, beforeEach } from "vitest"; 25 + import { signal } from "../../src/tui/index.ts"; 26 + import type { 27 + KeyEvent, MouseEvent, ScreenContext, 28 + } from "../../src/tui/index.ts"; 29 + import { createFocusManager } from "../../src/tui/index.ts"; 30 + import { 31 + registerPlaygroundScopes, makeGlobalChords, handleMouse, type RouterDeps, 32 + } from "./router.ts"; 33 + import type { Demo } from "./types.ts"; 34 + 35 + function k(name: string, extra: Partial<KeyEvent> = {}): KeyEvent { 36 + return { kind: "key", name, ctrl: false, alt: false, shift: false, ...extra }; 37 + } 38 + 39 + function me(x: number, y: number, extra: Partial<MouseEvent> = {}): MouseEvent { 40 + return { 41 + kind: "mouse", action: "press", button: "left", 42 + x, y, ctrl: false, alt: false, shift: false, 43 + ...extra, 44 + }; 45 + } 46 + 47 + function mkCtx(quit: () => void): ScreenContext { 48 + return { 49 + rows: 40, cols: 100, 50 + theme: {} as any, 51 + boxStyle: "rounded", 52 + navigate: () => {}, 53 + back: () => {}, 54 + openOverlay: () => {}, 55 + closeOverlay: () => {}, 56 + isTextInputActive: () => false, 57 + setTextInputActive: () => {}, 58 + quit, 59 + }; 60 + } 61 + 62 + /** Build a self-contained router harness with fake demos so we can 63 + * observe forwarded keys without real demos side-effecting. */ 64 + function harness(demoCount = 10) { 65 + const selectedDemoId = signal<string>("demo-0"); 66 + const selectedPane = signal<"sidebar" | "main">("sidebar"); 67 + const showHelp = signal(false); 68 + const showSource = signal(true); 69 + 70 + let lastForwarded: { key: KeyEvent | null } = { key: null }; 71 + const fakeDemo: Demo = { 72 + id: "demo-0", 73 + category: "cat", 74 + name: "demo-0", 75 + blurb: "", 76 + source: "", 77 + render: () => [], 78 + handleKey: (key) => { lastForwarded.key = key; return true; }, 79 + }; 80 + 81 + const entries = [ 82 + { kind: "header" as const, demoId: undefined }, 83 + ...Array.from({ length: demoCount }, (_, i) => ({ 84 + kind: "demo" as const, 85 + demoId: `demo-${i}`, 86 + })), 87 + ]; 88 + 89 + let demoIdx = 0; 90 + const step = (delta: number) => { 91 + demoIdx = Math.max(0, Math.min(demoCount - 1, demoIdx + delta)); 92 + selectedDemoId.set(`demo-${demoIdx}`); 93 + }; 94 + 95 + const deps: RouterDeps = { 96 + selectedDemoId, 97 + selectedPane, 98 + showHelp, 99 + showSource, 100 + sidebarEntries: () => entries, 101 + selectedDemo: () => fakeDemo, 102 + selectNextDemo: step, 103 + stepDemos: step, 104 + }; 105 + 106 + const focus = createFocusManager(); 107 + const dispose = registerPlaygroundScopes(focus, deps); 108 + const globalChords = makeGlobalChords(deps); 109 + 110 + // Mirror app()'s real dispatch order: onKey (global chords) first, 111 + // then the focus stack. 112 + const handleKey = (key: KeyEvent, ctx: ScreenContext): boolean => { 113 + if (globalChords(key)) return true; 114 + return focus.dispatchKey(key, ctx); 115 + }; 116 + 117 + return { 118 + deps, selectedPane, selectedDemoId, showHelp, showSource, 119 + lastForwarded, entries, focus, handleKey, dispose, 120 + }; 121 + } 122 + 123 + describe("playground routing — sidebar focused", () => { 124 + let h: ReturnType<typeof harness>; 125 + beforeEach(() => { h = harness(); }); 126 + 127 + it("up/down move the selection", () => { 128 + h.handleKey(k("down"), mkCtx(() => {})); 129 + expect(h.selectedDemoId.get()).toBe("demo-1"); 130 + h.handleKey(k("down"), mkCtx(() => {})); 131 + expect(h.selectedDemoId.get()).toBe("demo-2"); 132 + h.handleKey(k("up"), mkCtx(() => {})); 133 + expect(h.selectedDemoId.get()).toBe("demo-1"); 134 + }); 135 + 136 + it("pageup/pagedown jump by 5", () => { 137 + h.handleKey(k("pagedown"), mkCtx(() => {})); 138 + expect(h.selectedDemoId.get()).toBe("demo-5"); 139 + h.handleKey(k("pageup"), mkCtx(() => {})); 140 + expect(h.selectedDemoId.get()).toBe("demo-0"); 141 + }); 142 + 143 + it("right / tab / return focus main", () => { 144 + h.handleKey(k("right"), mkCtx(() => {})); 145 + expect(h.selectedPane.get()).toBe("main"); 146 + }); 147 + 148 + it("q quits, ? toggles help, s toggles source", () => { 149 + let quit = 0; 150 + h.handleKey(k("q", { char: "q" }), mkCtx(() => { quit++; })); 151 + expect(quit).toBe(1); 152 + h.handleKey(k("?", { char: "?" }), mkCtx(() => {})); 153 + expect(h.showHelp.get()).toBe(true); 154 + const before = h.showSource.get(); 155 + h.handleKey(k("s", { char: "s" }), mkCtx(() => {})); 156 + expect(h.showSource.get()).toBe(!before); 157 + }); 158 + 159 + it("arbitrary keys are swallowed — don't leak to demo", () => { 160 + h.handleKey(k("x", { char: "x" }), mkCtx(() => {})); 161 + expect(h.selectedPane.get()).toBe("sidebar"); 162 + expect(h.lastForwarded.key).toBeNull(); 163 + }); 164 + }); 165 + 166 + describe("playground routing — main focused (the regression guards)", () => { 167 + let h: ReturnType<typeof harness>; 168 + beforeEach(() => { 169 + h = harness(); 170 + h.handleKey(k("right"), mkCtx(() => {})); 171 + expect(h.selectedPane.get()).toBe("main"); 172 + }); 173 + 174 + it("down arrow goes to the demo, NOT the sidebar", () => { 175 + const before = h.selectedDemoId.get(); 176 + h.handleKey(k("down"), mkCtx(() => {})); 177 + expect(h.selectedDemoId.get()).toBe(before); 178 + expect(h.lastForwarded.key?.name).toBe("down"); 179 + }); 180 + 181 + it("up arrow goes to the demo, NOT the sidebar", () => { 182 + const before = h.selectedDemoId.get(); 183 + h.handleKey(k("up"), mkCtx(() => {})); 184 + expect(h.selectedDemoId.get()).toBe(before); 185 + expect(h.lastForwarded.key?.name).toBe("up"); 186 + }); 187 + 188 + it("tab goes to the demo (forms use it)", () => { 189 + h.handleKey(k("tab"), mkCtx(() => {})); 190 + expect(h.selectedPane.get()).toBe("main"); 191 + expect(h.lastForwarded.key?.name).toBe("tab"); 192 + }); 193 + 194 + it("left arrow goes to the demo (date picker uses it)", () => { 195 + h.handleKey(k("left"), mkCtx(() => {})); 196 + expect(h.selectedPane.get()).toBe("main"); 197 + expect(h.lastForwarded.key?.name).toBe("left"); 198 + }); 199 + 200 + it("q / s / ? go to the demo (could be text input)", () => { 201 + h.handleKey(k("q", { char: "q" }), mkCtx(() => {})); 202 + h.handleKey(k("s", { char: "s" }), mkCtx(() => {})); 203 + h.handleKey(k("?", { char: "?" }), mkCtx(() => {})); 204 + expect(h.showHelp.get()).toBe(false); 205 + expect(h.showSource.get()).toBe(true); 206 + expect(h.lastForwarded.key?.char).toBe("?"); 207 + }); 208 + 209 + it("escape does NOT change focus — demos may need it", () => { 210 + h.handleKey(k("escape"), mkCtx(() => {})); 211 + expect(h.selectedPane.get()).toBe("main"); 212 + expect(h.lastForwarded.key?.name).toBe("escape"); 213 + }); 214 + 215 + it("ctrl+h is the escape hatch back to the sidebar", () => { 216 + h.handleKey(k("h", { ctrl: true }), mkCtx(() => {})); 217 + expect(h.selectedPane.get()).toBe("sidebar"); 218 + }); 219 + }); 220 + 221 + describe("playground routing — universal shortcuts", () => { 222 + it("ctrl+l focuses main from sidebar", () => { 223 + const h = harness(); 224 + h.handleKey(k("l", { ctrl: true }), mkCtx(() => {})); 225 + expect(h.selectedPane.get()).toBe("main"); 226 + }); 227 + 228 + it("ctrl+h focuses sidebar from main", () => { 229 + const h = harness(); 230 + h.handleKey(k("right"), mkCtx(() => {})); 231 + h.handleKey(k("h", { ctrl: true }), mkCtx(() => {})); 232 + expect(h.selectedPane.get()).toBe("sidebar"); 233 + }); 234 + }); 235 + 236 + describe("playground routing — mouse", () => { 237 + it("click on a sidebar demo row selects + focuses main", () => { 238 + const h = harness(); 239 + // Panel border is row 0. entries[0] is the "cat" header at row 1. 240 + // entries[1] is demo-0 at row 2. entries[2] is demo-1 at row 3. 241 + // So y=3 → entryIdx=2 → demo-1. 242 + handleMouse(h.deps, me(10, 3)); 243 + expect(h.selectedDemoId.get()).toBe("demo-1"); 244 + expect(h.selectedPane.get()).toBe("main"); 245 + }); 246 + 247 + it("scroll over sidebar cycles demos without changing focus", () => { 248 + const h = harness(); 249 + handleMouse(h.deps, me(10, 5, { action: "scrollDown", button: "none" })); 250 + expect(h.selectedDemoId.get()).toBe("demo-1"); 251 + expect(h.selectedPane.get()).toBe("sidebar"); 252 + }); 253 + 254 + it("click on main pane only focuses main, doesn't change selection", () => { 255 + const h = harness(); 256 + const before = h.selectedDemoId.get(); 257 + handleMouse(h.deps, me(50, 10)); 258 + expect(h.selectedPane.get()).toBe("main"); 259 + expect(h.selectedDemoId.get()).toBe(before); 260 + }); 261 + });
+24
demos/playground/types.ts
··· 1 + // Shape every playground demo exports. 2 + // 3 + // A demo is a bite-sized interactive example: its own state, its own 4 + // render + key handler, and a source snippet shown under the live view. 5 + // The main app routes input to whichever demo is currently selected. 6 + 7 + import type { UINode, ScreenContext, KeyEvent } from "../../src/tui/index.ts"; 8 + 9 + export interface Demo { 10 + /** Stable id — used as the tree node key in the sidebar. */ 11 + id: string; 12 + /** Sidebar category label. */ 13 + category: string; 14 + /** Display name inside the category. */ 15 + name: string; 16 + /** One-line blurb shown above the live area. */ 17 + blurb: string; 18 + /** Source snippet printed under the live view. */ 19 + source: string; 20 + /** Render the live view. Return an array of nodes for the main pane. */ 21 + render(ctx: ScreenContext): UINode[]; 22 + /** Handle a key. Return true if consumed. */ 23 + handleKey(key: KeyEvent, ctx: ScreenContext): boolean; 24 + }
+3 -1
demos/run
··· 2 2 # Run a demo app: ./demos/run file-browser [path] 3 3 # ./demos/run reminders 4 4 # ./demos/run agent-teams 5 + # ./demos/run playground 5 6 set -e 6 7 7 8 demo="$1" ··· 14 15 echo " file-browser [path] Two-pane file browser with preview" 15 16 echo " reminders CRUD reminders backed by .md files" 16 17 echo " agent-teams Live AI agent hierarchy dashboard (SPEED=6 for fast)" 18 + echo " playground Interactive catalog of every TUI widget" 17 19 exit 1 18 20 fi 19 21 ··· 21 23 22 24 if [ ! -f "$script" ]; then 23 25 echo "Unknown demo: $demo" 24 - echo "Available: file-browser, reminders, agent-teams" 26 + echo "Available: file-browser, reminders, agent-teams, playground" 25 27 exit 1 26 28 fi 27 29
+51 -11
src/tui/app.ts
··· 2 2 // Supports pause/resume for handing the terminal to another process (e.g. attach). 3 3 import type { Screen, ScreenContext } from "./types.ts"; 4 4 import type { Theme, BoxStyle } from "./colors.ts"; 5 - import type { KeyEvent } from "./input.ts"; 6 - import { parseKey } from "./input.ts"; 5 + import type { KeyEvent, MouseEvent, InputEvent } from "./input.ts"; 6 + import { parseInput, isMouseEvent, MOUSE_ENABLE_SGR, MOUSE_DISABLE_SGR } from "./input.ts"; 7 + import { createFocusManager, type FocusManager } from "./focus.ts"; 7 8 import { hideCursor, showCursor, reset } from "./colors.ts"; 8 9 import { CellBuffer, diff, fullRender } from "./buffer.ts"; 9 10 import { recordFrame, getCurrentFPS, isFPSVisible } from "./fps.ts"; ··· 21 22 overlay?: () => Screen | null; 22 23 /** Called before the screen's handleKey. Return true = key consumed, false = pass to screen. */ 23 24 onKey?: (key: KeyEvent) => boolean; 25 + /** Called before the screen's handleMouse. Return true = consumed. */ 26 + onMouse?: (event: MouseEvent) => boolean; 24 27 /** Theme provider. Defaults to coolBlue. */ 25 28 theme?: () => Theme; 26 29 /** Box style provider. Defaults to "rounded". */ 27 30 boxStyle?: () => BoxStyle; 31 + /** Enable SGR mouse reporting on start (CSI ?1002h + ?1006h) and disable 32 + * on stop. Default: false. Enable this when your app wants to receive 33 + * mouse events via screen.handleMouse / onMouse. */ 34 + mouse?: boolean; 35 + /** Shared focus manager. If omitted, `app()` creates one automatically 36 + * and exposes it as `ctx.focus` on every screen render/handleKey call. 37 + * Provide your own to share focus state across app instances (rare). */ 38 + focus?: FocusManager; 28 39 } 29 40 30 41 /** A running TUI app with lifecycle control. */ ··· 53 64 let exitHandler: (() => void) | null = null; 54 65 let activeScreen: Screen | null = null; 55 66 let activeOverlay: Screen | null = null; 67 + const focus: FocusManager = config.focus ?? createFocusManager(); 56 68 57 69 function getSize(): [number, number] { 58 70 return [(stdout as any).rows ?? 35, (stdout as any).columns ?? 120]; ··· 82 94 closeOverlay: () => {}, 83 95 isTextInputActive: () => false, 84 96 setTextInputActive: () => {}, 97 + quit: () => { 98 + self.stop(); 99 + process.exit(0); 100 + }, 101 + focus, 85 102 }; 86 103 } 87 104 ··· 166 183 function registerListeners(): void { 167 184 stdinHandler = (data: Buffer | string) => { 168 185 const buf = typeof data === "string" ? Buffer.from(data) : data; 169 - const keys = parseKey(buf); 170 - for (const key of keys) { 171 - // Global key interceptor 186 + const events = parseInput(buf); 187 + for (const event of events) { 188 + if (isMouseEvent(event)) { 189 + // Global mouse interceptor. 190 + if (config.onMouse && config.onMouse(event)) continue; 191 + const [rows, cols] = getSize(); 192 + const ctx = createContext(rows, cols); 193 + const scr = resolveScreen(); 194 + scr.handleMouse?.(event, ctx); 195 + continue; 196 + } 197 + const key = event; 198 + 199 + // Global key interceptor — if onKey returns true, the screen never 200 + // sees the event (used for global shortcuts like command palette). 172 201 if (config.onKey && config.onKey(key)) continue; 173 - // Screen key handler 202 + 203 + // Default ctrl+c handler: quits unless the global onKey already 204 + // consumed it above. Screens that want to override this (e.g. a 205 + // composer with double-ctrl-c to confirm) can intercept via 206 + // config.onKey. 207 + if (key.name === "c" && key.ctrl) { 208 + self.stop(); 209 + process.exit(130); 210 + } 211 + 212 + // Screen key handler. Return value is a hint for nested routing; 213 + // the app ignores it. Screens that want to quit call ctx.quit() 214 + // explicitly — returning false from here used to quit the app 215 + // and was a footgun. 174 216 const [rows, cols] = getSize(); 175 217 const ctx = createContext(rows, cols); 176 218 const scr = resolveScreen(); 177 - const cont = scr.handleKey(key, ctx); 178 - if (!cont) { 179 - self.stop(); 180 - process.exit(0); 181 - } 219 + scr.handleKey(key, ctx); 182 220 } 183 221 }; 184 222 stdin.on("data", stdinHandler); ··· 207 245 208 246 function enterTerminal(): void { 209 247 stdout.write(enterAltScreen + hideCursor()); 248 + if (config.mouse) stdout.write(MOUSE_ENABLE_SGR); 210 249 if (stdin.isTTY) stdin.setRawMode(true); 211 250 stdin.resume(); 212 251 } 213 252 214 253 function leaveTerminal(full: boolean): void { 254 + if (config.mouse) stdout.write(MOUSE_DISABLE_SGR); 215 255 if (full) { 216 256 stdout.write(showCursor() + reset() + leaveAltScreen); 217 257 } else {
+11 -3
src/tui/builders.ts
··· 62 62 export function text( 63 63 str: string, 64 64 color?: Color, 65 - opts?: { bold?: boolean; dim?: boolean; italic?: boolean; truncate?: boolean; wrap?: boolean; highlight?: (text: string) => import("./nodes.ts").Span[] }, 65 + opts?: { 66 + bold?: boolean; dim?: boolean; italic?: boolean; inverse?: boolean; 67 + background?: Color; 68 + truncate?: boolean; wrap?: boolean; 69 + highlight?: (text: string) => import("./nodes.ts").Span[]; 70 + }, 66 71 ): TextNode { 67 72 return { type: "text", text: str, color, ...opts }; 68 73 } ··· 127 132 export function panel( 128 133 title: string, 129 134 children: UINode[], 130 - style?: BoxStyle, 135 + opts?: BoxStyle | { style?: BoxStyle; footerTitle?: string }, 131 136 ): PanelNode { 132 - return { type: "panel", title, children, style }; 137 + if (opts == null || typeof opts === "string") { 138 + return { type: "panel", title, children, style: opts }; 139 + } 140 + return { type: "panel", title, children, style: opts.style, footerTitle: opts.footerTitle }; 133 141 } 134 142 135 143 export function scrollable<T>(
+134
src/tui/focus.ts
··· 1 + // Focus manager — a small, stack-based event router for TUI apps that 2 + // have panes / overlays / nested widgets. 3 + // 4 + // Why: without this, every app rolls its own "which-area-gets-this-key" 5 + // dispatcher. We've done that in the reminders TUI, the playground, and 6 + // the interactive pty list — three apps, three bespoke routers, three 7 + // ways to get it wrong. The most common bug: a key that one layer 8 + // "didn't care about" silently reaches another layer that DID care and 9 + // triggers the wrong action. 10 + // 11 + // Model: 12 + // - Scopes form a stack. Innermost = most recently pushed. 13 + // - Each scope has optional onKey / onMouse handlers. 14 + // - Dispatch walks innermost -> outermost. First handler to return 15 + // true consumes the event; the rest are skipped. 16 + // - A scope can declare itself conditionally active via `active()`. 17 + // Inactive scopes are skipped during dispatch (but still kept in 18 + // the stack so their disposers and onKey closures stay stable). 19 + // 20 + // Typical use: 21 + // 22 + // // Outermost: global shortcuts. 23 + // focus.push({ 24 + // id: "global", 25 + // onKey(key, ctx) { 26 + // if (key.char === "q") { ctx.quit(); return true; } 27 + // return false; 28 + // }, 29 + // }); 30 + // 31 + // // Per-pane scopes. Both stay pushed; only the active one dispatches. 32 + // focus.push({ 33 + // id: "sidebar", 34 + // active: () => pane.get() === "sidebar", 35 + // onKey(key) { if (key.name === "up") { ... return true; } return false; }, 36 + // }); 37 + // focus.push({ 38 + // id: "main", 39 + // active: () => pane.get() === "main", 40 + // onKey(key, ctx) { 41 + // return currentDemo().handleKey(key, ctx); 42 + // }, 43 + // }); 44 + // 45 + // // Modal / overlay appears on top — its onKey runs first while alive. 46 + // const dispose = focus.push({ id: "confirm-delete", onKey: ... }); 47 + // // when closed: 48 + // dispose(); 49 + 50 + import type { KeyEvent, MouseEvent } from "./input.ts"; 51 + import type { ScreenContext } from "./types.ts"; 52 + 53 + export interface FocusScope { 54 + /** Short identifier — used for debugging and introspection, not 55 + * required to be unique (though it usually is). */ 56 + id: string; 57 + /** Predicate controlling whether this scope dispatches events. Defaults 58 + * to always-active. Use this to keep multiple sibling scopes in the 59 + * stack (e.g. one per pane) and switch which one responds. */ 60 + active?: () => boolean; 61 + /** Returns true when the scope consumed the key. False lets the event 62 + * bubble to the next-outer scope. */ 63 + onKey?: (key: KeyEvent, ctx: ScreenContext) => boolean; 64 + /** Same semantics as onKey for mouse events. */ 65 + onMouse?: (event: MouseEvent, ctx: ScreenContext) => boolean; 66 + } 67 + 68 + export interface FocusManager { 69 + /** Push a scope onto the stack. Returns a disposer that removes it. 70 + * The disposer is idempotent — calling it twice is safe. */ 71 + push(scope: FocusScope): () => void; 72 + /** Current innermost-active scope, or null if none. */ 73 + current(): FocusScope | null; 74 + /** Snapshot of every scope, root -> innermost. */ 75 + stack(): readonly FocusScope[]; 76 + /** Dispatch a key event. Returns true if any scope consumed it. */ 77 + dispatchKey(key: KeyEvent, ctx: ScreenContext): boolean; 78 + /** Dispatch a mouse event. */ 79 + dispatchMouse(event: MouseEvent, ctx: ScreenContext): boolean; 80 + } 81 + 82 + export function createFocusManager(): FocusManager { 83 + const scopes: FocusScope[] = []; 84 + 85 + function isActive(scope: FocusScope): boolean { 86 + return scope.active ? scope.active() : true; 87 + } 88 + 89 + return { 90 + push(scope: FocusScope): () => void { 91 + scopes.push(scope); 92 + let disposed = false; 93 + return () => { 94 + if (disposed) return; 95 + disposed = true; 96 + const i = scopes.indexOf(scope); 97 + if (i >= 0) scopes.splice(i, 1); 98 + }; 99 + }, 100 + 101 + current(): FocusScope | null { 102 + for (let i = scopes.length - 1; i >= 0; i--) { 103 + if (isActive(scopes[i])) return scopes[i]; 104 + } 105 + return null; 106 + }, 107 + 108 + stack(): readonly FocusScope[] { 109 + return scopes.slice(); 110 + }, 111 + 112 + dispatchKey(key: KeyEvent, ctx: ScreenContext): boolean { 113 + // Iterate on a snapshot so a handler can push or pop scopes 114 + // without disturbing the current dispatch pass. 115 + const snapshot = scopes.slice(); 116 + for (let i = snapshot.length - 1; i >= 0; i--) { 117 + const scope = snapshot[i]; 118 + if (!isActive(scope)) continue; 119 + if (scope.onKey && scope.onKey(key, ctx)) return true; 120 + } 121 + return false; 122 + }, 123 + 124 + dispatchMouse(event: MouseEvent, ctx: ScreenContext): boolean { 125 + const snapshot = scopes.slice(); 126 + for (let i = snapshot.length - 1; i >= 0; i--) { 127 + const scope = snapshot[i]; 128 + if (!isActive(scope)) continue; 129 + if (scope.onMouse && scope.onMouse(event, ctx)) return true; 130 + } 131 + return false; 132 + }, 133 + }; 134 + }
+82
src/tui/hit-test.ts
··· 1 + // Hit-testing for UINode trees. After layoutRoot runs, every node has a 2 + // _rect (x, y, width, height) in screen coordinates. Given a mouse event 3 + // with (x, y) in the same coordinate space, this walks the tree top-down 4 + // and returns the deepest node whose rect contains the point, plus the 5 + // chain of ancestors that also contained it. 6 + // 7 + // Consumers use this to route clicks to the right widget. Example: 8 + // 9 + // handleMouse(e, ctx) { 10 + // const hit = hitTest(renderedNodes, e.x, e.y); 11 + // if (!hit) return false; 12 + // // hit.path is [root, ..., deepest] — find the nearest selectable. 13 + // const sel = hit.path.find(n => n.type === "selectable"); 14 + // if (sel) /* route the click to the selectable */; 15 + // } 16 + 17 + import type { UINode, Rect } from "./nodes.ts"; 18 + 19 + export interface HitResult { 20 + /** The deepest node containing the point. */ 21 + node: UINode; 22 + /** Root-first chain of nodes containing the point. Last element === node. */ 23 + path: UINode[]; 24 + } 25 + 26 + function rectContains(rect: Rect, x: number, y: number): boolean { 27 + return x >= rect.x && x < rect.x + rect.width 28 + && y >= rect.y && y < rect.y + rect.height; 29 + } 30 + 31 + /** Children that contain other UINodes and therefore participate in 32 + * hit-testing. Leaf nodes (text, spacer, etc.) have no children. */ 33 + function childrenOf(node: UINode): UINode[] { 34 + switch (node.type) { 35 + case "row": return node.children; 36 + case "column": return node.children; 37 + case "hstack": return node.children as unknown as UINode[]; 38 + case "panel": return node.children; 39 + case "scrollable": 40 + case "selectable": 41 + // Items are arrays of arrays; flatten one level so walkers see every 42 + // rendered child row. 43 + return node.items.flat(); 44 + default: return []; 45 + } 46 + } 47 + 48 + /** Find the deepest node whose _rect contains (x, y). Returns null if no 49 + * node covers the point (e.g. click outside the rendered area). */ 50 + export function hitTest(roots: UINode[], x: number, y: number): HitResult | null { 51 + const path: UINode[] = []; 52 + for (const root of roots) { 53 + if (!root._rect || !rectContains(root._rect, x, y)) continue; 54 + path.push(root); 55 + descend(root, x, y, path); 56 + return { node: path[path.length - 1], path }; 57 + } 58 + return null; 59 + } 60 + 61 + function descend(node: UINode, x: number, y: number, path: UINode[]): void { 62 + const kids = childrenOf(node); 63 + for (const child of kids) { 64 + if (!child._rect || !rectContains(child._rect, x, y)) continue; 65 + path.push(child); 66 + descend(child, x, y, path); 67 + return; // Only one child per depth level should contain the point. 68 + } 69 + } 70 + 71 + /** Convenience: find the nearest ancestor of a given type in a hit path. 72 + * Useful when you want "whichever scrollable/selectable/tabs the user 73 + * clicked inside, not the deepest text node." */ 74 + export function findInPath<T extends UINode["type"]>( 75 + hit: HitResult, 76 + type: T, 77 + ): Extract<UINode, { type: T }> | null { 78 + for (let i = hit.path.length - 1; i >= 0; i--) { 79 + if (hit.path[i].type === type) return hit.path[i] as Extract<UINode, { type: T }>; 80 + } 81 + return null; 82 + }
+15 -1
src/tui/index.ts
··· 1 1 // Public API for the declarative TUI framework 2 2 3 3 // Input 4 - export { parseKey, type KeyEvent } from "./input.ts"; 4 + export { 5 + parseKey, parseInput, isMouseEvent, 6 + MOUSE_ENABLE_SGR, MOUSE_DISABLE_SGR, 7 + type KeyEvent, type MouseEvent, type MouseButton, type MouseAction, 8 + type InputEvent, 9 + } from "./input.ts"; 5 10 6 11 // Signals 7 12 export { ··· 80 85 // Screen wrapper 81 86 export { screen, overlay, type DeclarativeScreenConfig, type OverlayConfig } from "./screen.ts"; 82 87 88 + // Hit-testing (for mouse handlers) 89 + export { hitTest, findInPath, type HitResult } from "./hit-test.ts"; 90 + 91 + // Focus manager — stack-based key/mouse routing for panes, overlays, nested widgets. 92 + export { createFocusManager, type FocusManager, type FocusScope } from "./focus.ts"; 93 + 83 94 // Animation 84 95 export { 85 96 spinnerChar, startSpinnerTimer, stopSpinnerTimer, isSpinnerRunning, ··· 92 103 93 104 // App lifecycle 94 105 export { app, type AppConfig, type App } from "./app.ts"; 106 + 107 + // Widgets — higher-level components built on the core primitives. 108 + export * from "./widgets/index.ts"; 95 109 96 110 // Session management 97 111 export {
+117 -2
src/tui/input.ts
··· 1 - // Raw stdin keypress parsing 1 + // Raw stdin input parsing — keyboard + mouse. 2 2 3 3 export interface KeyEvent { 4 + /** Discriminator for InputEvent union. Implicit on the existing API so 5 + * pre-mouse consumers keep working — the default is "key" when absent. */ 6 + kind?: "key"; 4 7 name: string; 5 8 char?: string; 6 9 ctrl: boolean; ··· 8 11 shift: boolean; 9 12 } 10 13 14 + export type MouseButton = "left" | "middle" | "right" | "none"; 15 + export type MouseAction = "press" | "release" | "drag" | "move" | "scrollUp" | "scrollDown"; 16 + 17 + export interface MouseEvent { 18 + kind: "mouse"; 19 + action: MouseAction; 20 + button: MouseButton; 21 + /** 0-based column of the cell the pointer is over. */ 22 + x: number; 23 + /** 0-based row. */ 24 + y: number; 25 + ctrl: boolean; 26 + alt: boolean; 27 + shift: boolean; 28 + } 29 + 30 + export type InputEvent = KeyEvent | MouseEvent; 31 + 32 + /** Type guard: narrows an InputEvent to a MouseEvent. */ 33 + export function isMouseEvent(e: InputEvent): e is MouseEvent { 34 + return (e as MouseEvent).kind === "mouse"; 35 + } 36 + 37 + /** ANSI sequences to enable / disable SGR mouse reporting. Use these when 38 + * your app wants to receive mouse events. `parseInput` knows how to 39 + * decode them once enabled. */ 40 + export const MOUSE_ENABLE_SGR = "\x1b[?1002h\x1b[?1006h"; 41 + export const MOUSE_DISABLE_SGR = "\x1b[?1006l\x1b[?1002l"; 42 + 43 + function decodeMouse( 44 + buttonCode: number, x: number, y: number, isRelease: boolean, 45 + ): MouseEvent | null { 46 + // SGR mouse button-code encoding: 47 + // low 2 bits: button (0=left, 1=middle, 2=right, 3=none for motion) 48 + // bit 2 (0x04): shift 49 + // bit 3 (0x08): alt 50 + // bit 4 (0x10): ctrl 51 + // bit 5 (0x20): motion flag (drag when a button is down) 52 + // bit 6 (0x40): wheel (64 = scroll-up, 65 = scroll-down) 53 + const shift = (buttonCode & 0x04) !== 0; 54 + const alt = (buttonCode & 0x08) !== 0; 55 + const ctrl = (buttonCode & 0x10) !== 0; 56 + const motion = (buttonCode & 0x20) !== 0; 57 + const wheel = (buttonCode & 0x40) !== 0; 58 + const low = buttonCode & 0x03; 59 + 60 + const common = { x: Math.max(0, x - 1), y: Math.max(0, y - 1), ctrl, alt, shift }; 61 + 62 + if (wheel) { 63 + return { 64 + kind: "mouse", 65 + action: low === 0 ? "scrollUp" : "scrollDown", 66 + button: "none", 67 + ...common, 68 + }; 69 + } 70 + 71 + const button: MouseButton = 72 + low === 0 ? "left" : low === 1 ? "middle" : low === 2 ? "right" : "none"; 73 + 74 + if (isRelease) { 75 + return { kind: "mouse", action: "release", button, ...common }; 76 + } 77 + if (motion) { 78 + return { kind: "mouse", action: button === "none" ? "move" : "drag", button, ...common }; 79 + } 80 + return { kind: "mouse", action: "press", button, ...common }; 81 + } 82 + 83 + /** Legacy entry point — keyboard only. Existing consumers keep using this; 84 + * new code that wants mouse should call `parseInput` instead. */ 11 85 export function parseKey(data: Buffer): KeyEvent[] { 12 - const events: KeyEvent[] = []; 86 + return parseInput(data).filter((e): e is KeyEvent => !isMouseEvent(e)); 87 + } 88 + 89 + /** Parse a stdin chunk into an ordered list of keyboard + mouse events. */ 90 + export function parseInput(data: Buffer): InputEvent[] { 91 + const events: InputEvent[] = []; 13 92 const str = data.toString("utf8"); 14 93 let i = 0; 15 94 ··· 20 99 if (i + 1 < str.length && str[i + 1] === "[") { 21 100 const rest = str.slice(i + 2); 22 101 102 + // SGR mouse: ESC[<b;x;y;(M|m) 103 + // Recognised BEFORE the generic-arrow branches because the leading 104 + // `<` disambiguates unambiguously and any other CSI starts with a 105 + // parameter or a letter (never `<`). 106 + if (rest[0] === "<") { 107 + const mouseMatch = rest.match(/^<(\d+);(\d+);(\d+)([Mm])/); 108 + if (mouseMatch) { 109 + const b = parseInt(mouseMatch[1], 10); 110 + const x = parseInt(mouseMatch[2], 10); 111 + const y = parseInt(mouseMatch[3], 10); 112 + const release = mouseMatch[4] === "m"; 113 + const ev = decodeMouse(b, x, y, release); 114 + if (ev) events.push(ev); 115 + i += 2 + mouseMatch[0].length; 116 + continue; 117 + } 118 + } 119 + 23 120 // Arrow keys, home, end 24 121 if (rest[0] === "A") { events.push({ name: "up", ctrl: false, alt: false, shift: false }); i += 3; continue; } 25 122 if (rest[0] === "B") { events.push({ name: "down", ctrl: false, alt: false, shift: false }); i += 3; continue; } ··· 27 124 if (rest[0] === "D") { events.push({ name: "left", ctrl: false, alt: false, shift: false }); i += 3; continue; } 28 125 if (rest[0] === "H") { events.push({ name: "home", ctrl: false, alt: false, shift: false }); i += 3; continue; } 29 126 if (rest[0] === "F") { events.push({ name: "end", ctrl: false, alt: false, shift: false }); i += 3; continue; } 127 + 128 + // Arrows with modifiers: ESC[1;<mods><letter>. 129 + // mods - 1 is a bitmask: bit0=shift, bit1=alt, bit2=ctrl (same scheme 130 + // as the kitty keyboard protocol). Terminal.app and kitty both emit 131 + // this form for option+arrow (alt) and shift+arrow. 132 + const modArrow = rest.match(/^1;(\d+)([ABCDHF])/); 133 + if (modArrow) { 134 + const mods = parseInt(modArrow[1], 10) - 1; 135 + const shift = (mods & 0x01) !== 0; 136 + const alt = (mods & 0x02) !== 0; 137 + const ctrl = (mods & 0x04) !== 0; 138 + const letterToName: Record<string, string> = { 139 + A: "up", B: "down", C: "right", D: "left", H: "home", F: "end", 140 + }; 141 + events.push({ name: letterToName[modArrow[2]], ctrl, alt, shift }); 142 + i += 2 + modArrow[0].length; 143 + continue; 144 + } 30 145 31 146 // Shift+Tab (legacy xterm encoding): ESC[Z 32 147 if (rest[0] === "Z") { events.push({ name: "backtab", ctrl: false, alt: false, shift: true }); i += 3; continue; }
+9 -6
src/tui/interactive.ts
··· 375 375 ]; 376 376 }, 377 377 378 - handleKey(key: KeyEvent, _ctx: ScreenContext): boolean { 378 + handleKey(key: KeyEvent, ctx: ScreenContext): boolean { 379 379 const total = totalItems.get(); 380 380 const idx = selectedIndex.peek(); 381 381 const maxIndex = total - 1; ··· 437 437 batch(() => { filterText.set(""); selectedIndex.set(0); }); 438 438 return true; 439 439 } 440 - return false; // quit 440 + ctx.quit(); 441 + return true; 441 442 } 442 443 if (key.char === "q" && !key.ctrl && !key.alt && !filterText.peek()) { 443 - return false; // quit 444 + ctx.quit(); 445 + return true; 444 446 } 445 - if (key.name === "c" && key.ctrl) { 446 - return false; // quit 447 - } 447 + // ctrl+c is now handled globally by app(); leave the explicit check off 448 + // here so the global default fires. If someone binds it in a context 449 + // that should NOT quit (e.g. a composer with double-ctrl-c semantics), 450 + // they intercept via AppConfig.onKey. 448 451 if (key.name === "backspace") { 449 452 if (filterText.peek().length > 0) { 450 453 batch(() => { filterText.set(filterText.peek().slice(0, -1)); selectedIndex.set(0); });
+9
src/tui/nodes.ts
··· 37 37 type: "text"; 38 38 text: string; 39 39 color?: Color; 40 + /** Explicit background color. Default is the ambient panel/screen bg. */ 41 + background?: Color; 40 42 bold?: boolean; 41 43 dim?: boolean; 42 44 italic?: boolean; 45 + /** Swap fg/bg for the rendered cells. Used for text cursors (paint the 46 + * character UNDER the cursor with colors swapped, so the glyph doesn't 47 + * push neighbors sideways). */ 48 + inverse?: boolean; 43 49 truncate?: boolean; 44 50 /** Soft-wrap text to fit the available width. Expands height as needed. */ 45 51 wrap?: boolean; ··· 130 136 export interface PanelNode { 131 137 type: "panel"; 132 138 title: string; 139 + /** Optional caption rendered on the bottom border (like mactop's 140 + * "4/17 layout (skyblue)" strip). Same style as the top title. */ 141 + footerTitle?: string; 133 142 children: UINode[]; 134 143 style?: BoxStyle; 135 144 _rect?: Rect;
+44 -9
src/tui/screen.ts
··· 43 43 id: string; 44 44 render: (ctx: ScreenContext) => UINode[]; 45 45 handleKey?: (key: KeyEvent, ctx: ScreenContext) => boolean; 46 + handleMouse?: (event: import("./input.ts").MouseEvent, ctx: ScreenContext) => boolean; 46 47 onEnter?: (ctx: ScreenContext) => void; 47 48 onLeave?: (ctx: ScreenContext) => void; 48 49 /** ··· 121 122 return config.handleKey?.(key, ctx) ?? true; 122 123 }, 123 124 125 + handleMouse(event, ctx) { 126 + return config.handleMouse?.(event, ctx) ?? false; 127 + }, 128 + 124 129 onEnter(ctx: ScreenContext): void { 125 130 config.onEnter?.(ctx); 126 131 if (config.tick && !tickTimer) { ··· 151 156 height: number | ((rows: number) => number); 152 157 render: (ctx: ScreenContext) => UINode[]; 153 158 handleKey?: (key: KeyEvent, ctx: ScreenContext) => boolean; 159 + handleMouse?: (event: import("./input.ts").MouseEvent, ctx: ScreenContext) => boolean; 154 160 onEnter?: (ctx: ScreenContext) => void; 155 161 onLeave?: (ctx: ScreenContext) => void; 156 162 } ··· 250 256 return config.handleKey?.(key, ctx) ?? true; 251 257 }, 252 258 259 + handleMouse(event, ctx) { 260 + return config.handleMouse?.(event, ctx) ?? false; 261 + }, 262 + 253 263 onEnter(ctx: ScreenContext): void { 254 264 config.onEnter?.(ctx); 255 265 }, ··· 427 437 ): void { 428 438 if (row < 0 || row >= buf.rows) return; 429 439 const b = boxChars(style); 430 - if (col >= 0 && col < buf.cols) buf.cells[row][col] = makeCell(b.lj, fgc, bgc); 440 + // Preserve the existing background at each cell. Separators live inside 441 + // panels; if we zero the bg to null here the row renders with the 442 + // terminal's default background, showing through as a grey band. 443 + const bgAt = (c: number): [number, number, number] | null => 444 + bgc ?? buf.cells[row][c]?.bg ?? null; 445 + if (col >= 0 && col < buf.cols) buf.cells[row][col] = makeCell(b.lj, fgc, bgAt(col)); 431 446 for (let c = col + 1; c < col + width - 1; c++) { 432 - if (c >= 0 && c < buf.cols) buf.cells[row][c] = makeCell(b.h, fgc, bgc); 447 + if (c >= 0 && c < buf.cols) buf.cells[row][c] = makeCell(b.h, fgc, bgAt(c)); 433 448 } 434 - if (col + width - 1 >= 0 && col + width - 1 < buf.cols) buf.cells[row][col + width - 1] = makeCell(b.rj, fgc, bgc); 449 + const last = col + width - 1; 450 + if (last >= 0 && last < buf.cols) buf.cells[row][last] = makeCell(b.rj, fgc, bgAt(last)); 435 451 } 436 452 437 453 // --- Main tree-to-buffer renderer --- ··· 461 477 462 478 switch (node.type) { 463 479 case "text": { 464 - const defaultColor = resolveColor(node.color, theme); 480 + let fgColor = resolveColor(node.color, theme); 481 + let bgColor = resolveColor(node.background, theme); 482 + // inverse: swap fg/bg. If bg wasn't set, fall back to a sensible 483 + // default so the cell is visibly "highlighted" — use the ambient 484 + // foreground as the new bg, and the ambient bg as the new fg. 485 + if (node.inverse) { 486 + const fallbackBg = fgColor ?? theme.fg1; 487 + const fallbackFg = bgColor ?? theme.bg1; 488 + fgColor = fallbackFg; 489 + bgColor = fallbackBg; 490 + } 465 491 const defaultBold = node.bold ?? false; 466 492 const defaultDim = node.dim ?? false; 467 493 const defaultItalic = node.italic ?? false; ··· 478 504 const lineY = rect.y + i; 479 505 if (lineY >= buf.rows) break; 480 506 if (spans) { 481 - writeSpannedBuf(buf, lineY, rect.x, lines[i], offsets[i], spans, defaultColor, defaultBold, defaultDim, defaultItalic, theme, rect.width); 507 + writeSpannedBuf(buf, lineY, rect.x, lines[i], offsets[i], spans, fgColor, defaultBold, defaultDim, defaultItalic, theme, rect.width); 482 508 } else { 483 - writeStringBuf(buf, lineY, rect.x, lines[i], defaultColor, null, defaultBold, defaultDim, defaultItalic); 509 + writeStringBuf(buf, lineY, rect.x, lines[i], fgColor, bgColor, defaultBold, defaultDim, defaultItalic); 484 510 } 485 511 } 486 512 } else { ··· 502 528 } 503 529 } 504 530 if (spans) { 505 - writeSpannedBuf(buf, rect.y, rect.x, displayContent, 0, spans, defaultColor, defaultBold, defaultDim, defaultItalic, theme, rect.width); 531 + writeSpannedBuf(buf, rect.y, rect.x, displayContent, 0, spans, fgColor, defaultBold, defaultDim, defaultItalic, theme, rect.width); 506 532 } else { 507 - writeStringBuf(buf, rect.y, rect.x, displayContent, defaultColor, null, defaultBold, defaultDim, defaultItalic); 533 + writeStringBuf(buf, rect.y, rect.x, displayContent, fgColor, bgColor, defaultBold, defaultDim, defaultItalic); 508 534 } 509 535 } 510 536 break; ··· 561 587 fillBufRect(buf, rect.y, rect.x, rect.width, rect.height, null, theme.bg2); 562 588 // Draw border 563 589 drawBoxBuf(buf, rect.y, rect.x, rect.width, rect.height, style, theme.border, theme.bg2); 564 - // Title 590 + // Top title 565 591 if (node.title) { 566 592 writeStringBuf(buf, rect.y, rect.x + 2, " ", theme.border, theme.bg2); 567 593 writeStringBuf(buf, rect.y, rect.x + 3, node.title, theme.fgAc, theme.bg2, true); 568 594 const titleEnd = rect.x + 3 + textWidth(node.title); 569 595 writeStringBuf(buf, rect.y, titleEnd, " ", theme.border, theme.bg2); 596 + } 597 + // Bottom caption (optional) — rendered on the bottom border with the 598 + // same chrome as the top title. Left-aligned to mirror the top. 599 + if (node.footerTitle) { 600 + const by = rect.y + rect.height - 1; 601 + writeStringBuf(buf, by, rect.x + 2, " ", theme.border, theme.bg2); 602 + writeStringBuf(buf, by, rect.x + 3, node.footerTitle, theme.fgAc, theme.bg2, true); 603 + const capEnd = rect.x + 3 + textWidth(node.footerTitle); 604 + writeStringBuf(buf, by, capEnd, " ", theme.border, theme.bg2); 570 605 } 571 606 // Render children 572 607 for (const child of node.children) {
+13
src/tui/types.ts
··· 49 49 closeOverlay: () => void; 50 50 isTextInputActive: () => boolean; 51 51 setTextInputActive: (active: boolean) => void; 52 + /** Explicit app-exit hook. Screens that want to quit (on q, ctrl+c, 53 + * a menu action, etc.) call this instead of returning false from 54 + * handleKey — returning false used to quit the app and was a footgun. 55 + * Safe to call from anywhere; tears down the app and exits the process. */ 56 + quit: () => void; 57 + /** Stack-based focus manager for routing keys/mouse to nested scopes. 58 + * Provided by `app()` automatically — every screen has access to the 59 + * same instance. Screens opt into it by calling `ctx.focus.dispatchKey` 60 + * from their `handleKey` (or ignore it entirely). */ 61 + focus: import("./focus.ts").FocusManager; 52 62 [key: string]: any; 53 63 } 54 64 ··· 58 68 render(ctx: ScreenContext): string; 59 69 renderToBuffer(ctx: ScreenContext): import("./buffer.ts").CellBuffer; 60 70 handleKey(key: KeyEvent, ctx: ScreenContext): boolean; 71 + /** Optional mouse event handler. Only fires when AppConfig.mouse is on. 72 + * Return value is a hint for parent routing; app() ignores it. */ 73 + handleMouse?(event: import("./input.ts").MouseEvent, ctx: ScreenContext): boolean; 61 74 onEnter?(ctx: ScreenContext): void; 62 75 onLeave?(ctx: ScreenContext): void; 63 76 }
+109
src/tui/widgets/bar-chart.ts
··· 1 + // Bar chart — vertical-bar histogram rendered with unicode block characters. 2 + // Good for "CPU per core", power-draw over time, any small-cardinality 3 + // histogram. For a high-cardinality scrolling chart use `sparkline` instead. 4 + // 5 + // Each bar is `barWidth` columns wide (default 2) with a one-column gap 6 + // between bars (default). The bar height is encoded with the same 8-step 7 + // block scale as sparkline, but here we use `height` rows per bar instead 8 + // of a single row. 9 + 10 + import { canvas } from "../builders.ts"; 11 + import type { UINode, Color } from "../nodes.ts"; 12 + 13 + const BLOCKS = [" ", "\u2581", "\u2582", "\u2583", "\u2584", 14 + "\u2585", "\u2586", "\u2587", "\u2588"]; 15 + 16 + export interface BarChartItem { 17 + label?: string; 18 + value: number; 19 + /** Per-bar color override. Falls back to `opts.color`. */ 20 + color?: Color; 21 + } 22 + 23 + export interface BarChartOptions { 24 + /** Total canvas height in rows (excluding label row). Default 6. */ 25 + height?: number; 26 + /** Width of each bar in columns. Default 2. */ 27 + barWidth?: number; 28 + /** Gap between bars in columns. Default 1. */ 29 + gap?: number; 30 + /** Lower bound of the value axis. Default min(values). */ 31 + min?: number; 32 + /** Upper bound of the value axis. Default max(values). */ 33 + max?: number; 34 + /** Default bar color. Default "accent". */ 35 + color?: Color; 36 + /** Render short labels under each bar (first char of `item.label`). */ 37 + showLabels?: boolean; 38 + /** Color for labels. Default "muted". */ 39 + labelColor?: Color; 40 + } 41 + 42 + /** Render a bar chart as a Canvas node. The caller places it inside a 43 + * row/column/panel like any other UINode. */ 44 + export function barChart(items: readonly BarChartItem[], opts: BarChartOptions = {}): UINode { 45 + const height = Math.max(2, Math.floor(opts.height ?? 6)); 46 + const barWidth = Math.max(1, Math.floor(opts.barWidth ?? 2)); 47 + const gap = Math.max(0, Math.floor(opts.gap ?? 1)); 48 + const showLabels = opts.showLabels ?? false; 49 + const labelColor = opts.labelColor ?? "muted"; 50 + const baseColor = opts.color ?? "accent"; 51 + 52 + const values = items.map(it => it.value); 53 + let lo = opts.min; 54 + let hi = opts.max; 55 + if (lo == null) { 56 + lo = values.length ? Math.min(...values.filter(Number.isFinite)) : 0; 57 + if (!Number.isFinite(lo)) lo = 0; 58 + } 59 + if (hi == null) { 60 + hi = values.length ? Math.max(...values.filter(Number.isFinite)) : 1; 61 + if (!Number.isFinite(hi)) hi = 1; 62 + } 63 + const range = hi - lo; 64 + 65 + const rowsForBars = height; 66 + const totalRows = showLabels ? rowsForBars + 1 : rowsForBars; 67 + 68 + return canvas((ctx) => { 69 + // Draw each bar column. 70 + items.forEach((item, barIdx) => { 71 + const v = Number.isFinite(item.value) ? item.value : lo!; 72 + const fracTotal = range <= 0 ? 0.5 : (v - lo!) / range; 73 + const clamped = Math.max(0, Math.min(1, fracTotal)); 74 + // Encode the total height in 1/8ths per row. Rows fill from the 75 + // bottom up, then the top row uses a partial block. 76 + const totalEighths = Math.round(clamped * rowsForBars * 8); 77 + const fullRows = Math.floor(totalEighths / 8); 78 + const topRemainder = totalEighths % 8; 79 + 80 + const barColor = item.color ?? baseColor; 81 + const leftCol = barIdx * (barWidth + gap); 82 + 83 + // Full rows (from the bottom). 84 + for (let r = 0; r < fullRows; r++) { 85 + const y = rowsForBars - 1 - r; 86 + for (let c = 0; c < barWidth; c++) { 87 + ctx.write(leftCol + c, y, BLOCKS[8], barColor); 88 + } 89 + } 90 + // Partial top row. 91 + if (topRemainder > 0) { 92 + const y = rowsForBars - 1 - fullRows; 93 + if (y >= 0) { 94 + for (let c = 0; c < barWidth; c++) { 95 + ctx.write(leftCol + c, y, BLOCKS[topRemainder], barColor); 96 + } 97 + } 98 + } 99 + 100 + // Label row. 101 + if (showLabels) { 102 + const labelStr = (item.label ?? "").slice(0, barWidth); 103 + if (labelStr.length > 0) { 104 + ctx.write(leftCol, rowsForBars, labelStr, labelColor); 105 + } 106 + } 107 + }); 108 + }, { height: totalRows }); 109 + }
+135
src/tui/widgets/command-palette.ts
··· 1 + // Command palette — a fuzzy-matched action runner overlay. A classic 2 + // "ctrl+p" / "ctrl+k" pattern: type → filter → enter to run. 3 + // 4 + // The consumer owns a `signal<CommandPaletteState | null>` (null when 5 + // the palette is closed). When opening, register your commands and pass 6 + // them through on every render + key dispatch. State is pure. 7 + 8 + import { row, column, text, panel } from "../builders.ts"; 9 + import type { UINode, ColumnNode } from "../nodes.ts"; 10 + import type { KeyEvent } from "../input.ts"; 11 + import { fuzzyMatch } from "../fuzzy.ts"; 12 + import { applyTextKey, type TextFieldState } from "./form.ts"; 13 + 14 + export interface Command { 15 + id: string; 16 + label: string; 17 + /** Optional one-liner shown dim next to the label. */ 18 + hint?: string; 19 + /** Arbitrary keywords mixed into the fuzzy match (tags, shortcuts). */ 20 + keywords?: string[]; 21 + /** Invoked when the user selects this command. Up to the consumer to 22 + * define — a command is just a row with an id + a callback. */ 23 + run: () => void; 24 + } 25 + 26 + export interface CommandPaletteState { 27 + query: TextFieldState; 28 + selectedIndex: number; 29 + } 30 + 31 + export function createCommandPaletteState(): CommandPaletteState { 32 + return { query: { text: "", cursor: 0 }, selectedIndex: 0 }; 33 + } 34 + 35 + export interface RankedCommand { 36 + cmd: Command; 37 + score: number; 38 + } 39 + 40 + /** Filter + rank commands by fuzzy match. Empty query returns all commands 41 + * in their original order. */ 42 + export function filterCommands( 43 + commands: readonly Command[], 44 + query: string, 45 + ): RankedCommand[] { 46 + const q = query.trim(); 47 + if (q.length === 0) return commands.map(c => ({ cmd: c, score: 0 })); 48 + const ranked: RankedCommand[] = []; 49 + for (const cmd of commands) { 50 + const hay = [cmd.label, cmd.hint ?? "", ...(cmd.keywords ?? [])].join(" "); 51 + const m = fuzzyMatch(q, hay); 52 + if (m.match) ranked.push({ cmd, score: m.score }); 53 + } 54 + ranked.sort((a, b) => b.score - a.score); 55 + return ranked; 56 + } 57 + 58 + export interface HandleCommandPaletteResult { 59 + state: CommandPaletteState; 60 + action: "run" | "cancel" | "edited" | "moved" | "none"; 61 + /** Present when action === "run". */ 62 + command?: Command; 63 + } 64 + 65 + export function handleCommandPaletteKey( 66 + state: CommandPaletteState, 67 + commands: readonly Command[], 68 + key: KeyEvent, 69 + ): HandleCommandPaletteResult { 70 + if (key.name === "escape") { 71 + return { state, action: "cancel" }; 72 + } 73 + 74 + const ranked = filterCommands(commands, state.query.text); 75 + 76 + if (key.name === "up") { 77 + const next = Math.max(0, state.selectedIndex - 1); 78 + return { state: { ...state, selectedIndex: next }, action: "moved" }; 79 + } 80 + if (key.name === "down") { 81 + const next = Math.min(Math.max(0, ranked.length - 1), state.selectedIndex + 1); 82 + return { state: { ...state, selectedIndex: next }, action: "moved" }; 83 + } 84 + if (key.name === "return") { 85 + const picked = ranked[state.selectedIndex]; 86 + if (picked) return { state, action: "run", command: picked.cmd }; 87 + return { state, action: "none" }; 88 + } 89 + 90 + const updatedQuery = applyTextKey(state.query, key); 91 + if (updatedQuery) { 92 + return { 93 + state: { query: updatedQuery, selectedIndex: 0 }, 94 + action: "edited", 95 + }; 96 + } 97 + return { state, action: "none" }; 98 + } 99 + 100 + /** Render the palette as an overlay panel with a query line + up to `limit` 101 + * matching commands. The selected row is highlighted. */ 102 + export function renderCommandPalette( 103 + state: CommandPaletteState, 104 + commands: readonly Command[], 105 + opts: { title?: string; limit?: number } = {}, 106 + ): UINode { 107 + const ranked = filterCommands(commands, state.query.text); 108 + const limit = opts.limit ?? 10; 109 + const visible = ranked.slice(0, limit); 110 + 111 + const queryLine = row( 112 + text(" > ", "accent", { bold: true }), 113 + text(state.query.text || "", "primary"), 114 + text("\u2588", "accent"), 115 + ); 116 + 117 + const rows: UINode[] = [queryLine]; 118 + if (visible.length === 0) { 119 + rows.push(row(text(" no matches", "muted", { dim: true }))); 120 + } else { 121 + visible.forEach((r, i) => { 122 + const selected = i === state.selectedIndex; 123 + const prefix = selected ? text(" \u25b8 ", "accent", { bold: true }) 124 + : text(" ", "muted"); 125 + const label = text(r.cmd.label, selected ? "accent" : "primary", { bold: selected }); 126 + const children: UINode[] = [prefix, label]; 127 + if (r.cmd.hint) { 128 + children.push(text(` ${r.cmd.hint}`, "muted", { dim: true })); 129 + } 130 + rows.push(row(...children)); 131 + }); 132 + } 133 + 134 + return panel(opts.title ?? "command palette", rows); 135 + }
+113
src/tui/widgets/command-registry.ts
··· 1 + // Global, signal-backed command registry. 2 + // 3 + // The bare `command-palette.ts` takes `Command[]` at render time — fine for 4 + // small apps, awkward for anything where commands should be "contributed" 5 + // from all corners of the codebase. This registry fixes that. 6 + // 7 + // Two entry points: 8 + // 9 + // registerGlobalCommand(cmd) -> dispose 10 + // Registers a command for the lifetime of the app. Returns a disposer 11 + // that removes it. 12 + // 13 + // useCommandScope(scopeId, commands) -> dispose 14 + // Registers commands under a named scope. Use this inside a screen's 15 + // mount/unmount lifecycle to surface commands only while the screen is 16 + // active. The common contextual use-case is binding to a focused item: 17 + // "useCommandScope(`focused:${id}`, [{ ... }])". 18 + // 19 + // `allCommands` is a computed signal that flattens everything currently 20 + // registered. The `command-palette.ts` widget already takes a `Command[]` 21 + // array — just pass `allCommands.get()` to get all live commands. 22 + 23 + import { signal, computed, type Signal, type Computed } from "../signals.ts"; 24 + import type { Command } from "./command-palette.ts"; 25 + 26 + /** Internal: rev-counter that bumps on every change so `allCommands` 27 + * recomputes. We store commands in a Map keyed by scopeId; the Map itself 28 + * is the state, the rev signal is how we propagate through the reactive 29 + * graph without allocating a new Map on every registration. */ 30 + const rev = signal(0); 31 + const scopes = new Map<string, Command[]>(); 32 + const GLOBAL = "__global__"; 33 + 34 + function touch(): void { 35 + rev.set(rev.peek() + 1); 36 + } 37 + 38 + /** Register a single command globally. Returns a disposer. */ 39 + export function registerGlobalCommand(cmd: Command): () => void { 40 + const existing = scopes.get(GLOBAL) ?? []; 41 + scopes.set(GLOBAL, [...existing, cmd]); 42 + touch(); 43 + return () => { 44 + const list = scopes.get(GLOBAL); 45 + if (!list) return; 46 + const next = list.filter(c => c.id !== cmd.id); 47 + if (next.length === 0) scopes.delete(GLOBAL); 48 + else scopes.set(GLOBAL, next); 49 + touch(); 50 + }; 51 + } 52 + 53 + /** Register a batch of commands under `scopeId`. Calling again with the 54 + * same `scopeId` REPLACES the previous batch — convenient for contextual 55 + * ("focused thing") commands that change with selection. Returns a 56 + * disposer that removes the whole scope. */ 57 + export function useCommandScope(scopeId: string, commands: Command[]): () => void { 58 + if (scopeId === GLOBAL) { 59 + throw new Error(`scope id "${GLOBAL}" is reserved`); 60 + } 61 + if (commands.length === 0) { 62 + scopes.delete(scopeId); 63 + } else { 64 + scopes.set(scopeId, commands); 65 + } 66 + touch(); 67 + return () => { 68 + if (scopes.delete(scopeId)) touch(); 69 + }; 70 + } 71 + 72 + /** Remove every command under `scopeId` (no-op if unknown). Useful when 73 + * you want to clear a scope without holding a disposer. */ 74 + export function clearCommandScope(scopeId: string): void { 75 + if (scopes.delete(scopeId)) touch(); 76 + } 77 + 78 + /** Look up a command by id across all scopes. Returns the first match. */ 79 + export function findCommand(id: string): Command | undefined { 80 + for (const list of scopes.values()) { 81 + const m = list.find(c => c.id === id); 82 + if (m) return m; 83 + } 84 + return undefined; 85 + } 86 + 87 + /** Reactive view of every registered command, flattened. Scope order is 88 + * insertion-order of the underlying Map. Global commands stay at the 89 + * front because they're inserted with the sentinel key at registration. */ 90 + export const allCommands: Computed<Command[]> = computed(() => { 91 + // Touch the rev signal so this recomputes on every registration change. 92 + rev.get(); 93 + const out: Command[] = []; 94 + // Iterate insertion-ordered entries. 95 + for (const list of scopes.values()) out.push(...list); 96 + return out; 97 + }); 98 + 99 + /** Run a command by id. Silently does nothing if the id is not registered. 100 + * Convenience for keybindings that trigger a named command without 101 + * opening the palette. */ 102 + export function runCommand(id: string): boolean { 103 + const cmd = findCommand(id); 104 + if (!cmd) return false; 105 + cmd.run(); 106 + return true; 107 + } 108 + 109 + /** For tests / debugging — wipes the entire registry. */ 110 + export function _resetCommandRegistry(): void { 111 + scopes.clear(); 112 + touch(); 113 + }
+85
src/tui/widgets/confirm.ts
··· 1 + // Confirm modal — convenience over `overlay()` for yes/no dialogs. 2 + // 3 + // Usage: own a `signal<ConfirmState | null>`. When you want to ask, set it 4 + // to a fresh state via `createConfirm(...)`. Route its overlay through 5 + // your app config's `overlay: () => ...`. On the `action: "yes" | "no"` 6 + // from `handleConfirmKey`, clear the signal and run whatever the answer 7 + // means in your app. 8 + 9 + import { row, column, text, panel, separator } from "../builders.ts"; 10 + import type { UINode } from "../nodes.ts"; 11 + import type { KeyEvent } from "../input.ts"; 12 + 13 + export interface ConfirmState { 14 + title: string; 15 + message: string; 16 + yesLabel: string; 17 + noLabel: string; 18 + focused: "yes" | "no"; 19 + } 20 + 21 + export interface CreateConfirmOptions { 22 + title: string; 23 + message: string; 24 + yesLabel?: string; 25 + noLabel?: string; 26 + /** Which button starts focused. Default "no" — safer for destructive ops. */ 27 + defaultFocus?: "yes" | "no"; 28 + } 29 + 30 + export function createConfirm(opts: CreateConfirmOptions): ConfirmState { 31 + return { 32 + title: opts.title, 33 + message: opts.message, 34 + yesLabel: opts.yesLabel ?? "Yes", 35 + noLabel: opts.noLabel ?? "No", 36 + focused: opts.defaultFocus ?? "no", 37 + }; 38 + } 39 + 40 + export interface HandleConfirmResult { 41 + state: ConfirmState; 42 + action: "yes" | "no" | "pending"; 43 + } 44 + 45 + /** Default bindings: 46 + * - left / right / tab / backtab -> toggle focus 47 + * - return -> commit the focused button 48 + * - escape -> always "no" 49 + * - y / n -> shortcut commit (case-insensitive) */ 50 + export function handleConfirmKey(state: ConfirmState, key: KeyEvent): HandleConfirmResult { 51 + if (key.name === "left" || key.name === "right" || 52 + key.name === "tab" || key.name === "backtab") { 53 + return { state: { ...state, focused: state.focused === "yes" ? "no" : "yes" }, action: "pending" }; 54 + } 55 + if (key.name === "return") { 56 + return { state, action: state.focused }; 57 + } 58 + if (key.name === "escape") { 59 + return { state, action: "no" }; 60 + } 61 + if (key.char === "y" || key.char === "Y") return { state, action: "yes" }; 62 + if (key.char === "n" || key.char === "N") return { state, action: "no" }; 63 + return { state, action: "pending" }; 64 + } 65 + 66 + /** Render the confirm dialog body — wrap in an `overlay()` screen. */ 67 + export function confirmPanel(state: ConfirmState): UINode { 68 + const y = state.focused === "yes" 69 + ? text(` ${state.yesLabel} `, "accent", { bold: true }) 70 + : text(` ${state.yesLabel} `, "muted"); 71 + const n = state.focused === "no" 72 + ? text(` ${state.noLabel} `, "accent", { bold: true }) 73 + : text(` ${state.noLabel} `, "muted"); 74 + return panel(state.title, [ 75 + row(text(state.message, "primary")), 76 + separator(), 77 + row( 78 + text(" ", "muted"), 79 + y, 80 + text(" ", "muted"), 81 + n, 82 + ), 83 + row(text(" y / n / enter / esc", "muted", { dim: true })), 84 + ]); 85 + }
+150
src/tui/widgets/date-picker.ts
··· 1 + // Date picker widget — calendar grid + time shift helpers + overlay factory. 2 + // 3 + // State-first: the consumer owns a `DatePickerState` signal and passes it in 4 + // on every render. Key handling is a pure function that returns a new state 5 + // (or null when the key wasn't consumed so the caller can escape/submit). 6 + // 7 + // Promoted from demos/reminders/tui/widgets/date-picker.ts with a cleaner API. 8 + 9 + import { canvas, row, text, column, panel } from "../builders.ts"; 10 + import type { UINode } from "../nodes.ts"; 11 + import type { KeyEvent } from "../input.ts"; 12 + 13 + export interface DatePickerState { 14 + year: number; 15 + month: number; // 0-11 16 + day: number; // 1-31 17 + hour: number; // 0-23 18 + minute: number; // 0-59 19 + } 20 + 21 + const DAY_HEADERS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"]; 22 + 23 + export const MONTH_NAMES = [ 24 + "January", "February", "March", "April", "May", "June", 25 + "July", "August", "September", "October", "November", "December", 26 + ]; 27 + 28 + export function daysInMonth(year: number, month: number): number { 29 + return new Date(year, month + 1, 0).getDate(); 30 + } 31 + 32 + /** Anchored "now" as a DatePickerState, useful as a default. */ 33 + export function datePickerFromDate(d: Date): DatePickerState { 34 + return { 35 + year: d.getFullYear(), 36 + month: d.getMonth(), 37 + day: d.getDate(), 38 + hour: d.getHours(), 39 + minute: d.getMinutes(), 40 + }; 41 + } 42 + 43 + /** Clamp the day into the range for the current year/month. */ 44 + export function clampDay(state: DatePickerState): DatePickerState { 45 + const max = daysInMonth(state.year, state.month); 46 + if (state.day > max) return { ...state, day: max }; 47 + if (state.day < 1) return { ...state, day: 1 }; 48 + return state; 49 + } 50 + 51 + export function shiftDay(state: DatePickerState, delta: number): DatePickerState { 52 + const d = new Date(state.year, state.month, state.day); 53 + d.setDate(d.getDate() + delta); 54 + return { 55 + ...state, 56 + year: d.getFullYear(), 57 + month: d.getMonth(), 58 + day: d.getDate(), 59 + }; 60 + } 61 + 62 + export function shiftMonth(state: DatePickerState, delta: number): DatePickerState { 63 + const d = new Date(state.year, state.month + delta, 1); 64 + return clampDay({ 65 + ...state, 66 + year: d.getFullYear(), 67 + month: d.getMonth(), 68 + }); 69 + } 70 + 71 + export function shiftTime(state: DatePickerState, unit: "h" | "m", delta: number): DatePickerState { 72 + if (unit === "h") { 73 + return { ...state, hour: (state.hour + delta + 24) % 24 }; 74 + } 75 + return { ...state, minute: (state.minute + delta + 60) % 60 }; 76 + } 77 + 78 + export function toDate(state: DatePickerState): Date { 79 + return new Date(state.year, state.month, state.day, state.hour, state.minute, 0, 0); 80 + } 81 + 82 + /** Default key bindings. Returns a new state when the key was consumed, 83 + * or `null` when the caller should handle it (escape / enter to commit). */ 84 + export function handleDatePickerKey( 85 + state: DatePickerState, 86 + key: KeyEvent, 87 + ): DatePickerState | null { 88 + switch (key.name) { 89 + case "up": return shiftDay(state, -7); 90 + case "down": return shiftDay(state, 7); 91 + case "left": return shiftDay(state, -1); 92 + case "right": return shiftDay(state, 1); 93 + case "[": return shiftMonth(state, -1); 94 + case "]": return shiftMonth(state, 1); 95 + case "h": return shiftTime(state, "h", -1); 96 + case "H": return shiftTime(state, "h", 1); 97 + case "m": return shiftTime(state, "m", -5); 98 + case "M": return shiftTime(state, "m", 5); 99 + } 100 + return null; 101 + } 102 + 103 + /** Render a month grid as a Canvas node, selection highlighted. */ 104 + export function calendarCanvas(state: DatePickerState): UINode { 105 + const first = new Date(state.year, state.month, 1); 106 + const firstDow = first.getDay(); 107 + const max = daysInMonth(state.year, state.month); 108 + 109 + // 6 week rows worst case + 1 header row = 7 rows. 110 + return canvas((ctx) => { 111 + const cellW = 4; 112 + for (let d = 0; d < 7; d++) { 113 + ctx.write(d * cellW, 0, DAY_HEADERS[d], "accent", undefined, true); 114 + } 115 + let rowIdx = 1; 116 + let col = firstDow; 117 + for (let day = 1; day <= max; day++) { 118 + const x = col * cellW; 119 + const y = rowIdx; 120 + const isSelected = day === state.day; 121 + if (isSelected) { 122 + ctx.write(x, y, String(day).padStart(2), "accent", "accent", true); 123 + } else { 124 + ctx.write(x, y, String(day).padStart(2), "primary"); 125 + } 126 + col++; 127 + if (col > 6) { col = 0; rowIdx++; } 128 + } 129 + }, { height: 7 }); 130 + } 131 + 132 + /** Full overlay body: month/year header, calendar grid, time line, hints. 133 + * Returns a UINode[] suitable for putting inside an `overlay()` screen. */ 134 + export function datePickerBody(state: DatePickerState): UINode[] { 135 + const pad2 = (n: number) => String(n).padStart(2, "0"); 136 + const timeStr = `${pad2(state.hour)}:${pad2(state.minute)}`; 137 + const heading = `${MONTH_NAMES[state.month]} ${state.year}`; 138 + return [ 139 + row(text(heading, "accent", { bold: true })), 140 + calendarCanvas(state), 141 + row(text(`time ${timeStr}`, "muted")), 142 + row(text("\u2190\u2192\u2191\u2193 day [ ] month h/H hour m/M \u00b15 min enter ok esc cancel", "muted", { dim: true })), 143 + ]; 144 + } 145 + 146 + /** Convenience: wrap the body in a panel — useful when embedding inside an 147 + * app's own overlay render() rather than as a full-fledged screen. */ 148 + export function datePickerPanel(state: DatePickerState, title = "pick a date"): UINode { 149 + return panel(title, datePickerBody(state)); 150 + }
+238
src/tui/widgets/form.ts
··· 1 + // Form widget — multi-field focus management on top of simple text fields. 2 + // 3 + // State-first: you own a `FormState<T>` signal. Key handling is a pure 4 + // function that delegates to `applyTextKey` for printable/edit keys and to 5 + // a focus-ring walker for tab/backtab. 6 + // 7 + // Promoted from demos/reminders/tui/widgets/form.ts with: 8 + // - Generic over a field-id enum or string union. 9 + // - Dedicated backtab handling (pty's input parser now emits it). 10 + // - A `handleFormKey` umbrella that consumers can delegate to. 11 + 12 + import type { KeyEvent } from "../input.ts"; 13 + import { text as textBuilder } from "../builders.ts"; 14 + 15 + export interface TextFieldState { 16 + text: string; 17 + cursor: number; 18 + } 19 + 20 + /** Word-class predicates: alphanumerics and underscores are "word" chars, 21 + * everything else is "non-word". Matches `\w` in most regex flavours and 22 + * feels right for typical CLI / prose input. */ 23 + function isWordChar(ch: string): boolean { 24 + return /[\p{L}\p{N}_]/u.test(ch); 25 + } 26 + 27 + /** Find the index of the start of the previous word, searching backward 28 + * from `pos`. Skips any trailing whitespace/punctuation first (so the 29 + * first backward keystroke always feels like it did something). */ 30 + export function prevWordBoundary(text: string, pos: number): number { 31 + let i = pos; 32 + // Skip non-word chars immediately behind the cursor. 33 + while (i > 0 && !isWordChar(text[i - 1])) i--; 34 + // Skip the word itself. 35 + while (i > 0 && isWordChar(text[i - 1])) i--; 36 + return i; 37 + } 38 + 39 + /** Find the index one past the end of the next word, searching forward. */ 40 + export function nextWordBoundary(text: string, pos: number): number { 41 + let i = pos; 42 + while (i < text.length && !isWordChar(text[i])) i++; 43 + while (i < text.length && isWordChar(text[i])) i++; 44 + return i; 45 + } 46 + 47 + /** Handle a keystroke against a simple single-line text field. 48 + * Returns a new state if the key was consumed, else `null` so the caller 49 + * can dispatch it elsewhere (e.g. treat Enter as submit at the form level). */ 50 + export function applyTextKey(state: TextFieldState, key: KeyEvent): TextFieldState | null { 51 + if (key.name === "backspace") { 52 + if (state.cursor === 0) return state; 53 + return { 54 + text: state.text.slice(0, state.cursor - 1) + state.text.slice(state.cursor), 55 + cursor: state.cursor - 1, 56 + }; 57 + } 58 + if (key.name === "delete") { 59 + if (state.cursor >= state.text.length) return state; 60 + return { 61 + text: state.text.slice(0, state.cursor) + state.text.slice(state.cursor + 1), 62 + cursor: state.cursor, 63 + }; 64 + } 65 + if (key.name === "left") { 66 + if (key.alt) return { ...state, cursor: prevWordBoundary(state.text, state.cursor) }; 67 + if (state.cursor === 0) return state; 68 + return { ...state, cursor: state.cursor - 1 }; 69 + } 70 + if (key.name === "right") { 71 + if (key.alt) return { ...state, cursor: nextWordBoundary(state.text, state.cursor) }; 72 + if (state.cursor >= state.text.length) return state; 73 + return { ...state, cursor: state.cursor + 1 }; 74 + } 75 + // emacs-style alt+b / alt+f word motion, same semantics as alt+arrows. 76 + if (key.alt && key.char === "b") { 77 + return { ...state, cursor: prevWordBoundary(state.text, state.cursor) }; 78 + } 79 + if (key.alt && key.char === "f") { 80 + return { ...state, cursor: nextWordBoundary(state.text, state.cursor) }; 81 + } 82 + if (key.name === "home" || (key.name === "a" && key.ctrl)) { 83 + return { ...state, cursor: 0 }; 84 + } 85 + if (key.name === "end" || (key.name === "e" && key.ctrl)) { 86 + return { ...state, cursor: state.text.length }; 87 + } 88 + if (key.name === "u" && key.ctrl) { 89 + return { text: state.text.slice(state.cursor), cursor: 0 }; 90 + } 91 + // Printable character — ignore ctrl/alt-modified keys so shortcuts don't 92 + // leak as text into the field. 93 + if (key.char && !key.ctrl && !key.alt) { 94 + return { 95 + text: state.text.slice(0, state.cursor) + key.char + state.text.slice(state.cursor), 96 + cursor: state.cursor + key.char.length, 97 + }; 98 + } 99 + return null; 100 + } 101 + 102 + /** Compose a field display string with a block cursor when the field is 103 + * active. This is the legacy "insert a block character" API — it pushes 104 + * neighbors sideways and is kept only for consumers that want a single 105 + * string back. For proper cursor-on-top-of-character rendering, use 106 + * `renderFieldNodes` which returns 3 TextNodes (before / inverse-cursor / 107 + * after). */ 108 + export function renderFieldText(text: string, cursor: number, active: boolean): string { 109 + if (!active) return text || ""; 110 + const before = text.slice(0, cursor); 111 + const after = text.slice(cursor); 112 + return `${before}\u2588${after}`; 113 + } 114 + 115 + /** Render a text field as three inline TextNodes (before, cursor, after) 116 + * so the cursor paints ON TOP of the character at `cursor` instead of 117 + * shoving it sideways. Pass through `row(...renderFieldNodes(...))`. */ 118 + export function renderFieldNodes( 119 + text: string, 120 + cursor: number, 121 + active: boolean, 122 + opts: { color?: import("../nodes.ts").Color; bold?: boolean } = {}, 123 + ): import("../nodes.ts").UINode[] { 124 + const color = opts.color ?? "primary"; 125 + const bold = opts.bold ?? false; 126 + if (!active) { 127 + return [textBuilder(text || "", color, { bold })]; 128 + } 129 + const before = text.slice(0, cursor); 130 + const under = text.slice(cursor, cursor + 1) || " "; 131 + const after = text.slice(cursor + 1); 132 + return [ 133 + textBuilder(before, color, { bold }), 134 + textBuilder(under, color, { bold, inverse: true }), 135 + textBuilder(after, color, { bold }), 136 + ]; 137 + } 138 + 139 + /** A form's per-field state. The consumer provides the field ids; the form 140 + * tracks which one is focused and walks between them on tab/backtab. */ 141 + export interface FormState<Id extends string> { 142 + values: Record<Id, TextFieldState>; 143 + /** null only when the field set is empty — otherwise always one of `order`. */ 144 + focused: Id | null; 145 + order: readonly Id[]; 146 + } 147 + 148 + export function createFormState<Id extends string>( 149 + order: readonly Id[], 150 + initial: Record<Id, string>, 151 + ): FormState<Id> { 152 + const values = {} as Record<Id, TextFieldState>; 153 + for (const id of order) { 154 + const t = initial[id] ?? ""; 155 + values[id] = { text: t, cursor: t.length }; 156 + } 157 + return { 158 + values, 159 + focused: order[0] ?? null, 160 + order, 161 + }; 162 + } 163 + 164 + function walkFocus<Id extends string>(state: FormState<Id>, delta: 1 | -1): FormState<Id> { 165 + if (state.focused == null || state.order.length === 0) return state; 166 + const idx = state.order.indexOf(state.focused); 167 + if (idx < 0) return state; 168 + const nextIdx = (idx + delta + state.order.length) % state.order.length; 169 + return { ...state, focused: state.order[nextIdx] }; 170 + } 171 + 172 + export function focusField<Id extends string>(state: FormState<Id>, id: Id): FormState<Id> { 173 + if (!state.order.includes(id)) return state; 174 + return { ...state, focused: id }; 175 + } 176 + 177 + export function setFieldText<Id extends string>( 178 + state: FormState<Id>, 179 + id: Id, 180 + text: string, 181 + ): FormState<Id> { 182 + const current = state.values[id]; 183 + if (!current) return state; 184 + return { 185 + ...state, 186 + values: { ...state.values, [id]: { text, cursor: text.length } }, 187 + }; 188 + } 189 + 190 + export interface HandleFormKeyResult<Id extends string> { 191 + state: FormState<Id>; 192 + /** What happened — lets the consumer react (e.g. open a picker on Enter 193 + * in a "due" field). "submit" means Enter in the last field, "cancel" 194 + * means Escape anywhere, "activate" means Enter in a non-last field. */ 195 + action: "edited" | "moved" | "submit" | "cancel" | "activate" | "none"; 196 + } 197 + 198 + /** Default form key dispatch: 199 + * - tab -> focus next field 200 + * - backtab -> focus previous field (requires pty's backtab support) 201 + * - enter -> "activate" if a non-last field, "submit" if the last 202 + * - escape -> "cancel" 203 + * - other -> delegate to `applyTextKey` on the focused field 204 + * Consumers that want a custom behaviour (e.g. enter-opens-picker for the 205 + * "due" field) should check `action` / `state.focused` and react BEFORE 206 + * calling this, or construct their own dispatcher using the helpers. */ 207 + export function handleFormKey<Id extends string>( 208 + state: FormState<Id>, 209 + key: KeyEvent, 210 + ): HandleFormKeyResult<Id> { 211 + if (key.name === "tab") { 212 + return { state: walkFocus(state, 1), action: "moved" }; 213 + } 214 + if (key.name === "backtab") { 215 + return { state: walkFocus(state, -1), action: "moved" }; 216 + } 217 + if (key.name === "escape") { 218 + return { state, action: "cancel" }; 219 + } 220 + if (key.name === "return") { 221 + if (!state.focused) return { state, action: "none" }; 222 + const idx = state.order.indexOf(state.focused); 223 + const isLast = idx === state.order.length - 1; 224 + return { state, action: isLast ? "submit" : "activate" }; 225 + } 226 + 227 + if (!state.focused) return { state, action: "none" }; 228 + const field = state.values[state.focused]; 229 + const updated = applyTextKey(field, key); 230 + if (updated === null) return { state, action: "none" }; 231 + return { 232 + state: { 233 + ...state, 234 + values: { ...state.values, [state.focused]: updated }, 235 + }, 236 + action: "edited", 237 + }; 238 + }
+51
src/tui/widgets/help-overlay.ts
··· 1 + // Help overlay — a keybinding reference. Consumers bind "?" to toggle a 2 + // signal that owns a HelpSection[], then render the result via 3 + // `helpPanel(sections)` as the body of an `overlay()` screen. Reads like a 4 + // cheat-sheet, which is the point. 5 + 6 + import { row, column, text, panel, separator } from "../builders.ts"; 7 + import type { UINode, ColumnNode } from "../nodes.ts"; 8 + 9 + export interface HelpBinding { 10 + key: string; 11 + desc: string; 12 + } 13 + 14 + export interface HelpSection { 15 + title: string; 16 + bindings: HelpBinding[]; 17 + } 18 + 19 + function renderSection(sec: HelpSection, keyWidth: number): UINode[] { 20 + const out: UINode[] = [ 21 + row(text(sec.title, "accent", { bold: true })), 22 + ]; 23 + for (const b of sec.bindings) { 24 + out.push(row( 25 + text(" ", "muted"), 26 + text(b.key.padEnd(keyWidth + 2), "accent"), 27 + text(b.desc, "primary"), 28 + )); 29 + } 30 + return out; 31 + } 32 + 33 + /** Render the help as a panel body. Columns align within a section via a 34 + * shared keyWidth derived from the widest key across ALL sections, so the 35 + * sections line up visually when stacked. */ 36 + export function helpPanel(sections: readonly HelpSection[], title = "keybindings"): UINode { 37 + let keyWidth = 0; 38 + for (const s of sections) for (const b of s.bindings) keyWidth = Math.max(keyWidth, b.key.length); 39 + 40 + const children: UINode[] = []; 41 + sections.forEach((sec, i) => { 42 + if (i > 0) { 43 + children.push(separator()); 44 + } 45 + children.push(...renderSection(sec, keyWidth)); 46 + }); 47 + children.push(separator()); 48 + children.push(row(text(" press ? or esc to close", "muted", { dim: true }))); 49 + 50 + return panel(title, children); 51 + }
+113
src/tui/widgets/index.ts
··· 1 + // Public surface for the widgets tier — higher-level components built on 2 + // the core TUI primitives. All state-first: you own the state, widgets are 3 + // pure render + pure key dispatch. 4 + 5 + export { 6 + type TreeNode, type TreeRow, type TreeState, 7 + createTreeState, flattenTree, toggleExpanded, selectById, 8 + moveSelection, handleTreeKey, treeGlyph, 9 + } from "./tree.ts"; 10 + 11 + export { 12 + type DatePickerState, 13 + MONTH_NAMES, daysInMonth, 14 + datePickerFromDate, clampDay, 15 + shiftDay, shiftMonth, shiftTime, 16 + toDate, handleDatePickerKey, 17 + calendarCanvas, datePickerBody, datePickerPanel, 18 + } from "./date-picker.ts"; 19 + 20 + export { 21 + type TextFieldState, type FormState, type HandleFormKeyResult, 22 + applyTextKey, renderFieldText, renderFieldNodes, 23 + prevWordBoundary, nextWordBoundary, 24 + createFormState, focusField, setFieldText, handleFormKey, 25 + } from "./form.ts"; 26 + 27 + export { 28 + type MarkdownOptions, 29 + parseMarkdown, parseInline, renderMarkdown, 30 + } from "./markdown.ts"; 31 + 32 + export { 33 + type TextAreaState, 34 + createTextArea, textAreaToString, applyTextAreaKey, renderTextArea, 35 + } from "./text-area.ts"; 36 + 37 + export { 38 + type VirtualListState, type VirtualWindow, 39 + type HandleVirtualKeyResult, type HandleVirtualMouseResult, 40 + createVirtualListState, clampVirtual, virtualWindow, 41 + moveVirtualSelection, pageVirtual, 42 + jumpVirtualToStart, jumpVirtualToEnd, handleVirtualKey, handleVirtualMouse, 43 + renderVirtualList, 44 + } from "./virtual-list.ts"; 45 + 46 + export { 47 + type StreamViewState, 48 + createStreamView, isPinned as streamIsPinned, streamPin, 49 + streamScrollUp, streamScrollDown, streamWindow, 50 + handleStreamKey, handleStreamMouse, renderStreamView, 51 + } from "./stream-view.ts"; 52 + 53 + export { 54 + type TabDef, type TabsState, 55 + createTabsState, selectTab, nextTab, prevTab, 56 + handleTabsKey, handleTabsMouse, 57 + renderTabs, 58 + } from "./tabs.ts"; 59 + 60 + export { 61 + type ConfirmState, type CreateConfirmOptions, type HandleConfirmResult, 62 + createConfirm, handleConfirmKey, confirmPanel, 63 + } from "./confirm.ts"; 64 + 65 + export { 66 + type Toast, type ToastKind, type ToastQueue, type PushToastOptions, 67 + createToastQueue, pushToast, pruneExpired, dismissToast, renderToasts, 68 + } from "./toast.ts"; 69 + 70 + export { 71 + type Command, type CommandPaletteState, type RankedCommand, 72 + type HandleCommandPaletteResult, 73 + createCommandPaletteState, filterCommands, 74 + handleCommandPaletteKey, renderCommandPalette, 75 + } from "./command-palette.ts"; 76 + 77 + export { 78 + registerGlobalCommand, useCommandScope, clearCommandScope, 79 + findCommand, runCommand, allCommands, 80 + _resetCommandRegistry, 81 + } from "./command-registry.ts"; 82 + 83 + export { 84 + type TableColumn, type TableState, type TableAlign, 85 + type HandleTableKeyResult, 86 + createTableState, sortRows, handleTableKey, renderTable, 87 + } from "./table.ts"; 88 + 89 + export { 90 + type HelpSection, type HelpBinding, 91 + helpPanel, 92 + } from "./help-overlay.ts"; 93 + 94 + export { 95 + type PromptBarValue, type PromptBarTitle, type PromptBarStatus, 96 + type PromptBarOptions, type TitleAlign, 97 + promptBar, 98 + } from "./prompt-bar.ts"; 99 + 100 + export { 101 + type ToolbarItem, type ToolbarOptions, 102 + toolbar, toolbarItemFor, 103 + } from "./toolbar.ts"; 104 + 105 + export { 106 + type SparklineOptions, 107 + sparkline, sparklineString, 108 + } from "./sparkline.ts"; 109 + 110 + export { 111 + type BarChartItem, type BarChartOptions, 112 + barChart, 113 + } from "./bar-chart.ts";
+345
src/tui/widgets/markdown.ts
··· 1 + // Markdown renderer — parses a subset of CommonMark and returns UINodes. 2 + // 3 + // Subset supported (v1): 4 + // - Headings (#, ##, ###, ####) 5 + // - Paragraphs (word-wrapped via `wrapText`) 6 + // - Bold (**x**), italic (*x*), inline code (`x`), links ([text](url)) 7 + // - Fenced code blocks (```) — dim color, no syntax highlighting 8 + // - Unordered lists (-, *) and ordered lists (1.) 9 + // - Task lists (- [ ], - [x]) 10 + // - Blockquotes (> line) 11 + // - Horizontal rules (---, ***) 12 + // 13 + // Deliberately NOT supported: 14 + // - Tables (too column-layout-dependent for terminal; can add later) 15 + // - HTML passthrough (security + rendering both complicated) 16 + // - Nested lists (would need indentation tracking; can add later) 17 + 18 + import { text, row, separator, indent, checkbox, spacer, column } from "../builders.ts"; 19 + import type { UINode, Color } from "../nodes.ts"; 20 + 21 + export interface MarkdownOptions { 22 + /** Maximum width the renderer should assume when word-wrapping paragraphs. 23 + * If omitted, paragraphs are not wrapped — useful when the renderer is 24 + * nested inside a framework that does its own wrapping. */ 25 + width?: number; 26 + } 27 + 28 + interface Block { 29 + kind: 30 + | "heading" | "paragraph" | "code" | "bullet" | "ordered" 31 + | "task" | "quote" | "hr"; 32 + /** heading level (1-4) */ 33 + level?: number; 34 + /** raw text for paragraph / heading / quote */ 35 + text?: string; 36 + /** lines inside a code block */ 37 + lines?: string[]; 38 + /** items inside a list block */ 39 + items?: string[]; 40 + /** task items: [done, text] pairs for tasks */ 41 + tasks?: { done: boolean; text: string }[]; 42 + } 43 + 44 + const HEADING_RE = /^(#{1,4})\s+(.*)$/; 45 + const BULLET_RE = /^[-*]\s+(.*)$/; 46 + const ORDERED_RE = /^\d+\.\s+(.*)$/; 47 + const TASK_RE = /^[-*]\s+\[([ xX])\]\s+(.*)$/; 48 + const QUOTE_RE = /^>\s?(.*)$/; 49 + const FENCE_RE = /^```/; 50 + const HR_RE = /^(-{3,}|\*{3,}|_{3,})\s*$/; 51 + 52 + /** Split the source into a list of block-level descriptors. This is the 53 + * only parsing pass — rendering is then purely structural. */ 54 + export function parseMarkdown(source: string): Block[] { 55 + const lines = source.split("\n"); 56 + const blocks: Block[] = []; 57 + let para: string[] = []; 58 + 59 + const flushPara = () => { 60 + if (para.length === 0) return; 61 + blocks.push({ kind: "paragraph", text: para.join(" ") }); 62 + para = []; 63 + }; 64 + 65 + for (let i = 0; i < lines.length; i++) { 66 + const line = lines[i]; 67 + 68 + // Fenced code — consume until the closing fence. 69 + if (FENCE_RE.test(line)) { 70 + flushPara(); 71 + const codeLines: string[] = []; 72 + i++; 73 + while (i < lines.length && !FENCE_RE.test(lines[i])) { 74 + codeLines.push(lines[i]); 75 + i++; 76 + } 77 + blocks.push({ kind: "code", lines: codeLines }); 78 + continue; 79 + } 80 + 81 + // Blank line — ends the current paragraph. 82 + if (line.trim() === "") { 83 + flushPara(); 84 + continue; 85 + } 86 + 87 + // Horizontal rule. 88 + if (HR_RE.test(line)) { 89 + flushPara(); 90 + blocks.push({ kind: "hr" }); 91 + continue; 92 + } 93 + 94 + // Heading. 95 + const h = HEADING_RE.exec(line); 96 + if (h) { 97 + flushPara(); 98 + blocks.push({ kind: "heading", level: h[1].length, text: h[2] }); 99 + continue; 100 + } 101 + 102 + // Task item (must match BEFORE bullet since task is a bullet superset). 103 + const t = TASK_RE.exec(line); 104 + if (t) { 105 + flushPara(); 106 + const prev = blocks[blocks.length - 1]; 107 + if (prev && prev.kind === "task") { 108 + prev.tasks!.push({ done: t[1] !== " ", text: t[2] }); 109 + } else { 110 + blocks.push({ kind: "task", tasks: [{ done: t[1] !== " ", text: t[2] }] }); 111 + } 112 + continue; 113 + } 114 + 115 + // Bullet list. 116 + const b = BULLET_RE.exec(line); 117 + if (b) { 118 + flushPara(); 119 + const prev = blocks[blocks.length - 1]; 120 + if (prev && prev.kind === "bullet") prev.items!.push(b[1]); 121 + else blocks.push({ kind: "bullet", items: [b[1]] }); 122 + continue; 123 + } 124 + 125 + // Ordered list. 126 + const o = ORDERED_RE.exec(line); 127 + if (o) { 128 + flushPara(); 129 + const prev = blocks[blocks.length - 1]; 130 + if (prev && prev.kind === "ordered") prev.items!.push(o[1]); 131 + else blocks.push({ kind: "ordered", items: [o[1]] }); 132 + continue; 133 + } 134 + 135 + // Blockquote. 136 + const q = QUOTE_RE.exec(line); 137 + if (q) { 138 + flushPara(); 139 + const prev = blocks[blocks.length - 1]; 140 + if (prev && prev.kind === "quote") prev.text! += "\n" + q[1]; 141 + else blocks.push({ kind: "quote", text: q[1] }); 142 + continue; 143 + } 144 + 145 + // Default — part of a paragraph. 146 + para.push(line); 147 + } 148 + flushPara(); 149 + return blocks; 150 + } 151 + 152 + /** Split a line of text into inline styled segments — bold, italic, code, 153 + * link. Returns alternating plain+styled chunks as children of a row. 154 + * Simplified inline grammar; good enough for most content, keeps the 155 + * renderer output predictable. */ 156 + interface InlineSegment { 157 + text: string; 158 + bold?: boolean; 159 + italic?: boolean; 160 + code?: boolean; 161 + url?: string; 162 + } 163 + 164 + export function parseInline(src: string): InlineSegment[] { 165 + const out: InlineSegment[] = []; 166 + let i = 0; 167 + let buf = ""; 168 + const flushBuf = () => { 169 + if (buf.length > 0) { out.push({ text: buf }); buf = ""; } 170 + }; 171 + 172 + while (i < src.length) { 173 + // Links: [text](url) 174 + if (src[i] === "[") { 175 + const end = src.indexOf("]", i + 1); 176 + if (end !== -1 && src[end + 1] === "(") { 177 + const urlEnd = src.indexOf(")", end + 2); 178 + if (urlEnd !== -1) { 179 + flushBuf(); 180 + out.push({ text: src.slice(i + 1, end), url: src.slice(end + 2, urlEnd) }); 181 + i = urlEnd + 1; 182 + continue; 183 + } 184 + } 185 + } 186 + // Inline code: `x` 187 + if (src[i] === "`") { 188 + const end = src.indexOf("`", i + 1); 189 + if (end !== -1) { 190 + flushBuf(); 191 + out.push({ text: src.slice(i + 1, end), code: true }); 192 + i = end + 1; 193 + continue; 194 + } 195 + } 196 + // Bold / italic. When we see `*`, decide which one we're looking for. 197 + // Unmatched delimiters emit as plain text (via buf) rather than eating 198 + // the next char on a mis-paired fallback. 199 + if (src[i] === "*") { 200 + if (src[i + 1] === "*") { 201 + const end = src.indexOf("**", i + 2); 202 + if (end !== -1) { 203 + flushBuf(); 204 + out.push({ text: src.slice(i + 2, end), bold: true }); 205 + i = end + 2; 206 + continue; 207 + } 208 + buf += "**"; 209 + i += 2; 210 + continue; 211 + } 212 + const end = src.indexOf("*", i + 1); 213 + if (end !== -1) { 214 + flushBuf(); 215 + out.push({ text: src.slice(i + 1, end), italic: true }); 216 + i = end + 1; 217 + continue; 218 + } 219 + buf += "*"; 220 + i++; 221 + continue; 222 + } 223 + buf += src[i]; 224 + i++; 225 + } 226 + flushBuf(); 227 + return out; 228 + } 229 + 230 + function renderInline(segments: InlineSegment[], baseColor: Color = "primary"): UINode[] { 231 + return segments.map(s => { 232 + if (s.code) return text(s.text, "accent", { italic: false }); 233 + if (s.url) return text(`${s.text} `, "accent"); 234 + return text(s.text, baseColor, { bold: !!s.bold, italic: !!s.italic }); 235 + }); 236 + } 237 + 238 + function headingColor(level: number): Color { 239 + switch (level) { 240 + case 1: return "accent"; 241 + case 2: return "primary"; 242 + case 3: return "accent"; 243 + default: return "muted"; 244 + } 245 + } 246 + 247 + function wrapLine(src: string, width?: number): string[] { 248 + if (!width || width <= 0) return [src]; 249 + // Simple word wrap that keeps whole words together. 250 + const words = src.split(/(\s+)/); 251 + const lines: string[] = []; 252 + let line = ""; 253 + for (const w of words) { 254 + if ((line + w).length <= width) { line += w; continue; } 255 + if (line.length > 0) { lines.push(line.trimEnd()); line = ""; } 256 + if (w.length > width) { 257 + // Unbreakable word longer than width — hard-split. 258 + for (let i = 0; i < w.length; i += width) lines.push(w.slice(i, i + width)); 259 + } else if (w.trim().length > 0) { 260 + line = w; 261 + } 262 + } 263 + if (line.length > 0) lines.push(line.trimEnd()); 264 + return lines; 265 + } 266 + 267 + /** Render markdown source as an array of UINodes ready for a panel/column. */ 268 + export function renderMarkdown(source: string, opts: MarkdownOptions = {}): UINode[] { 269 + const blocks = parseMarkdown(source); 270 + const out: UINode[] = []; 271 + 272 + for (let bi = 0; bi < blocks.length; bi++) { 273 + const b = blocks[bi]; 274 + // Visual spacing between blocks: add a blank row between non-first blocks 275 + // that aren't tightly related (lists, tasks accept internal spacing). 276 + if (bi > 0) out.push(row(spacer())); 277 + 278 + switch (b.kind) { 279 + case "heading": { 280 + const c = headingColor(b.level!); 281 + out.push(row( 282 + text("#".repeat(b.level!) + " ", "muted", { dim: true }), 283 + ...renderInline(parseInline(b.text!), c).map((n, i) => { 284 + // First inline in heading is bold; subsequent inherit their own style. 285 + if (i === 0 && (n as any).type === "text") { 286 + return text((n as any).text, c, { bold: true }); 287 + } 288 + return n; 289 + }), 290 + )); 291 + break; 292 + } 293 + case "paragraph": { 294 + const wrapped = wrapLine(b.text ?? "", opts.width); 295 + for (const ln of wrapped) out.push(row(...renderInline(parseInline(ln)))); 296 + break; 297 + } 298 + case "code": { 299 + for (const ln of b.lines!) { 300 + out.push(row(text(" " + ln, "muted", { dim: false }))); 301 + } 302 + break; 303 + } 304 + case "bullet": { 305 + for (const item of b.items!) { 306 + out.push(row(text(" • ", "muted"), ...renderInline(parseInline(item)))); 307 + } 308 + break; 309 + } 310 + case "ordered": { 311 + b.items!.forEach((item, i) => { 312 + out.push(row( 313 + text(` ${i + 1}. `, "muted"), 314 + ...renderInline(parseInline(item)), 315 + )); 316 + }); 317 + break; 318 + } 319 + case "task": { 320 + for (const t of b.tasks!) { 321 + out.push(row( 322 + text(" ", "muted"), 323 + checkbox(t.done, t.done ? "muted" : "accent"), 324 + text(" ", "muted"), 325 + ...renderInline(parseInline(t.text), t.done ? "muted" : "primary"), 326 + )); 327 + } 328 + break; 329 + } 330 + case "quote": { 331 + for (const ln of (b.text ?? "").split("\n")) { 332 + const wrapped = wrapLine(ln, opts.width ? opts.width - 2 : undefined); 333 + for (const w of wrapped) { 334 + out.push(row(text("\u2502 ", "muted"), ...renderInline(parseInline(w), "muted"))); 335 + } 336 + } 337 + break; 338 + } 339 + case "hr": 340 + out.push(separator()); 341 + break; 342 + } 343 + } 344 + return out; 345 + }
+135
src/tui/widgets/prompt-bar.ts
··· 1 + // Prompt bar — a full-width input styled like Claude Code's message box. 2 + // 3 + // Layout: 4 + // ───────────[title]──────────── 5 + // ❯ the input goes here█ 6 + // ─────────────────────────────── 7 + // status left status right 8 + // 9 + // Top and bottom horizontal rules (no side borders, unlike `panel`), a 10 + // prompt glyph, the text field itself, and an optional status strip. 11 + // Works with both single-line (TextFieldState) and multi-line 12 + // (TextAreaState) values — pass whichever you own. 13 + 14 + import { row, column, text, separator, spacer } from "../builders.ts"; 15 + import type { UINode, ColumnNode, Color } from "../nodes.ts"; 16 + import { renderFieldNodes, type TextFieldState } from "./form.ts"; 17 + import { renderTextArea, type TextAreaState } from "./text-area.ts"; 18 + 19 + export type PromptBarValue = 20 + | { kind: "single"; state: TextFieldState } 21 + | { kind: "multi"; state: TextAreaState }; 22 + 23 + export type TitleAlign = "left" | "center" | "right"; 24 + 25 + export interface PromptBarTitle { 26 + text: string; 27 + align?: TitleAlign; 28 + /** Color for the title text. Defaults to "accent". */ 29 + color?: Color; 30 + } 31 + 32 + export interface PromptBarStatus { 33 + left?: string; 34 + right?: string; 35 + /** Color for the status text. Defaults to "muted". */ 36 + color?: Color; 37 + } 38 + 39 + export interface PromptBarOptions { 40 + /** Prompt glyph rendered before the value. Default: "\u276f" (❯). */ 41 + glyph?: string; 42 + /** Color for the glyph. Default "accent". */ 43 + glyphColor?: Color; 44 + /** Title overlaid on the top rule. Omit for a plain rule. */ 45 + title?: PromptBarTitle; 46 + /** Optional status strip rendered under the bottom rule. */ 47 + status?: PromptBarStatus; 48 + /** Whether the field is focused — controls cursor rendering. Default true. */ 49 + active?: boolean; 50 + } 51 + 52 + function titleRule(title: PromptBarTitle | undefined, color: Color = "muted"): UINode { 53 + if (!title) return separator(); 54 + const label = ` ${title.text} `; 55 + const align = title.align ?? "left"; 56 + // Separator can't carry embedded text, so we approximate with a row made 57 + // of three text segments: left-rule, title, right-rule. The layout engine 58 + // handles width. We use long rule strings that will get clipped to width; 59 + // for a principled fixed-width build use separator() without a title. 60 + const ruleChar = "\u2500"; 61 + const leftRule = align === "left" ? ruleChar.repeat(2) 62 + : align === "center" ? ruleChar.repeat(20) 63 + : ruleChar.repeat(40); 64 + const rightRule = align === "right" ? ruleChar.repeat(2) 65 + : align === "center" ? ruleChar.repeat(20) 66 + : ruleChar.repeat(40); 67 + return row( 68 + text(leftRule, color, { dim: true }), 69 + text(label, title.color ?? "accent", { bold: true }), 70 + text(rightRule, color, { dim: true }), 71 + ); 72 + } 73 + 74 + function statusRow(s: PromptBarStatus | undefined): UINode | null { 75 + if (!s) return null; 76 + if (!s.left && !s.right) return null; 77 + const color = s.color ?? "muted"; 78 + const children: UINode[] = []; 79 + if (s.left) children.push(text(s.left, color, { dim: true })); 80 + children.push(spacer()); 81 + if (s.right) children.push(text(s.right + " ", color, { dim: true })); 82 + return row(...children); 83 + } 84 + 85 + /** Render a prompt bar around `value`. Returns a column of rows — drop it 86 + * into a screen's render() array directly. */ 87 + export function promptBar(value: PromptBarValue, opts: PromptBarOptions = {}): UINode { 88 + const glyph = opts.glyph ?? "\u276f"; 89 + const active = opts.active ?? true; 90 + const glyphColor = opts.glyphColor ?? "accent"; 91 + 92 + const children: UINode[] = [titleRule(opts.title)]; 93 + 94 + if (value.kind === "multi") { 95 + // Multi-line: prompt glyph on the FIRST row only; the text area 96 + // is rendered as its own column of rows below (continued-line 97 + // rows are indented to line up with the glyph width). 98 + const lines = value.state.lines; 99 + const active_ = active; 100 + lines.forEach((line, i) => { 101 + const onCursor = active_ && i === value.state.row; 102 + const prompt = i === 0 103 + ? text(` ${glyph} `, glyphColor, { bold: true }) 104 + : text(` `, "muted"); 105 + if (!onCursor) { 106 + children.push(row(prompt, text(line.length === 0 ? " " : line, "primary"))); 107 + } else { 108 + const col = value.state.col; 109 + const before = line.slice(0, col); 110 + const under = line.slice(col, col + 1) || " "; 111 + const after = line.slice(col + 1); 112 + children.push(row( 113 + prompt, 114 + text(before, "primary"), 115 + text(under, "primary", { inverse: true }), 116 + text(after, "primary"), 117 + )); 118 + } 119 + }); 120 + } else { 121 + // Single-line: everything fits on one row. 122 + const s = value.state; 123 + children.push(row( 124 + text(` ${glyph} `, glyphColor, { bold: true }), 125 + ...renderFieldNodes(s.text, s.cursor, active), 126 + )); 127 + } 128 + 129 + children.push(separator()); 130 + const status = statusRow(opts.status); 131 + if (status) children.push(status); 132 + 133 + const node: ColumnNode = { type: "column", children }; 134 + return node; 135 + }
+74
src/tui/widgets/sparkline.ts
··· 1 + // Sparkline — a compact inline chart drawn with unicode block characters. 2 + // One character per sample; height encoded via eight steps from " " (empty) 3 + // through "\u2581" (lower eighth) up to "\u2588" (full block). 4 + // 5 + // Pure: given a numeric series and a width, returns the string. Wrap it in 6 + // text(...) / canvas(...) to place it anywhere. 7 + 8 + import { text } from "../builders.ts"; 9 + import type { UINode, Color } from "../nodes.ts"; 10 + 11 + /** 0..8 block glyph levels used to pack a sample into a single cell. */ 12 + const BLOCKS = [" ", "\u2581", "\u2582", "\u2583", "\u2584", 13 + "\u2585", "\u2586", "\u2587", "\u2588"]; 14 + 15 + export interface SparklineOptions { 16 + /** Explicit render width. If omitted, renders `series.length` cells. */ 17 + width?: number; 18 + /** Explicit min/max — useful when the bounds should be stable across 19 + * frames (e.g. 0..100 for a CPU %) rather than per-sample. */ 20 + min?: number; 21 + max?: number; 22 + } 23 + 24 + /** Render `series` as a unicode sparkline string. NaN / Infinity samples 25 + * render as the empty cell. */ 26 + export function sparklineString(series: readonly number[], opts: SparklineOptions = {}): string { 27 + const width = Math.max(0, Math.floor(opts.width ?? series.length)); 28 + if (width === 0 || series.length === 0) return ""; 29 + 30 + // If the caller wants a fixed width, sample the tail of the series. 31 + // This matches the "scrolling window" UX of metric sparklines. 32 + const slice = series.length <= width 33 + ? series 34 + : series.slice(series.length - width); 35 + 36 + let lo = opts.min; 37 + let hi = opts.max; 38 + if (lo == null || hi == null) { 39 + let ilo = Infinity, ihi = -Infinity; 40 + for (const v of slice) { 41 + if (!Number.isFinite(v)) continue; 42 + if (v < ilo) ilo = v; 43 + if (v > ihi) ihi = v; 44 + } 45 + if (!Number.isFinite(ilo)) ilo = 0; 46 + if (!Number.isFinite(ihi)) ihi = 1; 47 + if (lo == null) lo = ilo; 48 + if (hi == null) hi = ihi; 49 + } 50 + 51 + const range = hi! - lo!; 52 + const out: string[] = []; 53 + // Left-pad if the series is shorter than width. 54 + const pad = width - slice.length; 55 + for (let i = 0; i < pad; i++) out.push(BLOCKS[0]); 56 + 57 + for (const v of slice) { 58 + if (!Number.isFinite(v)) { out.push(BLOCKS[0]); continue; } 59 + if (range <= 0) { 60 + // All samples equal — pick the middle block so there's *something*. 61 + out.push(BLOCKS[4]); 62 + continue; 63 + } 64 + const frac = (v - lo!) / range; 65 + const idx = Math.max(0, Math.min(8, Math.round(frac * 8))); 66 + out.push(BLOCKS[idx]); 67 + } 68 + return out.join(""); 69 + } 70 + 71 + /** Convenience: render the sparkline as a text node with a given color. */ 72 + export function sparkline(series: readonly number[], opts: SparklineOptions & { color?: Color } = {}): UINode { 73 + return text(sparklineString(series, opts), opts.color ?? "accent"); 74 + }
+122
src/tui/widgets/stream-view.ts
··· 1 + // Stream view — "chat window" layout. Items flow in; by default the view 2 + // is pinned to the newest (bottom). Scrolling up unpins and preserves the 3 + // user's position even as new items arrive. Scrolling back to the bottom 4 + // or explicitly re-pinning restores auto-follow. 5 + // 6 + // Used by chat apps, log tails, REPL output, streaming AI replies. 7 + 8 + import { column, row, text } from "../builders.ts"; 9 + import type { UINode, ColumnNode, Rect } from "../nodes.ts"; 10 + import type { KeyEvent, MouseEvent } from "../input.ts"; 11 + 12 + export interface StreamViewState { 13 + /** Number of items to scroll back from the most recent. 0 = pinned. */ 14 + scrollback: number; 15 + } 16 + 17 + export function createStreamView(): StreamViewState { 18 + return { scrollback: 0 }; 19 + } 20 + 21 + export function isPinned(state: StreamViewState): boolean { 22 + return state.scrollback === 0; 23 + } 24 + 25 + export function streamPin(state: StreamViewState): StreamViewState { 26 + return state.scrollback === 0 ? state : { ...state, scrollback: 0 }; 27 + } 28 + 29 + export function streamScrollUp( 30 + state: StreamViewState, 31 + delta: number, 32 + total: number, 33 + viewport: number, 34 + ): StreamViewState { 35 + const maxScrollback = Math.max(0, total - viewport); 36 + const next = Math.min(maxScrollback, state.scrollback + delta); 37 + if (next === state.scrollback) return state; 38 + return { ...state, scrollback: next }; 39 + } 40 + 41 + export function streamScrollDown(state: StreamViewState, delta: number): StreamViewState { 42 + const next = Math.max(0, state.scrollback - delta); 43 + if (next === state.scrollback) return state; 44 + return { ...state, scrollback: next }; 45 + } 46 + 47 + /** Compute the window of item indexes to draw. `total` and `viewport` are 48 + * provided by the caller from layout each tick. */ 49 + export function streamWindow( 50 + state: StreamViewState, 51 + total: number, 52 + viewport: number, 53 + ): { start: number; end: number } { 54 + if (total <= 0 || viewport <= 0) return { start: 0, end: 0 }; 55 + // end (exclusive) = total - scrollback; start = end - viewport, clamped. 56 + const end = Math.max(0, total - state.scrollback); 57 + const start = Math.max(0, end - viewport); 58 + return { start, end }; 59 + } 60 + 61 + /** Route a mouse event. Scroll-wheel adjusts scrollback; no click handling 62 + * since items aren't individually selectable in a plain stream view. */ 63 + export function handleStreamMouse( 64 + state: StreamViewState, 65 + event: MouseEvent, 66 + rect: Rect, 67 + total: number, 68 + viewport: number, 69 + ): StreamViewState | null { 70 + const inside = event.x >= rect.x && event.x < rect.x + rect.width 71 + && event.y >= rect.y && event.y < rect.y + rect.height; 72 + if (!inside) return null; 73 + if (event.action === "scrollUp") return streamScrollUp(state, 3, total, viewport); 74 + if (event.action === "scrollDown") return streamScrollDown(state, 3); 75 + return null; 76 + } 77 + 78 + /** Default key handler — up/down for scrollback, pageup/pagedown for bigger 79 + * jumps, end to re-pin. `total` and `viewport` are required for clamping. */ 80 + export function handleStreamKey( 81 + state: StreamViewState, 82 + key: KeyEvent, 83 + total: number, 84 + viewport: number, 85 + ): StreamViewState | null { 86 + switch (key.name) { 87 + case "up": return streamScrollUp(state, 1, total, viewport); 88 + case "down": return streamScrollDown(state, 1); 89 + case "pageup": return streamScrollUp(state, viewport, total, viewport); 90 + case "pagedown": return streamScrollDown(state, viewport); 91 + case "end": return streamPin(state); 92 + case "home": return streamScrollUp(state, total, total, viewport); 93 + } 94 + return null; 95 + } 96 + 97 + /** Render visible items using a per-item renderer. Appends an "N unread 98 + * below" indicator row when scrolled-back and new items have arrived. */ 99 + export function renderStreamView<T>( 100 + items: T[], 101 + state: StreamViewState, 102 + viewport: number, 103 + renderItem: (item: T, index: number) => UINode, 104 + ): UINode { 105 + const total = items.length; 106 + const { start, end } = streamWindow(state, total, viewport); 107 + const children: UINode[] = []; 108 + for (let i = start; i < end; i++) { 109 + children.push(renderItem(items[i], i)); 110 + } 111 + if (!isPinned(state) && state.scrollback > 0) { 112 + // Bottom hint row — unread count between the window and the tail. 113 + const behind = total - end; 114 + if (behind > 0) { 115 + children.push(row( 116 + text(`— ${behind} more below (end to jump) —`, "accent", { dim: true }), 117 + )); 118 + } 119 + } 120 + const node: ColumnNode = { type: "column", children }; 121 + return node; 122 + }
+168
src/tui/widgets/table.ts
··· 1 + // Sortable table. Columns describe: how to render a row's cell, how to 2 + // extract a sort value, and an alignment hint. State tracks the selected 3 + // row, the active sort column, and direction. Pure helpers for sort and 4 + // key dispatch; the render is straight-forward once the data is sorted. 5 + 6 + import { row, column, text } from "../builders.ts"; 7 + import type { UINode, ColumnNode, Color } from "../nodes.ts"; 8 + import type { KeyEvent } from "../input.ts"; 9 + 10 + export type TableAlign = "left" | "right"; 11 + 12 + export interface TableColumn<Row> { 13 + id: string; 14 + header: string; 15 + /** Render the cell as a plain string. Width/truncation handled outside. */ 16 + render: (row: Row) => string; 17 + /** Value used to sort. Defaults to the rendered string. */ 18 + getSortValue?: (row: Row) => string | number; 19 + align?: TableAlign; 20 + /** Explicit column width. Omitted means "auto" (max of header + cells). */ 21 + width?: number; 22 + } 23 + 24 + export interface TableState { 25 + sortColumnId: string | null; 26 + sortDirection: "asc" | "desc"; 27 + selectedIndex: number; 28 + } 29 + 30 + export function createTableState<Row>( 31 + columns: readonly TableColumn<Row>[], 32 + initialSortId?: string, 33 + ): TableState { 34 + const sortColumnId = initialSortId ?? columns[0]?.id ?? null; 35 + return { sortColumnId, sortDirection: "asc", selectedIndex: 0 }; 36 + } 37 + 38 + function valueOf<Row>(col: TableColumn<Row>, r: Row): string | number { 39 + return col.getSortValue ? col.getSortValue(r) : col.render(r); 40 + } 41 + 42 + /** Returns a NEW array sorted per `state`. Stable — preserves original 43 + * order for equal-keyed rows via the index tie-breaker. */ 44 + export function sortRows<Row>( 45 + rows: readonly Row[], 46 + columns: readonly TableColumn<Row>[], 47 + state: TableState, 48 + ): Row[] { 49 + if (!state.sortColumnId) return [...rows]; 50 + const col = columns.find(c => c.id === state.sortColumnId); 51 + if (!col) return [...rows]; 52 + const sign = state.sortDirection === "asc" ? 1 : -1; 53 + const indexed = rows.map((r, i) => ({ r, i })); 54 + indexed.sort((a, b) => { 55 + const va = valueOf(col, a.r); 56 + const vb = valueOf(col, b.r); 57 + if (va < vb) return -1 * sign; 58 + if (va > vb) return 1 * sign; 59 + return a.i - b.i; 60 + }); 61 + return indexed.map(x => x.r); 62 + } 63 + 64 + export interface HandleTableKeyResult<Row> { 65 + state: TableState; 66 + action: "moved" | "sorted" | "activate" | "none"; 67 + activated?: Row; 68 + } 69 + 70 + /** Default bindings: 71 + * up/down -> move selection 72 + * home/end -> first/last row 73 + * pageup/down -> +/- 10 rows 74 + * return -> "activate" with the selected row 75 + * 1..9 -> toggle sort on that column (by position). Same column 76 + * twice flips direction. */ 77 + export function handleTableKey<Row>( 78 + state: TableState, 79 + sortedRows: readonly Row[], 80 + columns: readonly TableColumn<Row>[], 81 + key: KeyEvent, 82 + ): HandleTableKeyResult<Row> { 83 + const clamp = (i: number) => Math.max(0, Math.min(Math.max(0, sortedRows.length - 1), i)); 84 + 85 + switch (key.name) { 86 + case "up": return { state: { ...state, selectedIndex: clamp(state.selectedIndex - 1) }, action: "moved" }; 87 + case "down": return { state: { ...state, selectedIndex: clamp(state.selectedIndex + 1) }, action: "moved" }; 88 + case "pageup": return { state: { ...state, selectedIndex: clamp(state.selectedIndex - 10) }, action: "moved" }; 89 + case "pagedown": return { state: { ...state, selectedIndex: clamp(state.selectedIndex + 10) }, action: "moved" }; 90 + case "home": return { state: { ...state, selectedIndex: 0 }, action: "moved" }; 91 + case "end": return { state: { ...state, selectedIndex: clamp(sortedRows.length - 1) }, action: "moved" }; 92 + case "return": 93 + return { state, action: "activate", activated: sortedRows[state.selectedIndex] }; 94 + } 95 + 96 + if (key.char && /^[1-9]$/.test(key.char) && !key.ctrl && !key.alt) { 97 + const idx = parseInt(key.char, 10) - 1; 98 + const col = columns[idx]; 99 + if (col) { 100 + if (state.sortColumnId === col.id) { 101 + return { 102 + state: { ...state, sortDirection: state.sortDirection === "asc" ? "desc" : "asc" }, 103 + action: "sorted", 104 + }; 105 + } 106 + return { 107 + state: { ...state, sortColumnId: col.id, sortDirection: "asc" }, 108 + action: "sorted", 109 + }; 110 + } 111 + } 112 + 113 + return { state, action: "none" }; 114 + } 115 + 116 + function columnWidths<Row>( 117 + rows: readonly Row[], 118 + columns: readonly TableColumn<Row>[], 119 + ): number[] { 120 + return columns.map(col => { 121 + if (col.width != null) return col.width; 122 + let w = col.header.length; 123 + for (const r of rows) w = Math.max(w, col.render(r).length); 124 + return w; 125 + }); 126 + } 127 + 128 + function padCell(s: string, w: number, align: TableAlign): string { 129 + if (s.length >= w) return s.slice(0, w); 130 + const pad = " ".repeat(w - s.length); 131 + return align === "right" ? pad + s : s + pad; 132 + } 133 + 134 + /** Render a sortable table. Rows should already be sorted via `sortRows` 135 + * (we don't sort inside render so the caller can memoize). */ 136 + export function renderTable<Row>( 137 + sortedRows: readonly Row[], 138 + columns: readonly TableColumn<Row>[], 139 + state: TableState, 140 + ): UINode { 141 + const widths = columnWidths(sortedRows, columns); 142 + const arrow = (colId: string): string => { 143 + if (state.sortColumnId !== colId) return " "; 144 + return state.sortDirection === "asc" ? " \u25b2" : " \u25bc"; 145 + }; 146 + 147 + const header = row(...columns.flatMap((col, i) => { 148 + const label = padCell(col.header + arrow(col.id), widths[i] + 2, col.align ?? "left"); 149 + return [text(label, "accent", { bold: true })]; 150 + })); 151 + 152 + const separator = row(...columns.flatMap((_, i) => [ 153 + text("\u2500".repeat(widths[i]) + " ", "muted", { dim: true }), 154 + ])); 155 + 156 + const body: UINode[] = sortedRows.map((r, idx) => { 157 + const selected = idx === state.selectedIndex; 158 + const color: Color = selected ? "accent" : "primary"; 159 + const cells = columns.flatMap((col, i) => [ 160 + text(padCell(col.render(r), widths[i], col.align ?? "left") + " ", 161 + color, { bold: selected }), 162 + ]); 163 + return row(...cells); 164 + }); 165 + 166 + const node: ColumnNode = { type: "column", children: [header, separator, ...body] }; 167 + return node; 168 + }
+106
src/tui/widgets/tabs.ts
··· 1 + // Tabs — a horizontal strip of labels with one active. Lets apps organise 2 + // multiple top-level views (like a mail client with Inbox/Sent/Drafts). 3 + // 4 + // Navigation: tab / ctrl+tab cycle forward, backtab / ctrl+shift+tab cycle 5 + // backward. Or bind numbers 1-9 to jump directly. Activation is cosmetic 6 + // only — the consumer decides what "active" means for their app. 7 + 8 + import { row, text } from "../builders.ts"; 9 + import type { UINode, Rect } from "../nodes.ts"; 10 + import type { KeyEvent, MouseEvent } from "../input.ts"; 11 + 12 + export interface TabDef<T = unknown> { 13 + id: string; 14 + label: string; 15 + /** Optional data bag for the consumer — tabs don't care what this is. */ 16 + data?: T; 17 + } 18 + 19 + export interface TabsState { 20 + activeId: string | null; 21 + } 22 + 23 + export function createTabsState<T>(tabs: readonly TabDef<T>[], initial?: string): TabsState { 24 + return { activeId: initial ?? tabs[0]?.id ?? null }; 25 + } 26 + 27 + export function selectTab(state: TabsState, id: string): TabsState { 28 + if (state.activeId === id) return state; 29 + return { activeId: id }; 30 + } 31 + 32 + function stepTab<T>(state: TabsState, tabs: readonly TabDef<T>[], delta: 1 | -1): TabsState { 33 + if (tabs.length === 0 || !state.activeId) return state; 34 + const idx = tabs.findIndex(t => t.id === state.activeId); 35 + if (idx < 0) return state; 36 + const next = (idx + delta + tabs.length) % tabs.length; 37 + return { activeId: tabs[next].id }; 38 + } 39 + 40 + export function nextTab<T>(state: TabsState, tabs: readonly TabDef<T>[]): TabsState { 41 + return stepTab(state, tabs, 1); 42 + } 43 + 44 + export function prevTab<T>(state: TabsState, tabs: readonly TabDef<T>[]): TabsState { 45 + return stepTab(state, tabs, -1); 46 + } 47 + 48 + /** Default key dispatch — ctrl+tab / ctrl+shift+tab to cycle. Returns 49 + * `null` when the key wasn't consumed so the caller can handle focus. */ 50 + export function handleTabsKey<T>( 51 + state: TabsState, 52 + tabs: readonly TabDef<T>[], 53 + key: KeyEvent, 54 + ): TabsState | null { 55 + if (key.name === "tab" && key.ctrl) return nextTab(state, tabs); 56 + if (key.name === "backtab" && key.ctrl) return prevTab(state, tabs); 57 + // Numeric shortcuts: 1..9 select tabs 1..9. 58 + if (key.char && /^[1-9]$/.test(key.char) && !key.ctrl && !key.alt) { 59 + const idx = parseInt(key.char, 10) - 1; 60 + const t = tabs[idx]; 61 + if (t) return selectTab(state, t.id); 62 + } 63 + return null; 64 + } 65 + 66 + /** Route a left-click within the tab bar's rect to whichever tab was 67 + * clicked. Returns the new state (or the same one if the click was 68 + * outside the bar or on a gap). Computes tab widths on the fly from the 69 + * same rendering rule renderTabs uses — consumers pass the row's rect 70 + * (from layout) and the tabs array. */ 71 + export function handleTabsMouse<T>( 72 + state: TabsState, 73 + tabs: readonly TabDef<T>[], 74 + event: MouseEvent, 75 + rect: Rect, 76 + ): TabsState | null { 77 + if (event.action !== "press" || event.button !== "left") return null; 78 + if (event.y !== rect.y) return null; 79 + // renderTabs layout: each tab is "[ Label ]" (active) or " Label " 80 + // (inactive), joined by " " separators. Widths: "[ L ]" = L.length + 4. 81 + let cursor = rect.x; 82 + for (let i = 0; i < tabs.length; i++) { 83 + if (i > 0) cursor += 2; // " " separator 84 + const w = tabs[i].label.length + 4; 85 + if (event.x >= cursor && event.x < cursor + w) { 86 + return selectTab(state, tabs[i].id); 87 + } 88 + cursor += w; 89 + } 90 + return null; 91 + } 92 + 93 + /** Render a tabs row: `[Active] inactive1 inactive2`. Active tab is bold 94 + * with brackets; inactive tabs are dim. */ 95 + export function renderTabs<T>(state: TabsState, tabs: readonly TabDef<T>[]): UINode { 96 + const parts: UINode[] = []; 97 + tabs.forEach((t, i) => { 98 + if (i > 0) parts.push(text(" ", "muted")); 99 + if (t.id === state.activeId) { 100 + parts.push(text(`[ ${t.label} ]`, "accent", { bold: true })); 101 + } else { 102 + parts.push(text(` ${t.label} `, "muted", { dim: true })); 103 + } 104 + }); 105 + return row(...parts); 106 + }
+199
src/tui/widgets/text-area.ts
··· 1 + // Multi-line text input widget (a.k.a. text area / composer). 2 + // 3 + // State-first: the consumer owns `TextAreaState`. Key handling is a pure 4 + // function returning a new state or `null` when the key should escape the 5 + // widget (unknown, or `ctrl+enter` — used by most shells to mean "submit" 6 + // from a multi-line prompt). 7 + // 8 + // Model: `lines: string[]` (each is the logical line; not yet visually 9 + // wrapped). `row`/`col` index into lines + character offset. 10 + // Rendering walks each line and inserts a block cursor at the active cell 11 + // for the focused row. 12 + 13 + import { text, row as uiRow } from "../builders.ts"; 14 + import type { ColumnNode } from "../nodes.ts"; 15 + import type { UINode } from "../nodes.ts"; 16 + import type { KeyEvent } from "../input.ts"; 17 + import { prevWordBoundary, nextWordBoundary } from "./form.ts"; 18 + 19 + export interface TextAreaState { 20 + lines: string[]; 21 + /** 0-based line index of the cursor. Always valid: >= 0 and < lines.length. */ 22 + row: number; 23 + /** 0-based column offset into lines[row]. 0 ≤ col ≤ lines[row].length. */ 24 + col: number; 25 + } 26 + 27 + export function createTextArea(initial = ""): TextAreaState { 28 + const lines = initial.length === 0 ? [""] : initial.split("\n"); 29 + return { lines, row: 0, col: 0 }; 30 + } 31 + 32 + export function textAreaToString(state: TextAreaState): string { 33 + return state.lines.join("\n"); 34 + } 35 + 36 + /** Apply one key to the text area. Returns a new state when consumed, else 37 + * `null` — consumers interpret `null` as "use this key for something else" 38 + * (e.g. submit the form, cancel, etc). Specifically: 39 + * - tab / backtab / escape always return null (owned by the outer form) 40 + * - ctrl+return returns null (conventional "submit" from multi-line input) 41 + * - all other editing keys return a new state */ 42 + export function applyTextAreaKey(state: TextAreaState, key: KeyEvent): TextAreaState | null { 43 + // Reserved outer-form keys. 44 + if (key.name === "tab" || key.name === "backtab" || key.name === "escape") return null; 45 + if (key.name === "return" && key.ctrl) return null; 46 + 47 + // Newline. 48 + if (key.name === "return") { 49 + const curLine = state.lines[state.row] ?? ""; 50 + const before = curLine.slice(0, state.col); 51 + const after = curLine.slice(state.col); 52 + const nextLines = [ 53 + ...state.lines.slice(0, state.row), 54 + before, 55 + after, 56 + ...state.lines.slice(state.row + 1), 57 + ]; 58 + return { lines: nextLines, row: state.row + 1, col: 0 }; 59 + } 60 + 61 + // Deletions. 62 + if (key.name === "backspace") { 63 + // At col 0 of a non-first line: merge up. 64 + if (state.col === 0) { 65 + if (state.row === 0) return state; 66 + const prev = state.lines[state.row - 1]; 67 + const cur = state.lines[state.row]; 68 + const mergedCol = prev.length; 69 + const merged = prev + cur; 70 + const nextLines = [ 71 + ...state.lines.slice(0, state.row - 1), 72 + merged, 73 + ...state.lines.slice(state.row + 1), 74 + ]; 75 + return { lines: nextLines, row: state.row - 1, col: mergedCol }; 76 + } 77 + // Mid-line backspace. 78 + const curLine = state.lines[state.row]; 79 + const nextLine = curLine.slice(0, state.col - 1) + curLine.slice(state.col); 80 + return { 81 + lines: state.lines.map((l, i) => (i === state.row ? nextLine : l)), 82 + row: state.row, 83 + col: state.col - 1, 84 + }; 85 + } 86 + if (key.name === "delete") { 87 + const curLine = state.lines[state.row]; 88 + // At end of line: merge with next line. 89 + if (state.col >= curLine.length) { 90 + if (state.row === state.lines.length - 1) return state; 91 + const merged = curLine + state.lines[state.row + 1]; 92 + const nextLines = [ 93 + ...state.lines.slice(0, state.row), 94 + merged, 95 + ...state.lines.slice(state.row + 2), 96 + ]; 97 + return { lines: nextLines, row: state.row, col: state.col }; 98 + } 99 + // Mid-line delete. 100 + const nextLine = curLine.slice(0, state.col) + curLine.slice(state.col + 1); 101 + return { 102 + lines: state.lines.map((l, i) => (i === state.row ? nextLine : l)), 103 + row: state.row, 104 + col: state.col, 105 + }; 106 + } 107 + 108 + // Cursor movement. 109 + if (key.name === "left") { 110 + if (key.alt) { 111 + const cur = state.lines[state.row]; 112 + if (state.col > 0) return { ...state, col: prevWordBoundary(cur, state.col) }; 113 + if (state.row > 0) { 114 + const prev = state.lines[state.row - 1]; 115 + return { ...state, row: state.row - 1, col: prev.length }; 116 + } 117 + return state; 118 + } 119 + if (state.col > 0) return { ...state, col: state.col - 1 }; 120 + if (state.row > 0) { 121 + const prev = state.lines[state.row - 1]; 122 + return { ...state, row: state.row - 1, col: prev.length }; 123 + } 124 + return state; 125 + } 126 + if (key.name === "right") { 127 + const cur = state.lines[state.row]; 128 + if (key.alt) { 129 + if (state.col < cur.length) return { ...state, col: nextWordBoundary(cur, state.col) }; 130 + if (state.row < state.lines.length - 1) { 131 + return { ...state, row: state.row + 1, col: 0 }; 132 + } 133 + return state; 134 + } 135 + if (state.col < cur.length) return { ...state, col: state.col + 1 }; 136 + if (state.row < state.lines.length - 1) { 137 + return { ...state, row: state.row + 1, col: 0 }; 138 + } 139 + return state; 140 + } 141 + if (key.alt && key.char === "b") { 142 + const cur = state.lines[state.row]; 143 + return { ...state, col: prevWordBoundary(cur, state.col) }; 144 + } 145 + if (key.alt && key.char === "f") { 146 + const cur = state.lines[state.row]; 147 + return { ...state, col: nextWordBoundary(cur, state.col) }; 148 + } 149 + if (key.name === "up") { 150 + if (state.row === 0) return state; 151 + const prev = state.lines[state.row - 1]; 152 + return { ...state, row: state.row - 1, col: Math.min(state.col, prev.length) }; 153 + } 154 + if (key.name === "down") { 155 + if (state.row === state.lines.length - 1) return state; 156 + const next = state.lines[state.row + 1]; 157 + return { ...state, row: state.row + 1, col: Math.min(state.col, next.length) }; 158 + } 159 + if (key.name === "home" || (key.name === "a" && key.ctrl)) { 160 + return { ...state, col: 0 }; 161 + } 162 + if (key.name === "end" || (key.name === "e" && key.ctrl)) { 163 + return { ...state, col: state.lines[state.row].length }; 164 + } 165 + 166 + // Printable character. 167 + if (key.char && !key.ctrl && !key.alt) { 168 + const cur = state.lines[state.row]; 169 + const next = cur.slice(0, state.col) + key.char + cur.slice(state.col); 170 + return { 171 + lines: state.lines.map((l, i) => (i === state.row ? next : l)), 172 + row: state.row, 173 + col: state.col + key.char.length, 174 + }; 175 + } 176 + 177 + return null; 178 + } 179 + 180 + /** Render the text area as a column of rows. When `active`, the focused row 181 + * paints an inverse-styled cell at the cursor position — the character 182 + * UNDER the cursor gets its fg/bg swapped so neighbors don't shift. */ 183 + export function renderTextArea(state: TextAreaState, active: boolean): UINode { 184 + const children: UINode[] = state.lines.map((line, i) => { 185 + if (!active || i !== state.row) { 186 + return uiRow(text(line.length === 0 ? " " : line, "primary")); 187 + } 188 + const before = line.slice(0, state.col); 189 + const under = line.slice(state.col, state.col + 1) || " "; 190 + const after = line.slice(state.col + 1); 191 + return uiRow( 192 + text(before, "primary"), 193 + text(under, "primary", { inverse: true }), 194 + text(after, "primary"), 195 + ); 196 + }); 197 + const node: ColumnNode = { type: "column", children }; 198 + return node; 199 + }
+103
src/tui/widgets/toast.ts
··· 1 + // Toast / notification queue. Ephemeral banners that auto-dismiss after 2 + // a timeout. Safe for non-blocking feedback ("saved", "sync failed") — 3 + // not a replacement for confirm modals or dialogs. 4 + // 5 + // Queue is pure: `pushToast` / `pruneExpired` return new queues, never 6 + // mutate. The consumer owns a `signal<ToastQueue>` and an effect that 7 + // schedules `pruneExpired` via setInterval when the queue is non-empty. 8 + 9 + import { column, row, text } from "../builders.ts"; 10 + import type { UINode, ColumnNode, Color } from "../nodes.ts"; 11 + 12 + export type ToastKind = "info" | "success" | "warn" | "error"; 13 + 14 + export interface Toast { 15 + id: string; 16 + kind: ToastKind; 17 + text: string; 18 + /** Epoch ms when this toast should be dropped. */ 19 + expiresAt: number; 20 + } 21 + 22 + export interface ToastQueue { 23 + toasts: Toast[]; 24 + } 25 + 26 + let nextToastId = 0; 27 + function genToastId(): string { 28 + nextToastId += 1; 29 + return `toast-${nextToastId}`; 30 + } 31 + 32 + export function createToastQueue(): ToastQueue { 33 + return { toasts: [] }; 34 + } 35 + 36 + export interface PushToastOptions { 37 + kind?: ToastKind; 38 + /** Milliseconds the toast is visible. Default 3000. */ 39 + durationMs?: number; 40 + now?: number; 41 + } 42 + 43 + export function pushToast( 44 + queue: ToastQueue, 45 + message: string, 46 + opts: PushToastOptions = {}, 47 + ): ToastQueue { 48 + const now = opts.now ?? Date.now(); 49 + const toast: Toast = { 50 + id: genToastId(), 51 + kind: opts.kind ?? "info", 52 + text: message, 53 + expiresAt: now + (opts.durationMs ?? 3000), 54 + }; 55 + return { toasts: [...queue.toasts, toast] }; 56 + } 57 + 58 + /** Drop toasts whose `expiresAt` has passed. Call from a setInterval. */ 59 + export function pruneExpired(queue: ToastQueue, now?: number): ToastQueue { 60 + const t = now ?? Date.now(); 61 + const kept = queue.toasts.filter(x => x.expiresAt > t); 62 + if (kept.length === queue.toasts.length) return queue; 63 + return { toasts: kept }; 64 + } 65 + 66 + export function dismissToast(queue: ToastQueue, id: string): ToastQueue { 67 + return { toasts: queue.toasts.filter(t => t.id !== id) }; 68 + } 69 + 70 + function kindColor(kind: ToastKind): Color { 71 + switch (kind) { 72 + case "success": return "ok"; 73 + case "warn": return "warn"; 74 + case "error": return "error"; 75 + default: return "accent"; 76 + } 77 + } 78 + 79 + function kindGlyph(kind: ToastKind): string { 80 + switch (kind) { 81 + case "success": return "\u2713"; // check 82 + case "warn": return "\u26a0"; // warning sign 83 + case "error": return "\u2717"; // ballot x 84 + default: return "\u25cf"; // bullet 85 + } 86 + } 87 + 88 + /** Render the queue as a column of rows. Consumers typically overlay this 89 + * in a corner of their app. */ 90 + export function renderToasts(queue: ToastQueue): UINode { 91 + if (queue.toasts.length === 0) { 92 + const empty: ColumnNode = { type: "column", children: [] }; 93 + return empty; 94 + } 95 + const children: UINode[] = queue.toasts.map(t => 96 + row( 97 + text(` ${kindGlyph(t.kind)} `, kindColor(t.kind), { bold: true }), 98 + text(t.text, "primary"), 99 + ), 100 + ); 101 + const node: ColumnNode = { type: "column", children }; 102 + return node; 103 + }
+94
src/tui/widgets/toolbar.ts
··· 1 + // Toolbar — a horizontal strip of labeled actions with highlighted hotkeys. 2 + // Useful as a compact keybind legend or as a "buttons" row for mouse-free 3 + // interfaces. The `[X]abel` convention makes the bound letter unambiguous 4 + // without needing a separate legend. 5 + 6 + import { row, text } from "../builders.ts"; 7 + import type { UINode, Color } from "../nodes.ts"; 8 + 9 + export interface ToolbarItem { 10 + /** The key bound to this action. One character — letter or digit. */ 11 + key: string; 12 + /** Label around the key. Leave {key} placeholder or the widget will 13 + * prepend "[key] " to `label`. See `format` option. */ 14 + label: string; 15 + /** Optional one-line hint shown dim after the label. */ 16 + hint?: string; 17 + /** Mark this item currently active (highlighted). */ 18 + active?: boolean; 19 + /** Disable visually (dim). Key-dispatching is up to the caller. */ 20 + disabled?: boolean; 21 + } 22 + 23 + export interface ToolbarOptions { 24 + /** Separator between items. Defaults to " " (two spaces). */ 25 + separator?: string; 26 + /** Render format: 27 + * - "bracket" (default): `[N]ew [S]ave` 28 + * - "inline": first occurrence of `key` inside `label` is highlighted. 29 + * Useful when the label naturally contains the key: 30 + * { key: "n", label: "new" } -> "[n]ew". */ 31 + format?: "bracket" | "inline"; 32 + /** Accent color for active items. Default "accent". */ 33 + activeColor?: Color; 34 + } 35 + 36 + function bracketize(item: ToolbarItem): UINode[] { 37 + const baseColor: Color = item.active ? "accent" : "primary"; 38 + const hintColor: Color = item.disabled ? "muted" : "muted"; 39 + const cell: UINode[] = [ 40 + text("[", baseColor, { bold: item.active }), 41 + text(item.key.toUpperCase(), baseColor, { bold: true }), 42 + text("]", baseColor, { bold: item.active }), 43 + text(item.label, item.disabled ? "muted" : baseColor, { bold: item.active, dim: item.disabled }), 44 + ]; 45 + if (item.hint) { 46 + cell.push(text(` ${item.hint}`, hintColor, { dim: true })); 47 + } 48 + return cell; 49 + } 50 + 51 + function inlineize(item: ToolbarItem): UINode[] { 52 + const baseColor: Color = item.active ? "accent" : "primary"; 53 + const keyIdx = item.label.toLowerCase().indexOf(item.key.toLowerCase()); 54 + if (keyIdx < 0) return bracketize(item); 55 + const before = item.label.slice(0, keyIdx); 56 + const kChar = item.label[keyIdx]; 57 + const after = item.label.slice(keyIdx + 1); 58 + const cell: UINode[] = [ 59 + text(before, item.disabled ? "muted" : baseColor, { dim: item.disabled }), 60 + text(kChar, item.active ? "accent" : "accent", { bold: true, dim: item.disabled }), 61 + text(after, item.disabled ? "muted" : baseColor, { dim: item.disabled }), 62 + ]; 63 + if (item.hint) cell.push(text(` ${item.hint}`, "muted", { dim: true })); 64 + return cell; 65 + } 66 + 67 + /** Render the toolbar as a single row. */ 68 + export function toolbar(items: readonly ToolbarItem[], opts: ToolbarOptions = {}): UINode { 69 + const separator = opts.separator ?? " "; 70 + const format = opts.format ?? "bracket"; 71 + const children: UINode[] = []; 72 + items.forEach((item, i) => { 73 + if (i > 0) children.push(text(separator, "muted")); 74 + const cell = format === "inline" ? inlineize(item) : bracketize(item); 75 + children.push(...cell); 76 + }); 77 + return row(...children); 78 + } 79 + 80 + /** Check whether a KeyEvent-like `char` matches any non-disabled item's 81 + * key. The caller wires the action via their own `switch (key.char)` — this 82 + * helper is just for feature-detection / highlight decisions. */ 83 + export function toolbarItemFor( 84 + items: readonly ToolbarItem[], 85 + char: string | undefined, 86 + ): ToolbarItem | null { 87 + if (!char) return null; 88 + const c = char.toLowerCase(); 89 + for (const it of items) { 90 + if (it.disabled) continue; 91 + if (it.key.toLowerCase() === c) return it; 92 + } 93 + return null; 94 + }
+145
src/tui/widgets/tree.ts
··· 1 + // Tree view widget — keyboard-navigable, expand/collapse, depth-aware. 2 + // 3 + // Design: state-first. `flattenTree` takes the tree + an expanded-set and 4 + // returns a flat list of visible rows (each carrying a depth). The consumer 5 + // renders those rows however it likes. Selection and expansion state are 6 + // just signals you own. 7 + // 8 + // Promoted from local helpers in demos/reminders and demos/file-browser. 9 + // Both relied on a flat visible-rows shape; this is the shared version. 10 + 11 + import type { KeyEvent } from "../input.ts"; 12 + 13 + export interface TreeNode<T> { 14 + /** Stable, unique id within the tree. Used for selection + expansion keys. */ 15 + id: string; 16 + label: string; 17 + data: T; 18 + children?: TreeNode<T>[]; 19 + } 20 + 21 + export interface TreeRow<T> { 22 + node: TreeNode<T>; 23 + depth: number; 24 + hasChildren: boolean; 25 + expanded: boolean; 26 + } 27 + 28 + export interface TreeState { 29 + /** Ids of nodes whose children are shown. */ 30 + expanded: Set<string>; 31 + /** Currently highlighted node, or null when the tree is empty. */ 32 + selectedId: string | null; 33 + } 34 + 35 + export function createTreeState(): TreeState { 36 + return { expanded: new Set(), selectedId: null }; 37 + } 38 + 39 + /** Depth-first walk of the tree, skipping the children of any node that is 40 + * not in `expanded`. The resulting list is what the renderer draws. */ 41 + export function flattenTree<T>(roots: TreeNode<T>[], expanded: Set<string>): TreeRow<T>[] { 42 + const out: TreeRow<T>[] = []; 43 + const walk = (nodes: TreeNode<T>[], depth: number) => { 44 + for (const node of nodes) { 45 + const hasChildren = !!node.children && node.children.length > 0; 46 + const isExpanded = hasChildren && expanded.has(node.id); 47 + out.push({ node, depth, hasChildren, expanded: isExpanded }); 48 + if (isExpanded && node.children) walk(node.children, depth + 1); 49 + } 50 + }; 51 + walk(roots, 0); 52 + return out; 53 + } 54 + 55 + export function toggleExpanded(state: TreeState, id: string): TreeState { 56 + const next = new Set(state.expanded); 57 + if (next.has(id)) next.delete(id); 58 + else next.add(id); 59 + return { ...state, expanded: next }; 60 + } 61 + 62 + export function selectById<T>(state: TreeState, id: string | null): TreeState { 63 + return { ...state, selectedId: id }; 64 + } 65 + 66 + /** Move selection to the row `delta` away from the currently selected row. 67 + * Clamps at the ends. Returns the same state reference if nothing changes. */ 68 + export function moveSelection<T>( 69 + state: TreeState, 70 + rows: TreeRow<T>[], 71 + delta: number, 72 + ): TreeState { 73 + if (rows.length === 0) return state; 74 + const idx = state.selectedId 75 + ? rows.findIndex(r => r.node.id === state.selectedId) 76 + : -1; 77 + // Nothing selected yet — any arrow key lands on the first row, rather than 78 + // skipping past it. Deliberate: it's what users expect the first press to do. 79 + if (idx === -1) return { ...state, selectedId: rows[0].node.id }; 80 + const next = Math.max(0, Math.min(rows.length - 1, idx + delta)); 81 + if (next === idx) return state; 82 + return { ...state, selectedId: rows[next].node.id }; 83 + } 84 + 85 + /** Opinionated default key bindings — up/down moves, left/right collapses or 86 + * expands, enter toggles expansion on folders. Returns the new state (or the 87 + * same reference if the key was not consumed). The caller receives an extra 88 + * hint about what happened via the returned `action` field. */ 89 + export interface HandleKeyResult<T> { 90 + state: TreeState; 91 + /** What happened — useful for the consumer to wire side effects (e.g., 92 + * "activate" when the user hits enter on a leaf). */ 93 + action: "moved" | "expanded" | "collapsed" | "activated" | "none"; 94 + /** The row the action applied to, if any. */ 95 + row: TreeRow<T> | null; 96 + } 97 + 98 + export function handleTreeKey<T>( 99 + state: TreeState, 100 + rows: TreeRow<T>[], 101 + key: KeyEvent, 102 + ): HandleKeyResult<T> { 103 + const selectedRow = 104 + state.selectedId != null 105 + ? rows.find(r => r.node.id === state.selectedId) ?? null 106 + : null; 107 + 108 + if (key.name === "up") { 109 + return { state: moveSelection(state, rows, -1), action: "moved", row: null }; 110 + } 111 + if (key.name === "down") { 112 + return { state: moveSelection(state, rows, 1), action: "moved", row: null }; 113 + } 114 + if (!selectedRow) { 115 + return { state, action: "none", row: null }; 116 + } 117 + if (key.name === "right") { 118 + if (selectedRow.hasChildren && !selectedRow.expanded) { 119 + return { state: toggleExpanded(state, selectedRow.node.id), action: "expanded", row: selectedRow }; 120 + } 121 + return { state, action: "none", row: selectedRow }; 122 + } 123 + if (key.name === "left") { 124 + if (selectedRow.hasChildren && selectedRow.expanded) { 125 + return { state: toggleExpanded(state, selectedRow.node.id), action: "collapsed", row: selectedRow }; 126 + } 127 + return { state, action: "none", row: selectedRow }; 128 + } 129 + if (key.name === "return") { 130 + if (selectedRow.hasChildren) { 131 + return { state: toggleExpanded(state, selectedRow.node.id), 132 + action: selectedRow.expanded ? "collapsed" : "expanded", 133 + row: selectedRow }; 134 + } 135 + return { state, action: "activated", row: selectedRow }; 136 + } 137 + return { state, action: "none", row: null }; 138 + } 139 + 140 + /** Glyph for the expand/collapse indicator in front of a row. 141 + * ▸ collapsed folder, ▾ expanded folder, empty string for leaves. */ 142 + export function treeGlyph<T>(row: TreeRow<T>): string { 143 + if (!row.hasChildren) return " "; 144 + return row.expanded ? "\u25be " : "\u25b8 "; 145 + }
+158
src/tui/widgets/virtual-list.ts
··· 1 + // Virtualized list — keyboard-navigable list that only renders a visible 2 + // slice. For datasets where rendering every row would be wasteful (email 3 + // inboxes, RSS archives, long logs). The consumer still gives us `total` 4 + // and an item-by-index callback; we never look at the full array. 5 + // 6 + // State-first: the caller owns `VirtualListState`. `moveVirtualSelection` 7 + // and arrow-key handlers return new states. 8 + 9 + import { column, row, text } from "../builders.ts"; 10 + import type { UINode, ColumnNode, Rect } from "../nodes.ts"; 11 + import type { KeyEvent, MouseEvent } from "../input.ts"; 12 + 13 + export interface VirtualListState { 14 + total: number; 15 + selectedIndex: number; 16 + /** First visible index (scroll offset). */ 17 + offset: number; 18 + /** How many rows the viewport shows. The caller drives this from layout. */ 19 + viewport: number; 20 + } 21 + 22 + export interface VirtualWindow { 23 + /** Inclusive first index to render. */ 24 + start: number; 25 + /** Exclusive last index to render. */ 26 + end: number; 27 + } 28 + 29 + export function createVirtualListState(total: number, viewport: number): VirtualListState { 30 + return { 31 + total, 32 + selectedIndex: total > 0 ? 0 : -1, 33 + offset: 0, 34 + viewport: Math.max(1, viewport), 35 + }; 36 + } 37 + 38 + /** Re-normalise state after total/viewport change. Keeps the selection in 39 + * view and within bounds. */ 40 + export function clampVirtual(state: VirtualListState): VirtualListState { 41 + const total = Math.max(0, state.total); 42 + const viewport = Math.max(1, state.viewport); 43 + if (total === 0) { 44 + return { total: 0, selectedIndex: -1, offset: 0, viewport }; 45 + } 46 + const sel = Math.max(0, Math.min(total - 1, state.selectedIndex)); 47 + const maxOffset = Math.max(0, total - viewport); 48 + let offset = Math.max(0, Math.min(maxOffset, state.offset)); 49 + if (sel < offset) offset = sel; 50 + if (sel >= offset + viewport) offset = sel - viewport + 1; 51 + return { total, selectedIndex: sel, offset, viewport }; 52 + } 53 + 54 + /** Compute the window of indexes that should be drawn right now. */ 55 + export function virtualWindow(state: VirtualListState): VirtualWindow { 56 + const s = clampVirtual(state); 57 + return { start: s.offset, end: Math.min(s.total, s.offset + s.viewport) }; 58 + } 59 + 60 + /** Move selection by `delta`, adjusting offset to keep the selection in 61 + * the viewport. Returns the same reference when nothing changed. */ 62 + export function moveVirtualSelection( 63 + state: VirtualListState, 64 + delta: number, 65 + ): VirtualListState { 66 + if (state.total === 0) return state; 67 + const target = Math.max(0, Math.min(state.total - 1, state.selectedIndex + delta)); 68 + if (target === state.selectedIndex) return state; 69 + return clampVirtual({ ...state, selectedIndex: target }); 70 + } 71 + 72 + export function pageVirtual(state: VirtualListState, delta: number): VirtualListState { 73 + return moveVirtualSelection(state, delta * state.viewport); 74 + } 75 + 76 + export function jumpVirtualToStart(state: VirtualListState): VirtualListState { 77 + return clampVirtual({ ...state, selectedIndex: 0 }); 78 + } 79 + 80 + export function jumpVirtualToEnd(state: VirtualListState): VirtualListState { 81 + return clampVirtual({ ...state, selectedIndex: Math.max(0, state.total - 1) }); 82 + } 83 + 84 + export interface HandleVirtualKeyResult { 85 + state: VirtualListState; 86 + /** What the consumer might want to react to. "activate" = the user hit 87 + * Enter on the selected row; the consumer should open it. */ 88 + action: "moved" | "activate" | "none"; 89 + } 90 + 91 + export interface HandleVirtualMouseResult { 92 + state: VirtualListState; 93 + /** "moved" = scroll or selection changed. "activate" = click picked a 94 + * specific row the caller should open. "none" = event outside the list's 95 + * rendered rect, or on the empty-state row. */ 96 + action: "moved" | "activate" | "none"; 97 + } 98 + 99 + /** Route a mouse event to the list given its rendered rect (from the 100 + * layout pass). Handles click-to-select and scroll-wheel-to-scroll. */ 101 + export function handleVirtualMouse( 102 + state: VirtualListState, 103 + event: MouseEvent, 104 + rect: Rect, 105 + ): HandleVirtualMouseResult { 106 + const inside = event.x >= rect.x && event.x < rect.x + rect.width 107 + && event.y >= rect.y && event.y < rect.y + rect.height; 108 + if (!inside) return { state, action: "none" }; 109 + 110 + if (event.action === "scrollUp") { 111 + return { state: moveVirtualSelection(state, -3), action: "moved" }; 112 + } 113 + if (event.action === "scrollDown") { 114 + return { state: moveVirtualSelection(state, 3), action: "moved" }; 115 + } 116 + if (event.action === "press" && event.button === "left") { 117 + const rowIdx = (event.y - rect.y) + state.offset; 118 + if (rowIdx < 0 || rowIdx >= state.total) return { state, action: "none" }; 119 + return { 120 + state: clampVirtual({ ...state, selectedIndex: rowIdx }), 121 + action: "activate", 122 + }; 123 + } 124 + return { state, action: "none" }; 125 + } 126 + 127 + /** Default key bindings: up/down, pageup/pagedown, home/end, return. */ 128 + export function handleVirtualKey(state: VirtualListState, key: KeyEvent): HandleVirtualKeyResult { 129 + switch (key.name) { 130 + case "up": return { state: moveVirtualSelection(state, -1), action: "moved" }; 131 + case "down": return { state: moveVirtualSelection(state, 1), action: "moved" }; 132 + case "pageup": return { state: pageVirtual(state, -1), action: "moved" }; 133 + case "pagedown": return { state: pageVirtual(state, 1), action: "moved" }; 134 + case "home": return { state: jumpVirtualToStart(state), action: "moved" }; 135 + case "end": return { state: jumpVirtualToEnd(state), action: "moved" }; 136 + case "return": return { state, action: "activate" }; 137 + default: return { state, action: "none" }; 138 + } 139 + } 140 + 141 + /** Render a column with one row per visible index. `renderItem(index, selected)` 142 + * returns the UINode for that row. Never called for indexes outside the 143 + * window — so this scales to arbitrary `total`. */ 144 + export function renderVirtualList( 145 + state: VirtualListState, 146 + renderItem: (index: number, selected: boolean) => UINode, 147 + ): UINode { 148 + const win = virtualWindow(state); 149 + const children: UINode[] = []; 150 + for (let i = win.start; i < win.end; i++) { 151 + children.push(renderItem(i, i === state.selectedIndex)); 152 + } 153 + if (children.length === 0) { 154 + children.push(row(text("(empty)", "muted", { dim: true }))); 155 + } 156 + const node: ColumnNode = { type: "column", children }; 157 + return node; 158 + }
+214
tests/focus.test.ts
··· 1 + import { describe, it, expect } from "vitest"; 2 + import { createFocusManager } from "../src/tui/focus.ts"; 3 + import type { KeyEvent, MouseEvent, ScreenContext } from "../src/tui/index.ts"; 4 + 5 + function k(name: string, extra: Partial<KeyEvent> = {}): KeyEvent { 6 + return { kind: "key", name, ctrl: false, alt: false, shift: false, ...extra }; 7 + } 8 + function me(action: MouseEvent["action"] = "press"): MouseEvent { 9 + return { kind: "mouse", action, button: "left", x: 0, y: 0, ctrl: false, alt: false, shift: false }; 10 + } 11 + function mkCtx(): ScreenContext { 12 + return { 13 + rows: 40, cols: 100, theme: {} as any, boxStyle: "rounded", 14 + navigate: () => {}, back: () => {}, openOverlay: () => {}, closeOverlay: () => {}, 15 + isTextInputActive: () => false, setTextInputActive: () => {}, 16 + quit: () => {}, 17 + focus: {} as any, // not used in these tests 18 + }; 19 + } 20 + 21 + describe("createFocusManager — stack semantics", () => { 22 + it("starts empty", () => { 23 + const f = createFocusManager(); 24 + expect(f.current()).toBeNull(); 25 + expect(f.stack()).toEqual([]); 26 + }); 27 + 28 + it("push returns a disposer that removes the scope", () => { 29 + const f = createFocusManager(); 30 + const d = f.push({ id: "a" }); 31 + expect(f.stack().map(s => s.id)).toEqual(["a"]); 32 + d(); 33 + expect(f.stack()).toEqual([]); 34 + }); 35 + 36 + it("disposer is idempotent", () => { 37 + const f = createFocusManager(); 38 + const d = f.push({ id: "a" }); 39 + d(); d(); d(); 40 + expect(f.stack()).toEqual([]); 41 + }); 42 + 43 + it("current() returns the innermost-active scope", () => { 44 + const f = createFocusManager(); 45 + f.push({ id: "a" }); 46 + f.push({ id: "b" }); 47 + f.push({ id: "c" }); 48 + expect(f.current()?.id).toBe("c"); 49 + }); 50 + }); 51 + 52 + describe("dispatchKey — bubbling", () => { 53 + it("innermost wins", () => { 54 + const f = createFocusManager(); 55 + const calls: string[] = []; 56 + f.push({ id: "outer", onKey: () => { calls.push("outer"); return true; } }); 57 + f.push({ id: "inner", onKey: () => { calls.push("inner"); return true; } }); 58 + f.dispatchKey(k("a"), mkCtx()); 59 + expect(calls).toEqual(["inner"]); 60 + }); 61 + 62 + it("returning false bubbles to the next-outer scope", () => { 63 + const f = createFocusManager(); 64 + const calls: string[] = []; 65 + f.push({ id: "outer", onKey: () => { calls.push("outer"); return true; } }); 66 + f.push({ id: "inner", onKey: () => { calls.push("inner"); return false; } }); 67 + f.dispatchKey(k("a"), mkCtx()); 68 + expect(calls).toEqual(["inner", "outer"]); 69 + }); 70 + 71 + it("returns true when any scope consumed, false when none did", () => { 72 + const f = createFocusManager(); 73 + f.push({ id: "a", onKey: () => false }); 74 + f.push({ id: "b", onKey: () => false }); 75 + expect(f.dispatchKey(k("x"), mkCtx())).toBe(false); 76 + 77 + f.push({ id: "c", onKey: () => true }); 78 + expect(f.dispatchKey(k("x"), mkCtx())).toBe(true); 79 + }); 80 + 81 + it("scopes without onKey are silently skipped in the bubble", () => { 82 + const f = createFocusManager(); 83 + const calls: string[] = []; 84 + f.push({ id: "outer", onKey: () => { calls.push("outer"); return true; } }); 85 + f.push({ id: "middle" }); // no onKey 86 + f.push({ id: "inner", onKey: () => { calls.push("inner"); return false; } }); 87 + f.dispatchKey(k("a"), mkCtx()); 88 + expect(calls).toEqual(["inner", "outer"]); 89 + }); 90 + }); 91 + 92 + describe("active() predicate — sibling scopes", () => { 93 + it("skips inactive scopes", () => { 94 + const f = createFocusManager(); 95 + let paneIsA = true; 96 + const calls: string[] = []; 97 + f.push({ 98 + id: "A", 99 + active: () => paneIsA, 100 + onKey: () => { calls.push("A"); return true; }, 101 + }); 102 + f.push({ 103 + id: "B", 104 + active: () => !paneIsA, 105 + onKey: () => { calls.push("B"); return true; }, 106 + }); 107 + f.dispatchKey(k("x"), mkCtx()); 108 + expect(calls).toEqual(["A"]); 109 + calls.length = 0; 110 + paneIsA = false; 111 + f.dispatchKey(k("x"), mkCtx()); 112 + expect(calls).toEqual(["B"]); 113 + }); 114 + 115 + it("inactive scopes still appear in stack() but not current()", () => { 116 + const f = createFocusManager(); 117 + f.push({ id: "A", active: () => false }); 118 + f.push({ id: "B", active: () => true }); 119 + expect(f.stack().map(s => s.id)).toEqual(["A", "B"]); 120 + expect(f.current()?.id).toBe("B"); 121 + }); 122 + }); 123 + 124 + describe("nested scopes — classic app shape", () => { 125 + it("global + pane + modal: modal wins, then pane, then global", () => { 126 + const f = createFocusManager(); 127 + const calls: string[] = []; 128 + f.push({ 129 + id: "global", 130 + onKey: (key) => { 131 + calls.push("global"); 132 + if (key.name === "c" && key.ctrl) return true; 133 + return false; 134 + }, 135 + }); 136 + let paneActive = true; 137 + f.push({ 138 + id: "pane", 139 + active: () => paneActive, 140 + onKey: (key) => { 141 + calls.push("pane"); 142 + if (key.char === "n") return true; 143 + return false; 144 + }, 145 + }); 146 + const disposeModal = f.push({ 147 + id: "modal", 148 + onKey: (key) => { 149 + calls.push("modal"); 150 + if (key.name === "escape") return true; 151 + return false; 152 + }, 153 + }); 154 + 155 + // esc: modal consumes it. 156 + f.dispatchKey(k("escape"), mkCtx()); 157 + expect(calls).toEqual(["modal"]); 158 + 159 + // n: modal doesn't consume, pane does. 160 + calls.length = 0; 161 + f.dispatchKey(k("n", { char: "n" }), mkCtx()); 162 + expect(calls).toEqual(["modal", "pane"]); 163 + 164 + // ctrl+c: bubbles all the way up. 165 + calls.length = 0; 166 + f.dispatchKey(k("c", { ctrl: true }), mkCtx()); 167 + expect(calls).toEqual(["modal", "pane", "global"]); 168 + 169 + // Close the modal, rerun esc: pane doesn't consume, bubbles to global. 170 + disposeModal(); 171 + calls.length = 0; 172 + f.dispatchKey(k("escape"), mkCtx()); 173 + expect(calls).toEqual(["pane", "global"]); 174 + }); 175 + 176 + it("handlers that pop scopes mid-dispatch don't break the pass", () => { 177 + const f = createFocusManager(); 178 + let disposeInner: (() => void) | null = null; 179 + const outerCalls: string[] = []; 180 + f.push({ 181 + id: "outer", 182 + onKey: () => { outerCalls.push("outer"); return true; }, 183 + }); 184 + disposeInner = f.push({ 185 + id: "inner", 186 + onKey: () => { disposeInner!(); return false; }, // pop ourselves, bubble up 187 + }); 188 + f.dispatchKey(k("x"), mkCtx()); 189 + expect(outerCalls).toEqual(["outer"]); 190 + expect(f.stack().map(s => s.id)).toEqual(["outer"]); 191 + }); 192 + }); 193 + 194 + describe("dispatchMouse — same semantics for mouse", () => { 195 + it("bubbles with active predicate", () => { 196 + const f = createFocusManager(); 197 + const calls: string[] = []; 198 + f.push({ id: "o", onMouse: () => { calls.push("o"); return false; } }); 199 + f.push({ id: "i", onMouse: () => { calls.push("i"); return false; } }); 200 + f.dispatchMouse(me(), mkCtx()); 201 + expect(calls).toEqual(["i", "o"]); 202 + }); 203 + 204 + it("scopes without onMouse don't intercept key events (and vice versa)", () => { 205 + const f = createFocusManager(); 206 + let keyCount = 0, mouseCount = 0; 207 + f.push({ id: "a", onKey: () => { keyCount++; return true; } }); 208 + f.push({ id: "b", onMouse: () => { mouseCount++; return true; } }); 209 + f.dispatchKey(k("x"), mkCtx()); 210 + f.dispatchMouse(me(), mkCtx()); 211 + expect(keyCount).toBe(1); 212 + expect(mouseCount).toBe(1); 213 + }); 214 + });
+49
tests/hit-test.test.ts
··· 1 + import { describe, it, expect } from "vitest"; 2 + import { row, column, panel, text } from "../src/tui/builders.ts"; 3 + import { layoutRoot } from "../src/tui/layout.ts"; 4 + import { hitTest, findInPath } from "../src/tui/hit-test.ts"; 5 + import type { UINode } from "../src/tui/nodes.ts"; 6 + 7 + function build(): UINode[] { 8 + return [ 9 + panel("left", [ 10 + row(text("aaa")), 11 + row(text("bbb")), 12 + ]), 13 + panel("right", [ 14 + row(text("ccc")), 15 + row(text("ddd")), 16 + ]), 17 + ]; 18 + } 19 + 20 + describe("hitTest", () => { 21 + it("returns null outside all nodes", () => { 22 + const nodes = build(); 23 + // Lay out into a small rect; clicks at 9999,9999 are clearly outside. 24 + layoutRoot(nodes, { x: 0, y: 0, width: 40, height: 10 }); 25 + const h = hitTest(nodes, 9999, 9999); 26 + expect(h).toBeNull(); 27 + }); 28 + 29 + it("finds the deepest node under a point", () => { 30 + const nodes = build(); 31 + layoutRoot(nodes, { x: 0, y: 0, width: 40, height: 10 }); 32 + const panelRect = nodes[0]._rect!; 33 + // Click somewhere inside the first panel, on the first row. 34 + const h = hitTest(nodes, panelRect.x + 3, panelRect.y + 1); 35 + expect(h).not.toBeNull(); 36 + // The root of the path is the panel; deepest should be a text node. 37 + expect(h!.path[0].type).toBe("panel"); 38 + expect(h!.node.type).toBe("text"); 39 + }); 40 + 41 + it("findInPath locates an ancestor by type", () => { 42 + const nodes = build(); 43 + layoutRoot(nodes, { x: 0, y: 0, width: 40, height: 10 }); 44 + const panelRect = nodes[0]._rect!; 45 + const h = hitTest(nodes, panelRect.x + 3, panelRect.y + 1)!; 46 + const p = findInPath(h, "panel"); 47 + expect(p?.title).toBe("left"); 48 + }); 49 + });
+23
tests/input-parse.test.ts
··· 74 74 }); 75 75 }); 76 76 77 + describe("parseKey: modified arrow keys (ESC[1;mods<letter>)", () => { 78 + it("option+left (mods=3 → alt) -> left with alt true", () => { 79 + expect(parse("\x1b[1;3D")).toEqual([ 80 + { name: "left", ctrl: false, alt: true, shift: false }, 81 + ]); 82 + }); 83 + it("option+right -> right with alt true", () => { 84 + expect(parse("\x1b[1;3C")).toEqual([ 85 + { name: "right", ctrl: false, alt: true, shift: false }, 86 + ]); 87 + }); 88 + it("shift+up (mods=2) -> up with shift true", () => { 89 + expect(parse("\x1b[1;2A")).toEqual([ 90 + { name: "up", ctrl: false, alt: false, shift: true }, 91 + ]); 92 + }); 93 + it("ctrl+shift+alt+end (mods=8) -> all three modifiers on end", () => { 94 + expect(parse("\x1b[1;8F")).toEqual([ 95 + { name: "end", ctrl: true, alt: true, shift: true }, 96 + ]); 97 + }); 98 + }); 99 + 77 100 describe("parseKey: kitty protocol modifier extraction", () => { 78 101 // Regression guard: before this change, shift was never extracted from 79 102 // the kitty modifier bitmask. Now it is — keep it that way.
+81
tests/mouse-parse.test.ts
··· 1 + import { describe, it, expect } from "vitest"; 2 + import { parseInput, isMouseEvent, type MouseEvent } from "../src/tui/input.ts"; 3 + 4 + function parse(s: string) { 5 + return parseInput(Buffer.from(s, "utf8")); 6 + } 7 + 8 + function mouse(s: string): MouseEvent { 9 + const events = parse(s); 10 + expect(events).toHaveLength(1); 11 + expect(isMouseEvent(events[0])).toBe(true); 12 + return events[0] as MouseEvent; 13 + } 14 + 15 + describe("SGR mouse parsing", () => { 16 + it("left-button press at (col 10, row 5) — 0-based output", () => { 17 + const e = mouse("\x1b[<0;10;5M"); 18 + expect(e.action).toBe("press"); 19 + expect(e.button).toBe("left"); 20 + expect(e.x).toBe(9); 21 + expect(e.y).toBe(4); 22 + expect(e.ctrl).toBe(false); 23 + expect(e.alt).toBe(false); 24 + expect(e.shift).toBe(false); 25 + }); 26 + 27 + it("left-button release — lowercase `m`", () => { 28 + const e = mouse("\x1b[<0;10;5m"); 29 + expect(e.action).toBe("release"); 30 + expect(e.button).toBe("left"); 31 + }); 32 + 33 + it("middle and right buttons", () => { 34 + expect(mouse("\x1b[<1;1;1M").button).toBe("middle"); 35 + expect(mouse("\x1b[<2;1;1M").button).toBe("right"); 36 + }); 37 + 38 + it("drag: motion flag + button low bits", () => { 39 + // button code = 0 (left) | 0x20 (motion) = 32 40 + const e = mouse("\x1b[<32;5;5M"); 41 + expect(e.action).toBe("drag"); 42 + expect(e.button).toBe("left"); 43 + }); 44 + 45 + it("hover: motion flag + button=none (3)", () => { 46 + // button code = 3 (none) | 0x20 (motion) = 35 47 + const e = mouse("\x1b[<35;5;5M"); 48 + expect(e.action).toBe("move"); 49 + expect(e.button).toBe("none"); 50 + }); 51 + 52 + it("scroll wheel: bit 6 = 64 (up) / 65 (down)", () => { 53 + const up = mouse("\x1b[<64;10;10M"); 54 + expect(up.action).toBe("scrollUp"); 55 + expect(up.button).toBe("none"); 56 + const down = mouse("\x1b[<65;10;10M"); 57 + expect(down.action).toBe("scrollDown"); 58 + }); 59 + 60 + it("modifiers: shift / alt / ctrl from bits 2/3/4", () => { 61 + // button = left (0) + shift (4) + alt (8) + ctrl (16) = 28 62 + const e = mouse("\x1b[<28;1;1M"); 63 + expect(e.shift).toBe(true); 64 + expect(e.alt).toBe(true); 65 + expect(e.ctrl).toBe(true); 66 + }); 67 + 68 + it("interleaves mouse with key events in a single chunk", () => { 69 + const events = parse("a\x1b[<0;3;4Mb"); 70 + expect(events).toHaveLength(3); 71 + expect((events[0] as any).name).toBe("a"); 72 + expect(isMouseEvent(events[1])).toBe(true); 73 + expect((events[2] as any).name).toBe("b"); 74 + }); 75 + 76 + it("parseKey (legacy) filters out mouse events", async () => { 77 + const { parseKey } = await import("../src/tui/input.ts"); 78 + const keys = parseKey(Buffer.from("a\x1b[<0;3;4Mb", "utf8")); 79 + expect(keys.map(k => k.name)).toEqual(["a", "b"]); 80 + }); 81 + });
+28
tests/widgets-bar-chart.test.ts
··· 1 + import { describe, it, expect } from "vitest"; 2 + import { barChart } from "../src/tui/widgets/bar-chart.ts"; 3 + 4 + describe("barChart", () => { 5 + it("returns a canvas node with the expected height", () => { 6 + const n = barChart([{ value: 1 }, { value: 2 }], { height: 4 }); 7 + expect((n as any).type).toBe("canvas"); 8 + expect((n as any).height).toBe(4); 9 + }); 10 + 11 + it("height + 1 when showLabels is set", () => { 12 + const n = barChart([{ label: "A", value: 1 }], { height: 4, showLabels: true }); 13 + expect((n as any).height).toBe(5); 14 + }); 15 + 16 + it("accepts an empty item list (renders nothing)", () => { 17 + const n = barChart([], { height: 3 }); 18 + expect((n as any).type).toBe("canvas"); 19 + expect((n as any).height).toBe(3); 20 + }); 21 + 22 + it("clamps gracefully when min equals max", () => { 23 + // Should not throw; picks a constant mid-level per bar. We verify the 24 + // draw callback runs by checking it produces a canvas node. 25 + const n = barChart([{ value: 5 }, { value: 5 }], { min: 5, max: 5 }); 26 + expect((n as any).type).toBe("canvas"); 27 + }); 28 + });
+95
tests/widgets-command-palette.test.ts
··· 1 + import { describe, it, expect } from "vitest"; 2 + import { 3 + createCommandPaletteState, filterCommands, 4 + handleCommandPaletteKey, renderCommandPalette, 5 + type Command, 6 + } from "../src/tui/widgets/command-palette.ts"; 7 + import type { KeyEvent } from "../src/tui/input.ts"; 8 + 9 + function k(name: string, opts: Partial<KeyEvent> = {}): KeyEvent { 10 + return { name, char: opts.char, ctrl: opts.ctrl ?? false, alt: opts.alt ?? false, shift: opts.shift ?? false }; 11 + } 12 + 13 + const commands: Command[] = [ 14 + { id: "open", label: "Open file", hint: "in the editor", keywords: ["edit"], run() {} }, 15 + { id: "save", label: "Save", hint: "current file", run() {} }, 16 + { id: "quit", label: "Quit", keywords: ["exit"], run() {} }, 17 + { id: "new", label: "New reminder", run() {} }, 18 + ]; 19 + 20 + describe("command palette — filtering", () => { 21 + it("empty query returns all commands in order", () => { 22 + const ranked = filterCommands(commands, ""); 23 + expect(ranked.map(r => r.cmd.id)).toEqual(["open", "save", "quit", "new"]); 24 + }); 25 + 26 + it("fuzzy match across label + hint + keywords", () => { 27 + expect(filterCommands(commands, "ex").map(r => r.cmd.id)).toContain("quit"); // via "exit" keyword 28 + expect(filterCommands(commands, "edi").map(r => r.cmd.id)).toContain("open"); // via "edit" keyword 29 + }); 30 + 31 + it("ranks exact prefix matches above mid-string ones", () => { 32 + const r = filterCommands(commands, "sa"); 33 + // "Save" should beat "reminder" (no "sa" in reminder) 34 + expect(r[0].cmd.id).toBe("save"); 35 + }); 36 + 37 + it("no match when the query cannot be formed", () => { 38 + expect(filterCommands(commands, "xyzq")).toHaveLength(0); 39 + }); 40 + }); 41 + 42 + describe("command palette — keys", () => { 43 + const s0 = createCommandPaletteState(); 44 + 45 + it("printable keys edit the query and reset selection", () => { 46 + const r = handleCommandPaletteKey({ ...s0, selectedIndex: 3 }, commands, k("o", { char: "o" })); 47 + expect(r.action).toBe("edited"); 48 + expect(r.state.query.text).toBe("o"); 49 + expect(r.state.selectedIndex).toBe(0); 50 + }); 51 + 52 + it("up/down walks the ranked list", () => { 53 + // Empty query returns all commands — walk through them. 54 + const down1 = handleCommandPaletteKey(s0, commands, k("down")); 55 + expect(down1.state.selectedIndex).toBe(1); 56 + expect(down1.action).toBe("moved"); 57 + const down2 = handleCommandPaletteKey(down1.state, commands, k("down")); 58 + expect(down2.state.selectedIndex).toBe(2); 59 + const up = handleCommandPaletteKey(down2.state, commands, k("up")); 60 + expect(up.state.selectedIndex).toBe(1); 61 + // Up at top clamps. 62 + const clamped = handleCommandPaletteKey(s0, commands, k("up")); 63 + expect(clamped.state.selectedIndex).toBe(0); 64 + }); 65 + 66 + it("return emits 'run' with the command", () => { 67 + const r = handleCommandPaletteKey(s0, commands, k("return")); 68 + expect(r.action).toBe("run"); 69 + expect(r.command?.id).toBe("open"); 70 + }); 71 + 72 + it("escape emits 'cancel'", () => { 73 + const r = handleCommandPaletteKey(s0, commands, k("escape")); 74 + expect(r.action).toBe("cancel"); 75 + }); 76 + }); 77 + 78 + describe("command palette — rendering", () => { 79 + it("renders a panel with query + limited commands", () => { 80 + const s = createCommandPaletteState(); 81 + const node = renderCommandPalette(s, commands, { limit: 2 }); 82 + expect((node as any).type).toBe("panel"); 83 + // 1 query row + 2 command rows = 3 rows in the panel body. 84 + expect((node as any).children).toHaveLength(3); 85 + }); 86 + 87 + it("shows 'no matches' when the query excludes everything", () => { 88 + const s = { query: { text: "xyzq", cursor: 4 }, selectedIndex: 0 }; 89 + const node = renderCommandPalette(s, commands); 90 + const children = (node as any).children; 91 + // row 0 is query; row 1 is the no-matches message. 92 + const msg = children[1].children[0].text; 93 + expect(msg).toMatch(/no matches/); 94 + }); 95 + });
+107
tests/widgets-command-registry.test.ts
··· 1 + import { describe, it, expect, beforeEach } from "vitest"; 2 + import { 3 + registerGlobalCommand, useCommandScope, clearCommandScope, 4 + findCommand, runCommand, allCommands, _resetCommandRegistry, 5 + } from "../src/tui/widgets/command-registry.ts"; 6 + 7 + beforeEach(() => { _resetCommandRegistry(); }); 8 + 9 + describe("command registry — global", () => { 10 + it("starts empty", () => { 11 + expect(allCommands.get()).toEqual([]); 12 + }); 13 + 14 + it("registerGlobalCommand adds, disposer removes", () => { 15 + const dispose = registerGlobalCommand({ id: "a", label: "A", run() {} }); 16 + expect(allCommands.get().map(c => c.id)).toEqual(["a"]); 17 + dispose(); 18 + expect(allCommands.get()).toEqual([]); 19 + }); 20 + 21 + it("multiple global commands accumulate", () => { 22 + registerGlobalCommand({ id: "a", label: "A", run() {} }); 23 + registerGlobalCommand({ id: "b", label: "B", run() {} }); 24 + expect(allCommands.get().map(c => c.id)).toEqual(["a", "b"]); 25 + }); 26 + }); 27 + 28 + describe("command registry — scopes", () => { 29 + it("useCommandScope adds under the given id", () => { 30 + useCommandScope("s1", [ 31 + { id: "s1.new", label: "New", run() {} }, 32 + { id: "s1.remove", label: "Remove", run() {} }, 33 + ]); 34 + expect(allCommands.get().map(c => c.id)).toEqual(["s1.new", "s1.remove"]); 35 + }); 36 + 37 + it("replacing a scope replaces its commands entirely", () => { 38 + useCommandScope("sel", [{ id: "a", label: "A", run() {} }]); 39 + useCommandScope("sel", [{ id: "b", label: "B", run() {} }]); 40 + expect(allCommands.get().map(c => c.id)).toEqual(["b"]); 41 + }); 42 + 43 + it("scope disposer removes the whole batch", () => { 44 + const dispose = useCommandScope("sel", [ 45 + { id: "a", label: "A", run() {} }, 46 + { id: "b", label: "B", run() {} }, 47 + ]); 48 + dispose(); 49 + expect(allCommands.get()).toEqual([]); 50 + }); 51 + 52 + it("clearing a scope by id works without holding a disposer", () => { 53 + useCommandScope("sel", [{ id: "a", label: "A", run() {} }]); 54 + clearCommandScope("sel"); 55 + expect(allCommands.get()).toEqual([]); 56 + }); 57 + 58 + it("rejects the reserved global sentinel id", () => { 59 + expect(() => useCommandScope("__global__", [])).toThrow(); 60 + }); 61 + 62 + it("compose: global + screen + focus", () => { 63 + registerGlobalCommand({ id: "g.quit", label: "Quit", run() {} }); 64 + useCommandScope("screen:list", [{ id: "list.new", label: "New", run() {} }]); 65 + useCommandScope("focused:a", [{ id: "a.complete", label: "Complete A", run() {} }]); 66 + const ids = allCommands.get().map(c => c.id); 67 + expect(ids).toContain("g.quit"); 68 + expect(ids).toContain("list.new"); 69 + expect(ids).toContain("a.complete"); 70 + expect(ids).toHaveLength(3); 71 + }); 72 + }); 73 + 74 + describe("command registry — lookup + run", () => { 75 + it("findCommand locates across scopes", () => { 76 + useCommandScope("s", [{ id: "x", label: "X", run() {} }]); 77 + expect(findCommand("x")?.id).toBe("x"); 78 + expect(findCommand("nope")).toBeUndefined(); 79 + }); 80 + 81 + it("runCommand invokes by id and returns true", () => { 82 + let called = 0; 83 + useCommandScope("s", [{ id: "x", label: "X", run() { called++; } }]); 84 + expect(runCommand("x")).toBe(true); 85 + expect(called).toBe(1); 86 + }); 87 + 88 + it("runCommand returns false for unknown ids", () => { 89 + expect(runCommand("nope")).toBe(false); 90 + }); 91 + }); 92 + 93 + describe("command registry — reactive", () => { 94 + it("allCommands recomputes on registration", () => { 95 + let observed = allCommands.get().length; 96 + expect(observed).toBe(0); 97 + 98 + const dispose = registerGlobalCommand({ id: "q", label: "Q", run() {} }); 99 + expect(allCommands.get().length).toBe(1); 100 + 101 + useCommandScope("s", [{ id: "a", label: "A", run() {} }]); 102 + expect(allCommands.get().length).toBe(2); 103 + 104 + dispose(); 105 + expect(allCommands.get().length).toBe(1); 106 + }); 107 + });
+62
tests/widgets-confirm.test.ts
··· 1 + import { describe, it, expect } from "vitest"; 2 + import { 3 + createConfirm, handleConfirmKey, confirmPanel, 4 + } from "../src/tui/widgets/confirm.ts"; 5 + import type { KeyEvent } from "../src/tui/input.ts"; 6 + 7 + function k(name: string, opts: Partial<KeyEvent> = {}): KeyEvent { 8 + return { name, char: opts.char, ctrl: opts.ctrl ?? false, alt: opts.alt ?? false, shift: opts.shift ?? false }; 9 + } 10 + 11 + describe("confirm — state", () => { 12 + it("defaults focus to 'no' for safety", () => { 13 + const s = createConfirm({ title: "Delete?", message: "really?" }); 14 + expect(s.focused).toBe("no"); 15 + }); 16 + 17 + it("respects defaultFocus option", () => { 18 + const s = createConfirm({ title: "", message: "", defaultFocus: "yes" }); 19 + expect(s.focused).toBe("yes"); 20 + }); 21 + }); 22 + 23 + describe("confirm — keys", () => { 24 + const s0 = createConfirm({ title: "x", message: "y" }); 25 + 26 + it("arrows / tab toggle focus but don't commit", () => { 27 + const r1 = handleConfirmKey(s0, k("right")); 28 + expect(r1.action).toBe("pending"); 29 + expect(r1.state.focused).toBe("yes"); 30 + const r2 = handleConfirmKey(r1.state, k("tab")); 31 + expect(r2.state.focused).toBe("no"); 32 + expect(r2.action).toBe("pending"); 33 + }); 34 + 35 + it("return commits the focused button", () => { 36 + const r1 = handleConfirmKey(s0, k("return")); 37 + expect(r1.action).toBe("no"); 38 + const focusedYes = { ...s0, focused: "yes" as const }; 39 + const r2 = handleConfirmKey(focusedYes, k("return")); 40 + expect(r2.action).toBe("yes"); 41 + }); 42 + 43 + it("escape is always 'no'", () => { 44 + const r = handleConfirmKey({ ...s0, focused: "yes" }, k("escape")); 45 + expect(r.action).toBe("no"); 46 + }); 47 + 48 + it("y / n shortcuts commit directly", () => { 49 + expect(handleConfirmKey(s0, k("y", { char: "y" })).action).toBe("yes"); 50 + expect(handleConfirmKey(s0, k("Y", { char: "Y" })).action).toBe("yes"); 51 + expect(handleConfirmKey(s0, k("n", { char: "n" })).action).toBe("no"); 52 + }); 53 + }); 54 + 55 + describe("confirm — rendering", () => { 56 + it("renders a panel titled from state", () => { 57 + const s = createConfirm({ title: "Drop the bomb?", message: "boom" }); 58 + const node = confirmPanel(s); 59 + expect((node as any).type).toBe("panel"); 60 + expect((node as any).title).toBe("Drop the bomb?"); 61 + }); 62 + });
+92
tests/widgets-date-picker.test.ts
··· 1 + import { describe, it, expect } from "vitest"; 2 + import { 3 + shiftDay, shiftMonth, shiftTime, clampDay, daysInMonth, 4 + datePickerFromDate, toDate, handleDatePickerKey, 5 + type DatePickerState, 6 + } from "../src/tui/widgets/date-picker.ts"; 7 + import type { KeyEvent } from "../src/tui/input.ts"; 8 + 9 + function k(name: string): KeyEvent { 10 + return { name, ctrl: false, alt: false, shift: false }; 11 + } 12 + 13 + const apr20 = (): DatePickerState => ({ year: 2026, month: 3, day: 20, hour: 9, minute: 0 }); 14 + 15 + describe("date-picker math", () => { 16 + it("daysInMonth handles leap years", () => { 17 + expect(daysInMonth(2024, 1)).toBe(29); 18 + expect(daysInMonth(2025, 1)).toBe(28); 19 + expect(daysInMonth(2026, 1)).toBe(28); 20 + }); 21 + 22 + it("shiftDay rolls over month and year", () => { 23 + const jan30 = { year: 2026, month: 0, day: 30, hour: 0, minute: 0 }; 24 + const plus3 = shiftDay(jan30, 3); 25 + expect(plus3).toEqual({ year: 2026, month: 1, day: 2, hour: 0, minute: 0 }); 26 + 27 + const dec31 = { year: 2026, month: 11, day: 31, hour: 0, minute: 0 }; 28 + const plus1 = shiftDay(dec31, 1); 29 + expect(plus1).toEqual({ year: 2027, month: 0, day: 1, hour: 0, minute: 0 }); 30 + }); 31 + 32 + it("shiftMonth clamps the day when overflowing (Jan 31 -> Feb 28)", () => { 33 + const jan31 = { year: 2026, month: 0, day: 31, hour: 0, minute: 0 }; 34 + const feb = shiftMonth(jan31, 1); 35 + expect(feb.month).toBe(1); 36 + expect(feb.day).toBe(28); // 2026 is not a leap year 37 + }); 38 + 39 + it("shiftTime wraps hour across midnight", () => { 40 + const base = apr20(); 41 + expect(shiftTime(base, "h", -10)).toEqual({ ...base, hour: 23 }); 42 + expect(shiftTime(base, "h", 25)).toEqual({ ...base, hour: 10 }); 43 + }); 44 + 45 + it("shiftTime wraps minute across the hour", () => { 46 + const base = apr20(); 47 + expect(shiftTime(base, "m", -5)).toEqual({ ...base, minute: 55 }); 48 + expect(shiftTime(base, "m", 65)).toEqual({ ...base, minute: 5 }); 49 + }); 50 + 51 + it("clampDay clamps up and down", () => { 52 + expect(clampDay({ year: 2026, month: 1, day: 30, hour: 0, minute: 0 }).day).toBe(28); 53 + expect(clampDay({ year: 2026, month: 1, day: 0, hour: 0, minute: 0 }).day).toBe(1); 54 + }); 55 + 56 + it("round-trips through Date", () => { 57 + const s = apr20(); 58 + const d = toDate(s); 59 + expect(datePickerFromDate(d)).toEqual(s); 60 + }); 61 + }); 62 + 63 + describe("handleDatePickerKey default bindings", () => { 64 + it("arrow left/right move by one day, up/down by a week", () => { 65 + const s = apr20(); 66 + expect(handleDatePickerKey(s, k("right"))?.day).toBe(21); 67 + expect(handleDatePickerKey(s, k("left"))?.day).toBe(19); 68 + expect(handleDatePickerKey(s, k("down"))?.day).toBe(27); 69 + expect(handleDatePickerKey(s, k("up"))?.day).toBe(13); 70 + }); 71 + 72 + it("[ and ] shift month", () => { 73 + const s = apr20(); 74 + expect(handleDatePickerKey(s, k("["))?.month).toBe(2); 75 + expect(handleDatePickerKey(s, k("]"))?.month).toBe(4); 76 + }); 77 + 78 + it("h/H shift hour; m/M shift minute by 5", () => { 79 + const s = apr20(); 80 + expect(handleDatePickerKey(s, k("h"))?.hour).toBe(8); 81 + expect(handleDatePickerKey(s, k("H"))?.hour).toBe(10); 82 + expect(handleDatePickerKey(s, k("M"))?.minute).toBe(5); 83 + expect(handleDatePickerKey(s, k("m"))?.minute).toBe(55); 84 + }); 85 + 86 + it("returns null for unhandled keys", () => { 87 + const s = apr20(); 88 + expect(handleDatePickerKey(s, k("x"))).toBeNull(); 89 + expect(handleDatePickerKey(s, k("return"))).toBeNull(); 90 + expect(handleDatePickerKey(s, k("escape"))).toBeNull(); 91 + }); 92 + });
+171
tests/widgets-form.test.ts
··· 1 + import { describe, it, expect } from "vitest"; 2 + import { 3 + applyTextKey, renderFieldText, renderFieldNodes, 4 + prevWordBoundary, nextWordBoundary, 5 + createFormState, handleFormKey, focusField, setFieldText, 6 + type TextFieldState, type FormState, 7 + } from "../src/tui/widgets/form.ts"; 8 + import type { KeyEvent } from "../src/tui/input.ts"; 9 + 10 + function k(name: string, opts: Partial<KeyEvent> = {}): KeyEvent { 11 + return { name, char: opts.char, ctrl: opts.ctrl ?? false, alt: opts.alt ?? false, shift: opts.shift ?? false }; 12 + } 13 + 14 + describe("applyTextKey", () => { 15 + const empty: TextFieldState = { text: "", cursor: 0 }; 16 + const hello: TextFieldState = { text: "hello", cursor: 5 }; 17 + 18 + it("inserts a printable character at the cursor", () => { 19 + const r = applyTextKey(hello, k("!", { char: "!" })); 20 + expect(r).toEqual({ text: "hello!", cursor: 6 }); 21 + }); 22 + 23 + it("backspace deletes the preceding character", () => { 24 + const r = applyTextKey(hello, k("backspace")); 25 + expect(r).toEqual({ text: "hell", cursor: 4 }); 26 + }); 27 + 28 + it("delete removes the character under the cursor", () => { 29 + const mid: TextFieldState = { text: "hello", cursor: 2 }; 30 + const r = applyTextKey(mid, k("delete")); 31 + expect(r).toEqual({ text: "helo", cursor: 2 }); 32 + }); 33 + 34 + it("arrow keys move the cursor within bounds", () => { 35 + expect(applyTextKey(hello, k("left"))).toEqual({ text: "hello", cursor: 4 }); 36 + expect(applyTextKey(empty, k("left"))).toEqual(empty); 37 + expect(applyTextKey(empty, k("right"))).toEqual(empty); 38 + }); 39 + 40 + it("home/end jump to the extremes", () => { 41 + expect(applyTextKey(hello, k("home"))).toEqual({ text: "hello", cursor: 0 }); 42 + expect(applyTextKey({ text: "hello", cursor: 0 }, k("end"))).toEqual({ text: "hello", cursor: 5 }); 43 + }); 44 + 45 + it("ctrl+u clears from start to cursor", () => { 46 + const mid: TextFieldState = { text: "hello", cursor: 3 }; 47 + expect(applyTextKey(mid, k("u", { ctrl: true }))).toEqual({ text: "lo", cursor: 0 }); 48 + }); 49 + 50 + it("ignores ctrl/alt-modified printable keys so shortcuts don't leak", () => { 51 + expect(applyTextKey(hello, k("s", { char: "s", ctrl: true }))).toBeNull(); 52 + expect(applyTextKey(hello, k("q", { char: "q", alt: true }))).toBeNull(); 53 + }); 54 + }); 55 + 56 + describe("renderFieldText (legacy string API)", () => { 57 + it("shows a block cursor only when active", () => { 58 + expect(renderFieldText("foo", 1, false)).toBe("foo"); 59 + expect(renderFieldText("foo", 1, true)).toBe("f\u2588oo"); 60 + expect(renderFieldText("foo", 3, true)).toBe("foo\u2588"); 61 + }); 62 + }); 63 + 64 + describe("renderFieldNodes (inverse-cursor API)", () => { 65 + it("inactive: one text node, no cursor", () => { 66 + const nodes = renderFieldNodes("hello", 2, false); 67 + expect(nodes).toHaveLength(1); 68 + expect((nodes[0] as any).text).toBe("hello"); 69 + }); 70 + 71 + it("active mid-string: three nodes with inverse cell on the char under the cursor", () => { 72 + const nodes = renderFieldNodes("hello", 2, true); 73 + expect(nodes).toHaveLength(3); 74 + expect((nodes[0] as any).text).toBe("he"); 75 + expect((nodes[1] as any).text).toBe("l"); 76 + expect((nodes[1] as any).inverse).toBe(true); 77 + expect((nodes[2] as any).text).toBe("lo"); 78 + }); 79 + 80 + it("active at end: inverse cell is a single space", () => { 81 + const nodes = renderFieldNodes("hi", 2, true); 82 + expect((nodes[1] as any).text).toBe(" "); 83 + expect((nodes[1] as any).inverse).toBe(true); 84 + }); 85 + }); 86 + 87 + describe("word boundaries", () => { 88 + it("prevWordBoundary jumps back over whitespace then word", () => { 89 + // "hello world|" — cursor at 13. Expect jump to "world" start (7). 90 + expect(prevWordBoundary("hello world", 12)).toBe(7); 91 + // Already at a boundary — jump further. 92 + expect(prevWordBoundary("hello world", 6)).toBe(0); 93 + }); 94 + 95 + it("nextWordBoundary jumps forward over non-word then word", () => { 96 + // Cursor at the start of "hello" — jumps to end of "hello". 97 + expect(nextWordBoundary("hello world", 0)).toBe(5); 98 + // Cursor at the space — skip the space, jump to end of "world". 99 + expect(nextWordBoundary("hello world", 5)).toBe(11); 100 + }); 101 + 102 + it("handles leading/trailing whitespace", () => { 103 + expect(prevWordBoundary(" abc", 6)).toBe(3); 104 + expect(nextWordBoundary("abc ", 0)).toBe(3); 105 + expect(nextWordBoundary("abc ", 3)).toBe(6); 106 + }); 107 + }); 108 + 109 + describe("applyTextKey — word motion via alt+arrows / alt+b / alt+f", () => { 110 + const state: TextFieldState = { text: "hello there friend", cursor: 11 }; 111 + it("alt+left jumps back one word", () => { 112 + const r = applyTextKey(state, k("left", { alt: true })); 113 + expect(r?.cursor).toBe(6); // start of "there" 114 + }); 115 + it("alt+right jumps forward one word", () => { 116 + const r = applyTextKey(state, k("right", { alt: true })); 117 + expect(r?.cursor).toBe(18); // end of "friend" 118 + }); 119 + it("alt+b == alt+left", () => { 120 + const r = applyTextKey(state, k("b", { char: "b", alt: true })); 121 + expect(r?.cursor).toBe(6); 122 + }); 123 + it("alt+f == alt+right", () => { 124 + const r = applyTextKey(state, k("f", { char: "f", alt: true })); 125 + expect(r?.cursor).toBe(18); 126 + }); 127 + }); 128 + 129 + describe("form focus ring with tab/backtab", () => { 130 + type F = "title" | "notes" | "due"; 131 + const order = ["title", "notes", "due"] as const satisfies readonly F[]; 132 + 133 + it("tab walks forward, backtab walks backward (wrapping)", () => { 134 + const s0 = createFormState(order, { title: "", notes: "", due: "" }); 135 + const r1 = handleFormKey(s0, k("tab")); 136 + expect(r1.state.focused).toBe("notes"); 137 + expect(r1.action).toBe("moved"); 138 + const r2 = handleFormKey(r1.state, k("tab")); 139 + expect(r2.state.focused).toBe("due"); 140 + const r3 = handleFormKey(r2.state, k("tab")); 141 + expect(r3.state.focused).toBe("title"); // wraps 142 + const r4 = handleFormKey(r3.state, k("backtab")); 143 + expect(r4.state.focused).toBe("due"); // wraps backward 144 + }); 145 + 146 + it("enter in a non-last field is 'activate', in the last field is 'submit'", () => { 147 + const s0 = createFormState(order, { title: "", notes: "", due: "" }); 148 + expect(handleFormKey(s0, k("return")).action).toBe("activate"); 149 + const focusedLast = { ...s0, focused: "due" as const }; 150 + expect(handleFormKey(focusedLast, k("return")).action).toBe("submit"); 151 + }); 152 + 153 + it("escape is 'cancel'", () => { 154 + const s0 = createFormState(order, { title: "", notes: "", due: "" }); 155 + expect(handleFormKey(s0, k("escape")).action).toBe("cancel"); 156 + }); 157 + 158 + it("edits the focused field when a printable key arrives", () => { 159 + const s0 = createFormState(order, { title: "", notes: "", due: "" }); 160 + const r = handleFormKey(s0, k("a", { char: "a" })); 161 + expect(r.action).toBe("edited"); 162 + expect(r.state.values.title.text).toBe("a"); 163 + }); 164 + 165 + it("setFieldText resets cursor to end and works without focus change", () => { 166 + const s0 = createFormState(order, { title: "old", notes: "", due: "" }); 167 + const s1 = setFieldText(s0, "notes", "hello"); 168 + expect(s1.values.notes).toEqual({ text: "hello", cursor: 5 }); 169 + expect(s1.focused).toBe("title"); 170 + }); 171 + });
+57
tests/widgets-help-overlay.test.ts
··· 1 + import { describe, it, expect } from "vitest"; 2 + import { helpPanel, type HelpSection } from "../src/tui/widgets/help-overlay.ts"; 3 + 4 + const sections: HelpSection[] = [ 5 + { 6 + title: "Navigation", 7 + bindings: [ 8 + { key: "j/k", desc: "down/up" }, 9 + { key: "enter", desc: "open" }, 10 + ], 11 + }, 12 + { 13 + title: "Editing", 14 + bindings: [ 15 + { key: "n", desc: "new" }, 16 + { key: "x", desc: "delete" }, 17 + ], 18 + }, 19 + ]; 20 + 21 + describe("help overlay", () => { 22 + it("renders a panel whose title defaults to 'keybindings'", () => { 23 + const node = helpPanel(sections); 24 + expect((node as any).type).toBe("panel"); 25 + expect((node as any).title).toBe("keybindings"); 26 + }); 27 + 28 + it("respects a custom title", () => { 29 + const node = helpPanel(sections, "Shortcuts"); 30 + expect((node as any).title).toBe("Shortcuts"); 31 + }); 32 + 33 + it("includes one row per binding plus section headers + separators", () => { 34 + const node = helpPanel(sections); 35 + // 2 sections: 2 titles + 4 bindings = 6 core rows 36 + // + 1 separator between sections 37 + // + 1 separator + 1 hint at the end 38 + // = 9 39 + const count = (node as any).children.length; 40 + expect(count).toBe(9); 41 + }); 42 + 43 + it("key column is padded to the widest key across ALL sections", () => { 44 + const node = helpPanel(sections); 45 + // "enter" is the longest key (5 chars). Render pads to 5+2=7 chars. 46 + const children = (node as any).children; 47 + // Find a key text node ("j/k" padded to 7 + trailing spaces). 48 + for (const child of children) { 49 + if (child.type !== "row") continue; 50 + const keyCell = child.children?.[1]; 51 + if (keyCell?.text?.includes("j/k")) { 52 + // padEnd(5+2) means "j/k" + 4 spaces + 2 trailing. 53 + expect(keyCell.text.length).toBe(7); 54 + } 55 + } 56 + }); 57 + });
+108
tests/widgets-markdown.test.ts
··· 1 + import { describe, it, expect } from "vitest"; 2 + import { parseMarkdown, parseInline, renderMarkdown } from "../src/tui/widgets/markdown.ts"; 3 + 4 + describe("parseMarkdown — blocks", () => { 5 + it("splits paragraphs on blank lines", () => { 6 + const b = parseMarkdown("first para\nmore of it\n\nsecond para"); 7 + expect(b).toHaveLength(2); 8 + expect(b[0].kind).toBe("paragraph"); 9 + expect(b[0].text).toBe("first para more of it"); 10 + expect(b[1].kind).toBe("paragraph"); 11 + }); 12 + 13 + it("captures headings with their level", () => { 14 + const b = parseMarkdown("# h1\n## h2\n### h3\n#### h4"); 15 + expect(b.map(x => x.kind)).toEqual(["heading", "heading", "heading", "heading"]); 16 + expect(b.map(x => x.level)).toEqual([1, 2, 3, 4]); 17 + expect(b[0].text).toBe("h1"); 18 + }); 19 + 20 + it("captures fenced code blocks as-is", () => { 21 + const src = "```\nconst x = 1;\nconsole.log(x);\n```"; 22 + const b = parseMarkdown(src); 23 + expect(b).toHaveLength(1); 24 + expect(b[0].kind).toBe("code"); 25 + expect(b[0].lines).toEqual(["const x = 1;", "console.log(x);"]); 26 + }); 27 + 28 + it("groups contiguous bullet items into one block", () => { 29 + const b = parseMarkdown("- a\n- b\n- c"); 30 + expect(b).toHaveLength(1); 31 + expect(b[0].kind).toBe("bullet"); 32 + expect(b[0].items).toEqual(["a", "b", "c"]); 33 + }); 34 + 35 + it("parses task lists, distinguishing done from not-done", () => { 36 + const b = parseMarkdown("- [ ] todo\n- [x] done\n- [X] also done"); 37 + expect(b).toHaveLength(1); 38 + expect(b[0].kind).toBe("task"); 39 + expect(b[0].tasks).toEqual([ 40 + { done: false, text: "todo" }, 41 + { done: true, text: "done" }, 42 + { done: true, text: "also done" }, 43 + ]); 44 + }); 45 + 46 + it("parses ordered lists", () => { 47 + const b = parseMarkdown("1. first\n2. second"); 48 + expect(b).toHaveLength(1); 49 + expect(b[0].kind).toBe("ordered"); 50 + expect(b[0].items).toEqual(["first", "second"]); 51 + }); 52 + 53 + it("parses blockquotes, joining multiple > lines", () => { 54 + const b = parseMarkdown("> one\n> two"); 55 + expect(b).toHaveLength(1); 56 + expect(b[0].kind).toBe("quote"); 57 + expect(b[0].text).toBe("one\ntwo"); 58 + }); 59 + 60 + it("recognises horizontal rules", () => { 61 + const b = parseMarkdown("para\n\n---\n\nmore"); 62 + expect(b.map(x => x.kind)).toEqual(["paragraph", "hr", "paragraph"]); 63 + }); 64 + }); 65 + 66 + describe("parseInline — spans", () => { 67 + it("extracts bold, italic, and inline code", () => { 68 + const s = parseInline("plain **bold** *italic* `code`"); 69 + expect(s).toEqual([ 70 + { text: "plain " }, 71 + { text: "bold", bold: true }, 72 + { text: " " }, 73 + { text: "italic", italic: true }, 74 + { text: " " }, 75 + { text: "code", code: true }, 76 + ]); 77 + }); 78 + 79 + it("extracts links", () => { 80 + const s = parseInline("see [docs](https://example.com) now"); 81 + expect(s).toEqual([ 82 + { text: "see " }, 83 + { text: "docs", url: "https://example.com" }, 84 + { text: " now" }, 85 + ]); 86 + }); 87 + 88 + it("leaves unclosed delimiters as plain text", () => { 89 + const s = parseInline("nothing **here"); 90 + expect(s).toEqual([{ text: "nothing **here" }]); 91 + }); 92 + }); 93 + 94 + describe("renderMarkdown — smoke", () => { 95 + it("produces a non-empty node array for a mixed document", () => { 96 + const nodes = renderMarkdown( 97 + "# Title\n\nsome **emphasis**\n\n- [ ] a task\n- [x] done\n\n```\ncode here\n```\n\n> a quote\n", 98 + ); 99 + expect(nodes.length).toBeGreaterThan(0); 100 + }); 101 + 102 + it("respects the width option for paragraph wrapping", () => { 103 + const long = "one two three four five six seven eight nine ten eleven twelve"; 104 + const wide = renderMarkdown(long); 105 + const narrow = renderMarkdown(long, { width: 20 }); 106 + expect(narrow.length).toBeGreaterThan(wide.length); 107 + }); 108 + });
+60
tests/widgets-prompt-bar.test.ts
··· 1 + import { describe, it, expect } from "vitest"; 2 + import { promptBar } from "../src/tui/widgets/prompt-bar.ts"; 3 + import { createTextArea } from "../src/tui/widgets/text-area.ts"; 4 + 5 + describe("promptBar", () => { 6 + it("renders a column of three rows with single-line value and no extras", () => { 7 + const node = promptBar({ kind: "single", state: { text: "hi", cursor: 2 } }); 8 + expect((node as any).type).toBe("column"); 9 + // separator, input row, separator = 3 children. 10 + expect((node as any).children).toHaveLength(3); 11 + }); 12 + 13 + it("adds a status row when status has left or right", () => { 14 + const node = promptBar( 15 + { kind: "single", state: { text: "", cursor: 0 } }, 16 + { status: { left: "L", right: "R" } }, 17 + ); 18 + expect((node as any).children).toHaveLength(4); 19 + }); 20 + 21 + it("title overlays the top rule with the title text", () => { 22 + const node = promptBar( 23 + { kind: "single", state: { text: "", cursor: 0 } }, 24 + { title: { text: "compose", align: "center" } }, 25 + ); 26 + const top = (node as any).children[0]; 27 + // Title becomes a row with rule/title/rule segments. 28 + expect(top.type).toBe("row"); 29 + const texts = top.children.map((c: any) => c.text).join(""); 30 + expect(texts).toContain("compose"); 31 + }); 32 + 33 + it("honours the custom glyph", () => { 34 + const node = promptBar( 35 + { kind: "single", state: { text: "", cursor: 0 } }, 36 + { glyph: "$" }, 37 + ); 38 + const inputRow = (node as any).children[1]; 39 + expect(inputRow.children[0].text).toBe(" $ "); 40 + }); 41 + 42 + it("works with a multi-line TextAreaState — one row per line plus title + separator", () => { 43 + // New shape: each line of the text area gets its own row inside the 44 + // prompt bar's column. So for "line one\nline two" we have: 45 + // title rule, line 1 row, line 2 row, bottom separator = 4 children. 46 + const node = promptBar( 47 + { kind: "multi", state: createTextArea("line one\nline two") }, 48 + { title: { text: "chat" } }, 49 + ); 50 + const children = (node as any).children; 51 + expect(children).toHaveLength(4); 52 + // The first content row has the prompt glyph as its first child. 53 + const firstLine = children[1]; 54 + expect(firstLine.type).toBe("row"); 55 + expect(firstLine.children[0].text).toContain("\u276f"); 56 + // Continuation rows are indented (no glyph). 57 + const secondLine = children[2]; 58 + expect(secondLine.children[0].text).toBe(" "); 59 + }); 60 + });
+47
tests/widgets-sparkline.test.ts
··· 1 + import { describe, it, expect } from "vitest"; 2 + import { sparklineString } from "../src/tui/widgets/sparkline.ts"; 3 + 4 + describe("sparklineString", () => { 5 + it("renders one character per sample", () => { 6 + expect(sparklineString([0, 1, 2, 3]).length).toBe(4); 7 + }); 8 + 9 + it("left-pads empties when width > series length", () => { 10 + const s = sparklineString([8], { width: 4, min: 0, max: 8 }); 11 + expect(s.length).toBe(4); 12 + // Last char is full block; first three are empties. 13 + expect(s[3]).toBe("\u2588"); 14 + expect(s[0]).toBe(" "); 15 + }); 16 + 17 + it("tail-slices when series is longer than width", () => { 18 + const samples = [1, 2, 3, 4, 5, 6, 7, 8]; 19 + const s = sparklineString(samples, { width: 3, min: 1, max: 8 }); 20 + expect(s.length).toBe(3); 21 + // Tail is [6, 7, 8] — last char is the full-block (max). 22 + expect(s[2]).toBe("\u2588"); 23 + }); 24 + 25 + it("uses explicit min/max for stable scale across frames", () => { 26 + const a = sparklineString([50], { width: 1, min: 0, max: 100 }); 27 + const b = sparklineString([50], { width: 1, min: 0, max: 100 }); 28 + expect(a).toBe(b); 29 + }); 30 + 31 + it("NaN / Infinity samples render as empties", () => { 32 + const s = sparklineString([NaN, Infinity, 1], { min: 0, max: 1 }); 33 + expect(s[0]).toBe(" "); 34 + expect(s[1]).toBe(" "); 35 + expect(s[2]).toBe("\u2588"); 36 + }); 37 + 38 + it("all-equal series picks the middle block (not empty)", () => { 39 + const s = sparklineString([5, 5, 5], { min: 5, max: 5 }); 40 + expect(s).toBe("\u2584\u2584\u2584"); 41 + }); 42 + 43 + it("empty input renders empty string", () => { 44 + expect(sparklineString([])).toBe(""); 45 + expect(sparklineString([1, 2, 3], { width: 0 })).toBe(""); 46 + }); 47 + });
+69
tests/widgets-stream-view.test.ts
··· 1 + import { describe, it, expect } from "vitest"; 2 + import { 3 + createStreamView, isPinned, streamPin, 4 + streamScrollUp, streamScrollDown, streamWindow, 5 + handleStreamKey, renderStreamView, 6 + } from "../src/tui/widgets/stream-view.ts"; 7 + import type { KeyEvent } from "../src/tui/input.ts"; 8 + 9 + function k(name: string): KeyEvent { 10 + return { name, ctrl: false, alt: false, shift: false }; 11 + } 12 + 13 + describe("stream-view — pinning and scrollback", () => { 14 + it("starts pinned at the bottom", () => { 15 + const s = createStreamView(); 16 + expect(isPinned(s)).toBe(true); 17 + }); 18 + 19 + it("window with total=10 viewport=3 pinned shows items 7-9", () => { 20 + const s = createStreamView(); 21 + expect(streamWindow(s, 10, 3)).toEqual({ start: 7, end: 10 }); 22 + }); 23 + 24 + it("scrollUp increases scrollback, clamped by total - viewport", () => { 25 + const s0 = createStreamView(); 26 + const s1 = streamScrollUp(s0, 1, 10, 3); 27 + expect(isPinned(s1)).toBe(false); 28 + expect(streamWindow(s1, 10, 3)).toEqual({ start: 6, end: 9 }); 29 + const s2 = streamScrollUp(s1, 1000, 10, 3); 30 + // Clamped at total - viewport = 7 31 + expect(s2.scrollback).toBe(7); 32 + expect(streamWindow(s2, 10, 3)).toEqual({ start: 0, end: 3 }); 33 + }); 34 + 35 + it("scrollDown decreases scrollback, clamped at 0", () => { 36 + const s0 = streamScrollUp(createStreamView(), 5, 10, 3); 37 + const s1 = streamScrollDown(s0, 2); 38 + expect(s1.scrollback).toBe(3); 39 + const s2 = streamScrollDown(s1, 999); 40 + expect(s2.scrollback).toBe(0); 41 + expect(isPinned(s2)).toBe(true); 42 + }); 43 + 44 + it("end key re-pins to bottom", () => { 45 + const s0 = streamScrollUp(createStreamView(), 3, 20, 5); 46 + const s1 = handleStreamKey(s0, k("end"), 20, 5); 47 + expect(s1).not.toBeNull(); 48 + expect(isPinned(s1!)).toBe(true); 49 + }); 50 + 51 + it("rendering includes an 'N more below' indicator when scrolled up", () => { 52 + const items = Array.from({ length: 10 }, (_, i) => `item-${i}`); 53 + const s = streamScrollUp(createStreamView(), 3, 10, 3); 54 + const node = renderStreamView(items, s, 3, (it) => ({ type: "row", children: [{ type: "text", text: it, color: "primary", style: {} }] } as any)); 55 + const children = (node as any).children; 56 + const last = children[children.length - 1]; 57 + const lastText = last.children[0].text as string; 58 + expect(lastText).toMatch(/more below/); 59 + }); 60 + 61 + it("rendering has no indicator when pinned", () => { 62 + const items = Array.from({ length: 10 }, (_, i) => `item-${i}`); 63 + const s = createStreamView(); 64 + const node = renderStreamView(items, s, 3, (it) => ({ type: "row", children: [{ type: "text", text: it, color: "primary", style: {} }] } as any)); 65 + const children = (node as any).children; 66 + // Last child is the last visible item, not an indicator. 67 + expect((children[children.length - 1] as any).children[0].text).toBe("item-9"); 68 + }); 69 + });
+100
tests/widgets-table.test.ts
··· 1 + import { describe, it, expect } from "vitest"; 2 + import { 3 + createTableState, sortRows, handleTableKey, renderTable, 4 + type TableColumn, 5 + } from "../src/tui/widgets/table.ts"; 6 + import type { KeyEvent } from "../src/tui/input.ts"; 7 + 8 + function k(name: string, opts: Partial<KeyEvent> = {}): KeyEvent { 9 + return { name, char: opts.char, ctrl: opts.ctrl ?? false, alt: opts.alt ?? false, shift: opts.shift ?? false }; 10 + } 11 + 12 + interface Person { name: string; age: number } 13 + 14 + const cols: TableColumn<Person>[] = [ 15 + { id: "name", header: "Name", render: r => r.name }, 16 + { id: "age", header: "Age", render: r => String(r.age), getSortValue: r => r.age, align: "right" }, 17 + ]; 18 + 19 + const rows: Person[] = [ 20 + { name: "Bea", age: 30 }, 21 + { name: "Alex", age: 25 }, 22 + { name: "Dan", age: 40 }, 23 + { name: "Cam", age: 35 }, 24 + ]; 25 + 26 + describe("table — sorting", () => { 27 + it("initial sort defaults to first column ascending", () => { 28 + const s = createTableState(cols); 29 + const sorted = sortRows(rows, cols, s); 30 + expect(sorted.map(r => r.name)).toEqual(["Alex", "Bea", "Cam", "Dan"]); 31 + }); 32 + 33 + it("numeric sort uses getSortValue, not the rendered string", () => { 34 + const s = { ...createTableState(cols), sortColumnId: "age", sortDirection: "asc" as const }; 35 + const sorted = sortRows(rows, cols, s); 36 + expect(sorted.map(r => r.age)).toEqual([25, 30, 35, 40]); 37 + }); 38 + 39 + it("desc flips direction", () => { 40 + const s = { ...createTableState(cols), sortColumnId: "age", sortDirection: "desc" as const }; 41 + const sorted = sortRows(rows, cols, s); 42 + expect(sorted.map(r => r.age)).toEqual([40, 35, 30, 25]); 43 + }); 44 + 45 + it("stable: equal keys keep original order", () => { 46 + const rs: Person[] = [ 47 + { name: "Alex", age: 25 }, { name: "Bea", age: 25 }, { name: "Cam", age: 25 }, 48 + ]; 49 + const s = { ...createTableState(cols), sortColumnId: "age", sortDirection: "asc" as const }; 50 + expect(sortRows(rs, cols, s).map(r => r.name)).toEqual(["Alex", "Bea", "Cam"]); 51 + }); 52 + }); 53 + 54 + describe("table — keys", () => { 55 + it("numeric keys toggle sort on that column", () => { 56 + const s0 = createTableState(cols); 57 + // "2" -> toggle sort on the 2nd column (age). 58 + const r1 = handleTableKey(s0, rows, cols, k("2", { char: "2" })); 59 + expect(r1.action).toBe("sorted"); 60 + expect(r1.state.sortColumnId).toBe("age"); 61 + expect(r1.state.sortDirection).toBe("asc"); 62 + // Same column again flips direction. 63 + const r2 = handleTableKey(r1.state, rows, cols, k("2", { char: "2" })); 64 + expect(r2.state.sortDirection).toBe("desc"); 65 + }); 66 + 67 + it("up/down move selection within bounds", () => { 68 + const s0 = createTableState(cols); 69 + const sorted = sortRows(rows, cols, s0); 70 + const r = handleTableKey(s0, sorted, cols, k("down")); 71 + expect(r.state.selectedIndex).toBe(1); 72 + expect(r.action).toBe("moved"); 73 + }); 74 + 75 + it("return activates the selected row", () => { 76 + const s0 = { ...createTableState(cols), selectedIndex: 2 }; 77 + const sorted = sortRows(rows, cols, s0); 78 + const r = handleTableKey(s0, sorted, cols, k("return")); 79 + expect(r.action).toBe("activate"); 80 + expect(r.activated!.name).toBe(sorted[2].name); 81 + }); 82 + }); 83 + 84 + describe("table — rendering", () => { 85 + it("renders header + separator + data rows", () => { 86 + const s = createTableState(cols); 87 + const sorted = sortRows(rows, cols, s); 88 + const node = renderTable(sorted, cols, s); 89 + expect((node as any).children).toHaveLength(2 + rows.length); 90 + }); 91 + 92 + it("shows a sort arrow on the active column", () => { 93 + const s = createTableState(cols, "age"); 94 + const sorted = sortRows(rows, cols, s); 95 + const node = renderTable(sorted, cols, s); 96 + const headerRow = (node as any).children[0]; 97 + const ageHeader = headerRow.children.find((c: any) => c.text?.includes("Age")); 98 + expect(ageHeader.text).toMatch(/▲/); 99 + }); 100 + });
+51
tests/widgets-tabs-mouse.test.ts
··· 1 + import { describe, it, expect } from "vitest"; 2 + import { 3 + createTabsState, handleTabsMouse, type TabDef, 4 + } from "../src/tui/widgets/tabs.ts"; 5 + import type { MouseEvent } from "../src/tui/input.ts"; 6 + 7 + function me(x: number, y: number, action: MouseEvent["action"] = "press"): MouseEvent { 8 + return { kind: "mouse", action, button: "left", x, y, ctrl: false, alt: false, shift: false }; 9 + } 10 + 11 + const tabs: TabDef[] = [ 12 + { id: "inbox", label: "Inbox" }, // "[ Inbox ]" width = 5 + 4 = 9 13 + { id: "sent", label: "Sent" }, // "[ Sent ]" width = 4 + 4 = 8 14 + { id: "drafts", label: "Drafts" }, // width = 10 15 + ]; 16 + 17 + // Rendered: "[ Inbox ] [ Sent ] [ Drafts ]" = 9 + 2 + 8 + 2 + 10 = 31 cols. 18 + const rect = { x: 0, y: 2, width: 40, height: 1 }; 19 + 20 + describe("handleTabsMouse", () => { 21 + it("click on the first tab selects it", () => { 22 + const s = { activeId: "sent" }; 23 + const r = handleTabsMouse(s, tabs, me(3, 2), rect); 24 + expect(r?.activeId).toBe("inbox"); 25 + }); 26 + 27 + it("click on the middle tab selects it", () => { 28 + const s = createTabsState(tabs); 29 + // Middle tab starts at col 9 + 2 = 11, width 8. 30 + const r = handleTabsMouse(s, tabs, me(15, 2), rect); 31 + expect(r?.activeId).toBe("sent"); 32 + }); 33 + 34 + it("click in the gap between tabs is ignored", () => { 35 + const s = createTabsState(tabs); 36 + const r = handleTabsMouse(s, tabs, me(10, 2), rect); 37 + expect(r).toBeNull(); 38 + }); 39 + 40 + it("click on a different row is ignored", () => { 41 + const s = createTabsState(tabs); 42 + const r = handleTabsMouse(s, tabs, me(3, 5), rect); 43 + expect(r).toBeNull(); 44 + }); 45 + 46 + it("non-press / non-left-button events are ignored", () => { 47 + const s = createTabsState(tabs); 48 + expect(handleTabsMouse(s, tabs, me(3, 2, "release"), rect)).toBeNull(); 49 + expect(handleTabsMouse(s, tabs, { ...me(3, 2), button: "right" }, rect)).toBeNull(); 50 + }); 51 + });
+73
tests/widgets-tabs.test.ts
··· 1 + import { describe, it, expect } from "vitest"; 2 + import { 3 + createTabsState, selectTab, nextTab, prevTab, handleTabsKey, renderTabs, 4 + type TabDef, 5 + } from "../src/tui/widgets/tabs.ts"; 6 + import type { KeyEvent } from "../src/tui/input.ts"; 7 + 8 + function k(name: string, opts: Partial<KeyEvent> = {}): KeyEvent { 9 + return { name, char: opts.char, ctrl: opts.ctrl ?? false, alt: opts.alt ?? false, shift: opts.shift ?? false }; 10 + } 11 + 12 + const tabs: TabDef[] = [ 13 + { id: "inbox", label: "Inbox" }, 14 + { id: "sent", label: "Sent" }, 15 + { id: "drafts", label: "Drafts" }, 16 + ]; 17 + 18 + describe("tabs — state", () => { 19 + it("createTabsState defaults to the first tab", () => { 20 + expect(createTabsState(tabs).activeId).toBe("inbox"); 21 + }); 22 + 23 + it("selectTab changes activeId", () => { 24 + const s = selectTab(createTabsState(tabs), "sent"); 25 + expect(s.activeId).toBe("sent"); 26 + }); 27 + 28 + it("next / prev wrap around", () => { 29 + const s0 = createTabsState(tabs); 30 + const s1 = nextTab(s0, tabs); 31 + const s2 = nextTab(s1, tabs); 32 + const s3 = nextTab(s2, tabs); 33 + expect([s1, s2, s3].map(s => s.activeId)).toEqual(["sent", "drafts", "inbox"]); 34 + const sp = prevTab(s0, tabs); 35 + expect(sp.activeId).toBe("drafts"); 36 + }); 37 + }); 38 + 39 + describe("tabs — key dispatch", () => { 40 + it("ctrl+tab / ctrl+backtab cycle", () => { 41 + const s0 = createTabsState(tabs); 42 + const fwd = handleTabsKey(s0, tabs, k("tab", { ctrl: true })); 43 + expect(fwd?.activeId).toBe("sent"); 44 + const back = handleTabsKey(s0, tabs, k("backtab", { ctrl: true })); 45 + expect(back?.activeId).toBe("drafts"); 46 + }); 47 + 48 + it("number keys jump directly", () => { 49 + const s0 = createTabsState(tabs); 50 + const r = handleTabsKey(s0, tabs, k("3", { char: "3" })); 51 + expect(r?.activeId).toBe("drafts"); 52 + }); 53 + 54 + it("returns null for unrelated keys", () => { 55 + const s0 = createTabsState(tabs); 56 + expect(handleTabsKey(s0, tabs, k("x"))).toBeNull(); 57 + // Non-ctrl tab is owned by forms, not tabs — let it bubble. 58 + expect(handleTabsKey(s0, tabs, k("tab"))).toBeNull(); 59 + }); 60 + }); 61 + 62 + describe("tabs — rendering", () => { 63 + it("active tab is bracketed, inactive tabs padded", () => { 64 + const s = createTabsState(tabs); 65 + const node = renderTabs(s, tabs); 66 + // Children alternate tab nodes and separators. 67 + const children = (node as any).children; 68 + const active = children.find((c: any) => c.text?.includes("[ Inbox ]")); 69 + const inactive = children.find((c: any) => c.text?.includes("Sent")); 70 + expect(active).toBeDefined(); 71 + expect(inactive).toBeDefined(); 72 + }); 73 + });
+156
tests/widgets-text-area.test.ts
··· 1 + import { describe, it, expect } from "vitest"; 2 + import { 3 + createTextArea, textAreaToString, applyTextAreaKey, renderTextArea, 4 + type TextAreaState, 5 + } from "../src/tui/widgets/text-area.ts"; 6 + import type { KeyEvent } from "../src/tui/input.ts"; 7 + 8 + function k(name: string, opts: Partial<KeyEvent> = {}): KeyEvent { 9 + return { name, char: opts.char, ctrl: opts.ctrl ?? false, alt: opts.alt ?? false, shift: opts.shift ?? false }; 10 + } 11 + 12 + function typeIn(initial: TextAreaState, ...keys: KeyEvent[]): TextAreaState { 13 + let s: TextAreaState | null = initial; 14 + for (const ke of keys) { 15 + const r = applyTextAreaKey(s!, ke); 16 + if (r) s = r; 17 + } 18 + return s!; 19 + } 20 + 21 + describe("text area — basics", () => { 22 + it("starts empty with one empty line", () => { 23 + const s = createTextArea(); 24 + expect(s.lines).toEqual([""]); 25 + expect(s).toEqual({ lines: [""], row: 0, col: 0 }); 26 + expect(textAreaToString(s)).toBe(""); 27 + }); 28 + 29 + it("initial text is split on newlines", () => { 30 + const s = createTextArea("hello\nworld"); 31 + expect(s.lines).toEqual(["hello", "world"]); 32 + }); 33 + 34 + it("inserts printable chars at the cursor", () => { 35 + const s = typeIn( 36 + createTextArea(), 37 + k("h", { char: "h" }), k("i", { char: "i" }), 38 + ); 39 + expect(textAreaToString(s)).toBe("hi"); 40 + expect(s.col).toBe(2); 41 + }); 42 + 43 + it("return inserts a newline, splitting the current line", () => { 44 + const s0 = createTextArea("hello world"); 45 + const s1 = typeIn(s0, k("home"), k("right"), k("right"), k("right"), k("right"), k("right"), k("return")); 46 + expect(s1.lines).toEqual(["hello", " world"]); 47 + expect(s1.row).toBe(1); 48 + expect(s1.col).toBe(0); 49 + }); 50 + 51 + it("backspace at col 0 joins with the previous line", () => { 52 + const s = typeIn(createTextArea("line1\nline2"), k("down"), k("backspace")); 53 + expect(s.lines).toEqual(["line1line2"]); 54 + expect(s.row).toBe(0); 55 + expect(s.col).toBe(5); 56 + }); 57 + 58 + it("delete at end-of-line joins with the next line", () => { 59 + const s0 = createTextArea("line1\nline2"); 60 + const s1 = typeIn(s0, k("end"), k("delete")); 61 + expect(s1.lines).toEqual(["line1line2"]); 62 + expect(s1.row).toBe(0); 63 + expect(s1.col).toBe(5); 64 + }); 65 + }); 66 + 67 + describe("text area — cursor movement", () => { 68 + it("left at col 0 of a non-first line wraps to end of previous line", () => { 69 + const s = typeIn(createTextArea("ab\nxy"), k("down"), k("left")); 70 + expect(s.row).toBe(0); 71 + expect(s.col).toBe(2); 72 + }); 73 + 74 + it("right at end-of-line wraps to col 0 of next line", () => { 75 + const s = typeIn(createTextArea("ab\nxy"), k("end"), k("right")); 76 + expect(s.row).toBe(1); 77 + expect(s.col).toBe(0); 78 + }); 79 + 80 + it("up/down preserves column when lines are equal, clamps when shorter", () => { 81 + const s0 = typeIn(createTextArea("longer\nshrt"), k("end")); 82 + expect(s0.col).toBe(6); 83 + const s1 = typeIn(s0, k("down")); 84 + expect(s1.col).toBe(4); // clamped to shrt.length 85 + const s2 = typeIn(s1, k("up")); 86 + // Going back up does NOT restore the "wanted" column — it stays at 4 87 + // (matches the simple model; we can add a "preferred col" later if needed). 88 + expect(s2.col).toBe(4); 89 + }); 90 + 91 + it("home / end jump within the current line", () => { 92 + const s = typeIn(createTextArea("hello\nworld"), k("down"), k("end"), k("home")); 93 + expect(s.row).toBe(1); 94 + expect(s.col).toBe(0); 95 + }); 96 + }); 97 + 98 + describe("text area — keys that return null (caller handles)", () => { 99 + it("tab / backtab / escape pass through", () => { 100 + const s0 = createTextArea("x"); 101 + expect(applyTextAreaKey(s0, k("tab"))).toBeNull(); 102 + expect(applyTextAreaKey(s0, k("backtab"))).toBeNull(); 103 + expect(applyTextAreaKey(s0, k("escape"))).toBeNull(); 104 + }); 105 + 106 + it("ctrl+return passes through (conventional submit-from-composer)", () => { 107 + const s0 = createTextArea("x"); 108 + expect(applyTextAreaKey(s0, k("return", { ctrl: true }))).toBeNull(); 109 + }); 110 + 111 + it("ctrl/alt-modified printable keys pass through (no leaks)", () => { 112 + const s0 = createTextArea("x"); 113 + expect(applyTextAreaKey(s0, k("s", { char: "s", ctrl: true }))).toBeNull(); 114 + expect(applyTextAreaKey(s0, k("q", { char: "q", alt: true }))).toBeNull(); 115 + }); 116 + }); 117 + 118 + describe("text area — rendering", () => { 119 + it("returns a column with one row per line", () => { 120 + const s = createTextArea("one\ntwo\nthree"); 121 + const node = renderTextArea(s, false); 122 + // Root is a column node; structural check is enough here. 123 + expect((node as any).type).toBe("column"); 124 + expect((node as any).children).toHaveLength(3); 125 + }); 126 + 127 + it("renders the cursor row as three text nodes (before / inverse / after)", () => { 128 + // New rendering model: the cursor is an inverse-styled cell painted 129 + // on top of the char under it, not a glyph inserted into the string. 130 + // The focused row becomes a row of three text nodes. 131 + const s = createTextArea("abc\ndef"); 132 + const active = renderTextArea({ ...s, row: 1, col: 1 }, true); 133 + const firstRowChildren = (active as any).children[0].children; 134 + const secondRowChildren = (active as any).children[1].children; 135 + // First row: no cursor — one plain text node. 136 + expect(firstRowChildren).toHaveLength(1); 137 + expect(firstRowChildren[0].text).toBe("abc"); 138 + // Second row: before ("d"), inverse ("e"), after ("f"). 139 + expect(secondRowChildren).toHaveLength(3); 140 + expect(secondRowChildren[0].text).toBe("d"); 141 + expect(secondRowChildren[1].text).toBe("e"); 142 + expect(secondRowChildren[1].inverse).toBe(true); 143 + expect(secondRowChildren[2].text).toBe("f"); 144 + }); 145 + 146 + it("cursor at end-of-line renders a single-space inverse cell", () => { 147 + const s = createTextArea("abc"); 148 + const active = renderTextArea({ ...s, row: 0, col: 3 }, true); 149 + const ch = (active as any).children[0].children; 150 + expect(ch).toHaveLength(3); 151 + expect(ch[0].text).toBe("abc"); 152 + expect(ch[1].text).toBe(" "); 153 + expect(ch[1].inverse).toBe(true); 154 + expect(ch[2].text).toBe(""); 155 + }); 156 + });
+50
tests/widgets-toast.test.ts
··· 1 + import { describe, it, expect } from "vitest"; 2 + import { 3 + createToastQueue, pushToast, pruneExpired, dismissToast, renderToasts, 4 + } from "../src/tui/widgets/toast.ts"; 5 + 6 + describe("toast queue", () => { 7 + it("push adds a toast with an expiresAt derived from now + duration", () => { 8 + const t0 = 1000; 9 + const q = pushToast(createToastQueue(), "saved", { now: t0, durationMs: 2000 }); 10 + expect(q.toasts).toHaveLength(1); 11 + expect(q.toasts[0].text).toBe("saved"); 12 + expect(q.toasts[0].expiresAt).toBe(3000); 13 + }); 14 + 15 + it("pruneExpired drops toasts whose expiresAt passed", () => { 16 + const q0 = pushToast(pushToast(createToastQueue(), 17 + "a", { now: 1000, durationMs: 500 }), 18 + "b", { now: 1000, durationMs: 2000 }); 19 + const q1 = pruneExpired(q0, 1600); 20 + expect(q1.toasts.map(t => t.text)).toEqual(["b"]); 21 + }); 22 + 23 + it("returns the same reference when nothing pruned (stable for signals)", () => { 24 + const q0 = pushToast(createToastQueue(), "x", { now: 1000, durationMs: 5000 }); 25 + const q1 = pruneExpired(q0, 1100); 26 + expect(q1).toBe(q0); 27 + }); 28 + 29 + it("dismissToast removes by id", () => { 30 + const q0 = pushToast(createToastQueue(), "a", { now: 0 }); 31 + const id = q0.toasts[0].id; 32 + const q1 = dismissToast(q0, id); 33 + expect(q1.toasts).toHaveLength(0); 34 + }); 35 + 36 + it("kind colours the glyph", () => { 37 + const q = pushToast(createToastQueue(), "boom", { kind: "error", now: 0 }); 38 + const node = renderToasts(q); 39 + const firstRow = (node as any).children[0]; 40 + expect(firstRow.children[0].color).toBe("error"); 41 + }); 42 + 43 + it("renders one row per toast", () => { 44 + const q0 = createToastQueue(); 45 + const q1 = pushToast(q0, "one", { now: 0 }); 46 + const q2 = pushToast(q1, "two", { now: 0 }); 47 + const node = renderToasts(q2); 48 + expect((node as any).children).toHaveLength(2); 49 + }); 50 + });
+61
tests/widgets-toolbar.test.ts
··· 1 + import { describe, it, expect } from "vitest"; 2 + import { toolbar, toolbarItemFor, type ToolbarItem } from "../src/tui/widgets/toolbar.ts"; 3 + 4 + const items: ToolbarItem[] = [ 5 + { key: "n", label: "ew" }, 6 + { key: "s", label: "ave", active: true }, 7 + { key: "/", label: "Search", hint: "fuzzy" }, 8 + { key: "q", label: "uit", disabled: true }, 9 + ]; 10 + 11 + describe("toolbar — bracket format (default)", () => { 12 + const node = toolbar(items); 13 + 14 + it("is a single row", () => { 15 + expect((node as any).type).toBe("row"); 16 + }); 17 + 18 + it("wraps each key in [K]", () => { 19 + const flat = (node as any).children.map((c: any) => c.text ?? "").join(""); 20 + expect(flat).toContain("[N]"); 21 + expect(flat).toContain("[S]"); 22 + expect(flat).toContain("[/]"); 23 + }); 24 + 25 + it("uppercases the bound letter between brackets", () => { 26 + const flat = (node as any).children.map((c: any) => c.text ?? "").join(""); 27 + expect(flat).toContain("[N]"); 28 + // Original key was lowercase but render uppercases. 29 + expect(flat).not.toContain("[n]"); 30 + }); 31 + 32 + it("marks active items via bold", () => { 33 + // Find the [S] sub-text and check bold. Text builder spreads style onto 34 + // the node directly (not under .style). 35 + const save = (node as any).children.find((c: any) => c.text === "S"); 36 + expect(save.bold).toBe(true); 37 + }); 38 + 39 + it("marks disabled items dim", () => { 40 + const quit = (node as any).children.find((c: any) => c.text === "uit"); 41 + expect(quit.dim).toBe(true); 42 + }); 43 + }); 44 + 45 + describe("toolbar — inline format", () => { 46 + it("highlights the first occurrence of the key inside the label", () => { 47 + const node = toolbar([{ key: "n", label: "new" }], { format: "inline" }); 48 + const texts = (node as any).children.map((c: any) => c.text); 49 + // Expected sequence: "" (before), "n" (the bound char), "ew" 50 + expect(texts).toEqual(["", "n", "ew"]); 51 + }); 52 + }); 53 + 54 + describe("toolbarItemFor", () => { 55 + it("matches by key, skipping disabled items", () => { 56 + expect(toolbarItemFor(items, "n")?.key).toBe("n"); 57 + expect(toolbarItemFor(items, "Q")).toBeNull(); // disabled 58 + expect(toolbarItemFor(items, "x")).toBeNull(); // unknown 59 + expect(toolbarItemFor(items, undefined)).toBeNull(); 60 + }); 61 + });
+106
tests/widgets-tree.test.ts
··· 1 + import { describe, it, expect } from "vitest"; 2 + import { 3 + createTreeState, flattenTree, toggleExpanded, moveSelection, handleTreeKey, 4 + type TreeNode, 5 + } from "../src/tui/widgets/tree.ts"; 6 + import type { KeyEvent } from "../src/tui/input.ts"; 7 + 8 + function k(name: string): KeyEvent { 9 + return { name, ctrl: false, alt: false, shift: false }; 10 + } 11 + 12 + const sample: TreeNode<null>[] = [ 13 + { id: "root/a", label: "a", data: null, children: [ 14 + { id: "root/a/1", label: "a1", data: null }, 15 + { id: "root/a/2", label: "a2", data: null, children: [ 16 + { id: "root/a/2/x", label: "x", data: null }, 17 + ] }, 18 + ] }, 19 + { id: "root/b", label: "b", data: null }, 20 + ]; 21 + 22 + describe("flattenTree", () => { 23 + it("returns roots when nothing is expanded", () => { 24 + const rows = flattenTree(sample, new Set()); 25 + expect(rows.map(r => r.node.id)).toEqual(["root/a", "root/b"]); 26 + expect(rows[0].depth).toBe(0); 27 + expect(rows[0].hasChildren).toBe(true); 28 + expect(rows[0].expanded).toBe(false); 29 + expect(rows[1].hasChildren).toBe(false); 30 + }); 31 + 32 + it("expands children at depth + 1", () => { 33 + const rows = flattenTree(sample, new Set(["root/a"])); 34 + expect(rows.map(r => r.node.id)).toEqual(["root/a", "root/a/1", "root/a/2", "root/b"]); 35 + expect(rows[1].depth).toBe(1); 36 + expect(rows[2].expanded).toBe(false); // a/2 has children but not expanded 37 + }); 38 + 39 + it("expands grandchildren at depth + 2", () => { 40 + const rows = flattenTree(sample, new Set(["root/a", "root/a/2"])); 41 + expect(rows.map(r => r.node.id)).toEqual([ 42 + "root/a", "root/a/1", "root/a/2", "root/a/2/x", "root/b", 43 + ]); 44 + expect(rows[3].depth).toBe(2); 45 + }); 46 + }); 47 + 48 + describe("toggleExpanded", () => { 49 + it("adds when absent, removes when present", () => { 50 + const s0 = createTreeState(); 51 + const s1 = toggleExpanded(s0, "foo"); 52 + expect(s1.expanded.has("foo")).toBe(true); 53 + const s2 = toggleExpanded(s1, "foo"); 54 + expect(s2.expanded.has("foo")).toBe(false); 55 + }); 56 + }); 57 + 58 + describe("moveSelection", () => { 59 + it("clamps at the top and bottom", () => { 60 + const rows = flattenTree(sample, new Set()); 61 + const s0 = { expanded: new Set<string>(), selectedId: "root/a" }; 62 + expect(moveSelection(s0, rows, -5).selectedId).toBe("root/a"); 63 + expect(moveSelection(s0, rows, 5).selectedId).toBe("root/b"); 64 + }); 65 + 66 + it("picks first row when nothing selected", () => { 67 + const rows = flattenTree(sample, new Set()); 68 + const s0 = createTreeState(); 69 + expect(moveSelection(s0, rows, 1).selectedId).toBe("root/a"); 70 + }); 71 + }); 72 + 73 + describe("handleTreeKey", () => { 74 + it("arrow keys move selection", () => { 75 + const s0 = { expanded: new Set<string>(), selectedId: "root/a" }; 76 + const rows = flattenTree(sample, s0.expanded); 77 + const r = handleTreeKey(s0, rows, k("down")); 78 + expect(r.state.selectedId).toBe("root/b"); 79 + expect(r.action).toBe("moved"); 80 + }); 81 + 82 + it("right expands a folder", () => { 83 + const s0 = { expanded: new Set<string>(), selectedId: "root/a" }; 84 + const rows = flattenTree(sample, s0.expanded); 85 + const r = handleTreeKey(s0, rows, k("right")); 86 + expect(r.state.expanded.has("root/a")).toBe(true); 87 + expect(r.action).toBe("expanded"); 88 + }); 89 + 90 + it("left collapses an expanded folder", () => { 91 + const s0 = { expanded: new Set(["root/a"]), selectedId: "root/a" }; 92 + const rows = flattenTree(sample, s0.expanded); 93 + const r = handleTreeKey(s0, rows, k("left")); 94 + expect(r.state.expanded.has("root/a")).toBe(false); 95 + expect(r.action).toBe("collapsed"); 96 + }); 97 + 98 + it("enter activates leaves", () => { 99 + const expanded = new Set(["root/a"]); 100 + const rows = flattenTree(sample, expanded); 101 + const s0 = { expanded, selectedId: "root/a/1" }; 102 + const r = handleTreeKey(s0, rows, k("return")); 103 + expect(r.action).toBe("activated"); 104 + expect(r.row?.node.id).toBe("root/a/1"); 105 + }); 106 + });
+54
tests/widgets-virtual-list-mouse.test.ts
··· 1 + import { describe, it, expect } from "vitest"; 2 + import { 3 + createVirtualListState, handleVirtualMouse, virtualWindow, 4 + } from "../src/tui/widgets/virtual-list.ts"; 5 + import type { MouseEvent } from "../src/tui/input.ts"; 6 + 7 + function me(partial: Partial<MouseEvent> & { action: MouseEvent["action"] }): MouseEvent { 8 + return { 9 + kind: "mouse", 10 + button: "left", 11 + x: 0, y: 0, 12 + ctrl: false, alt: false, shift: false, 13 + ...partial, 14 + }; 15 + } 16 + 17 + const rect = { x: 2, y: 3, width: 40, height: 10 }; 18 + 19 + describe("handleVirtualMouse", () => { 20 + it("ignores events outside the rect", () => { 21 + const s = createVirtualListState(100, 10); 22 + const r = handleVirtualMouse(s, me({ action: "press", x: 0, y: 0 }), rect); 23 + expect(r.action).toBe("none"); 24 + }); 25 + 26 + it("left click selects the row under the cursor (accounting for offset)", () => { 27 + const s = { ...createVirtualListState(100, 10), offset: 20, selectedIndex: 20 }; 28 + // Click at y=5, rect.y=3 → rowWithinRect=2 → index=22. 29 + const r = handleVirtualMouse(s, me({ action: "press", x: 5, y: 5 }), rect); 30 + expect(r.action).toBe("activate"); 31 + expect(r.state.selectedIndex).toBe(22); 32 + }); 33 + 34 + it("scroll up moves selection back by 3", () => { 35 + const s = { ...createVirtualListState(100, 10), selectedIndex: 20, offset: 15 }; 36 + const r = handleVirtualMouse(s, me({ action: "scrollUp", x: 5, y: 5, button: "none" }), rect); 37 + expect(r.action).toBe("moved"); 38 + expect(r.state.selectedIndex).toBe(17); 39 + }); 40 + 41 + it("scroll down moves selection forward by 3", () => { 42 + const s = { ...createVirtualListState(100, 10), selectedIndex: 20, offset: 15 }; 43 + const r = handleVirtualMouse(s, me({ action: "scrollDown", x: 5, y: 5, button: "none" }), rect); 44 + expect(r.action).toBe("moved"); 45 + expect(r.state.selectedIndex).toBe(23); 46 + }); 47 + 48 + it("click outside the virtual rows (past `total`) is a no-op", () => { 49 + const s = { ...createVirtualListState(5, 10), offset: 0 }; 50 + // Click at y below the actual data rows. 51 + const r = handleVirtualMouse(s, me({ action: "press", x: 5, y: 9 }), rect); 52 + expect(r.action).toBe("none"); 53 + }); 54 + });
+89
tests/widgets-virtual-list.test.ts
··· 1 + import { describe, it, expect } from "vitest"; 2 + import { 3 + createVirtualListState, clampVirtual, virtualWindow, 4 + moveVirtualSelection, pageVirtual, 5 + jumpVirtualToStart, jumpVirtualToEnd, handleVirtualKey, 6 + renderVirtualList, 7 + } from "../src/tui/widgets/virtual-list.ts"; 8 + import { row, text } from "../src/tui/builders.ts"; 9 + import type { KeyEvent } from "../src/tui/input.ts"; 10 + 11 + function k(name: string): KeyEvent { 12 + return { name, ctrl: false, alt: false, shift: false }; 13 + } 14 + 15 + describe("virtual list — windowing", () => { 16 + it("window matches viewport at the top", () => { 17 + const s = createVirtualListState(100, 10); 18 + expect(virtualWindow(s)).toEqual({ start: 0, end: 10 }); 19 + }); 20 + 21 + it("scrolls the window to keep selection in view (downward)", () => { 22 + const s0 = createVirtualListState(100, 10); 23 + const s1 = moveVirtualSelection(s0, 15); 24 + expect(s1.selectedIndex).toBe(15); 25 + expect(virtualWindow(s1)).toEqual({ start: 6, end: 16 }); 26 + }); 27 + 28 + it("clamps offset at the bottom", () => { 29 + const s = createVirtualListState(100, 10); 30 + const s1 = jumpVirtualToEnd(s); 31 + expect(s1.selectedIndex).toBe(99); 32 + expect(virtualWindow(s1)).toEqual({ start: 90, end: 100 }); 33 + }); 34 + 35 + it("empty list has no selection and an empty window", () => { 36 + const s = createVirtualListState(0, 10); 37 + expect(s.selectedIndex).toBe(-1); 38 + expect(virtualWindow(s)).toEqual({ start: 0, end: 0 }); 39 + }); 40 + 41 + it("clampVirtual re-normalises after total/viewport shrink", () => { 42 + const s0 = createVirtualListState(100, 10); 43 + const s1 = jumpVirtualToEnd(s0); // selected = 99 44 + const s2 = clampVirtual({ ...s1, total: 20 }); 45 + expect(s2.selectedIndex).toBe(19); 46 + expect(virtualWindow(s2)).toEqual({ start: 10, end: 20 }); 47 + }); 48 + }); 49 + 50 + describe("virtual list — navigation", () => { 51 + it("pageVirtual jumps by a viewport height", () => { 52 + const s = createVirtualListState(100, 10); 53 + const s1 = pageVirtual(s, 1); 54 + expect(s1.selectedIndex).toBe(10); 55 + const s2 = pageVirtual(s1, -1); 56 + expect(s2.selectedIndex).toBe(0); 57 + }); 58 + 59 + it("home/end jump to edges", () => { 60 + const s = createVirtualListState(100, 10); 61 + const sDown = handleVirtualKey(s, k("end")).state; 62 + expect(sDown.selectedIndex).toBe(99); 63 + const sUp = handleVirtualKey(sDown, k("home")).state; 64 + expect(sUp.selectedIndex).toBe(0); 65 + }); 66 + 67 + it("return emits action=activate without changing state", () => { 68 + const s = createVirtualListState(100, 10); 69 + const r = handleVirtualKey(s, k("return")); 70 + expect(r.action).toBe("activate"); 71 + expect(r.state).toBe(s); 72 + }); 73 + }); 74 + 75 + describe("virtual list — rendering", () => { 76 + it("calls renderItem only for visible indexes", () => { 77 + const s = moveVirtualSelection(createVirtualListState(1000, 5), 50); 78 + const touched: number[] = []; 79 + renderVirtualList(s, (i, sel) => { 80 + touched.push(i); 81 + return row(text(`row ${i}${sel ? " *" : ""}`)); 82 + }); 83 + // Window is around index 50 — 5 rows. Exactly 5 calls to renderItem. 84 + expect(touched.length).toBe(5); 85 + expect(touched).toContain(50); 86 + expect(Math.min(...touched)).toBeGreaterThanOrEqual(46); 87 + expect(Math.max(...touched)).toBeLessThanOrEqual(54); 88 + }); 89 + });