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