From 6987cf9967c94fdc9156f24bbdd4d7d740f49a60 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sat, 12 Sep 2026 19:15:15 +0000 Subject: [PATCH] 0.5.0: every piece of chrome that looks clickable takes a click A status bar that says `F1 Help`, a dialog with a `Yes` button and a panel with a title were all things a mouse could point at, and none of them answered. Rows did, because the scrollable widgets register the region they draw; nothing else registered anything. An app built on hqtui was therefore keyboard-only at the exact places a user reaches for the mouse first. Every one of them now claims its cells: - `statusBar` items take `onPress`. The bar returns the span it drew for each item, and the container registers a region for the key cap and the label only, so the gap between two items belongs to neither. - `modal` buttons take `onPress`, and the dialog takes `onDismiss` for a click on the backdrop. Whether or not it is given one, the dialog now owns the screen while it is up: its body swallows clicks, and so does the backdrop, because a table drawn underneath must not select a row a click was aimed past it at. The button layout is shared between drawing and hit-testing so the cell that shows a button is the cell that presses it. - `panel` takes `onClick`, registered before its children draw, so it only answers for the cells nothing inside it claimed: the border, the title row, the blank space under a short list. - A double-click exists. The app counts presses on the same row within 400ms and delivers `clicks` on the event and to every region; scrollable widgets turn the second press into `onActivateRow`, after `onSelectRow`, which is how a file manager selects and then opens. The hit-test itself moved out of the app into `dispatchHit`, and the test screen gained `click()` and `scroll()` that run it, so a test can prove the cell showing `F2 Theme` is the cell that changes the theme, and that a click on a backdrop closes the dialog without also selecting the row behind it. The demo's key bar and both of its dialogs are wired through it. Bumps the library, the demo, the COBOL adapter and every workspace range that points at them, plus the CLI's VERSION, the demo's --version and the site's badge. Rendering is untouched, so the port fixtures do not move. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01DWtLsmAescX4Nb8QiBJd37 --- README.md | 2 +- apps/benchmark/package.json | 2 +- apps/demo/package.json | 4 +- apps/demo/src/main.ts | 41 +++-- apps/web/app/docs/page.tsx | 7 + apps/web/app/page.tsx | 2 +- apps/web/content/book/06-input.md | 31 ++++ apps/web/content/book/09-testing.md | 14 ++ apps/web/package.json | 2 +- bun.lock | 10 +- examples/package.json | 2 +- packages/hqtui/package.json | 2 +- packages/hqtui/src/app.ts | 30 ++-- packages/hqtui/src/cli.ts | 2 +- packages/hqtui/src/index.ts | 3 +- packages/hqtui/src/input.ts | 8 + packages/hqtui/src/testing.ts | 21 ++- packages/hqtui/src/ui.ts | 117 +++++++++++++- packages/hqtui/src/widgets/controls.ts | 46 +++++- packages/hqtui/src/widgets/text.ts | 27 +++- packages/hqtui/test/mouse.test.ts | 216 +++++++++++++++++++++++++ ports/cobol/adapter/package.json | 2 +- 22 files changed, 529 insertions(+), 62 deletions(-) create mode 100644 packages/hqtui/test/mouse.test.ts diff --git a/README.md b/README.md index b18aec6..91b4b8f 100644 --- a/README.md +++ b/README.md @@ -220,7 +220,7 @@ demo tells you which of those it could not read. | **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()` | -| **Input** | normalized keys with modifiers, SGR mouse (click, drag, scroll, move), bracketed paste, focus events, Tab focus traversal | +| **Input** | normalized keys with modifiers, SGR mouse (click, double-click, drag, scroll, move) delivered per widget — rows, status bar keys, dialog buttons and panels all take a click — bracketed paste, focus events, Tab focus traversal | | **Testing** | headless renderer: `renderToText`, `renderToScreen`, `renderToAnsi`, `renderToHtml` — no TTY required | ## Testing your TUI diff --git a/apps/benchmark/package.json b/apps/benchmark/package.json index 4576958..385603d 100644 --- a/apps/benchmark/package.json +++ b/apps/benchmark/package.json @@ -7,6 +7,6 @@ "start": "bun src/main.ts" }, "dependencies": { - "@profullstack/hqtui": "^0.4.0" + "@profullstack/hqtui": "^0.5.0" } } diff --git a/apps/demo/package.json b/apps/demo/package.json index 9e92cca..736a542 100644 --- a/apps/demo/package.json +++ b/apps/demo/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/hqtui-demo", - "version": "0.4.0", + "version": "0.5.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", @@ -27,7 +27,7 @@ "audit:scroll": "bun scripts/scrollaudit.ts" }, "dependencies": { - "@profullstack/hqtui": "^0.4.0" + "@profullstack/hqtui": "^0.5.0" }, "publishConfig": { "access": "public" diff --git a/apps/demo/src/main.ts b/apps/demo/src/main.ts index b494f1b..72b01f1 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.4.0"); process.exit(0); + case "--version": console.log("hqtui-demo 0.5.0"); process.exit(0); } } return options; @@ -167,7 +167,7 @@ async function main(): Promise { // whatever sits under the pointer rather than by one list per screen. }); - app.on("key", (event: KeyEvent) => { + const onKey = (event: KeyEvent): void => { state.lastKey = event.key; state.keyLog.push(`${clock()} ${event.key}${event.char ? ` "${event.char}"` : ""}`); if (state.keyLog.length > 100) state.keyLog.shift(); @@ -267,9 +267,13 @@ async function main(): Promise { const index = digit === 0 ? 9 : digit - 1; if (index < SCREENS.length) state.screen = SCREENS[index]; } + }; + app.on("key", onKey); - - }); + // A key cap in the status bar is a button: clicking `F2 Theme` does what + // pressing F2 does, through the same handler, so the two can never drift. + const press = (key: string) => + onKey({ type: "key", name: key, key, ctrl: key.startsWith("ctrl+"), alt: false, shift: false, raw: "" }); app.render(({ ui, theme, height }) => { ui.row({ size: 1 }, (header) => { @@ -305,14 +309,19 @@ async function main(): Promise { ui.spacer(1); ui.statusBar({ items: [ - { key: "F1", label: "Help" }, - { key: "F2", label: `Theme (${theme.name})` }, - { key: "F3", label: state.filtering ? `Filter: ${state.filter}_` : "Filter", active: state.filtering }, - { key: "c", label: "Collapse", active: app.collapseBorders }, - { key: "F6", label: `Sort: ${state.sort}` }, - { key: "^K", label: "Palette" }, - { key: "Tab", label: "Screen" }, - { key: "q", label: "Quit" }, + { key: "F1", label: "Help", onPress: () => press("f1") }, + { key: "F2", label: `Theme (${theme.name})`, onPress: () => press("f2") }, + { + key: "F3", + label: state.filtering ? `Filter: ${state.filter}_` : "Filter", + active: state.filtering, + onPress: () => press("f3"), + }, + { key: "c", label: "Collapse", active: app.collapseBorders, onPress: () => press("c") }, + { key: "F6", label: `Sort: ${state.sort}`, onPress: () => press("f6") }, + { key: "^K", label: "Palette", onPress: () => press("ctrl+k") }, + { key: "Tab", label: "Screen", onPress: () => press("tab") }, + { key: "q", label: "Quit", onPress: () => press("q") }, ], right: [{ label: `${num(state.renderMs, 2)}ms ${state.changedCells} cells ${state.bytes}B` }], }); @@ -339,7 +348,8 @@ async function main(): Promise { " sudo -E env \"PATH=$PATH\" bunx @profullstack/hqtui-demo") : "All metrics available on this platform.") + "\n\nPress any key to close.", - buttons: [{ label: "Close", focused: true }], + buttons: [{ label: "Close", focused: true, onPress: () => { state.showHelp = false; } }], + onDismiss: () => { state.showHelp = false; }, }); } if (state.showModal) { @@ -347,9 +357,10 @@ async function main(): Promise { 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 }, - { label: "No", variant: "ghost" }, + { label: "Yes", variant: "success", focused: true, onPress: () => { state.showModal = false; } }, + { label: "No", variant: "ghost", onPress: () => { state.showModal = false; } }, ], + onDismiss: () => { state.showModal = false; }, }); } if (state.showPalette) { diff --git a/apps/web/app/docs/page.tsx b/apps/web/app/docs/page.tsx index b6d8e62..85a2ee6 100644 --- a/apps/web/app/docs/page.tsx +++ b/apps/web/app/docs/page.tsx @@ -344,6 +344,13 @@ app.on("mouse", (event) => { if (event.action === "scroll") offset += event.scroll; }); +// Or let the widgets answer: the row under a click, the key under a click, +// the button under a click. A double-click arrives as clicks: 2. +ui.table({ rows, onSelectRow: select, onActivateRow: open }); +ui.statusBar({ items: [{ key: "F1", label: "Help", onPress: showHelp }] }); +ui.modal({ title: "Delete?", onDismiss: close, + buttons: [{ label: "Yes", onPress: confirm }] }); + // Controls that take an action join the Tab order automatically. p.button({ label: "Restart", onPress: () => restart() });`} /> diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index 08118a9..cdc75ee 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.4.0 · {COUNT} language demos · MIT + v0.5.0 · {COUNT} language demos · MIT

Terminal dashboards that diff --git a/apps/web/content/book/06-input.md b/apps/web/content/book/06-input.md index 1b660a1..828114a 100644 --- a/apps/web/content/book/06-input.md +++ b/apps/web/content/book/06-input.md @@ -52,6 +52,37 @@ Pass `onScroll` and the widget claims the region it drew; the wheel over that region scrolls it, and the wheel over the panel next to it scrolls that one instead. There is no global "which list has the mouse" state to maintain. +A click is answered by the topmost region under it, and a double-click is the +same press delivered with `clicks: 2`. Every chrome element that looks +clickable takes a handler: + +```ts +// A row: click selects, double-click opens. +ui.table({ rows, onSelectRow: select, onActivateRow: open }); + +// The key bar: each item that has an action is a button. +ui.statusBar({ items: [ + { key: "F1", label: "Help", onPress: showHelp }, + { key: "q", label: "Quit", onPress: () => app.quit() }, +]}); + +// A dialog: its buttons press, and a click on the backdrop dismisses it. +// While it is up, nothing underneath hears a click at all. +ui.modal({ + title: "Delete?", + buttons: [{ label: "Yes", onPress: confirm }, { label: "No", onPress: close }], + onDismiss: close, +}); + +// A panel: whatever its children did not claim — border, title, blank space. +ui.panel({ title: " Remote ", onClick: () => focus("remote") }, build); +``` + +The rule is that a region answers for exactly the cells it drew: a key cap and +its label, a button, a row. The gap between two items belongs to neither, so a +click between `F1 Help` and `F2 Theme` does nothing rather than doing the wrong +thing. + ## Controls, and how focus happens ```ts diff --git a/apps/web/content/book/09-testing.md b/apps/web/content/book/09-testing.md index 5589a4a..1d9f125 100644 --- a/apps/web/content/book/09-testing.md +++ b/apps/web/content/book/09-testing.md @@ -32,6 +32,9 @@ screen.line(3); // one row screen.find("CPU"); // { x, y } or null screen.cell(4, 3); // { char, fg, bg, attrs } screen.regions; // the mouse regions widgets registered +screen.click(x, y); // press there, exactly as the app would deliver it +screen.click(x, y, { clicks: 2 }); // a double-click +screen.scroll(x, y, 1); // turn the wheel over a cell ``` ## Assert on meaning, not on pixels @@ -58,6 +61,17 @@ That last one is worth calling out. A scroll handler that is never registered because the widget was drawn in a zero-height region is invisible in a text snapshot and obvious in `regions`. +`click` goes one step further and runs the same hit-test the app runs, so a +test can prove that the cell showing `F2 Theme` is the cell that changes the +theme, and that a click on a dialog's backdrop closes it without also +selecting the row it was drawn over: + +```ts +const at = screen.find("Theme")!; +expect(screen.click(at.x, at.y)).toBe(true); +expect(state.themeIndex).toBe(1); +``` + ## Colour and attributes `cell()` gives you the resolved colour, so you can assert that a value went diff --git a/apps/web/package.json b/apps/web/package.json index 967653e..ae92768 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.4.0", + "@profullstack/hqtui": "^0.5.0", "class-variance-authority": "0.7.1", "clsx": "2.1.1", "lucide-react": "1.37.0", diff --git a/bun.lock b/bun.lock index a0316f8..4b4ef5b 100644 --- a/bun.lock +++ b/bun.lock @@ -21,7 +21,7 @@ }, "apps/demo": { "name": "@profullstack/hqtui-demo", - "version": "0.4.0", + "version": "0.5.0", "bin": { "hqtui-demo": "./src/main.ts", }, @@ -34,7 +34,7 @@ "version": "0.1.0", "dependencies": { "@base-ui/react": "1.7.0", - "@profullstack/hqtui": "^0.4.0", + "@profullstack/hqtui": "^0.5.0", "class-variance-authority": "0.7.1", "clsx": "2.1.1", "lucide-react": "1.37.0", @@ -58,19 +58,19 @@ "name": "@profullstack/hqtui-examples", "version": "0.1.0", "dependencies": { - "@profullstack/hqtui": "^0.4.0", + "@profullstack/hqtui": "^0.5.0", }, }, "packages/hqtui": { "name": "@profullstack/hqtui", - "version": "0.4.0", + "version": "0.5.0", "bin": { "hqtui": "./bin/hqtui.mjs", }, }, "ports/cobol/adapter": { "name": "@profullstack/hqtui-cobol-adapter", - "version": "0.4.0", + "version": "0.5.0", "dependencies": { "@profullstack/hqtui": "workspace:*", }, diff --git a/examples/package.json b/examples/package.json index b507af5..fcadeb7 100644 --- a/examples/package.json +++ b/examples/package.json @@ -4,6 +4,6 @@ "version": "0.1.0", "type": "module", "dependencies": { - "@profullstack/hqtui": "^0.4.0" + "@profullstack/hqtui": "^0.5.0" } } diff --git a/packages/hqtui/package.json b/packages/hqtui/package.json index d501050..0ce4f83 100644 --- a/packages/hqtui/package.json +++ b/packages/hqtui/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/hqtui", - "version": "0.4.0", + "version": "0.5.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", diff --git a/packages/hqtui/src/app.ts b/packages/hqtui/src/app.ts index d919536..2a1d31c 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, type RenderContext, type HitRegion, type FocusRegistration } from "./ui.ts"; +import { Container, countClicks, dispatchHit, type RenderContext, type HitRegion, type FocusRegistration } from "./ui.ts"; import type { InputEvent, KeyEvent, MouseEvent, PasteEvent, FocusEvent } from "./input.ts"; import { matchKey } from "./input.ts"; @@ -346,8 +346,9 @@ export class App { return; } if (event.type === "mouse") { - this.dispatchMouse(event); - this.emit("mouse", event); + const counted = this.countPress(event); + this.dispatchMouse(counted); + this.emit("mouse", counted); return; } if (event.type === "paste") { @@ -358,18 +359,19 @@ export class App { this.emit("focus", event); } + private lastPress: { at: number; x: number; y: number; button: string; clicks: number } | null = null; + + /** The parser reports one press at a time; a double-click is two of them close together. */ + private countPress(event: MouseEvent): MouseEvent { + if (event.action !== "press") return event; + const press = { at: Date.now(), x: event.x, y: event.y, button: event.button }; + const clicks = countClicks(this.lastPress, press); + this.lastPress = { ...press, clicks }; + return { ...event, clicks }; + } + private dispatchMouse(event: MouseEvent): void { - // Later regions are drawn on top, so hit-test in reverse. - for (let i = this.hits.length - 1; i >= 0; i--) { - const hit = this.hits[i]; - const r = hit.rect; - if (event.x < r.x || event.y < r.y || event.x >= r.x + r.width || event.y >= r.y + r.height) continue; - if (event.action === "scroll") hit.onScroll?.(event.scroll); - else if (event.action === "press") hit.onClick?.(event.x - r.x, event.y - r.y, event.button); - else if (event.action === "move") hit.onHover?.(event.x - r.x, event.y - r.y); - this.dirty = true; - return; - } + if (dispatchHit(this.hits, event)) this.dirty = true; } /** Build one frame and push the difference to the terminal. */ diff --git a/packages/hqtui/src/cli.ts b/packages/hqtui/src/cli.ts index c773da6..9d45942 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.4.0"; +const VERSION = "0.5.0"; function help(): void { console.log(`hqtui ${VERSION} — High Quality Terminal UI for TypeScript diff --git a/packages/hqtui/src/index.ts b/packages/hqtui/src/index.ts index bb8c556..22b6e54 100644 --- a/packages/hqtui/src/index.ts +++ b/packages/hqtui/src/index.ts @@ -16,8 +16,9 @@ export { App, createApp, type AppOptions, type RenderArgs, type RenderFn, type FrameStats } from "./app.ts"; export { Container, GridContainer } from "./ui.ts"; export type { - RenderContext, ContainerOptions, PanelOptions, GridOptions, CellOptions, HitRegion, + RenderContext, ContainerOptions, PanelOptions, GridOptions, CellOptions, HitRegion, HitEvent, ScrollHandlers, } from "./ui.ts"; +export { dispatchHit, countClicks, DOUBLE_CLICK_MS } from "./ui.ts"; // Terminal + capabilities export { diff --git a/packages/hqtui/src/input.ts b/packages/hqtui/src/input.ts index f896af0..7922cb9 100644 --- a/packages/hqtui/src/input.ts +++ b/packages/hqtui/src/input.ts @@ -26,6 +26,13 @@ export interface MouseEvent { y: number; /** -1 up, 1 down; 0 when this is not a scroll. */ scroll: number; + /** + * How many presses this one makes in quick succession on the same cell: 1 + * for a click, 2 for a double-click. The parser sees one press at a time and + * always says 1; the app counts them, so a handler that wants a double-click + * reads it from the event the app delivers. + */ + clicks: number; ctrl: boolean; alt: boolean; shift: boolean; @@ -316,6 +323,7 @@ export class InputParser { x: Math.max(0, col - 1), y: Math.max(0, row - 1), scroll, + clicks: 1, ctrl, alt, shift, diff --git a/packages/hqtui/src/testing.ts b/packages/hqtui/src/testing.ts index 398ab53..25f2c2f 100644 --- a/packages/hqtui/src/testing.ts +++ b/packages/hqtui/src/testing.ts @@ -1,7 +1,7 @@ import { FrameBuffer } from "./buffer.ts"; import { Encoder } from "./diff.ts"; import { createSurface, Surface } from "./surface.ts"; -import { Container, type RenderContext, type HitRegion } from "./ui.ts"; +import { Container, dispatchHit, type RenderContext, type HitRegion } from "./ui.ts"; import { type Theme, type ThemeName, resolveTheme } from "./theme.ts"; import { detectCapabilities, type Capabilities, type CapabilityOverrides } from "./capabilities.ts"; import { CONTINUATION, cellText } from "./unicode.ts"; @@ -37,6 +37,15 @@ export interface RenderedScreen { * only observable by running a real terminal. */ regions: HitRegion[]; + /** + * Press the mouse on a screen cell, exactly as the app would deliver it: + * the topmost region containing the cell takes it. Returns whether any + * region did, so a test can prove a click lands on the thing it looks like + * it lands on. `clicks: 2` is a double-click. + */ + click(x: number, y: number, options?: { button?: "left" | "middle" | "right"; clicks?: number }): boolean; + /** Turn the wheel over a cell. `delta` is -1 up, 1 down. */ + scroll(x: number, y: number, delta: number): boolean; /** Plain text, one line per row, trailing spaces trimmed. */ text(): string; /** One row of plain text. */ @@ -117,6 +126,16 @@ export function renderToScreen( height, buffer, regions, + click: (x, y, options = {}) => + dispatchHit(regions, { + action: "press", + x, + y, + button: options.button ?? "left", + scroll: 0, + clicks: options.clicks ?? 1, + }), + scroll: (x, y, delta) => dispatchHit(regions, { action: "scroll", x, y, button: "none", scroll: delta, clicks: 0 }), text: () => buffer.toText(), line: (y: number) => buffer.rowText(y).replace(/\s+$/, ""), ansi: () => { diff --git a/packages/hqtui/src/ui.ts b/packages/hqtui/src/ui.ts index 07fc195..45549ea 100644 --- a/packages/hqtui/src/ui.ts +++ b/packages/hqtui/src/ui.ts @@ -15,11 +15,67 @@ import * as W from "./widgets/index.ts"; export interface HitRegion { rect: Rect; - onClick?: (x: number, y: number, button: string) => void; + /** + * A press inside the region, in coordinates relative to it. `clicks` is 1 + * for a click and 2 for the second press of a double-click; the app always + * supplies it, and a test that calls the handler by hand may leave it out. + */ + onClick?: (x: number, y: number, button: string, clicks?: number) => void; onScroll?: (delta: number) => void; onHover?: (x: number, y: number) => void; } +/** What a hit-test needs to know about a mouse event. */ +export interface HitEvent { + action: "press" | "release" | "move" | "drag" | "scroll"; + x: number; + y: number; + button: string; + scroll: number; + clicks: number; +} + +/** + * Hand a mouse event to the region under it. Later regions are drawn on top, + * so they are tested first, and the first one that contains the point takes + * the event whether or not it has a handler for it: a dialog with no click + * handler still stops the click reaching what it covers. Returns whether any + * region took it. + */ +export function dispatchHit(regions: readonly HitRegion[], event: HitEvent): boolean { + for (let i = regions.length - 1; i >= 0; i--) { + const hit = regions[i]; + const r = hit.rect; + if (event.x < r.x || event.y < r.y || event.x >= r.x + r.width || event.y >= r.y + r.height) continue; + if (event.action === "scroll") hit.onScroll?.(event.scroll); + else if (event.action === "press") hit.onClick?.(event.x - r.x, event.y - r.y, event.button, event.clicks); + else if (event.action === "move") hit.onHover?.(event.x - r.x, event.y - r.y); + return true; + } + return false; +} + +/** How long two presses on the same cell may be apart and still count as one double-click. */ +export const DOUBLE_CLICK_MS = 400; + +/** + * How many clicks a press makes, given the press before it: 2 when it lands + * on the same row, within a cell either way, inside the double-click window. + * Terminal cells are wide enough that a hand does not slip a whole column + * between presses, but it does slip a pixel, which is why the row is exact + * and the column is not. + */ +export function countClicks( + previous: { at: number; x: number; y: number; button: string; clicks: number } | null, + press: { at: number; x: number; y: number; button: string }, +): number { + if (!previous) return 1; + if (previous.button !== press.button || previous.y !== press.y) return 1; + if (Math.abs(previous.x - press.x) > 1) return 1; + if (press.at - previous.at > DOUBLE_CLICK_MS) return 1; + return previous.clicks + 1; +} + export interface FocusRegistration { index: number; focused: boolean; @@ -54,6 +110,12 @@ export interface ScrollHandlers { onScroll?: (delta: number) => void; /** Click on a visible row, counted from the first body row. */ onSelectRow?: (visibleRow: number) => void; + /** + * Double-click on a visible row. Fires after `onSelectRow` for the same + * press, so a directory is selected and then opened, the way a file manager + * does it. + */ + onActivateRow?: (visibleRow: number) => void; /** Click anywhere on the widget, including its header. */ onFocus?: () => void; } @@ -121,6 +183,13 @@ export interface PanelOptions extends ContainerOptions { focusable?: boolean; focused?: boolean; scroll?: number; + /** + * A click anywhere on the panel that no child claimed: the border, the + * title row, the padding, the empty space below a short list. Coordinates + * are relative to the panel's outer rect, so `y === 0` is the title row. + * The usual use is focusing the pane a click landed in. + */ + onClick?: (x: number, y: number, button: string, clicks: number) => void; } export interface GridOptions extends ContainerOptions { @@ -285,6 +354,12 @@ export class Container { panel(options: PanelOptions = {}, build?: (panel: Container) => void): this { const focus = options.focusable ? this.ctx.registerFocus() : undefined; return this.add((surface) => { + // Registered before the children draw, so a table inside the panel is + // tested first and the panel only answers for the cells nothing else did. + if (options.onClick) { + const onClick = options.onClick; + this.ctx.hit({ rect: surface.hitRect(), onClick: (x, y, button, clicks = 1) => onClick(x, y, button, clicks) }); + } const focused = options.focused ?? focus?.focused ?? false; const boxOptions: BoxOptions = { title: options.title, @@ -420,14 +495,16 @@ export class Container { /** Register the widget's rect so the wheel and clicks reach it. */ private attachScroll(surface: Surface, handlers: ScrollHandlers, headerRows = 0): void { - if (!handlers.onScroll && !handlers.onSelectRow && !handlers.onFocus) return; + if (!handlers.onScroll && !handlers.onSelectRow && !handlers.onActivateRow && !handlers.onFocus) return; this.ctx.hit({ rect: surface.hitRect(), onScroll: handlers.onScroll ? (delta) => handlers.onScroll?.(delta) : undefined, - onClick: (_x, y) => { + onClick: (_x, y, _button, clicks = 1) => { handlers.onFocus?.(); // Row 0 is the header when there is one; clicks there only focus. - if (handlers.onSelectRow && y >= headerRows) handlers.onSelectRow(y - headerRows); + if (y < headerRows) return; + handlers.onSelectRow?.(y - headerRows); + if (clicks >= 2) handlers.onActivateRow?.(y - headerRows); }, }); } @@ -601,7 +678,17 @@ export class Container { } statusBar(options: W.StatusBarOptions & ContainerOptions): this { - return this.add((s) => W.drawStatusBar(s, options), this.sizeOf(options, "auto", 1)); + return this.add((s) => { + const spans = W.drawStatusBar(s, options); + const origin = s.hitRect(); + for (const span of spans) { + if (!span.item.onPress || span.width <= 0) continue; + this.ctx.hit({ + rect: { x: origin.x + span.x, y: origin.y, width: span.width, height: 1 }, + onClick: () => span.item.onPress?.(), + }); + } + }, this.sizeOf(options, "auto", 1)); } // -------------------------------------------------------------- overlays @@ -610,6 +697,26 @@ export class Container { modal(options: W.ModalOptions, build?: (modal: Container) => void): this { this.ctx.overlay((root) => { const inner = W.drawModal(root, options); + // 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 + // the caller builds inside claim their own cells on top. + this.ctx.hit({ rect: root.hitRect(), onClick: () => options.onDismiss?.() }); + const interior = inner.hitRect(); + this.ctx.hit({ + rect: { x: interior.x - 1, y: interior.y - 1, width: interior.width + 2, height: interior.height + 2 }, + }); + if (options.buttons?.length) { + const rects = W.modalButtonLayout(inner, options.buttons); + options.buttons.forEach((button, i) => { + if (!button.onPress) return; + const rect = rects[i]; + this.ctx.hit({ + rect: { x: interior.x + rect.x, y: interior.y + rect.y, width: rect.width, height: 1 }, + onClick: () => button.onPress?.(), + }); + }); + } if (build) { const container = new Container(inner, this.ctx, "column", { padding: [1, 1] }); build(container); diff --git a/packages/hqtui/src/widgets/controls.ts b/packages/hqtui/src/widgets/controls.ts index a37d1d0..11b310e 100644 --- a/packages/hqtui/src/widgets/controls.ts +++ b/packages/hqtui/src/widgets/controls.ts @@ -189,9 +189,42 @@ export interface ModalOptions { height?: number; /** Dim the screen behind the dialog. Default true. */ backdrop?: boolean; - buttons?: { label: string; variant?: ButtonOptions["variant"]; focused?: boolean }[]; + buttons?: { label: string; variant?: ButtonOptions["variant"]; focused?: boolean; onPress?: () => void }[]; color?: Color; align?: Align; + /** + * 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; +} + +/** Where a modal's buttons go, relative to its interior. */ +export interface ModalButtonRect { + x: number; + y: number; + width: number; +} + +/** + * The row of buttons along the bottom of a dialog, centred and sized to their + * labels. Shared by the drawing and by whoever registers the click regions, so + * the cell that shows a button is the cell that presses it. + */ +export function modalButtonLayout( + inner: { width: number; height: number }, + buttons: NonNullable, +): ModalButtonRect[] { + const widths = buttons.map((b) => stringWidth(b.label) + 4); + const total = widths.reduce((a, b) => a + b + 2, -2); + let bx = Math.max(0, Math.floor((inner.width - total) / 2)); + const by = inner.height - 2; + return widths.map((width) => { + const rect = { x: bx, y: by, width }; + bx += width + 2; + return rect; + }); } /** @@ -232,18 +265,15 @@ export function drawModal(root: Surface, options: ModalOptions): Surface { } if (options.buttons?.length) { - const widths = options.buttons.map((b) => stringWidth(b.label) + 4); - const total = widths.reduce((a, b) => a + b + 2, -2); - let bx = Math.max(0, Math.floor((inner.width - total) / 2)); - const by = inner.height - 2; + const rects = modalButtonLayout(inner, options.buttons); options.buttons.forEach((button, i) => { - drawButton(inner.sub(bx, by, widths[i], 1), { + const rect = rects[i]; + drawButton(inner.sub(rect.x, rect.y, rect.width, 1), { label: button.label, variant: button.variant, focused: button.focused, - width: widths[i], + width: rect.width, }); - bx += widths[i] + 2; }); } diff --git a/packages/hqtui/src/widgets/text.ts b/packages/hqtui/src/widgets/text.ts index e709f74..f66a968 100644 --- a/packages/hqtui/src/widgets/text.ts +++ b/packages/hqtui/src/widgets/text.ts @@ -176,6 +176,15 @@ export interface StatusItem { color?: Color; /** Highlight this entry, e.g. the active tab or a live indicator. */ active?: boolean; + /** A click on this entry. The bar claims the cells it drew for it. */ + onPress?: () => void; +} + +/** Where one status item ended up, so a click can be handed back to it. */ +export interface StatusItemSpan { + item: StatusItem; + x: number; + width: number; } export interface StatusBarOptions { @@ -187,18 +196,26 @@ export interface StatusBarOptions { keyStyle?: "caps" | "plain"; } -/** The F1/F2/F10 bar along the bottom of every serious TUI. */ -export function drawStatusBar(surface: Surface, options: StatusBarOptions): void { - if (surface.empty) return; +/** + * The F1/F2/F10 bar along the bottom of every serious TUI. + * + * Returns the cells each item took, in the order they were drawn, so the + * caller can make them clickable: a key cap that says `F1 Help` and does + * nothing when clicked is a promise the bar does not keep. + */ +export function drawStatusBar(surface: Surface, options: StatusBarOptions): StatusItemSpan[] { + if (surface.empty) return []; const theme = surface.theme; const bg = options.background ?? elevate(theme, 0.04); surface.fill({ bg }); + const spans: StatusItemSpan[] = []; let x = 1; const drawItems = (items: StatusItem[], startX: number): number => { let cx = startX; for (const item of items) { if (cx >= surface.width) break; + const startedAt = cx; if (item.key) { const cap = options.keyStyle === "plain" ? item.key : item.key; const capStyle: Style = @@ -213,6 +230,9 @@ export function drawStatusBar(surface: Surface, options: StatusBarOptions): void bg, attrs: item.active ? Attr.Bold : 0, }); + // The span is the cap and the label, not the gap after them: two items + // side by side must not both answer a click on the space between. + spans.push({ item, x: startedAt, width: Math.max(0, Math.min(cx, surface.width) - startedAt) }); cx += surface.text(cx, 0, " ", { bg }); } return cx; @@ -227,4 +247,5 @@ export function drawStatusBar(surface: Surface, options: StatusBarOptions): void ); drawItems(options.right, Math.max(x, surface.width - width - 1)); } + return spans; } diff --git a/packages/hqtui/test/mouse.test.ts b/packages/hqtui/test/mouse.test.ts new file mode 100644 index 0000000..0d32832 --- /dev/null +++ b/packages/hqtui/test/mouse.test.ts @@ -0,0 +1,216 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { renderToScreen } from "../src/testing.ts"; +import { countClicks, DOUBLE_CLICK_MS } from "../src/ui.ts"; +import { InputParser } from "../src/input.ts"; +import type { MouseEvent } from "../src/input.ts"; + +/** + * Everything a click can land on, driven through the same hit-test the app + * uses. Before these, a status bar item, a modal button and a panel border + * were all things a mouse could point at and nothing would answer. + */ + +test("a status bar item with onPress claims exactly the cells it drew", () => { + const pressed: string[] = []; + const screen = renderToScreen(({ ui }) => { + ui.statusBar({ + items: [ + { key: "F1", label: "Help", onPress: () => pressed.push("help") }, + { key: "q", label: "Quit" }, + { label: "Sync", onPress: () => pressed.push("sync") }, + ], + right: [{ key: "?", label: "keys", onPress: () => pressed.push("keys") }], + }); + }, { width: 40, height: 1 }); + + // Only the two left items and the right item that asked are regions; the + // one without a handler is not, so a click on it falls through. + assert.equal(screen.regions.length, 3); + + const help = screen.find("Help")!; + assert.ok(screen.click(help.x, help.y)); + assert.ok(screen.click(help.x - 3, help.y), "the key cap is part of the item"); + assert.deepEqual(pressed, ["help", "help"]); + + const quit = screen.find("Quit")!; + assert.equal(screen.click(quit.x, quit.y), false, "an item with no handler is not clickable"); + + const keys = screen.find("keys")!; + assert.ok(screen.click(keys.x, keys.y)); + assert.deepEqual(pressed, ["help", "help", "keys"]); + + // The gap between two items belongs to neither. + const sync = screen.find("Sync")!; + assert.equal(screen.click(sync.x - 1, sync.y), false); + assert.ok(screen.click(sync.x + 3, sync.y), "the last cell of the label still counts"); + assert.deepEqual(pressed, ["help", "help", "keys", "sync"]); +}); + +test("a modal's buttons press, its backdrop dismisses, and its body swallows", () => { + const log: string[] = []; + let behind = 0; + const screen = renderToScreen(({ ui }) => { + ui.table({ + rows: [{ n: "a" }, { n: "b" }, { n: "c" }], + columns: [{ key: "n" }], + header: false, + onSelectRow: () => behind++, + }); + ui.modal({ + title: "Sure?", + message: "Delete it", + width: 30, + height: 7, + onDismiss: () => log.push("dismiss"), + buttons: [ + { label: "Yes", variant: "success", onPress: () => log.push("yes") }, + { label: "No", variant: "ghost", onPress: () => log.push("no") }, + ], + }); + }, { width: 60, height: 20 }); + + const yes = screen.find("Yes")!; + const no = screen.find("No")!; + assert.ok(screen.click(yes.x, yes.y)); + assert.ok(screen.click(no.x + 1, no.y)); + assert.deepEqual(log, ["yes", "no"]); + + // Inside the dialog but not on a button: taken, and nothing happens. + const title = screen.find("Delete it")!; + assert.ok(screen.click(title.x, title.y)); + assert.deepEqual(log, ["yes", "no"]); + + // Outside the dialog: the backdrop dismisses, and the table underneath, + // which would otherwise have selected row 0, never hears about it. + assert.ok(screen.click(0, 0)); + assert.deepEqual(log, ["yes", "no", "dismiss"]); + assert.equal(behind, 0); +}); + +test("a modal with no onDismiss still keeps clicks off what it covers", () => { + let behind = 0; + const screen = renderToScreen(({ ui }) => { + ui.table({ rows: [{ n: "a" }], columns: [{ key: "n" }], header: false, onSelectRow: () => behind++ }); + ui.modal({ title: "Wait", message: "Working" }); + }, { width: 40, height: 12 }); + assert.ok(screen.click(0, 0)); + assert.ok(screen.click(0, 1)); + assert.equal(behind, 0); +}); + +test("widgets built inside a modal still take their own clicks", () => { + let row = -1; + const screen = renderToScreen(({ ui }) => { + ui.modal({ title: "Pick", width: 30, height: 8 }, (modal) => { + modal.table({ + rows: [{ n: "alpha" }, { n: "beta" }], + columns: [{ key: "n" }], + header: false, + onSelectRow: (r) => { row = r; }, + }); + }); + }, { width: 50, height: 16 }); + const beta = screen.find("beta")!; + assert.ok(screen.click(beta.x, beta.y)); + assert.equal(row, 1); +}); + +test("a panel answers for the cells its children did not claim", () => { + const log: string[] = []; + const screen = renderToScreen(({ ui }) => { + ui.panel({ title: " Files ", onClick: (_x, y) => log.push(`panel:${y}`) }, (panel) => { + panel.table({ + rows: [{ n: "one" }, { n: "two" }], + columns: [{ key: "n" }], + header: false, + size: 2, + onSelectRow: (r) => log.push(`row:${r}`), + }); + }); + }, { width: 30, height: 10 }); + + // The title row is the panel's; a row of the table is the table's; the + // empty space under a short table is the panel's again. + assert.ok(screen.click(3, 0)); + const two = screen.find("two")!; + assert.ok(screen.click(two.x, two.y)); + // "two" is drawn on screen row 2 (title row, then a row each), so four + // rows below it is row 6 of the panel. + assert.ok(screen.click(two.x, two.y + 4)); + assert.deepEqual(log, ["panel:0", "row:1", "panel:6"]); +}); + +test("a panel without onClick claims nothing", () => { + const screen = renderToScreen(({ ui }) => { + ui.panel({ title: " Quiet " }, (panel) => panel.text("hi")); + }, { width: 20, height: 5 }); + assert.equal(screen.regions.length, 0); + assert.equal(screen.click(1, 1), false); +}); + +test("a double-click on a row selects it and then activates it", () => { + const log: string[] = []; + const screen = renderToScreen(({ ui }) => { + ui.table({ + rows: [{ n: "src" }, { n: "README" }], + columns: [{ key: "n" }], + header: false, + onSelectRow: (r) => log.push(`select:${r}`), + onActivateRow: (r) => log.push(`open:${r}`), + }); + }, { width: 20, height: 4 }); + + screen.click(0, 0); + screen.click(0, 0, { clicks: 2 }); + assert.deepEqual(log, ["select:0", "select:0", "open:0"]); +}); + +test("onActivateRow alone is enough to make a table clickable", () => { + let opened = -1; + const screen = renderToScreen(({ ui }) => { + ui.table({ rows: [{ n: "a" }, { n: "b" }], columns: [{ key: "n" }], onActivateRow: (r) => { opened = r; } }); + }, { width: 20, height: 4 }); + assert.equal(screen.regions.length, 1); + // Row 0 is the header. + screen.click(0, 2, { clicks: 2 }); + assert.equal(opened, 1); + // A header click activates nothing. + screen.click(0, 0, { clicks: 2 }); + assert.equal(opened, 1); +}); + +test("the wheel reaches the region under it through the test screen too", () => { + let delta = 0; + const screen = renderToScreen(({ ui }) => { + ui.log({ entries: [{ message: "x" }], onScroll: (d) => { delta += d; } }); + }, { width: 20, height: 3 }); + assert.ok(screen.scroll(0, 0, 1)); + assert.ok(screen.scroll(0, 0, -1)); + assert.equal(delta, 0); + assert.ok(screen.scroll(0, 0, 1)); + assert.equal(delta, 1); +}); + +test("presses are counted into clicks by time, row and button", () => { + const first = { at: 1000, x: 5, y: 3, button: "left" }; + assert.equal(countClicks(null, first), 1); + const one = { ...first, clicks: 1 }; + // Same cell, inside the window: a double-click. + assert.equal(countClicks(one, { at: 1000 + DOUBLE_CLICK_MS, x: 5, y: 3, button: "left" }), 2); + // A column either way is the hand slipping, not a different target. + assert.equal(countClicks(one, { at: 1100, x: 6, y: 3, button: "left" }), 2); + // Two columns is a different target. + assert.equal(countClicks(one, { at: 1100, x: 7, y: 3, button: "left" }), 1); + // Another row, another button, or too slow: back to one. + assert.equal(countClicks(one, { at: 1100, x: 5, y: 4, button: "left" }), 1); + assert.equal(countClicks(one, { at: 1100, x: 5, y: 3, button: "right" }), 1); + assert.equal(countClicks(one, { at: 1000 + DOUBLE_CLICK_MS + 1, x: 5, y: 3, button: "left" }), 1); + // A third press keeps counting, so a triple-click is distinguishable. + assert.equal(countClicks({ ...one, clicks: 2 }, { at: 1200, x: 5, y: 3, button: "left" }), 3); +}); + +test("the parser reports every press as a single click", () => { + const [event] = new InputParser().parse("\x1b[<0;10;5M") as MouseEvent[]; + assert.equal(event.clicks, 1); +}); diff --git a/ports/cobol/adapter/package.json b/ports/cobol/adapter/package.json index 3736aff..d342552 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.4.0", + "version": "0.5.0", "type": "module", "description": "Reads 80-column COBOL scene records and draws them with HQTUI.", "dependencies": {