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; + force: boolean; + result?: string; +} + +export type SendSignal = (pid: number, signal: "SIGTERM" | "SIGKILL") => unknown; + +/** Only a selected process can open a real process action. */ +export function openKillDialog(state: DemoState): void { + if (state.screen !== "dashboard" || state.focused.dashboard !== "dashboard.processes") return; + const process = visibleProcesses(state)[state.panes["dashboard.processes"]?.selected ?? 0]; + if (!process) return; + const { pid, name, command, user } = process; + state.killDialog = { target: { pid, name, command, user }, force: false }; + state.showModal = true; +} + +export function closeKillDialog(state: DemoState): void { + state.showModal = false; + state.killDialog = null; +} + +/** A successful send requests termination; the process may still be cleaning up. */ +export function confirmKillDialog(state: DemoState, send: SendSignal = process.kill): void { + const dialog = state.killDialog; + if (!state.showModal || !dialog || dialog.result) return; + const { pid, command, user } = dialog.target; + const signal = dialog.force ? "SIGKILL" : "SIGTERM"; + if (state.source.startsWith("simulated")) { + dialog.result = `Simulation: would send ${signal} to process ${pid}. No real process was signalled.`; + return; + } + // A zero/negative PID is a process group, never a selected process. + if (!Number.isSafeInteger(pid) || pid <= 0) { + dialog.result = "Cannot signal an invalid process ID."; + return; + } + const current = state.sample.processes.find((p) => p.pid === pid); + if (!current || current.command !== command || current.user !== user) { + dialog.result = `Process ${pid} has exited or changed since the dialog opened. Select it again.`; + return; + } + try { + send(pid, signal); + dialog.result = `Sent ${signal} to process ${pid}.` + (dialog.force ? "" : " It may take time to finish cleaning up."); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + dialog.result = code === "ESRCH" ? `Process ${pid} has already exited.` + : code === "EPERM" || code === "EACCES" ? `Permission denied: cannot send ${signal} to process ${pid}.` + : `Could not send ${signal} to process ${pid}: ${error instanceof Error ? error.message : String(error)}`; + } +} + +export function renderKillDialog(ui: Container, state: DemoState, send?: SendSignal): void { + if (!state.showModal) return; + const close = () => closeKillDialog(state); + const dialog = state.killDialog; + // The Components screen uses the same modal to demonstrate its buttons. + if (!dialog) { + ui.modal({ + title: "Confirm Action", + message: "This is an example confirmation dialog.", + buttons: [{ label: "Yes", variant: "success", onPress: close }, { label: "No", variant: "ghost", onPress: close }], + onDismiss: close, + onKey: (event) => { if (event.key === "y" || event.key === "n") close(); }, + }); + return; + } + if (dialog.result) { + ui.modal({ + title: "Process Action", + message: dialog.result, + width: 64, + buttons: [{ label: "Close", onPress: close }], + onDismiss: close, + onKey: (event) => { if (event.key === "n") close(); }, + }); + return; + } + const confirm = () => confirmKillDialog(state, send); + const key = (event: KeyEvent) => { + if (event.key === "n") close(); + else if (event.key === "y") confirm(); + }; + ui.modal({ + title: "Terminate Process", + width: 64, + height: 13, + buttons: [ + { label: "Yes", variant: dialog.force ? "danger" : "success", onPress: confirm }, + { label: "No", variant: "ghost", onPress: close }, + ], + onDismiss: close, + onKey: key, + }, (body) => { + body.text(`Terminate process ${dialog.target.pid} (${dialog.target.name})?`, { size: 2 }); + body.label(dialog.force ? "SIGKILL (9): stop immediately, without cleanup." : "SIGTERM (15): request a graceful shutdown.", { size: 2 }); + body.checkbox({ + label: "Force kill (-9 / SIGKILL)", + checked: dialog.force, + onToggle: () => { if (!dialog.result) dialog.force = !dialog.force; }, + }); + body.spacer(1); + body.label("Tab/arrows move · Space toggles · Enter selects"); + }); +} diff --git a/apps/demo/src/main.ts b/apps/demo/src/main.ts index d5b421f..cb88015 100755 --- a/apps/demo/src/main.ts +++ b/apps/demo/src/main.ts @@ -10,10 +10,11 @@ import { createApp, themeList, themes, type KeyEvent } from "@profullstack/hqtui"; import { createCollector } from "./system/index.ts"; import { intervalMs } from "./options.ts"; -import { createState, focusedPane, moveSelection, SCREENS, SCREEN_KEYS, type ScreenName } from "./state.ts"; +import { createState, moveSelection, SCREENS, SCREEN_KEYS, type ScreenName } from "./state.ts"; +import { openKillDialog, renderKillDialog } from "./kill-dialog.ts"; import { componentsScreen, dashboardScreen, graphicsScreen, inputScreen, networkScreen, servicesScreen, - sessionsScreen, stressScreen, themesScreen, trafficScreen, visibleProcesses, worldScreen, + sessionsScreen, stressScreen, themesScreen, trafficScreen, worldScreen, } from "./screens/index.ts"; import { clock, num } from "./format.ts"; @@ -51,7 +52,7 @@ function parseArgs(argv: string[]): Options { case "-h": case "--help": printHelp(); process.exit(0); case "-v": - case "--version": console.log("hqtui-demo 0.6.1"); process.exit(0); + case "--version": console.log("hqtui-demo 0.6.2"); process.exit(0); } } return options; @@ -77,6 +78,7 @@ Options: Keys: 1-0/w / Tab screens F2 theme F3 filter F6 sort Ctrl+K palette ↑/↓ select Space pause F1 help q quit + Enter terminate selected process (SIGTERM; optional Force -9 in dialog) `); } @@ -185,12 +187,7 @@ async function main(): Promise { else if (event.char) state.paletteQuery += event.char; return; } - if (state.showModal) { - if (event.name === "escape" || event.name === "enter" || event.name === "n" || event.name === "y") { - state.showModal = false; - } - return; - } + if (state.showModal) return; if (state.showHelp) { state.showHelp = false; return; @@ -246,7 +243,7 @@ async function main(): Promise { case "pagedown": moveSelection(state, 10); break; case "home": moveSelection(state, -Number.MAX_SAFE_INTEGER); break; case "end": moveSelection(state, Number.MAX_SAFE_INTEGER); break; - case "enter": state.showModal = true; return; + case "enter": openKillDialog(state); return; case "left": if (state.screen === "themes") { state.themeIndex = (state.themeIndex - 1 + themeList.length) % themeList.length; @@ -319,6 +316,7 @@ async function main(): Promise { }, { key: "c", label: "Collapse", active: app.collapseBorders, onPress: () => press("c") }, { key: "F6", label: `Sort: ${state.sort}`, onPress: () => press("f6") }, + ...(state.screen === "dashboard" ? [{ key: "Enter", label: "Kill", onPress: () => openKillDialog(state) }] : []), { key: "^K", label: "Palette", onPress: () => press("ctrl+k") }, { key: "Tab", label: "Screen", onPress: () => press("tab") }, { key: "q", label: "Quit", onPress: () => press("q") }, @@ -330,13 +328,15 @@ async function main(): Promise { ui.modal({ title: "HQTUI Demo — Help", width: 62, - height: 18, + height: 20, message: "1-0, w or Tab switch screens; w is the clickable world map.\n" + "F2 cycles themes, F3 filters processes, F6 changes sort.\n" + "c collapses adjacent panel borders into shared lines.\n" + "Ctrl+K opens the command palette, Space pauses updates.\n" + "Arrows, PageUp/PageDown, Home/End move the selection.\n" + + "Enter opens process termination: SIGTERM by default.\n" + + "Tab/arrows select Yes, No or Force (-9); Space toggles.\n" + "Mouse: click tabs and buttons, scroll the process list.\n\n" + (state.unavailable.length ? `Unavailable here: ${state.unavailable.join(", ")}.\n` + @@ -352,17 +352,7 @@ async function main(): Promise { onDismiss: () => { state.showHelp = false; }, }); } - if (state.showModal) { - ui.modal({ - title: "Confirm Action", - message: `Are you sure you want to terminate process ${visibleProcesses(state)[focusedPane(state)?.selected ?? 0]?.pid ?? "—"} (${visibleProcesses(state)[focusedPane(state)?.selected ?? 0]?.name ?? "—"})?`, - buttons: [ - { label: "Yes", variant: "success", focused: true, onPress: () => { state.showModal = false; } }, - { label: "No", variant: "ghost", onPress: () => { state.showModal = false; } }, - ], - onDismiss: () => { state.showModal = false; }, - }); - } + renderKillDialog(ui, state); if (state.showPalette) { const matches = PALETTE_COMMANDS.filter((c) => c.label.toLowerCase().includes(state.paletteQuery.toLowerCase())); ui.commandPalette({ diff --git a/apps/demo/src/state.ts b/apps/demo/src/state.ts index 9217d57..4cab93b 100644 --- a/apps/demo/src/state.ts +++ b/apps/demo/src/state.ts @@ -1,5 +1,6 @@ import type { Theme } from "@profullstack/hqtui"; import type { SystemSample } from "./system/index.ts"; +import type { KillDialog } from "./kill-dialog.ts"; export type ScreenName = | "dashboard" | "traffic" | "sessions" | "network" | "services" @@ -48,6 +49,7 @@ export interface DemoState { showHelp: boolean; showPalette: boolean; showModal: boolean; + killDialog: KillDialog | null; paletteQuery: string; paletteIndex: number; themeIndex: number; @@ -163,6 +165,7 @@ export function createState( showHelp: false, showPalette: false, showModal: false, + killDialog: null, paletteQuery: "", paletteIndex: 0, themeIndex: 0, diff --git a/apps/demo/test/kill-dialog.test.ts b/apps/demo/test/kill-dialog.test.ts new file mode 100644 index 0000000..da8aab3 --- /dev/null +++ b/apps/demo/test/kill-dialog.test.ts @@ -0,0 +1,213 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import { PassThrough } from "node:stream"; +import { App } from "@profullstack/hqtui"; +import { renderToScreen } from "@profullstack/hqtui/testing"; +import { createSystemSimulation, type ProcessSample } from "../src/simulation.ts"; +import { createState, pane, type DemoState } from "../src/state.ts"; +import { closeKillDialog, confirmKillDialog, openKillDialog, renderKillDialog } from "../src/kill-dialog.ts"; + +function state(source = "linux /proc"): DemoState { + const sample = createSystemSimulation({ seed: 1 }).current(); + const row: ProcessSample = { + pid: 12345, name: "worker", command: "worker --test", user: "tester", + cpu: 90, mem: 1, rss: 1024, threads: 1, state: "S", + }; + sample.processes = [row, { ...row, pid: 12346, name: "other", cpu: 10 }]; + const demo = createState(sample, source, []); + pane(demo, "dashboard.processes", sample.processes.length); + return demo; +} + +test("termination defaults to SIGTERM and retains the selected process across refresh sorting", () => { + const demo = state(); + openKillDialog(demo); + assert.equal(demo.killDialog?.force, false); + const screen = renderToScreen(({ ui }) => renderKillDialog(ui, demo)); + assert.match(screen.text(), /\[ \] Force kill \(-9 \/ SIGKILL\)/); + assert.match(screen.text(), /SIGTERM \(15\)/); + demo.sample.processes[0].cpu = 0; + demo.sample.processes[1].cpu = 99; + const sent: unknown[] = []; + confirmKillDialog(demo, (...args) => sent.push(args)); + confirmKillDialog(demo, (...args) => sent.push(args)); + assert.deepEqual(sent, [[12345, "SIGTERM"]], "redraws/repeated activation must not send twice"); + assert.match(demo.killDialog!.result!, /Sent SIGTERM/); +}); + +test("the force checkbox selects SIGKILL and resets when reopened", () => { + const demo = state(); + openKillDialog(demo); + const sent: unknown[] = []; + const render = () => renderToScreen(({ ui }) => renderKillDialog(ui, demo, (...args) => sent.push(args))); + let screen = render(); + const force = screen.find("Force kill")!; + screen.click(force.x, force.y); + assert.equal(demo.killDialog?.force, true); + screen = render(); + assert.match(screen.text(), /\[✓\]/); + const yes = screen.find("Yes")!; + screen.click(yes.x, yes.y); + assert.deepEqual(sent, [[12345, "SIGKILL"]]); + closeKillDialog(demo); + openKillDialog(demo); + assert.equal(demo.killDialog?.force, false); +}); + +test("mouse No and backdrop cancel without sending a signal", () => { + const demo = state(); + const send = () => assert.fail("cancel sent a signal"); + for (const cancel of ["No", "backdrop"]) { + openKillDialog(demo); + const screen = renderToScreen(({ ui }) => renderKillDialog(ui, demo, send)); + const at = cancel === "No" ? screen.find("No")! : { x: 0, y: 0 }; + screen.click(at.x, at.y); + assert.equal(demo.showModal, false); + confirmKillDialog(demo, send); + } +}); + +test("simulation and fallback simulation never signal host PIDs", () => { + for (const source of ["simulated", "simulated (freebsd not supported)"]) { + for (const force of [false, true]) { + const demo = state(source); + openKillDialog(demo); + demo.killDialog!.force = force; + confirmKillDialog(demo, () => assert.fail("simulation sent a signal")); + assert.match(demo.killDialog!.result!, /No real process was signalled/); + } + } +}); + +test("other screens, other panes and empty process lists do not open termination", () => { + for (const setup of [ + (demo: DemoState) => { demo.screen = "services"; }, + (demo: DemoState) => { demo.focused.dashboard = "dashboard.logs"; }, + (demo: DemoState) => { demo.sample.processes = []; }, + (demo: DemoState) => { demo.filter = "no matching process"; }, + ]) { + const demo = state(); + setup(demo); + openKillDialog(demo); + assert.equal(demo.showModal, false); + } +}); + +test("invalid, vanished and changed process targets are rejected", () => { + for (const pid of [0, -1, 1.5, NaN]) { + const demo = state(); + demo.sort = "name"; + demo.sample.processes = [{ ...demo.sample.processes[0], pid }]; + openKillDialog(demo); + confirmKillDialog(demo, () => assert.fail("invalid PID was signalled")); + assert.match(demo.killDialog!.result!, /invalid process ID/); + } + for (const change of [ + (demo: DemoState) => { demo.sample.processes = []; }, + (demo: DemoState) => { demo.sample.processes[0].command = "different worker"; }, + ]) { + const demo = state(); + openKillDialog(demo); + change(demo); + confirmKillDialog(demo, () => assert.fail("stale target was signalled")); + assert.match(demo.killDialog!.result!, /exited or changed/); + } +}); + +test("signal failures stay visible in the dialog", () => { + for (const [code, message] of [["EPERM", /Permission denied/], ["ESRCH", /already exited/], ["EINVAL", /Could not send SIGTERM/]] as const) { + const demo = state(); + openKillDialog(demo); + confirmKillDialog(demo, () => { throw Object.assign(new Error("signal failed"), { code }); }); + assert.equal(demo.showModal, true); + const screen = renderToScreen(({ ui }) => renderKillDialog(ui, demo)); + assert.match(screen.text(), message); + assert.ok(screen.find("Close")); + } +}); + +test("keyboard No, Force, Yes and Escape work through the demo's real modal", async (t) => { + const demo = state(); + 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: [], + }); + t.after(() => app.stop()); + const sent: unknown[] = []; + app.on("key", (event) => { + if (event.key === "enter") openKillDialog(demo); + if (event.key === "space") demo.paused = !demo.paused; + if (event.key === "tab") demo.screen = "services"; + }); + app.render(({ ui }) => { + ui.button({ label: "Background", onPress: () => assert.fail("background activated") }); + renderKillDialog(ui, demo, (...args) => sent.push(args)); + }); + openKillDialog(demo); + void app.start(); + const key = async (bytes: string) => { + input.write(bytes); + await new Promise((resolve) => setTimeout(resolve, bytes === "\x1b" ? 60 : 0)); + app.frame(); + }; + await key("\x1b[C"); + await key("\r"); + assert.equal(demo.showModal, false); + assert.deepEqual(sent, []); + openKillDialog(demo); + app.frame(); + await key("\x1b[Z"); // Yes -> Force + await key(" "); + assert.equal(demo.killDialog?.force, true); + assert.equal(demo.paused, false); + await key("\t"); // Force -> Yes + assert.equal(demo.screen, "dashboard"); + await key("\r"); + assert.deepEqual(sent, [[12345, "SIGKILL"]]); + await key("\r"); // close result + assert.equal(demo.showModal, false); + openKillDialog(demo); + app.frame(); + assert.equal(demo.killDialog?.force, false); + await key("n"); + assert.equal(demo.showModal, false); + openKillDialog(demo); + app.frame(); + await key("\x1b"); + assert.equal(demo.showModal, false); + assert.deepEqual(sent, [[12345, "SIGKILL"]]); +}); + +test("the process dialog survives a 1x1 terminal", () => { + const demo = state(); + openKillDialog(demo); + assert.doesNotThrow(() => renderToScreen(({ ui }) => renderKillDialog(ui, demo), { width: 1, height: 1 })); +}); + +for (const force of [false, true]) { + test(`a disposable child receives ${force ? "SIGKILL" : "SIGTERM and cleans up"}`, { skip: process.platform === "win32", timeout: 5000 }, async (t) => { + const child = spawn(process.execPath, ["-e", "process.on('SIGTERM', () => { console.log('cleaned'); process.exit(0); }); console.log('ready'); setInterval(() => {}, 1000);"], { stdio: ["ignore", "pipe", "pipe"] }); + t.after(() => { if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); }); + let output = ""; + child.stdout.on("data", (data) => { output += data; }); + await once(child.stdout, "data"); + const demo = state(); + demo.sample.processes[0].pid = child.pid!; + openKillDialog(demo); + demo.killDialog!.force = force; + const exited = once(child, "exit"); + confirmKillDialog(demo); + const [code, signal] = await exited; + assert.equal(code, force ? null : 0); + assert.equal(signal, force ? "SIGKILL" : null); + assert.equal(output.includes("cleaned"), !force); + }); +} diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index 15e0cdf..7333f73 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.1 · {COUNT} language demos · MIT + v0.6.2 · {COUNT} language demos · MIT

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