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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
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.4.0"
"@profullstack/hqtui": "^0.5.0"
}
}
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.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",
Expand All @@ -27,7 +27,7 @@
"audit:scroll": "bun scripts/scrollaudit.ts"
},
"dependencies": {
"@profullstack/hqtui": "^0.4.0"
"@profullstack/hqtui": "^0.5.0"
},
"publishConfig": {
"access": "public"
Expand Down
41 changes: 26 additions & 15 deletions 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.4.0"); process.exit(0);
case "--version": console.log("hqtui-demo 0.5.0"); process.exit(0);
}
}
return options;
Expand Down Expand Up @@ -167,7 +167,7 @@ async function main(): Promise<void> {
// 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();
Expand Down Expand Up @@ -267,9 +267,13 @@ async function main(): Promise<void> {
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) => {
Expand Down Expand Up @@ -305,14 +309,19 @@ async function main(): Promise<void> {
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` }],
});
Expand All @@ -339,17 +348,19 @@ async function main(): Promise<void> {
" 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) {
ui.modal({
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) {
Expand Down
7 changes: 7 additions & 0 deletions apps/web/app/docs/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() });`}
/>
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.4.0 · {COUNT} language demos · MIT
v0.5.0 · {COUNT} language demos · MIT
</Badge>
<p className="text-balance text-3xl font-bold tracking-tight sm:text-5xl">
Terminal dashboards that
Expand Down
31 changes: 31 additions & 0 deletions apps/web/content/book/06-input.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions apps/web/content/book/09-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
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.4.0",
"@profullstack/hqtui": "^0.5.0",
"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.4.0"
"@profullstack/hqtui": "^0.5.0"
}
}
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.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",
Expand Down
30 changes: 16 additions & 14 deletions packages/hqtui/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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") {
Expand All @@ -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. */
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.4.0";
const VERSION = "0.5.0";

function help(): void {
console.log(`hqtui ${VERSION} — High Quality Terminal UI for TypeScript
Expand Down
3 changes: 2 additions & 1 deletion packages/hqtui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
8 changes: 8 additions & 0 deletions packages/hqtui/src/input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
Loading