From 1be9e063eec75f3be0414e354cc5d9f38ddce278 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 13 Sep 2026 07:12:55 +0000 Subject: [PATCH] 0.6.1: the spinner honours reducedMotion, keeps its readout whole, and centres as one block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things a review of 0.6.0 turned up. `reducedMotion` has been a public AppOption since 0.1.0 with nothing reading it; it now rides the render context and the spinner, the first widget to drive its own redraws, holds its first frame and asks for no more frames under it. A readout that did not fit was cut to "34…", which reads as a different number; it is drawn whole or not at all, and a one-column remainder after the glyph stays blank instead of becoming a lone ellipsis. `align: "center"` measured the row as one block and then pinned the readout to the right edge; the readout now sits where it was measured. The root README's widget list gains the spinner, matching the package README. Tests cover each, plus the ASCII fallback through the capabilities override. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011gfb1vcQH5rf82HfbJvf1Z --- README.md | 2 +- 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/package.json | 2 +- packages/hqtui/src/app.ts | 2 ++ packages/hqtui/src/cli.ts | 2 +- packages/hqtui/src/testing.ts | 3 ++ packages/hqtui/src/ui.ts | 12 ++++++-- packages/hqtui/src/widgets/spinner.ts | 31 ++++++++++++------- packages/hqtui/test/spinner.test.ts | 44 ++++++++++++++++++++++++++- ports/cobol/adapter/package.json | 2 +- 16 files changed, 92 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 91b4b8f..3e85c6e 100644 --- a/README.md +++ b/README.md @@ -216,7 +216,7 @@ demo tells you which of those it could not read. | | | |---|---| | **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/apps/benchmark/package.json b/apps/benchmark/package.json index 45c7b34..f8226ce 100644 --- a/apps/benchmark/package.json +++ b/apps/benchmark/package.json @@ -7,6 +7,6 @@ "start": "bun src/main.ts" }, "dependencies": { - "@profullstack/hqtui": "^0.6.0" + "@profullstack/hqtui": "^0.6.1" } } diff --git a/apps/demo/package.json b/apps/demo/package.json index 4f73bc5..2bb17ba 100644 --- a/apps/demo/package.json +++ b/apps/demo/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/hqtui-demo", - "version": "0.6.0", + "version": "0.6.1", "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.6.0" + "@profullstack/hqtui": "^0.6.1" }, "publishConfig": { "access": "public" diff --git a/apps/demo/src/main.ts b/apps/demo/src/main.ts index 9f5e2f3..d5b421f 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.6.0"); process.exit(0); + case "--version": console.log("hqtui-demo 0.6.1"); process.exit(0); } } return options; diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index bf9508f..15e0cdf 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.6.0 · {COUNT} language demos · MIT + v0.6.1 · {COUNT} language demos · MIT

Terminal dashboards that diff --git a/apps/web/package.json b/apps/web/package.json index 818fd02..9f17ccd 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.6.0", + "@profullstack/hqtui": "^0.6.1", "class-variance-authority": "0.7.1", "clsx": "2.1.1", "lucide-react": "1.37.0", diff --git a/bun.lock b/bun.lock index 892e95a..fc7fa6d 100644 --- a/bun.lock +++ b/bun.lock @@ -21,7 +21,7 @@ }, "apps/demo": { "name": "@profullstack/hqtui-demo", - "version": "0.6.0", + "version": "0.6.1", "bin": { "hqtui-demo": "./src/main.ts", }, @@ -63,14 +63,14 @@ }, "packages/hqtui": { "name": "@profullstack/hqtui", - "version": "0.6.0", + "version": "0.6.1", "bin": { "hqtui": "./bin/hqtui.mjs", }, }, "ports/cobol/adapter": { "name": "@profullstack/hqtui-cobol-adapter", - "version": "0.6.0", + "version": "0.6.1", "dependencies": { "@profullstack/hqtui": "workspace:*", }, diff --git a/examples/package.json b/examples/package.json index d1064d1..7bd7deb 100644 --- a/examples/package.json +++ b/examples/package.json @@ -4,6 +4,6 @@ "version": "0.1.0", "type": "module", "dependencies": { - "@profullstack/hqtui": "^0.6.0" + "@profullstack/hqtui": "^0.6.1" } } diff --git a/packages/hqtui/package.json b/packages/hqtui/package.json index d4e0ef1..eb20461 100644 --- a/packages/hqtui/package.json +++ b/packages/hqtui/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/hqtui", - "version": "0.6.0", + "version": "0.6.1", "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/app.ts b/packages/hqtui/src/app.ts index 2a1d31c..7f08475 100644 --- a/packages/hqtui/src/app.ts +++ b/packages/hqtui/src/app.ts @@ -256,6 +256,7 @@ export class App { elapsed: Date.now() - this.startedAt, focusIndex: -1, collapseBorders: this.options.collapseBorders ?? false, + reducedMotion: this.options.reducedMotion ?? false, registerFocus: () => ({ index: -1, focused: false }), hit: () => {}, overlay: () => {}, @@ -402,6 +403,7 @@ export class App { elapsed: Date.now() - this.startedAt, focusIndex: this.focusIndex, collapseBorders: this.options.collapseBorders ?? false, + reducedMotion: this.options.reducedMotion ?? false, registerFocus: (action?: () => void): FocusRegistration => { const index = focusCursor++; if (action) this.focusActions[index] = action; diff --git a/packages/hqtui/src/cli.ts b/packages/hqtui/src/cli.ts index bc27ee5..2416929 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.6.0"; +const VERSION = "0.6.1"; function help(): void { console.log(`hqtui ${VERSION} — High Quality Terminal UI for TypeScript diff --git a/packages/hqtui/src/testing.ts b/packages/hqtui/src/testing.ts index 06d6e48..54d10a1 100644 --- a/packages/hqtui/src/testing.ts +++ b/packages/hqtui/src/testing.ts @@ -18,6 +18,8 @@ export interface RenderOptions { focus?: number; /** Merge the borders of adjacent panels, as `createApp` can. */ collapseBorders?: boolean; + /** Hold animated widgets still, as `createApp({ reducedMotion: true })` does. */ + reducedMotion?: boolean; } export interface CellSnapshot { @@ -108,6 +110,7 @@ export function renderToScreen( elapsed: options.elapsed ?? 0, focusIndex: options.focus ?? 0, collapseBorders: options.collapseBorders ?? false, + reducedMotion: options.reducedMotion ?? false, registerFocus: () => { const index = focusCursor++; return { index, focused: index === (options.focus ?? 0) }; diff --git a/packages/hqtui/src/ui.ts b/packages/hqtui/src/ui.ts index d1219c7..85b1b98 100644 --- a/packages/hqtui/src/ui.ts +++ b/packages/hqtui/src/ui.ts @@ -98,6 +98,11 @@ export interface RenderContext { * changes every layout that has two panels side by side. */ collapseBorders: boolean; + /** + * The app was started with `reducedMotion`: widgets that drive their own + * redraws (the spinner) draw a still frame and ask for nothing more. + */ + reducedMotion: boolean; registerFocus(action?: () => void): FocusRegistration; hit(region: HitRegion): void; overlay(draw: (root: Surface) => void): void; @@ -446,8 +451,11 @@ export class Container { * `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; + // Under reducedMotion the glyph holds its first frame and the app is not + // asked to redraw: the line still says "busy", it just does not move. + const still = this.ctx.reducedMotion; + if ((options.active ?? true) && !still) this.ctx.invalidate(); + const elapsed = still ? 0 : (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"); diff --git a/packages/hqtui/src/widgets/spinner.ts b/packages/hqtui/src/widgets/spinner.ts index 81ca98d..93a4bad 100644 --- a/packages/hqtui/src/widgets/spinner.ts +++ b/packages/hqtui/src/widgets/spinner.ts @@ -60,22 +60,31 @@ export function drawSpinner(surface: Surface, options: SpinnerOptions): number { 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 glyphWidth = stringWidth(glyph); + const width = surface.width; + // The label gets what the glyph leaves; a one-column remainder stays blank + // rather than becoming a lone ellipsis, so the separating space is drawn on + // its own and only the words are truncated. + const labelRoom = Math.max(0, width - glyphWidth - 1); + const label = options.label && labelRoom > 0 ? truncate(options.label, labelRoom) : ""; + const labelWidth = label ? 1 + stringWidth(label) : 0; + const leftWidth = glyphWidth + labelWidth; + // A readout is a number people read at a glance; "34…" for 349/1200 is a + // different number, so it is drawn whole or not at all. + const readWidth = options.text ? stringWidth(options.text) : 0; + const readout = readWidth > 0 && leftWidth + 1 + readWidth <= width ? options.text! : ""; + const total = Math.min(width, readout ? leftWidth + 1 + readWidth : leftWidth); + const x0 = options.align === "right" ? width - total : options.align === "center" ? Math.floor((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 (label) surface.text(x + glyphWidth + 1, 0, label, { 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 }); + // Left and right pin the readout to the right edge; centre keeps the + // block together, at the position `total` was measured for. + const rx = options.align === "center" ? x + leftWidth + 1 : width - readWidth; + surface.text(rx, 0, readout, { fg: theme.muted, bg }); } return total; } diff --git a/packages/hqtui/test/spinner.test.ts b/packages/hqtui/test/spinner.test.ts index 05c9a60..44f9303 100644 --- a/packages/hqtui/test/spinner.test.ts +++ b/packages/hqtui/test/spinner.test.ts @@ -31,7 +31,7 @@ test("an inactive spinner settles on a done mark and stops asking for frames", ( // 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 ctx = ui.ctx; const original = ctx.invalidate; ctx.invalidate = () => { asked += 1; original(); }; ui.spinner({ label: "busy", elapsed: 0 }); @@ -50,3 +50,45 @@ test("frames can be named or supplied, and the line truncates instead of overflo 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); }); + +test("a readout is drawn whole or not at all, and a one-column remainder stays blank", () => { + const glyph = SPINNER_FRAMES.dots[0]; + // Width 10: "⠋ abcdef" is 8 wide, the readout needs 4 more, so it is dropped rather than shown as "…". + const tight = renderToText(({ ui }) => ui.spinner({ label: "abcdef", text: "9/9", elapsed: 0 }), { width: 10, height: 1 }); + assert.equal(tight.trimEnd(), `${glyph} abcdef`); + const fits = renderToText(({ ui }) => ui.spinner({ label: "abcdef", text: "9/9", elapsed: 0 }), { width: 12, height: 1 }); + assert.equal(fits, `${glyph} abcdef 9/9`); + const wide = renderToText(({ ui }) => ui.spinner({ label: "abcdef", text: "9/9", elapsed: 0 }), { width: 16, height: 1 }); + assert.equal(wide, `${glyph} abcdef 9/9`); + // Width 2 leaves one column after the glyph: no lone ellipsis. + const two = renderToText(({ ui }) => ui.spinner({ label: "x", elapsed: 0 }), { width: 2, height: 1 }); + assert.equal(two.trimEnd(), glyph); +}); + +test("centre alignment keeps the readout with the label", () => { + const glyph = SPINNER_FRAMES.dots[0]; + const centred = renderToText(({ ui }) => ui.spinner({ label: "load", text: "3/9", elapsed: 0, align: "center" }), { width: 24, height: 1 }); + assert.equal(centred.trimEnd(), ` ${glyph} load 3/9`); + const right = renderToText(({ ui }) => ui.spinner({ label: "load", text: "3/9", elapsed: 0, align: "right" }), { width: 24, height: 1 }); + assert.equal(right, ` ${glyph} load 3/9`); +}); + +test("a terminal without Unicode gets the ascii set and a plain done mark", () => { + const busy = renderToText(({ ui }) => ui.spinner({ label: "x", elapsed: 80 }), { width: 10, height: 1, capabilities: { unicode: false } }); + assert.ok(busy.startsWith("/ x"), busy); + const done = renderToText(({ ui }) => ui.spinner({ label: "x", active: false, elapsed: 0 }), { width: 10, height: 1, capabilities: { unicode: false } }); + assert.ok(done.startsWith("* x"), done); +}); + +test("reducedMotion holds the first frame and asks for no redraw", () => { + let asked = 0; + const text = renderToScreen(({ ui }) => { + const ctx = ui.ctx; + const original = ctx.invalidate; + ctx.invalidate = () => { asked += 1; original(); }; + ui.spinner({ label: "busy", elapsed: 800 }); + ctx.invalidate = original; + }, { width: 20, height: 1, reducedMotion: true }).text(); + assert.equal(asked, 0); + assert.ok(text.startsWith(`${SPINNER_FRAMES.dots[0]} busy`), text); +}); diff --git a/ports/cobol/adapter/package.json b/ports/cobol/adapter/package.json index 4b48c70..2a9ee06 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.6.0", + "version": "0.6.1", "type": "module", "description": "Reads 80-column COBOL scene records and draws them with HQTUI.", "dependencies": {