Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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()` |
Expand Down
2 changes: 1 addition & 1 deletion apps/benchmark/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,6 @@
"start": "bun src/main.ts"
},
"dependencies": {
"@profullstack/hqtui": "^0.6.0"
"@profullstack/hqtui": "^0.6.1"
}
}
4 changes: 2 additions & 2 deletions apps/demo/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -27,7 +27,7 @@
"audit:scroll": "bun scripts/scrollaudit.ts"
},
"dependencies": {
"@profullstack/hqtui": "^0.6.0"
"@profullstack/hqtui": "^0.6.1"
},
"publishConfig": {
"access": "public"
Expand Down
2 changes: 1 addition & 1 deletion apps/demo/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion apps/web/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ export default async function Home() {
High Quality Terminal UI for TypeScript, Rust, Go, Python, Zig and C++
</p>
<Badge variant="secondary" className="mb-5 font-mono text-xs">
v0.6.0 · {COUNT} language demos · MIT
v0.6.1 · {COUNT} language demos · MIT
</Badge>
<p className="text-balance text-3xl font-bold tracking-tight sm:text-5xl">
Terminal dashboards that
Expand Down
2 changes: 1 addition & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 3 additions & 3 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion examples/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@
"version": "0.1.0",
"type": "module",
"dependencies": {
"@profullstack/hqtui": "^0.6.0"
"@profullstack/hqtui": "^0.6.1"
}
}
2 changes: 1 addition & 1 deletion packages/hqtui/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 2 additions & 0 deletions packages/hqtui/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: () => {},
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion packages/hqtui/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions packages/hqtui/src/testing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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) };
Expand Down
12 changes: 10 additions & 2 deletions packages/hqtui/src/ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -446,8 +451,11 @@ export class Container {
* `active: false` when the work is done and the line settles on a tick.
*/
spinner(options: Omit<W.SpinnerOptions, "elapsed"> & { 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");
Expand Down
31 changes: 20 additions & 11 deletions packages/hqtui/src/widgets/spinner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
44 changes: 43 additions & 1 deletion packages/hqtui/test/spinner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand All @@ -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);
});
2 changes: 1 addition & 1 deletion ports/cobol/adapter/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down