diff --git a/apps/benchmark/package.json b/apps/benchmark/package.json
index f8226ce..016005b 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.1"
+ "@profullstack/hqtui": "^0.6.2"
}
}
diff --git a/apps/benchmark/src/main.ts b/apps/benchmark/src/main.ts
index aa7791f..bda53b6 100644
--- a/apps/benchmark/src/main.ts
+++ b/apps/benchmark/src/main.ts
@@ -138,6 +138,16 @@ const results: Result[] = [];
renderToScreen(({ ui }) => ui.table({ rows, columns, zebra: true }), { width: 100, height: 40 });
}, "60 rows x 4 columns"));
+ results.push(bench("widgets.modal.controls", 500, (i) => {
+ renderToScreen(({ ui }) => {
+ ui.button({ label: "Background" });
+ ui.modal({
+ title: "Terminate Process", height: 10,
+ buttons: [{ label: "Yes", onPress: () => {} }, { label: "No", onPress: () => {} }],
+ }, (body) => body.checkbox({ label: "Force kill (-9)", checked: false, onToggle: () => {} }));
+ }, { width: 80, height: 24, focus: i % 3 });
+ }, "2 buttons, checkbox, isolated focus"));
+
results.push(bench("widgets.dashboard", 200, () => {
renderToScreen(({ ui }) => {
ui.grid({ columns: 3, rows: 2, gap: 1 }, (grid) => {
diff --git a/apps/demo/README.md b/apps/demo/README.md
index 714f015..cdbc37c 100644
--- a/apps/demo/README.md
+++ b/apps/demo/README.md
@@ -76,11 +76,19 @@ npx --yes @profullstack/hqtui-demo@latest # Node 22.6+ works too
| `Ctrl+K` | Command palette |
| `Space` | Pause updates |
| `↑` `↓` `PgUp` `PgDn` `Home` `End` | Move selection |
-| `Enter` | Confirmation dialog |
+| `Enter` | Terminate the selected dashboard process |
| `q`, `Ctrl+C` | Quit |
Mouse works too: click the tabs and buttons, scroll the process list.
+The termination dialog sends **SIGTERM** by default, allowing the process to
+clean up. **Force kill (-9 / SIGKILL)** starts unchecked each time. Use Tab,
+Shift+Tab or the arrow keys to move between Yes, No and the checkbox; Space
+toggles the checkbox and Enter activates the focused control. `y` confirms;
+`n`, Escape or a click outside the dialog cancels. The selected PID stays fixed
+while metrics refresh. Signal errors appear in the dialog, and simulation mode
+never signals a real process.
+
## Screens
### dashboard
diff --git a/apps/demo/package.json b/apps/demo/package.json
index 2bb17ba..403fc9b 100644
--- a/apps/demo/package.json
+++ b/apps/demo/package.json
@@ -1,6 +1,6 @@
{
"name": "@profullstack/hqtui-demo",
- "version": "0.6.1",
+ "version": "0.6.2",
"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.1"
+ "@profullstack/hqtui": "^0.6.2"
},
"publishConfig": {
"access": "public"
diff --git a/apps/demo/src/kill-dialog.ts b/apps/demo/src/kill-dialog.ts
new file mode 100644
index 0000000..c11abb3
--- /dev/null
+++ b/apps/demo/src/kill-dialog.ts
@@ -0,0 +1,113 @@
+import type { Container, KeyEvent } from "@profullstack/hqtui";
+import type { DemoState } from "./state.ts";
+import type { ProcessSample } from "./simulation.ts";
+import { visibleProcesses } from "./screens/dashboard.ts";
+
+export interface KillDialog {
+ /** Capture the row once: live CPU sorting must not change the target. */
+ target: Pick
Terminal dashboards that diff --git a/apps/web/package.json b/apps/web/package.json index 9f17ccd..12dbc2e 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.1", + "@profullstack/hqtui": "^0.6.2", "class-variance-authority": "0.7.1", "clsx": "2.1.1", "lucide-react": "1.37.0", diff --git a/bun.lock b/bun.lock index fc7fa6d..7205eed 100644 --- a/bun.lock +++ b/bun.lock @@ -21,7 +21,7 @@ }, "apps/demo": { "name": "@profullstack/hqtui-demo", - "version": "0.6.1", + "version": "0.6.2", "bin": { "hqtui-demo": "./src/main.ts", }, @@ -63,14 +63,14 @@ }, "packages/hqtui": { "name": "@profullstack/hqtui", - "version": "0.6.1", + "version": "0.6.2", "bin": { "hqtui": "./bin/hqtui.mjs", }, }, "ports/cobol/adapter": { "name": "@profullstack/hqtui-cobol-adapter", - "version": "0.6.1", + "version": "0.6.2", "dependencies": { "@profullstack/hqtui": "workspace:*", }, diff --git a/examples/modal.ts b/examples/modal.ts index d9c8653..9250616 100644 --- a/examples/modal.ts +++ b/examples/modal.ts @@ -2,24 +2,37 @@ import { createApp } from "@profullstack/hqtui"; let open = false; +let result = "No action taken yet."; +function answer(accepted: boolean): void { + result = accepted ? "Confirmed." : "Cancelled."; + open = false; +} const app = await createApp({ quitKeys: ["ctrl+c"] }); app.on("key", (event) => { if (event.name === "enter") open = true; - else if (event.name === "escape" || event.name === "y" || event.name === "n") open = false; else if (event.name === "q" && !open) app.quit(); }); app.render(({ ui }) => { ui.panel({ title: "Background" }, (p) => { p.text("Press Enter to open the dialog."); - p.label("Esc, y or n closes it. q quits."); + p.label("Tab/arrows select, Enter activates, Esc cancels. q quits."); + p.text(result); }); if (open) { ui.modal({ title: "Confirm Action", - message: "Are you sure you want to terminate process 48231 (bun)?", - buttons: [{ label: "Yes", variant: "success", focused: true }, { label: "No", variant: "ghost" }], + message: "Apply this change?", + buttons: [ + { label: "Yes", variant: "success", onPress: () => answer(true) }, + { label: "No", variant: "ghost", onPress: () => answer(false) }, + ], + onDismiss: () => answer(false), + onKey: (event) => { + if (event.key === "y") answer(true); + else if (event.key === "n") answer(false); + }, }); } }); diff --git a/examples/package.json b/examples/package.json index 7bd7deb..413a19f 100644 --- a/examples/package.json +++ b/examples/package.json @@ -4,6 +4,6 @@ "version": "0.1.0", "type": "module", "dependencies": { - "@profullstack/hqtui": "^0.6.1" + "@profullstack/hqtui": "^0.6.2" } } diff --git a/packages/hqtui/package.json b/packages/hqtui/package.json index eb20461..18f86bd 100644 --- a/packages/hqtui/package.json +++ b/packages/hqtui/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/hqtui", - "version": "0.6.1", + "version": "0.6.2", "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 7f08475..696f348 100644 --- a/packages/hqtui/src/app.ts +++ b/packages/hqtui/src/app.ts @@ -5,7 +5,7 @@ import { Terminal, type TerminalOptions, emergencyRestore } from "./terminal.ts" import type { Capabilities } from "./capabilities.ts"; import { type Theme, type ThemeName, resolveTheme, themes } from "./theme.ts"; import { Surface, createSurface } from "./surface.ts"; -import { Container, countClicks, dispatchHit, type RenderContext, type HitRegion, type FocusRegistration } from "./ui.ts"; +import { Container, countClicks, dispatchHit, type RenderContext, type HitRegion, type FocusRegistration, type OverlayOptions } from "./ui.ts"; import type { InputEvent, KeyEvent, MouseEvent, PasteEvent, FocusEvent } from "./input.ts"; import { matchKey } from "./input.ts"; @@ -102,7 +102,9 @@ export class App { private focusCount = 0; private focusActions: (() => void)[] = []; private hits: HitRegion[] = []; - private overlays: ((root: Surface) => void)[] = []; + private overlays: { draw: (root: Surface) => void; options?: OverlayOptions }[] = []; + private modal: OverlayOptions | undefined; + private backgroundFocusIndex = 0; private lastStats: FrameStats = { frame: 0, renderMs: 0, changedCells: 0, dirtyRows: 0, bytes: 0, fps: 0 }; constructor(options: AppOptions = {}) { @@ -338,10 +340,28 @@ export class App { if (this.options.focusNavigation !== false) { if (event.name === "tab") { this.focusNext(event.shift ? -1 : 1); + if (this.modal) return; + } else if (this.modal && ["left", "right", "up", "down"].includes(event.name)) { + this.focusNext(event.name === "left" || event.name === "up" ? -1 : 1); + return; } else if (event.name === "enter" || event.name === "space") { + const consumed = this.modal && this.focusActions[this.focusIndex]; this.activateFocused(); + // A callback may have closed the dialog. Its Enter must not then + // reach the app's key handler and open it again (or pause a demo). + if (consumed) return; } } + if (this.modal && event.name === "escape" && this.modal.onDismiss) { + this.modal.onDismiss(); + this.dirty = true; + return; + } + if (this.modal?.onKey) { + this.modal.onKey(event); + this.dirty = true; + return; + } this.emit("key", event); this.dirty = true; return; @@ -393,6 +413,11 @@ export class App { this.overlays = []; this.focusActions = []; let focusCursor = 0; + const wasModal = this.modal !== undefined; + const modalFocusIndex = this.focusIndex; + if (wasModal) this.focusIndex = this.backgroundFocusIndex; + else this.backgroundFocusIndex = this.focusIndex; + this.modal = undefined; const ctx: RenderContext = { theme: this.theme, @@ -410,7 +435,7 @@ export class App { return { index, focused: index === this.focusIndex }; }, hit: (region) => this.hits.push(region), - overlay: (draw) => this.overlays.push(draw), + overlay: (draw, options) => this.overlays.push({ draw, options }), invalidate: () => this.invalidate(), }; @@ -428,10 +453,26 @@ export class App { app: this, }); container.flush(); - for (const overlay of this.overlays) overlay(root); + for (const overlay of this.overlays) { + if (overlay.options?.modal) { + // Built-in buttons and custom controls form one focus order. Reset + // for each modal so only the topmost dialog can receive activation. + this.focusIndex = wasModal ? modalFocusIndex : 0; + ctx.focusIndex = this.focusIndex; + focusCursor = 0; + this.focusActions = []; + this.modal = overlay.options; + } + overlay.draw(root); + } this.focusCount = Math.max(focusCursor, 0); - if (this.focusCount > 0 && this.focusIndex >= this.focusCount) this.focusIndex = 0; + if (this.focusCount > 0 && this.focusIndex >= this.focusCount) { + this.focusIndex = 0; + // The controls were already painted with the old index. Repaint the + // new focus even when no further input or metric update arrives. + this.dirty = true; + } const result = this.encoder.encode(this.previous, this.current, this.forceRepaint); this.forceRepaint = false; diff --git a/packages/hqtui/src/cli.ts b/packages/hqtui/src/cli.ts index 2416929..e0a40c0 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.1"; +const VERSION = "0.6.2"; 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 54d10a1..f65b666 100644 --- a/packages/hqtui/src/testing.ts +++ b/packages/hqtui/src/testing.ts @@ -116,7 +116,10 @@ export function renderToScreen( return { index, focused: index === (options.focus ?? 0) }; }, hit: (region) => regions.push(region), - overlay: (draw) => overlays.push(draw), + overlay: (draw, overlayOptions) => overlays.push((root) => { + if (overlayOptions?.modal) focusCursor = 0; + draw(root); + }), invalidate: () => {}, }; diff --git a/packages/hqtui/src/ui.ts b/packages/hqtui/src/ui.ts index 85b1b98..cab51b7 100644 --- a/packages/hqtui/src/ui.ts +++ b/packages/hqtui/src/ui.ts @@ -3,6 +3,7 @@ import type { Style } from "./buffer.ts"; import type { Color } from "./color.ts"; import type { Theme } from "./theme.ts"; import type { Capabilities } from "./capabilities.ts"; +import type { KeyEvent } from "./input.ts"; import { type Constraint, type Justify, type Rect, type Padding, type Size, inset, stack, solve, isEmpty, } from "./layout.ts"; @@ -81,6 +82,13 @@ export interface FocusRegistration { focused: boolean; } +export interface OverlayOptions { + /** Give this overlay its own keyboard focus, excluding background controls. */ + modal?: boolean; + onDismiss?: () => void; + onKey?: (event: KeyEvent) => void; +} + /** Per-frame services the builder needs from the app. */ export interface RenderContext { theme: Theme; @@ -105,7 +113,7 @@ export interface RenderContext { reducedMotion: boolean; registerFocus(action?: () => void): FocusRegistration; hit(region: HitRegion): void; - overlay(draw: (root: Surface) => void): void; + overlay(draw: (root: Surface) => void, options?: OverlayOptions): void; invalidate(): void; } @@ -728,10 +736,15 @@ export class Container { // -------------------------------------------------------------- overlays - /** A centred dialog drawn above everything else this frame. */ + /** A centred dialog. Tab/arrows move focus; Enter/Space activate; Esc dismisses. */ modal(options: W.ModalOptions, build?: (modal: Container) => void): this { this.ctx.overlay((root) => { - const inner = W.drawModal(root, options); + const buttons = options.buttons?.map((button) => { + if (!button.onPress) return button; + const focus = this.ctx.registerFocus(button.onPress); + return { ...button, focused: button.focused ?? focus.focused }; + }); + const inner = W.drawModal(root, { ...options, buttons }); // The whole screen belongs to the dialog while it is up. The backdrop // takes every click outside it (and dismisses, if asked to), the dialog // takes every click inside it, and only then do the buttons and whatever @@ -757,7 +770,7 @@ export class Container { build(container); container.flush(); } - }); + }, { modal: true, onDismiss: options.onDismiss, onKey: options.onKey }); return this; } diff --git a/packages/hqtui/src/widgets/controls.ts b/packages/hqtui/src/widgets/controls.ts index 11b310e..e84a405 100644 --- a/packages/hqtui/src/widgets/controls.ts +++ b/packages/hqtui/src/widgets/controls.ts @@ -3,6 +3,7 @@ import { Attr, type Style } from "../buffer.ts"; import { type Color, mix } from "../color.ts"; import { fit, stringWidth, truncate, wrap } from "../unicode.ts"; import { elevate } from "../theme.ts"; +import type { KeyEvent } from "../input.ts"; export interface ButtonOptions { label: string; @@ -193,11 +194,13 @@ export interface ModalOptions { color?: Color; align?: Align; /** - * A click on the backdrop, outside the dialog. Without it the click is + * Escape or a click on the backdrop, outside the dialog. Without it the click is * swallowed: a dialog owns the screen while it is up, and whatever is drawn * underneath must not act on a click aimed at the dialog and missed. */ onDismiss?: () => void; + /** Other keys while open, e.g. y/n shortcuts. Navigation stays in the dialog. */ + onKey?: (event: KeyEvent) => void; } /** Where a modal's buttons go, relative to its interior. */ diff --git a/packages/hqtui/test/modal-input.test.ts b/packages/hqtui/test/modal-input.test.ts new file mode 100644 index 0000000..b46a4cb --- /dev/null +++ b/packages/hqtui/test/modal-input.test.ts @@ -0,0 +1,145 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { PassThrough } from "node:stream"; +import { App } from "../src/app.ts"; +import { renderToScreen } from "../src/testing.ts"; +import { themes } from "../src/theme.ts"; + +function harness() { + const input = new PassThrough(); + const output = new PassThrough(); + output.resume(); + Object.assign(output, { columns: 80, rows: 24 }); + const app = new App({ + input: input as unknown as NodeJS.ReadStream, + output: output as unknown as NodeJS.WriteStream, + installExitHandlers: false, + quitKeys: [], + }); + return { + app, + async key(bytes: string) { + input.write(bytes); + await new Promise((resolve) => setTimeout(resolve, bytes === "\x1b" ? 60 : 0)); + app.frame(); + }, + }; +} + +test("dialog arrows and Tab navigate buttons and a checkbox without activating the background", async (t) => { + const { app, key } = harness(); + t.after(() => app.stop()); + const pressed: string[] = []; + let checked = false; + const leaked: string[] = []; + app.on("key", (event) => leaked.push(event.key)); + app.render(({ ui }) => { + ui.button({ label: "Background", onPress: () => pressed.push("background") }); + ui.modal({ + height: 10, + buttons: [ + { label: "Yes", onPress: () => pressed.push("yes") }, + { label: "No", onPress: () => pressed.push("no") }, + ], + }, (body) => body.checkbox({ label: "Force", checked, onToggle: () => { checked = !checked; } })); + }); + void app.start(); + await key("\x1b[C"); // right: No + await key("\r"); + assert.deepEqual(pressed, ["no"]); + await key("\t"); // checkbox + await key(" "); + assert.equal(checked, true); + await key("\x1b[Z"); // Shift+Tab: No + await key("\x1b[D"); // left: Yes + await key("\r"); + await key("\x1b[A"); // up wraps to checkbox + await key(" "); + assert.equal(checked, false); + await key("\x1b[B"); // down wraps to Yes + await key("\r"); + assert.deepEqual(pressed, ["no", "yes", "yes"]); + assert.deepEqual(leaked, []); +}); + +test("dismissal does not re-open a dialog and restores the previous background focus", async (t) => { + const { app, key } = harness(); + t.after(() => app.stop()); + let open = false; + const pressed: string[] = []; + app.on("key", (event) => { if (event.name === "enter") open = true; }); + app.render(({ ui }) => { + ui.buttons([ + { label: "First", onPress: () => pressed.push("first") }, + { label: "Second", onPress: () => pressed.push("second") }, + ]); + if (open) ui.modal({ + buttons: [{ label: "Close", onPress: () => { open = false; } }], + onDismiss: () => { open = false; }, + }); + }); + void app.start(); + await key("\t"); // remember the second background button + open = true; + app.frame(); + await key("\r"); + assert.equal(open, false, "the closing Enter leaked to the opener"); + assert.deepEqual(pressed, []); + await key(" "); + assert.deepEqual(pressed, ["second"]); + open = true; + app.frame(); + await key("\x1b"); + assert.equal(open, false); +}); + +test("only the top modal activates and its custom shortcuts stay out of app key handlers", async (t) => { + const { app, key } = harness(); + t.after(() => app.stop()); + const calls: string[] = []; + app.on("key", () => calls.push("app")); + app.render(({ ui }) => { + ui.modal({ buttons: [{ label: "Lower", onPress: () => calls.push("lower") }] }); + ui.modal({ + buttons: [{ label: "Upper", onPress: () => calls.push("upper") }], + onKey: (event) => calls.push(event.key), + }); + }); + void app.start(); + await key("\r"); + await key("n"); + assert.deepEqual(calls, ["upper", "n"]); +}); + +test("modal focus highlights follow the dialog's order even with controls underneath", () => { + const draw = (focus: number) => renderToScreen(({ ui }) => { + ui.button({ label: "Background" }); + ui.modal({ buttons: [ + { label: "Yes", variant: "success", onPress: () => {} }, + { label: "No", variant: "ghost", onPress: () => {} }, + ] }); + }, { width: 80, height: 24, focus, theme: themes.dark }); + const yes = draw(0); + const no = draw(1); + const yesAt = yes.find("Yes")!; + const noAt = no.find("No")!; + assert.equal(yes.cell(yesAt.x, yesAt.y).bg, themes.dark.success); + assert.equal(no.cell(noAt.x, noAt.y).bg, themes.dark.muted); + assert.notEqual(no.cell(yesAt.x, yesAt.y).bg, themes.dark.success); +}); + +test("replacing a dialog with fewer controls repaints its clamped focus without more input", async (t) => { + const { app, key } = harness(); + t.after(() => app.stop()); + let result = false; + app.render(({ ui }) => ui.modal({ + buttons: (result ? ["Close"] : ["Yes", "No", "Force"]).map((label) => ({ label, onPress: () => {} })), + })); + void app.start(); + await key("\x1b[Z"); // Force, index 2 + result = true; + app.frame(); + const frame = app.stats.frame; + await new Promise((resolve) => setTimeout(resolve, 100)); + assert.ok(app.stats.frame > frame, "Close stayed unfocused until another event"); +}); diff --git a/ports/cobol/adapter/package.json b/ports/cobol/adapter/package.json index 2a9ee06..4d1c992 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.1", + "version": "0.6.2", "type": "module", "description": "Reads 80-column COBOL scene records and draws them with HQTUI.", "dependencies": {