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 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.5.1"
"@profullstack/hqtui": "^0.6.0"
}
}
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.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",
Expand All @@ -27,7 +27,7 @@
"audit:scroll": "bun scripts/scrollaudit.ts"
},
"dependencies": {
"@profullstack/hqtui": "^0.5.1"
"@profullstack/hqtui": "^0.6.0"
},
"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.5.1"); process.exit(0);
case "--version": console.log("hqtui-demo 0.6.0"); 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.5.1 · {COUNT} language demos · MIT
v0.6.0 · {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.5.1",
"@profullstack/hqtui": "^0.6.0",
"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.5.1"
"@profullstack/hqtui": "^0.6.0"
}
}
2 changes: 1 addition & 1 deletion packages/hqtui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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()` |
Expand Down
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.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",
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.5.1";
const VERSION = "0.6.0";

function help(): void {
console.log(`hqtui ${VERSION} — High Quality Terminal UI for TypeScript
Expand Down
16 changes: 16 additions & 0 deletions packages/hqtui/src/ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<W.SpinnerOptions, "elapsed"> & { 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<W.KeyValueOptions, "rows"> & ContainerOptions = {}): this {
return this.add((s) => W.drawKeyValues(s, { ...options, rows }), this.sizeOf(options, "auto", rows.length));
Expand Down
1 change: 1 addition & 0 deletions packages/hqtui/src/widgets/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ export * from "./scrollbar.ts";
export * from "./table.ts";
export * from "./meters.ts";
export * from "./controls.ts";
export * from "./spinner.ts";
81 changes: 81 additions & 0 deletions packages/hqtui/src/widgets/spinner.ts
Original file line number Diff line number Diff line change
@@ -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;
}
52 changes: 52 additions & 0 deletions packages/hqtui/test/spinner.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
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.5.1",
"version": "0.6.0",
"type": "module",
"description": "Reads 80-column COBOL scene records and draws them with HQTUI.",
"dependencies": {
Expand Down