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": {