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 @@ -372,7 +372,7 @@ ports. Modes, environment variables and the font installer are covered in the
| | |
|---|---|
| **Layout** | rows, columns, grid with spans, `"40%"`, `"2fr"`, `auto`, min/max, padding, gaps, clipping, responsive breakpoints |
| **Widgets** | panel, table, tree, list, log viewer, key/values, meter, gauge, donut, progress, sparkline, line/area/multi-series graph, histogram, heat bar, tabs, status bar, button, checkbox, toggle, radio, select, text input, modal, command palette, tooltip, badge, spinner, divider |
| **Widgets** | panel, table, tree, list, log viewer, key/values, meter, gauge, donut, progress, sparkline, line/area/multi-series graph, histogram, heat bar, tabs, status bar, button, checkbox, toggle, radio, select, text input, modal, command palette, context menu, tooltip, badge, spinner, divider |
| **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 |
| **Icons and emoji** | 370 OpenIcon icons (`icon()`: Nerd Font, Unicode or ASCII) and 3,963 OpenEmoji emoji (`emoji()`, `emojify()`, `emojiSearch()`), on by default |
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.11.1"
"@profullstack/hqtui": "^0.12.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.11.1",
"version": "0.12.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.11.1"
"@profullstack/hqtui": "^0.12.0"
},
"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 @@ -52,7 +52,7 @@ function parseArgs(argv: string[]): Options {
case "-h":
case "--help": printHelp(); process.exit(0);
case "-v":
case "--version": console.log("hqtui-demo 0.11.1"); process.exit(0);
case "--version": console.log("hqtui-demo 0.12.0"); process.exit(0);
}
}
return options;
Expand Down
33 changes: 32 additions & 1 deletion apps/demo/src/screens/components.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Container, Theme } from "@profullstack/hqtui";
import { focusPane, pane, panelGap, scrollPane, type DemoState } from "../state.ts";
import { DOC_TABS, focusPane, pane, panelGap, scrollPane, type DemoState } from "../state.ts";
import { bytes, percent } from "../format.ts";

const FILES = [
Expand Down Expand Up @@ -81,6 +81,37 @@ export function componentsScreen(ui: Container, state: DemoState, theme: Theme):
p.spacer(1);
p.meter({ label: "Slider", value: state.slider, style: "smooth", heat: false, color: theme.primary });
p.progress({ label: "Progress", value: 37, max: 120, showCount: true });
p.spacer(1);
// Right-click a tab for its menu, middle-click to close it. The strip
// itself is a context area, so an empty one can still bring them back.
p.contextArea({
size: 1,
menu: [{ label: "Reopen all tabs", onSelect: () => { state.docTabs = [...DOC_TABS]; state.docTab = 0; } }],
}, (area) => {
if (state.docTabs.length === 0) {
area.label("No tabs open. Right-click here to reopen them.");
return;
}
const close = (keep: (name: string, i: number) => boolean) => {
const active = state.docTabs[state.docTab];
state.docTabs = state.docTabs.filter(keep);
const still = state.docTabs.indexOf(active);
state.docTab = still >= 0 ? still : Math.max(0, Math.min(state.docTab, state.docTabs.length - 1));
};
area.tabs({
tabs: state.docTabs,
active: state.docTab,
variant: "underline",
onSelect: (i) => { state.docTab = i; },
onClose: (i) => close((_, j) => j !== i),
onCloseOthers: (i) => close((_, j) => j === i),
onCloseAll: () => close(() => false),
menu: (i) => [{
label: "Reload from disk",
onSelect: () => { state.inputValue = `reloaded ${state.docTabs[i] ?? ""}`; },
}],
});
});
});

left.panel({ title: "Table Widget", size: "1fr" }, (p) => {
Expand Down
8 changes: 8 additions & 0 deletions apps/demo/src/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ export const SCREENS: ScreenName[] = [
"components", "graphics", "themes", "input", "stress", "world",
];

/** The tabs the components screen starts with, and gets back from "Reopen all". */
export const DOC_TABS = ["README.md", "main.ts", "notes.txt"];

/**
* The key that jumps straight to each screen. Digits run out at ten, so the
* eleventh takes a letter rather than a second "1" that would shadow the first.
Expand Down Expand Up @@ -62,6 +65,9 @@ export interface DemoState {
checkbox: boolean;
selectOpen: boolean;
selectIndex: number;
/** Editor-style tabs on the components screen; right-click one for its menu. */
docTabs: string[];
docTab: number;
slider: number;
inputValue: string;
lastKey: string;
Expand Down Expand Up @@ -173,6 +179,8 @@ export function createState(
checkbox: true,
selectOpen: false,
selectIndex: 0,
docTabs: [...DOC_TABS],
docTab: 0,
slider: 0.7,
inputValue: "",
lastKey: "—",
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 @@ -170,7 +170,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.11.1 · {COUNT} language demos · MIT
v0.12.0 · {COUNT} language demos · MIT
</Badge>
<p className="text-balance text-3xl font-bold tracking-tight sm:text-5xl">
Terminal dashboards that
Expand Down
41 changes: 41 additions & 0 deletions apps/web/content/book/07-overlays.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,3 +108,44 @@ for hover text with the mouse, or to explain a value that has just gone red.
Tooltips are the one overlay that should usually be transient. Register it only
while the condition holds, and let the next frame not register it, rather than
tracking a dismissal.

## Context menus

A right-click opens a small menu where the pointer is. Unlike every other
overlay here, you do not keep its state: the app holds the one open menu across
frames, moves its highlight with the arrows and the pointer, and closes it when
an item is chosen, on Escape, or on a click outside.

Tabs have one built in. Give them close callbacks and a right-click offers them:

```ts
ui.tabs({
tabs: files.map((f) => f.name),
active,
onSelect: (i) => { active = i; },
onClose: (i) => closeFile(i), // "Close tab", and middle-click
onCloseOthers: (i) => keepOnly(i), // "Close all but this tab"
onCloseAll: () => closeAll(), // "Close all tabs"
menu: (i) => [{ label: "Reload from disk", onSelect: () => reload(i) }],
});
```

Anything else gets one by sitting inside a `contextArea`. Left clicks pass
straight through to what is inside it, and anything inside with a menu of its
own wins:

```ts
ui.contextArea({ menu: [
{ label: "New file", hint: "ctrl+n", onSelect: newFile },
MENU_SEPARATOR,
{ label: "Delete", danger: true, disabled: !selected, onSelect: remove },
] }, (area) => {
area.list({ items, selected, onSelectRow });
});
```

A menu near the right edge slides left and one near the bottom opens upward,
so it is always whole. To open one from your own code, call
`app.openContextMenu({ x, y, items })`. If you would rather own the state, as
with the emoji picker, `ui.contextMenu` draws one from `selected`, `onChange`,
`onSelect` and `onClose`, and `contextMenuKey` turns a key into what it does.
2 changes: 1 addition & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"dependencies": {
"@base-ui/react": "1.9.0",
"@profullstack/footer": "latest",
"@profullstack/hqtui": "^0.11.1",
"@profullstack/hqtui": "^0.12.0",
"@profullstack/libsql-pg": "^0.1.4",
"@profullstack/x402-gateway": "^0.13.1",
"class-variance-authority": "0.7.1",
Expand Down
12 changes: 6 additions & 6 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.11.1"
"@profullstack/hqtui": "^0.12.0"
}
}
29 changes: 28 additions & 1 deletion packages/hqtui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ activity, sessions, services and the full widget catalogue.
| | |
|---|---|
| **Layout** | rows, columns, grid with spans, `"40%"`, `"2fr"`, `auto`, min/max, padding, gaps, clipping, responsive breakpoints |
| **Widgets** | panel, table, data grid, tree, list, log viewer, key/values, meter, gauge, donut, progress, sparkline, line/area/multi-series graph, histogram, heat bar, tabs, status bar, button, checkbox, toggle, radio, select, text input, modal, command palette, tooltip, badge, spinner, divider |
| **Widgets** | panel, table, data grid, tree, list, log viewer, key/values, meter, gauge, donut, progress, sparkline, line/area/multi-series graph, histogram, heat bar, tabs, status bar, button, checkbox, toggle, radio, select, text input, modal, command palette, context menu, tooltip, badge, spinner, divider |
| **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()` |
Expand Down Expand Up @@ -137,6 +137,33 @@ the library free of dependencies. `onLayout` reports what fit (`capacity`, visib
columns) so page keys move by a real screenful. `dataGridWidths`, `dataGridLayout` and
`dataGridCellAt` are exported for callers that draw their own.

## Context menus

Right-click opens a menu at the pointer: clamped on screen, Up/Down/Enter/Escape,
hover to highlight, click outside to close. The app keeps it open for you.

```ts
ui.tabs({
tabs, active,
onSelect: (i) => { active = i; },
onClose: (i) => close(i), // "Close tab", and middle-click closes too
onCloseOthers: (i) => keepOnly(i), // "Close all but this tab"
onCloseAll: () => closeAll(), // "Close all tabs"
menu: (i) => [{ label: "Reload from disk", onSelect: () => reload(i) }],
});

// Any content: right-click inside opens the menu, left clicks pass through.
ui.contextArea({ menu: [
{ label: "New file", hint: "ctrl+n", onSelect: newFile },
MENU_SEPARATOR,
{ label: "Delete", danger: true, disabled: !selected, onSelect: remove },
] }, (area) => area.list({ items }));
```

`app.openContextMenu({ x, y, items })` opens one from code. `ui.contextMenu` draws
one whose state you own, and `drawContextMenu` / `contextMenuKey` are exported for
callers that draw their own.

## Icons

The [OpenIcon](https://logicsrc.com/openicon) pack is built in and on by
Expand Down
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.11.1",
"version": "0.12.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
57 changes: 54 additions & 3 deletions packages/hqtui/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@ 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, countClicks, dispatchHit, type RenderContext, type HitRegion, type FocusRegistration, type OverlayOptions } from "./ui.ts";
import {
Container, countClicks, dispatchHit,
type RenderContext, type HitRegion, type FocusRegistration, type OverlayOptions, type OpenContextMenu,
} from "./ui.ts";
import type { InputEvent, KeyEvent, MouseEvent, PasteEvent, FocusEvent } from "./input.ts";
import { matchKey } from "./input.ts";
import { clipboardSequence } from "./markdown.ts";
Expand Down Expand Up @@ -129,6 +132,8 @@ export class App {
private overlays: { draw: (root: Surface) => void; options?: OverlayOptions }[] = [];
private modal: OverlayOptions | undefined;
private backgroundFocusIndex = 0;
/** The context menu held open across frames, if any. One at a time, as on a desktop. */
private menu: OpenContextMenu | null = null;
private lastStats: FrameStats = { frame: 0, renderMs: 0, changedCells: 0, dirtyRows: 0, bytes: 0, fps: 0, repainted: false };

constructor(options: AppOptions = {}) {
Expand Down Expand Up @@ -244,6 +249,29 @@ export class App {
this.dirty = true;
}

/**
* Open a context menu at a screen point, replacing any that is open. It
* stays up across frames until an item is chosen (its `onSelect` runs after
* the menu closes), Escape is pressed, or the pointer clicks outside it.
* `ui.tabs` and `ui.contextArea` call this on a right-click.
*/
openContextMenu(menu: OpenContextMenu): void {
this.menu = { ...menu, selected: menu.selected ?? -1 };
this.invalidate();
}

/** Close the open context menu, if there is one. */
closeContextMenu(): void {
if (!this.menu) return;
this.menu = null;
this.invalidate();
}

/** The open context menu, or null. */
get contextMenu(): Readonly<OpenContextMenu> | null {
return this.menu;
}

/** Activate the focused control, as Enter does. */
activateFocused(): void {
this.focusActions[this.focusIndex]?.();
Expand Down Expand Up @@ -391,7 +419,9 @@ export class App {
if (event.name === "tab") {
this.focusNext(event.shift ? -1 : 1);
if (this.modal || this.focusConsumesKey.some(Boolean)) return;
} else if (this.modal && ["left", "right", "up", "down"].includes(event.name)) {
} else if (this.modal && this.focusCount > 0 && ["left", "right", "up", "down"].includes(event.name)) {
// Only a dialog with controls to move between takes the arrows; one
// without (a menu, the emoji picker) gets them through its onKey.
this.focusNext(event.name === "left" || event.name === "up" ? -1 : 1);
return;
} else if (event.name === "enter" || event.name === "space") {
Expand Down Expand Up @@ -492,10 +522,14 @@ export class App {
this.focusConsumesKey[index] = consumeKey;
return { index, focused: index === this.focusIndex && (!consumeKey || this.focusEngaged || !!this.modal) };
},
hit: (region) => this.hits.push(region),
// Everything a modal overlay registers is marked, so a right-click on a
// dialog never finds a context menu on the screen behind it.
hit: (region) => this.hits.push(layered ? { ...region, overlay: true } : region),
overlay: (draw, options) => this.overlays.push({ draw, options }),
invalidate: () => this.invalidate(),
openContextMenu: (menu) => this.openContextMenu(menu),
};
let layered = false;

const root = createSurface(this.current, this.theme);
const container = new Container(root, ctx, "column");
Expand All @@ -511,8 +545,25 @@ export class App {
app: this,
});
container.flush();
if (this.menu) {
// Last, so the menu sits over any dialog that opened it.
const menu = this.menu;
container.contextMenu({
x: menu.x,
y: menu.y,
items: menu.items,
selected: menu.selected,
onChange: (selected) => { if (this.menu === menu) menu.selected = selected; },
onClose: () => { if (this.menu === menu) this.closeContextMenu(); },
onSelect: (_index, item) => {
if (this.menu === menu) this.closeContextMenu();
item.onSelect?.();
},
});
}
for (const overlay of this.overlays) {
if (overlay.options?.modal) {
layered = true;
// Built-in buttons and custom controls form one focus order. Reset
// for each modal so only the topmost dialog can receive activation.
this.focusIndex = wasModal ? modalFocusIndex : 0;
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 @@ -14,7 +14,7 @@ import { detectCapabilities } from "./capabilities.ts";
import { themeList, themes } from "./theme.ts";
import { BrailleCanvas } from "./graphics/braille.ts";

const VERSION = "0.11.1";
const VERSION = "0.12.0";

function help(): void {
console.log(`hqtui ${VERSION} — High Quality Terminal UI for TypeScript
Expand Down
Loading
Loading