From 6987cf9967c94fdc9156f24bbdd4d7d740f49a60 Mon Sep 17 00:00:00 2001
From: Anthony Ettinger
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