Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/benchmark/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,6 @@
"start": "bun src/main.ts"
},
"dependencies": {
"@profullstack/hqtui": "^0.5.0"
"@profullstack/hqtui": "^0.5.1"
}
}
4 changes: 2 additions & 2 deletions apps/demo/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -27,7 +27,7 @@
"audit:scroll": "bun scripts/scrollaudit.ts"
},
"dependencies": {
"@profullstack/hqtui": "^0.5.0"
"@profullstack/hqtui": "^0.5.1"
},
"publishConfig": {
"access": "public"
Expand Down
2 changes: 1 addition & 1 deletion apps/demo/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion apps/web/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ export default async function Home() {
High Quality Terminal UI for TypeScript, Rust, Go, Python, Zig and C++
</p>
<Badge variant="secondary" className="mb-5 font-mono text-xs">
v0.5.0 · {COUNT} language demos · MIT
v0.5.1 · {COUNT} language demos · MIT
</Badge>
<p className="text-balance text-3xl font-bold tracking-tight sm:text-5xl">
Terminal dashboards that
Expand Down
15 changes: 15 additions & 0 deletions apps/web/content/book/06-input.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 5 additions & 5 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion examples/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@
"version": "0.1.0",
"type": "module",
"dependencies": {
"@profullstack/hqtui": "^0.5.0"
"@profullstack/hqtui": "^0.5.1"
}
}
2 changes: 1 addition & 1 deletion packages/hqtui/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion packages/hqtui/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions packages/hqtui/src/testing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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: () => {
Expand Down
13 changes: 12 additions & 1 deletion packages/hqtui/src/ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down
54 changes: 47 additions & 7 deletions packages/hqtui/src/widgets/table.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Row = Record<string, unknown>> {
/** Property to read, or use `render` for computed cells. */
Expand Down Expand Up @@ -43,6 +43,11 @@ export interface TableOptions<Row = Record<string, unknown>> {
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;
}

Expand Down Expand Up @@ -124,7 +129,14 @@ export function drawTable<Row>(surface: Surface, options: TableOptions<Row>): 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 });

Expand Down Expand Up @@ -153,9 +165,16 @@ export function drawTable<Row>(surface: Surface, options: TableOptions<Row>): 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;
Expand All @@ -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,
});
}
Expand All @@ -202,14 +223,25 @@ 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;
background?: Color;
/** 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 {
Expand All @@ -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,
);
Expand All @@ -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) {
Expand All @@ -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)), {
Expand All @@ -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);
}
}

Expand Down
53 changes: 53 additions & 0 deletions packages/hqtui/test/mouse.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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"));
});
2 changes: 1 addition & 1 deletion ports/cobol/adapter/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down