From c3914ecabe81dfcb91267adc97ee83d4ff7ad9d4 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 13 Sep 2026 05:18:17 +0000 Subject: [PATCH] 0.6.0: a spinner widget `ui.spinner({ label, text })` draws a spinning glyph, a label and a right-hand readout on one row. It keeps no timer of its own: the frame comes from the app clock (RenderArgs.elapsed) and, while the spinner is active, the container asks the app for another frame after this one, so a caller shows a busy line with no interval to manage. `active: false` settles it on a done mark without a layout change. Named frame sets (dots, arc, line, ascii) or your own; terminals without Unicode get ascii and a plain done mark. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011gfb1vcQH5rf82HfbJvf1Z --- apps/benchmark/package.json | 2 +- apps/demo/package.json | 4 +- apps/demo/src/main.ts | 2 +- apps/web/app/page.tsx | 2 +- apps/web/package.json | 2 +- bun.lock | 6 +- examples/package.json | 2 +- packages/hqtui/README.md | 2 +- packages/hqtui/package.json | 2 +- packages/hqtui/src/cli.ts | 2 +- packages/hqtui/src/ui.ts | 16 ++++++ packages/hqtui/src/widgets/index.ts | 1 + packages/hqtui/src/widgets/spinner.ts | 81 +++++++++++++++++++++++++++ packages/hqtui/test/spinner.test.ts | 52 +++++++++++++++++ ports/cobol/adapter/package.json | 2 +- 15 files changed, 164 insertions(+), 14 deletions(-) create mode 100644 packages/hqtui/src/widgets/spinner.ts create mode 100644 packages/hqtui/test/spinner.test.ts diff --git a/apps/benchmark/package.json b/apps/benchmark/package.json index 5271e40..45c7b34 100644 --- a/apps/benchmark/package.json +++ b/apps/benchmark/package.json @@ -7,6 +7,6 @@ "start": "bun src/main.ts" }, "dependencies": { - "@profullstack/hqtui": "^0.5.1" + "@profullstack/hqtui": "^0.6.0" } } diff --git a/apps/demo/package.json b/apps/demo/package.json index 2feebee..4f73bc5 100644 --- a/apps/demo/package.json +++ b/apps/demo/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/hqtui-demo", - "version": "0.5.1", + "version": "0.6.0", "description": "The HQTUI reference dashboard: a btop-grade terminal system monitor. Runs on real system metrics or a deterministic simulation.", "license": "MIT", "type": "module", @@ -27,7 +27,7 @@ "audit:scroll": "bun scripts/scrollaudit.ts" }, "dependencies": { - "@profullstack/hqtui": "^0.5.1" + "@profullstack/hqtui": "^0.6.0" }, "publishConfig": { "access": "public" diff --git a/apps/demo/src/main.ts b/apps/demo/src/main.ts index ce6f4e8..9f5e2f3 100755 --- a/apps/demo/src/main.ts +++ b/apps/demo/src/main.ts @@ -51,7 +51,7 @@ function parseArgs(argv: string[]): Options { case "-h": case "--help": printHelp(); process.exit(0); case "-v": - case "--version": console.log("hqtui-demo 0.5.1"); process.exit(0); + case "--version": console.log("hqtui-demo 0.6.0"); process.exit(0); } } return options; diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index 9424ec1..bf9508f 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -157,7 +157,7 @@ export default async function Home() { High Quality Terminal UI for TypeScript, Rust, Go, Python, Zig and C++

- v0.5.1 · {COUNT} language demos · MIT + v0.6.0 · {COUNT} language demos · MIT

Terminal dashboards that diff --git a/apps/web/package.json b/apps/web/package.json index cb05416..818fd02 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -9,7 +9,7 @@ }, "dependencies": { "@base-ui/react": "1.7.0", - "@profullstack/hqtui": "^0.5.1", + "@profullstack/hqtui": "^0.6.0", "class-variance-authority": "0.7.1", "clsx": "2.1.1", "lucide-react": "1.37.0", diff --git a/bun.lock b/bun.lock index cb74794..892e95a 100644 --- a/bun.lock +++ b/bun.lock @@ -21,7 +21,7 @@ }, "apps/demo": { "name": "@profullstack/hqtui-demo", - "version": "0.5.1", + "version": "0.6.0", "bin": { "hqtui-demo": "./src/main.ts", }, @@ -63,14 +63,14 @@ }, "packages/hqtui": { "name": "@profullstack/hqtui", - "version": "0.5.1", + "version": "0.6.0", "bin": { "hqtui": "./bin/hqtui.mjs", }, }, "ports/cobol/adapter": { "name": "@profullstack/hqtui-cobol-adapter", - "version": "0.5.1", + "version": "0.6.0", "dependencies": { "@profullstack/hqtui": "workspace:*", }, diff --git a/examples/package.json b/examples/package.json index 262c1bd..d1064d1 100644 --- a/examples/package.json +++ b/examples/package.json @@ -4,6 +4,6 @@ "version": "0.1.0", "type": "module", "dependencies": { - "@profullstack/hqtui": "^0.5.1" + "@profullstack/hqtui": "^0.6.0" } } diff --git a/packages/hqtui/README.md b/packages/hqtui/README.md index 921d694..40e2dd0 100644 --- a/packages/hqtui/README.md +++ b/packages/hqtui/README.md @@ -105,7 +105,7 @@ activity, sessions, services and the full widget catalogue. | | | |---|---| | **Layout** | rows, columns, grid with spans, `"40%"`, `"2fr"`, `auto`, min/max, padding, gaps, clipping, responsive breakpoints | -| **Widgets** | panel, table, tree, list, log viewer, key/values, meter, gauge, donut, progress, sparkline, line/area/multi-series graph, histogram, heat bar, tabs, status bar, button, checkbox, toggle, radio, select, text input, modal, command palette, tooltip, badge, divider | +| **Widgets** | panel, table, tree, list, log viewer, key/values, meter, gauge, donut, progress, sparkline, line/area/multi-series graph, histogram, heat bar, tabs, status bar, button, checkbox, toggle, radio, select, text input, modal, command palette, tooltip, badge, spinner, divider | | **Graphics** | Braille canvas (2×4 pixels per cell), block/half-block/quadrant/ASCII modes, gradients, software alpha blending | | **Color** | 24-bit truecolor, automatic 256 and 16-colour quantization, `NO_COLOR`, monochrome and high-contrast modes | | **Themes** | dark (default), dracula, nord, tokyo night, gruvbox, matrix, monochrome, high contrast, light — plus `defineTheme()` | diff --git a/packages/hqtui/package.json b/packages/hqtui/package.json index 382e80a..d4e0ef1 100644 --- a/packages/hqtui/package.json +++ b/packages/hqtui/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/hqtui", - "version": "0.5.1", + "version": "0.6.0", "description": "High Quality Terminal UI for TypeScript. btop-grade dashboards with a one-import API, dark by default, zero runtime dependencies.", "license": "MIT", "type": "module", diff --git a/packages/hqtui/src/cli.ts b/packages/hqtui/src/cli.ts index 47eae0d..bc27ee5 100644 --- a/packages/hqtui/src/cli.ts +++ b/packages/hqtui/src/cli.ts @@ -13,7 +13,7 @@ import { detectCapabilities } from "./capabilities.ts"; import { themeList, themes } from "./theme.ts"; import { BrailleCanvas } from "./graphics/braille.ts"; -const VERSION = "0.5.1"; +const VERSION = "0.6.0"; function help(): void { console.log(`hqtui ${VERSION} — High Quality Terminal UI for TypeScript diff --git a/packages/hqtui/src/ui.ts b/packages/hqtui/src/ui.ts index 67895e8..d1219c7 100644 --- a/packages/hqtui/src/ui.ts +++ b/packages/hqtui/src/ui.ts @@ -439,6 +439,22 @@ export class Container { return this.add((s) => W.drawBadge(s, options), this.sizeOf(options, "auto", 1)); } + /** + * A busy indicator that animates on its own. The frame comes from the app + * clock, and while the spinner is active the container asks for another + * frame after this one, so the caller never runs a timer. Pass + * `active: false` when the work is done and the line settles on a tick. + */ + spinner(options: Omit & { elapsed?: number } & ContainerOptions): this { + if (options.active ?? true) this.ctx.invalidate(); + const elapsed = options.elapsed ?? this.ctx.elapsed; + // A terminal without Unicode gets the ASCII set and a plain done mark. + const unicode = this.ctx.capabilities.unicode; + const frames = options.frames ?? (unicode ? "dots" : "ascii"); + const doneGlyph = options.doneGlyph ?? (unicode ? "✓" : "*"); + return this.add((s) => W.drawSpinner(s, { ...options, elapsed, frames, doneGlyph }), this.sizeOf(options, "auto", 1)); + } + /** Aligned label/value pairs. */ keyValues(rows: W.KeyValueRow[], options: Omit & ContainerOptions = {}): this { return this.add((s) => W.drawKeyValues(s, { ...options, rows }), this.sizeOf(options, "auto", rows.length)); diff --git a/packages/hqtui/src/widgets/index.ts b/packages/hqtui/src/widgets/index.ts index 49a88f2..05e2873 100644 --- a/packages/hqtui/src/widgets/index.ts +++ b/packages/hqtui/src/widgets/index.ts @@ -8,3 +8,4 @@ export * from "./scrollbar.ts"; export * from "./table.ts"; export * from "./meters.ts"; export * from "./controls.ts"; +export * from "./spinner.ts"; diff --git a/packages/hqtui/src/widgets/spinner.ts b/packages/hqtui/src/widgets/spinner.ts new file mode 100644 index 0000000..81ca98d --- /dev/null +++ b/packages/hqtui/src/widgets/spinner.ts @@ -0,0 +1,81 @@ +import type { Surface, Align } from "../surface.ts"; +import { Attr } from "../buffer.ts"; +import type { Color } from "../color.ts"; +import { stringWidth, truncate } from "../unicode.ts"; + +/** Frame sets a spinner can cycle. `ascii` is what a terminal without Unicode gets. */ +export const SPINNER_FRAMES = { + dots: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"], + arc: ["◜", "◠", "◝", "◞", "◡", "◟"], + line: ["─", "╲", "│", "╱"], + ascii: ["|", "/", "-", "\\"], +} as const; + +export type SpinnerFrames = keyof typeof SPINNER_FRAMES | readonly string[]; + +export interface SpinnerOptions { + /** Milliseconds since the app started (`RenderArgs.elapsed`); picks the frame. */ + elapsed: number; + /** What is happening, drawn after the glyph. */ + label?: string; + /** A right-hand readout, e.g. `349/1200`. */ + text?: string; + /** Named set or your own frames. Default `dots`, or `ascii` where the terminal has no Unicode. */ + frames?: SpinnerFrames; + /** Milliseconds per frame. Default 80. */ + interval?: number; + /** + * False stops the glyph and draws `doneGlyph` instead, so the same line can + * say "loading" and then "loaded" without a layout change. Default true. + */ + active?: boolean; + doneGlyph?: string; + color?: Color; + labelColor?: Color; + background?: Color; + align?: Align; +} + +/** Which frame `elapsed` lands on. Exported so a status line elsewhere can show the same glyph. */ +export function spinnerFrame(elapsed: number, frames: readonly string[], interval = 80): string { + if (frames.length === 0) return ""; + const step = Math.max(1, interval); + return frames[Math.floor(Math.max(0, elapsed) / step) % frames.length] ?? ""; +} + +/** + * A busy indicator: a spinning glyph, a label, and an optional readout on the + * right. One row. The widget does not keep time itself; it draws the frame + * `elapsed` names, and the container asks the app for another frame while it + * is active, so a spinner animates with no timer in the caller. + */ +export function drawSpinner(surface: Surface, options: SpinnerOptions): number { + if (surface.empty) return 0; + const theme = surface.theme; + const named = typeof options.frames === "string" ? SPINNER_FRAMES[options.frames] : options.frames; + const frames: readonly string[] = named ?? SPINNER_FRAMES.dots; + const active = options.active ?? true; + const glyph = active ? spinnerFrame(options.elapsed, frames, options.interval) : (options.doneGlyph ?? "✓"); + const color = options.color ?? (active ? theme.accent : theme.success); + const labelColor = options.labelColor ?? theme.foreground; + const bg = options.background; + + const label = options.label ? ` ${options.label}` : ""; + const readout = options.text ?? ""; + const left = `${glyph}${label}`; + const leftWidth = stringWidth(left); + const readWidth = stringWidth(readout); + const total = readout ? Math.min(surface.width, leftWidth + 1 + readWidth) : Math.min(surface.width, leftWidth); + const x0 = options.align === "right" ? surface.width - total : options.align === "center" ? Math.floor((surface.width - total) / 2) : 0; + const x = Math.max(0, x0); + + if (bg !== undefined) surface.fill({ bg }); + surface.text(x, 0, glyph, { fg: color, bg, attrs: Attr.Bold }); + const glyphWidth = stringWidth(glyph); + if (label) surface.text(x + glyphWidth, 0, truncate(label, Math.max(0, surface.width - x - glyphWidth)), { fg: labelColor, bg }); + if (readout) { + const room = surface.width - (x + leftWidth + 1); + if (room > 0) surface.text(surface.width - Math.min(readWidth, room), 0, truncate(readout, room), { fg: theme.muted, bg }); + } + return total; +} diff --git a/packages/hqtui/test/spinner.test.ts b/packages/hqtui/test/spinner.test.ts new file mode 100644 index 0000000..05c9a60 --- /dev/null +++ b/packages/hqtui/test/spinner.test.ts @@ -0,0 +1,52 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { renderToText, renderToScreen } from "../src/testing.ts"; +import { SPINNER_FRAMES, spinnerFrame } from "../src/widgets/spinner.ts"; + +test("the frame follows the clock and wraps", () => { + const frames = SPINNER_FRAMES.dots; + assert.equal(spinnerFrame(0, frames), frames[0]); + assert.equal(spinnerFrame(80, frames), frames[1]); + assert.equal(spinnerFrame(79, frames), frames[0]); + assert.equal(spinnerFrame(80 * frames.length, frames), frames[0]); + assert.equal(spinnerFrame(1000, frames, 500), frames[2]); + assert.equal(spinnerFrame(-5, frames), frames[0]); + assert.equal(spinnerFrame(50, []), ""); +}); + +test("a spinner draws the glyph, the label and the readout on one row", () => { + const text = renderToText(({ ui }) => { + ui.spinner({ label: "scanning last 7 days", text: "120/349", elapsed: 160 }); + }, { width: 40, height: 1 }); + assert.ok(text.startsWith(`${SPINNER_FRAMES.dots[2]} scanning last 7 days`), text); + assert.ok(text.trimEnd().endsWith("120/349"), text); +}); + +test("an inactive spinner settles on a done mark and stops asking for frames", () => { + let asked = 0; + const done = renderToText(({ ui }) => { + ui.spinner({ label: "loaded", active: false, elapsed: 160 }); + }, { width: 20, height: 1 }); + assert.ok(done.startsWith("✓ loaded"), done); + + // The container asks the app for another frame only while spinning. + const screen = renderToScreen(({ ui }) => { + const ctx = (ui as unknown as { ctx: { invalidate: () => void } }).ctx; + const original = ctx.invalidate; + ctx.invalidate = () => { asked += 1; original(); }; + ui.spinner({ label: "busy", elapsed: 0 }); + ui.spinner({ label: "idle", active: false, elapsed: 0 }); + ctx.invalidate = original; + }, { width: 20, height: 2 }); + assert.equal(asked, 1); + assert.ok(screen.text().includes("busy")); +}); + +test("frames can be named or supplied, and the line truncates instead of overflowing", () => { + const ascii = renderToText(({ ui }) => ui.spinner({ frames: "ascii", elapsed: 80, label: "x" }), { width: 10, height: 1 }); + assert.ok(ascii.startsWith("/ x"), ascii); + const own = renderToText(({ ui }) => ui.spinner({ frames: ["a", "b"], elapsed: 80 }), { width: 4, height: 1 }); + assert.ok(own.startsWith("b"), own); + const narrow = renderToText(({ ui }) => ui.spinner({ label: "a label that is far too long for the row", text: "9/9", elapsed: 0 }), { width: 12, height: 1 }); + assert.ok(narrow.split("\n").every((line) => line.length <= 12), narrow); +}); diff --git a/ports/cobol/adapter/package.json b/ports/cobol/adapter/package.json index 871bb6d..4b48c70 100644 --- a/ports/cobol/adapter/package.json +++ b/ports/cobol/adapter/package.json @@ -1,7 +1,7 @@ { "name": "@profullstack/hqtui-cobol-adapter", "private": true, - "version": "0.5.1", + "version": "0.6.0", "type": "module", "description": "Reads 80-column COBOL scene records and draws them with HQTUI.", "dependencies": {