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