From 10ee6c68ced6426fc3825a83cbd42f1076912029 Mon Sep 17 00:00:00 2001
From: Anthony Ettinger
Date: Sat, 12 Sep 2026 20:50:32 +0000
Subject: [PATCH] 0.5.1: a hovered row, and a tree that says which rows it drew
A folding tree that is driven by the mouse needs two things the widgets did
not offer. A click on a scrolled tree has to be turned back into a node, and
only the tree knows where its window starts, so `tree` now takes the same
`onRow(node, index, y)` the table has had. And a pointer needs a visible
target before anything is pressed, so table, list and tree take `hovered`,
drawn a shade lighter than the background and always losing to the selection,
with `onHoverRow` on every scrollable widget to feed it. The widget only hears
the pointer while it is inside, so the book shows the two-line `mouse`
listener that clears the hover when a move goes unclaimed.
The tree also gained the `scrollbar` the list and table already had, and the
test screen gained `hover(x, y)`.
Nothing renders differently unless `hovered` is set, so the port fixtures do
not move.
Co-Authored-By: Claude Fable 5.1
Claude-Session: https://claude.ai/code/session_01DWtLsmAescX4Nb8QiBJd37
---
apps/benchmark/package.json | 2 +-
apps/demo/package.json | 4 +--
apps/demo/src/main.ts | 2 +-
apps/web/app/page.tsx | 2 +-
apps/web/content/book/06-input.md | 15 ++++++++
apps/web/package.json | 2 +-
bun.lock | 10 +++---
examples/package.json | 2 +-
packages/hqtui/package.json | 2 +-
packages/hqtui/src/cli.ts | 2 +-
packages/hqtui/src/testing.ts | 3 ++
packages/hqtui/src/ui.ts | 13 ++++++-
packages/hqtui/src/widgets/table.ts | 54 +++++++++++++++++++++++++----
packages/hqtui/test/mouse.test.ts | 53 ++++++++++++++++++++++++++++
ports/cobol/adapter/package.json | 2 +-
15 files changed, 145 insertions(+), 23 deletions(-)
diff --git a/apps/benchmark/package.json b/apps/benchmark/package.json
index 385603d..5271e40 100644
--- a/apps/benchmark/package.json
+++ b/apps/benchmark/package.json
@@ -7,6 +7,6 @@
"start": "bun src/main.ts"
},
"dependencies": {
- "@profullstack/hqtui": "^0.5.0"
+ "@profullstack/hqtui": "^0.5.1"
}
}
diff --git a/apps/demo/package.json b/apps/demo/package.json
index 736a542..2feebee 100644
--- a/apps/demo/package.json
+++ b/apps/demo/package.json
@@ -1,6 +1,6 @@
{
"name": "@profullstack/hqtui-demo",
- "version": "0.5.0",
+ "version": "0.5.1",
"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.5.0"
+ "@profullstack/hqtui": "^0.5.1"
},
"publishConfig": {
"access": "public"
diff --git a/apps/demo/src/main.ts b/apps/demo/src/main.ts
index 72b01f1..ce6f4e8 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.5.0"); process.exit(0);
+ case "--version": console.log("hqtui-demo 0.5.1"); process.exit(0);
}
}
return options;
diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx
index cdc75ee..9424ec1 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.5.0 · {COUNT} language demos · MIT
+ v0.5.1 · {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 828114a..8159ffe 100644
--- a/apps/web/content/book/06-input.md
+++ b/apps/web/content/book/06-input.md
@@ -83,6 +83,21 @@ 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.
+Hover is the same shape. `onHoverRow` reports the row under the pointer, and
+`hovered` draws it a shade lighter, so the eye finds the target before the
+click. The widget only hears the pointer while it is inside, so clear the
+hover in your own `mouse` listener when a move arrives that no row claimed:
+
+```ts
+ui.tree({ nodes, selected, hovered: state.hover,
+ onHoverRow: (row) => { state.hover = row; state.hoverSeen = true; } });
+
+app.on("mouse", (event) => {
+ if (event.action === "move" && !state.hoverSeen) state.hover = undefined;
+ state.hoverSeen = false;
+});
+```
+
## Controls, and how focus happens
```ts
diff --git a/apps/web/package.json b/apps/web/package.json
index ae92768..cb05416 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.5.0",
+ "@profullstack/hqtui": "^0.5.1",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"lucide-react": "1.37.0",
diff --git a/bun.lock b/bun.lock
index 4b4ef5b..cb74794 100644
--- a/bun.lock
+++ b/bun.lock
@@ -21,7 +21,7 @@
},
"apps/demo": {
"name": "@profullstack/hqtui-demo",
- "version": "0.5.0",
+ "version": "0.5.1",
"bin": {
"hqtui-demo": "./src/main.ts",
},
@@ -34,7 +34,7 @@
"version": "0.1.0",
"dependencies": {
"@base-ui/react": "1.7.0",
- "@profullstack/hqtui": "^0.5.0",
+ "@profullstack/hqtui": "^0.5.1",
"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.5.0",
+ "@profullstack/hqtui": "^0.5.1",
},
},
"packages/hqtui": {
"name": "@profullstack/hqtui",
- "version": "0.5.0",
+ "version": "0.5.1",
"bin": {
"hqtui": "./bin/hqtui.mjs",
},
},
"ports/cobol/adapter": {
"name": "@profullstack/hqtui-cobol-adapter",
- "version": "0.5.0",
+ "version": "0.5.1",
"dependencies": {
"@profullstack/hqtui": "workspace:*",
},
diff --git a/examples/package.json b/examples/package.json
index fcadeb7..262c1bd 100644
--- a/examples/package.json
+++ b/examples/package.json
@@ -4,6 +4,6 @@
"version": "0.1.0",
"type": "module",
"dependencies": {
- "@profullstack/hqtui": "^0.5.0"
+ "@profullstack/hqtui": "^0.5.1"
}
}
diff --git a/packages/hqtui/package.json b/packages/hqtui/package.json
index 0ce4f83..382e80a 100644
--- a/packages/hqtui/package.json
+++ b/packages/hqtui/package.json
@@ -1,6 +1,6 @@
{
"name": "@profullstack/hqtui",
- "version": "0.5.0",
+ "version": "0.5.1",
"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/cli.ts b/packages/hqtui/src/cli.ts
index 9d45942..47eae0d 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.5.0";
+const VERSION = "0.5.1";
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 25f2c2f..06d6e48 100644
--- a/packages/hqtui/src/testing.ts
+++ b/packages/hqtui/src/testing.ts
@@ -46,6 +46,8 @@ export interface RenderedScreen {
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;
+ /** Move the pointer over a cell without pressing. */
+ hover(x: number, y: number): boolean;
/** Plain text, one line per row, trailing spaces trimmed. */
text(): string;
/** One row of plain text. */
@@ -136,6 +138,7 @@ export function renderToScreen(
clicks: options.clicks ?? 1,
}),
scroll: (x, y, delta) => dispatchHit(regions, { action: "scroll", x, y, button: "none", scroll: delta, clicks: 0 }),
+ hover: (x, y) => dispatchHit(regions, { action: "move", x, y, button: "none", scroll: 0, 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 45549ea..67895e8 100644
--- a/packages/hqtui/src/ui.ts
+++ b/packages/hqtui/src/ui.ts
@@ -118,6 +118,14 @@ export interface ScrollHandlers {
onActivateRow?: (visibleRow: number) => void;
/** Click anywhere on the widget, including its header. */
onFocus?: () => void;
+ /**
+ * The mouse moved over a visible row, counted from the first body row, or
+ * over the header (`null`). Pair it with the widget's `hovered` option to
+ * light the row up. The widget only hears about the pointer while it is
+ * inside; clear the hover from the app's own `mouse` listener when a move
+ * arrives that no row claimed.
+ */
+ onHoverRow?: (visibleRow: number | null) => void;
}
interface Child {
@@ -495,10 +503,13 @@ 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.onActivateRow && !handlers.onFocus) return;
+ if (
+ !handlers.onScroll && !handlers.onSelectRow && !handlers.onActivateRow && !handlers.onFocus && !handlers.onHoverRow
+ ) return;
this.ctx.hit({
rect: surface.hitRect(),
onScroll: handlers.onScroll ? (delta) => handlers.onScroll?.(delta) : undefined,
+ onHover: handlers.onHoverRow ? (_x, y) => handlers.onHoverRow?.(y >= headerRows ? y - headerRows : null) : undefined,
onClick: (_x, y, _button, clicks = 1) => {
handlers.onFocus?.();
// Row 0 is the header when there is one; clicks there only focus.
diff --git a/packages/hqtui/src/widgets/table.ts b/packages/hqtui/src/widgets/table.ts
index df25ba9..9dc22e8 100644
--- a/packages/hqtui/src/widgets/table.ts
+++ b/packages/hqtui/src/widgets/table.ts
@@ -9,7 +9,7 @@ import { drawScrollbar } from "./scrollbar.ts";
// its own, and the widgets here still draw with it.
export { drawScrollbar } from "./scrollbar.ts";
import { solve } from "../layout.ts";
-import { elevate } from "../theme.ts";
+import { elevate, type Theme } from "../theme.ts";
export interface Column> {
/** Property to read, or use `render` for computed cells. */
@@ -43,6 +43,11 @@ export interface TableOptions> {
rowColor?: (row: Row, index: number) => Color | undefined;
/** Show a scrollbar in the last column when rows overflow. */
scrollbar?: boolean;
+ /**
+ * The row under the mouse, drawn a shade lighter so the pointer has a
+ * visible target before anything is clicked. Selection wins where they meet.
+ */
+ hovered?: number;
onRow?: (row: Row, index: number, y: number) => void;
}
@@ -124,7 +129,14 @@ export function drawTable(surface: Surface, options: TableOptions): vo
if (row === undefined) break;
const y = i + headerRows;
const selected = options.selected === rowIndex;
- const rowBg = selected ? theme.selection : options.zebra && rowIndex % 2 === 1 ? zebraBg : options.background;
+ const hovered = !selected && options.hovered === rowIndex;
+ const rowBg = selected
+ ? theme.selection
+ : hovered
+ ? hoverBg(theme)
+ : options.zebra && rowIndex % 2 === 1
+ ? zebraBg
+ : options.background;
if (rowBg !== undefined) surface.fillRect(0, y, bodyWidth, 1, { bg: rowBg });
@@ -153,9 +165,16 @@ export function drawTable(surface: Surface, options: TableOptions): vo
}
}
+/** The background of the row under the mouse: lifted, but well short of selected. */
+export function hoverBg(theme: Theme): Color {
+ return elevate(theme, 0.1);
+}
+
export interface ListOptions {
items: (string | { label: string; color?: Color; badge?: string })[];
selected?: number;
+ /** The row under the mouse. See TableOptions.hovered. */
+ hovered?: number;
offset?: number;
/** Scroll so `selected` stays visible. */
followSelection?: boolean;
@@ -177,12 +196,14 @@ export function drawList(surface: Surface, options: ListOptions): void {
if (raw === undefined) break;
const item = typeof raw === "string" ? { label: raw } : raw;
const selected = options.selected === index;
+ const hovered = !selected && options.hovered === index;
const bullet = options.bullet ? `${options.bullet} ` : "";
const label = `${bullet}${item.label}`;
- if (selected) surface.fillRect(0, i, width, 1, { bg: theme.selection });
+ const bg = selected ? theme.selection : hovered ? hoverBg(theme) : options.background;
+ if (selected || hovered) surface.fillRect(0, i, width, 1, { bg });
surface.text(0, i, fit(truncate(label, width), width), {
fg: selected ? theme.selectionText : item.color ?? theme.foreground,
- bg: selected ? theme.selection : options.background,
+ bg,
attrs: selected ? Attr.Bold : 0,
});
}
@@ -202,7 +223,10 @@ export interface TreeNode {
export interface TreeOptions {
nodes: TreeNode[];
+ /** Index into the flattened, expanded tree, the order the rows are drawn in. */
selected?: number;
+ /** The row under the mouse. See TableOptions.hovered. */
+ hovered?: number;
offset?: number;
/** Scroll so `selected` stays visible. */
followSelection?: boolean;
@@ -210,6 +234,14 @@ export interface TreeOptions {
/** Draw the ├─ └─ connectors. */
guides?: boolean;
guideColor?: Color;
+ /** Show a scrollbar in the last column when rows overflow. */
+ scrollbar?: boolean;
+ /**
+ * Every row drawn, with its index into the flattened tree and its screen
+ * row. A click reports the row it landed on counted from the top of the
+ * visible window, and only the tree knows where that window starts.
+ */
+ onRow?: (node: TreeNode, index: number, y: number) => void;
}
interface FlatNode {
@@ -235,6 +267,8 @@ export function drawTree(surface: Surface, options: TreeOptions): void {
const flat: FlatNode[] = [];
flatten(options.nodes, 0, [], flat);
+ const scrollbar = options.scrollbar ?? false;
+ const width = surface.width - (scrollbar ? 1 : 0);
const offset = resolveOffset(
options.offset, options.selected, surface.height, flat.length, options.followSelection,
);
@@ -246,8 +280,9 @@ export function drawTree(surface: Surface, options: TreeOptions): void {
const entry = flat[index];
if (!entry) break;
const selected = options.selected === index;
- const bg = selected ? theme.selection : options.background;
- if (selected) surface.fillRect(0, i, surface.width, 1, { bg: theme.selection });
+ const hovered = !selected && options.hovered === index;
+ const bg = selected ? theme.selection : hovered ? hoverBg(theme) : options.background;
+ if (selected || hovered) surface.fillRect(0, i, width, 1, { bg });
let prefix = "";
if (guides) {
@@ -258,7 +293,7 @@ export function drawTree(surface: Surface, options: TreeOptions): void {
}
const valuesWidth = (entry.node.values ?? []).reduce((a, v) => a + v.width + 1, 0);
- const labelWidth = Math.max(0, surface.width - valuesWidth);
+ const labelWidth = Math.max(0, width - valuesWidth);
surface.text(0, i, truncate(prefix, labelWidth), { fg: guideColor, bg });
const px = Math.min(stringWidth(prefix), labelWidth);
surface.text(px, i, truncate(entry.node.label, Math.max(0, labelWidth - px)), {
@@ -275,6 +310,11 @@ export function drawTree(surface: Surface, options: TreeOptions): void {
});
vx += value.width + 1;
}
+ options.onRow?.(entry.node, index, i);
+ }
+
+ if (scrollbar && flat.length > surface.height && surface.height > 0) {
+ drawScrollbar(surface, surface.width - 1, 0, surface.height, flat.length, offset);
}
}
diff --git a/packages/hqtui/test/mouse.test.ts b/packages/hqtui/test/mouse.test.ts
index 0d32832..7c13245 100644
--- a/packages/hqtui/test/mouse.test.ts
+++ b/packages/hqtui/test/mouse.test.ts
@@ -1,6 +1,8 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { renderToScreen } from "../src/testing.ts";
+import { hoverBg } from "../src/widgets/table.ts";
+import { resolveTheme } from "../src/theme.ts";
import { countClicks, DOUBLE_CLICK_MS } from "../src/ui.ts";
import { InputParser } from "../src/input.ts";
import type { MouseEvent } from "../src/input.ts";
@@ -214,3 +216,54 @@ 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);
});
+
+test("a hovered row is lit, and the pointer reaches onHoverRow", () => {
+ const seen: (number | null)[] = [];
+ const theme = resolveTheme(undefined);
+ const screen = renderToScreen(({ ui }) => {
+ ui.table({
+ rows: [{ n: "a" }, { n: "b" }, { n: "c" }],
+ columns: [{ key: "n" }],
+ selected: 0,
+ hovered: 1,
+ onHoverRow: (row) => seen.push(row),
+ });
+ }, { width: 20, height: 5 });
+ // Row 0 is the header; body row 1 is "b", drawn on screen row 2.
+ assert.equal(screen.cell(0, 2).bg, hoverBg(theme));
+ assert.equal(screen.cell(0, 1).bg, theme.selection, "selection wins over hover");
+ assert.equal(screen.cell(0, 3).bg, theme.background);
+ assert.ok(screen.hover(0, 3));
+ assert.ok(screen.hover(0, 0));
+ assert.deepEqual(seen, [2, null]);
+});
+
+test("a tree reports every row it drew, and lights the hovered one", () => {
+ const drawn: [string, number, number][] = [];
+ const theme = resolveTheme(undefined);
+ const screen = renderToScreen(({ ui }) => {
+ ui.tree({
+ nodes: [
+ { label: "src", expanded: true, children: [{ label: "a.ts" }, { label: "b.ts" }] },
+ { label: "README" },
+ ],
+ selected: 3,
+ hovered: 1,
+ onRow: (node, index, y) => drawn.push([node.label, index, y]),
+ });
+ }, { width: 20, height: 4 });
+ assert.deepEqual(drawn, [["src", 0, 0], ["a.ts", 1, 1], ["b.ts", 2, 2], ["README", 3, 3]]);
+ assert.equal(screen.cell(0, 1).bg, hoverBg(theme));
+ assert.equal(screen.cell(0, 3).bg, theme.selection);
+});
+
+test("a tree can scroll with a bar, and onRow tells where the window starts", () => {
+ const drawn: number[] = [];
+ const nodes = Array.from({ length: 12 }, (_, i) => ({ label: `row-${i}` }));
+ const screen = renderToScreen(({ ui }) => {
+ ui.tree({ nodes, selected: 9, followSelection: true, scrollbar: true, onRow: (_n, i) => drawn.push(i) });
+ }, { width: 20, height: 4 });
+ assert.deepEqual(drawn, [6, 7, 8, 9]);
+ assert.ok(screen.contains("row-9"));
+ assert.ok(!screen.contains("row-0"));
+});
diff --git a/ports/cobol/adapter/package.json b/ports/cobol/adapter/package.json
index d342552..871bb6d 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.5.0",
+ "version": "0.5.1",
"type": "module",
"description": "Reads 80-column COBOL scene records and draws them with HQTUI.",
"dependencies": {