diff --git a/README.md b/README.md
index 785d4e4..5647053 100644
--- a/README.md
+++ b/README.md
@@ -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 |
diff --git a/apps/benchmark/package.json b/apps/benchmark/package.json
index e6148d4..8d31c06 100644
--- a/apps/benchmark/package.json
+++ b/apps/benchmark/package.json
@@ -7,6 +7,6 @@
"start": "bun src/main.ts"
},
"dependencies": {
- "@profullstack/hqtui": "^0.11.1"
+ "@profullstack/hqtui": "^0.12.0"
}
}
diff --git a/apps/demo/package.json b/apps/demo/package.json
index 5efb3b8..109ed7e 100644
--- a/apps/demo/package.json
+++ b/apps/demo/package.json
@@ -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",
@@ -27,7 +27,7 @@
"audit:scroll": "bun scripts/scrollaudit.ts"
},
"dependencies": {
- "@profullstack/hqtui": "^0.11.1"
+ "@profullstack/hqtui": "^0.12.0"
},
"publishConfig": {
"access": "public"
diff --git a/apps/demo/src/main.ts b/apps/demo/src/main.ts
index ec7b892..7809b7e 100755
--- a/apps/demo/src/main.ts
+++ b/apps/demo/src/main.ts
@@ -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;
diff --git a/apps/demo/src/screens/components.ts b/apps/demo/src/screens/components.ts
index 4d26720..4f5cf83 100644
--- a/apps/demo/src/screens/components.ts
+++ b/apps/demo/src/screens/components.ts
@@ -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 = [
@@ -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) => {
diff --git a/apps/demo/src/state.ts b/apps/demo/src/state.ts
index 4cab93b..b12bcd1 100644
--- a/apps/demo/src/state.ts
+++ b/apps/demo/src/state.ts
@@ -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.
@@ -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;
@@ -173,6 +179,8 @@ export function createState(
checkbox: true,
selectOpen: false,
selectIndex: 0,
+ docTabs: [...DOC_TABS],
+ docTab: 0,
slider: 0.7,
inputValue: "",
lastKey: "—",
diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx
index 8ad86c1..a9ed1ae 100644
--- a/apps/web/app/page.tsx
+++ b/apps/web/app/page.tsx
@@ -170,7 +170,7 @@ export default async function Home() {
High Quality Terminal UI for TypeScript, Rust, Go, Python, Zig and C++
- v0.11.1 · {COUNT} language demos · MIT
+ v0.12.0 · {COUNT} language demos · MIT
Terminal dashboards that
diff --git a/apps/web/content/book/07-overlays.md b/apps/web/content/book/07-overlays.md
index 1c291b4..113ceca 100644
--- a/apps/web/content/book/07-overlays.md
+++ b/apps/web/content/book/07-overlays.md
@@ -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.
diff --git a/apps/web/package.json b/apps/web/package.json
index 3c95d75..6b5c482 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -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",
diff --git a/bun.lock b/bun.lock
index e9e3fc9..afe70a0 100644
--- a/bun.lock
+++ b/bun.lock
@@ -16,17 +16,17 @@
"name": "@profullstack/hqtui-benchmark",
"version": "0.1.0",
"dependencies": {
- "@profullstack/hqtui": "^0.11.1",
+ "@profullstack/hqtui": "^0.12.0",
},
},
"apps/demo": {
"name": "@profullstack/hqtui-demo",
- "version": "0.11.1",
+ "version": "0.12.0",
"bin": {
"hqtui-demo": "./bin/hqtui-demo.mjs",
},
"dependencies": {
- "@profullstack/hqtui": "^0.11.1",
+ "@profullstack/hqtui": "^0.12.0",
},
},
"apps/web": {
@@ -35,7 +35,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",
@@ -61,12 +61,12 @@
"name": "@profullstack/hqtui-examples",
"version": "0.1.0",
"dependencies": {
- "@profullstack/hqtui": "^0.11.1",
+ "@profullstack/hqtui": "^0.12.0",
},
},
"packages/hqtui": {
"name": "@profullstack/hqtui",
- "version": "0.11.1",
+ "version": "0.12.0",
"bin": {
"hqtui": "./bin/hqtui.mjs",
},
diff --git a/examples/package.json b/examples/package.json
index 1310cb2..38fada9 100644
--- a/examples/package.json
+++ b/examples/package.json
@@ -4,6 +4,6 @@
"version": "0.1.0",
"type": "module",
"dependencies": {
- "@profullstack/hqtui": "^0.11.1"
+ "@profullstack/hqtui": "^0.12.0"
}
}
diff --git a/packages/hqtui/README.md b/packages/hqtui/README.md
index 93da4ad..8b817a6 100644
--- a/packages/hqtui/README.md
+++ b/packages/hqtui/README.md
@@ -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()` |
@@ -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
diff --git a/packages/hqtui/package.json b/packages/hqtui/package.json
index a9de814..1a631c1 100644
--- a/packages/hqtui/package.json
+++ b/packages/hqtui/package.json
@@ -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",
diff --git a/packages/hqtui/src/app.ts b/packages/hqtui/src/app.ts
index 7d7c87b..f08a9b5 100644
--- a/packages/hqtui/src/app.ts
+++ b/packages/hqtui/src/app.ts
@@ -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";
@@ -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 = {}) {
@@ -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 | null {
+ return this.menu;
+ }
+
/** Activate the focused control, as Enter does. */
activateFocused(): void {
this.focusActions[this.focusIndex]?.();
@@ -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") {
@@ -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");
@@ -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;
diff --git a/packages/hqtui/src/cli.ts b/packages/hqtui/src/cli.ts
index 9455461..53a7e9f 100644
--- a/packages/hqtui/src/cli.ts
+++ b/packages/hqtui/src/cli.ts
@@ -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
diff --git a/packages/hqtui/src/index.ts b/packages/hqtui/src/index.ts
index 825f5af..793429d 100644
--- a/packages/hqtui/src/index.ts
+++ b/packages/hqtui/src/index.ts
@@ -17,8 +17,13 @@ export { App, createApp, type AppOptions, type RenderArgs, type RenderFn, type F
export { Container, GridContainer } from "./ui.ts";
export type {
RenderContext, ContainerOptions, PanelOptions, GridOptions, CellOptions, HitRegion, HitEvent, ScrollHandlers, DataGridHandlers, MarkdownCopy,
+ OpenContextMenu, TabsMenuOptions,
} from "./ui.ts";
-export { dispatchHit, countClicks, DOUBLE_CLICK_MS } from "./ui.ts";
+export { dispatchHit, countClicks, DOUBLE_CLICK_MS, tabMenuItems } from "./ui.ts";
+export {
+ drawContextMenu, contextMenuLayout, contextMenuKey, contextMenuStep, MENU_SEPARATOR,
+ type ContextMenuItem, type ContextMenuOptions, type ContextMenuLayout, type ContextMenuKeyResult,
+} from "./widgets/context-menu.ts";
// Terminal + capabilities
export {
diff --git a/packages/hqtui/src/testing.ts b/packages/hqtui/src/testing.ts
index 87405d4..24ca6c7 100644
--- a/packages/hqtui/src/testing.ts
+++ b/packages/hqtui/src/testing.ts
@@ -1,7 +1,7 @@
import { FrameBuffer } from "./buffer.ts";
import { Encoder } from "./diff.ts";
import { createSurface, Surface } from "./surface.ts";
-import { Container, dispatchHit, type RenderContext, type HitRegion } from "./ui.ts";
+import { Container, dispatchHit, type RenderContext, type HitRegion, type OpenContextMenu } from "./ui.ts";
import { type Theme, type ThemeName, resolveTheme } from "./theme.ts";
import { detectCapabilities, type Capabilities, type CapabilityOverrides } from "./capabilities.ts";
import { CONTINUATION, cellText } from "./unicode.ts";
@@ -22,6 +22,11 @@ export interface RenderOptions {
collapseBorders?: boolean;
/** Hold animated widgets still, as `createApp({ reducedMotion: true })` does. */
reducedMotion?: boolean;
+ /**
+ * Render with this context menu open, as the app would after a right-click.
+ * Pass `screen.menu` from an earlier render to draw what it opened.
+ */
+ contextMenu?: OpenContextMenu | null;
}
export interface CellSnapshot {
@@ -43,6 +48,12 @@ export interface RenderedScreen {
* only observable by running a real terminal.
*/
regions: HitRegion[];
+ /**
+ * The context menu that is open: the one passed in `contextMenu`, or the
+ * one a right-click on this screen opened. Choosing an item or dismissing
+ * the drawn menu sets it back to null, as the app would.
+ */
+ menu: OpenContextMenu | null;
/**
* Press the mouse on a screen cell, exactly as the app would deliver it:
* the topmost region containing the cell takes it. Returns whether any
@@ -123,18 +134,36 @@ export function renderToScreen(
const index = focusCursor++;
return { index, focused: index === (options.focus ?? 0) };
},
- hit: (region) => regions.push(region),
+ hit: (region) => regions.push(layered ? { ...region, overlay: true } : region),
overlay: (draw, overlayOptions) => overlays.push((root) => {
- if (overlayOptions?.modal) focusCursor = 0;
+ if (overlayOptions?.modal) {
+ focusCursor = 0;
+ layered = true;
+ }
draw(root);
}),
invalidate: () => {},
+ openContextMenu: (opened) => { state.menu = { ...opened, selected: opened.selected ?? -1 }; },
};
+ let layered = false;
+ const state: { menu: OpenContextMenu | null } = { menu: options.contextMenu ?? null };
const root = createSurface(buffer, theme);
const container = new Container(root, ctx, "column");
view({ ui: container, theme, width, height, capabilities });
container.flush();
+ if (state.menu) {
+ const menu = state.menu;
+ container.contextMenu({
+ ...menu,
+ onChange: (selected) => { if (state.menu === menu) state.menu = { ...menu, selected }; },
+ onClose: () => { if (state.menu === menu) state.menu = null; },
+ onSelect: (_index, item) => {
+ if (state.menu === menu) state.menu = null;
+ item.onSelect?.();
+ },
+ });
+ }
for (const overlay of overlays) overlay(root);
return {
@@ -143,6 +172,7 @@ export function renderToScreen(
height,
buffer,
regions,
+ get menu() { return state.menu; },
click: (x, y, options = {}) =>
dispatchHit(regions, {
action: "press",
diff --git a/packages/hqtui/src/ui.ts b/packages/hqtui/src/ui.ts
index dece21e..036b7f5 100644
--- a/packages/hqtui/src/ui.ts
+++ b/packages/hqtui/src/ui.ts
@@ -29,6 +29,20 @@ export interface HitRegion {
onClick?: (x: number, y: number, button: string, clicks?: number) => void;
onScroll?: (delta: number) => void;
onHover?: (x: number, y: number) => void;
+ /**
+ * A right-click inside the region, relative to it. A right press goes to the
+ * topmost region under the pointer that has one of these, so a menu on a
+ * whole pane still opens over a table inside it that only handles left
+ * clicks. A region with only this handler is transparent to every other
+ * event: a left click falls through it to whatever it covers.
+ */
+ onContextMenu?: (x: number, y: number) => void;
+ /**
+ * Registered by a modal overlay. The search for a context menu stops here,
+ * so a right-click on a dialog never opens a menu belonging to the screen
+ * behind it. The app sets this; widgets do not need to.
+ */
+ overlay?: boolean;
}
/** What a hit-test needs to know about a mouse event. */
@@ -49,10 +63,24 @@ export interface HitEvent {
* region took it.
*/
export function dispatchHit(regions: readonly HitRegion[], event: HitEvent): boolean {
+ const inside = (r: Rect) => event.x >= r.x && event.y >= r.y && event.x < r.x + r.width && event.y < r.y + r.height;
+ if (event.action === "press" && event.button === "right") {
+ for (let i = regions.length - 1; i >= 0; i--) {
+ const hit = regions[i];
+ if (!inside(hit.rect)) continue;
+ if (hit.onContextMenu) {
+ hit.onContextMenu(event.x - hit.rect.x, event.y - hit.rect.y);
+ return true;
+ }
+ if (hit.overlay) break;
+ }
+ }
for (let i = regions.length - 1; i >= 0; i--) {
const hit = regions[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 (!inside(r)) continue;
+ // A menu-only region is a layer for right-clicks and nothing else.
+ if (hit.onContextMenu && !hit.onClick && !hit.onScroll && !hit.onHover && !hit.overlay) 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, event.clicks);
else if (event.action === "move") hit.onHover?.(event.x - r.x, event.y - r.y);
@@ -125,6 +153,51 @@ export interface RenderContext {
hit(region: HitRegion): void;
overlay(draw: (root: Surface) => void, options?: OverlayOptions): void;
invalidate(): void;
+ /**
+ * Open a context menu at a screen point. The app keeps it open across
+ * frames, handles its keys and pointer, and closes it when an item is
+ * chosen or it is dismissed. Absent where nothing can be opened (scrollback).
+ */
+ openContextMenu?(menu: OpenContextMenu): void;
+}
+
+/** A context menu the app holds open: where, and what is in it. */
+export interface OpenContextMenu {
+ /** Screen column. */
+ x: number;
+ /** Screen row. */
+ y: number;
+ items: W.ContextMenuItem[];
+ /** Highlighted item; -1 or absent highlights nothing until the pointer or a key moves it. */
+ selected?: number;
+}
+
+/** What a right-click on a tab offers. Every default appears only when its callback is given. */
+export interface TabsMenuOptions {
+ /** Close one tab: "Close tab" in the menu, and a middle-click on the tab. */
+ onClose?: (index: number) => void;
+ /** "Close all but this tab". Disabled while there is only one tab. */
+ onCloseOthers?: (index: number) => void;
+ /** "Close all tabs". */
+ onCloseAll?: () => void;
+ /**
+ * Extra items, listed above the close actions with a rule between, e.g.
+ * "Reload from disk". A function receives the right-clicked tab's index.
+ */
+ menu?: W.ContextMenuItem[] | ((index: number) => W.ContextMenuItem[]);
+}
+
+/** The menu a right-click on tab `index` opens. Exported for apps that draw their own tabs. */
+export function tabMenuItems(index: number, count: number, options: TabsMenuOptions): W.ContextMenuItem[] {
+ const extra = typeof options.menu === "function" ? options.menu(index) : options.menu ?? [];
+ const close: W.ContextMenuItem[] = [];
+ const { onClose, onCloseOthers, onCloseAll } = options;
+ if (onClose) close.push({ label: "Close tab", hint: "middle-click", onSelect: () => onClose(index) });
+ if (onCloseOthers) {
+ close.push({ label: "Close all but this tab", disabled: count < 2, onSelect: () => onCloseOthers(index) });
+ }
+ if (onCloseAll) close.push({ label: "Close all tabs", onSelect: () => onCloseAll() });
+ return extra.length && close.length ? [...extra, W.MENU_SEPARATOR, ...close] : [...extra, ...close];
}
/** Mouse behaviour shared by every scrollable widget. */
@@ -807,24 +880,74 @@ export class Container {
);
}
- tabs(options: W.TabsOptions & ContainerOptions & { onSelect?: (index: number) => void }): this {
+ /**
+ * A row of tabs. With `onClose`, `onCloseOthers`, `onCloseAll` or `menu`, a
+ * right-click on a tab opens a context menu of those actions, and with
+ * `onClose` a middle-click closes the tab outright, the way a browser does.
+ */
+ tabs(options: W.TabsOptions & ContainerOptions & TabsMenuOptions & { onSelect?: (index: number) => void }): this {
return this.add((s) => {
W.drawTabs(s, options);
- if (options.onSelect) {
- let x = 0;
- options.tabs.forEach((tab, i) => {
- const w = stringWidth(tab) + 4;
- const r = s.hitRect();
- this.ctx.hit({
- rect: { x: r.x + x, y: r.y, width: w, height: 1 },
- onClick: () => options.onSelect?.(i),
- });
- x += w;
+ const hasMenu = !!(options.onClose || options.onCloseOthers || options.onCloseAll || options.menu);
+ if (!options.onSelect && !hasMenu) return;
+ const r = s.hitRect();
+ // drawTabs offsets the row for center/right alignment; match it.
+ const total = options.tabs.reduce((a, t) => a + stringWidth(t) + 4, 0);
+ let x = options.align === "center" ? Math.max(0, Math.floor((s.width - total) / 2))
+ : options.align === "right" ? Math.max(0, s.width - total) : 0;
+ options.tabs.forEach((tab, i) => {
+ const w = stringWidth(tab) + 4;
+ const rect = { x: r.x + x, y: r.y, width: Math.max(0, Math.min(w, r.width - x)), height: 1 };
+ x += w;
+ if (rect.width <= 0) return;
+ this.ctx.hit({
+ rect,
+ onClick: (_x, _y, button) => {
+ if (button === "middle") {
+ options.onClose?.(i);
+ return;
+ }
+ options.onSelect?.(i);
+ },
+ onContextMenu: hasMenu
+ ? (cx) => {
+ const items = tabMenuItems(i, options.tabs.length, options);
+ // Open just under the tab, at the column that was clicked.
+ if (items.length) this.ctx.openContextMenu?.({ x: rect.x + cx, y: rect.y + 1, items });
+ }
+ : undefined,
});
- }
+ });
}, this.sizeOf(options, "auto", 1));
}
+ /**
+ * A column whose right-click opens a context menu, for any content: a pane,
+ * a list, a canvas. A tab or another area inside it with a menu of its own
+ * takes precedence; left clicks pass straight through to what is inside.
+ * `menu` may be a function of the click, relative to the area.
+ */
+ contextArea(
+ options: ContainerOptions & { menu: W.ContextMenuItem[] | ((x: number, y: number) => W.ContextMenuItem[]) },
+ build?: (area: Container) => void,
+ ): this {
+ const summary = this.summary?.child();
+ return this.add((surface) => {
+ const r = surface.hitRect();
+ // Registered before the children, so anything inside with its own menu is on top.
+ this.ctx.hit({
+ rect: r,
+ onContextMenu: (x, y) => {
+ const items = typeof options.menu === "function" ? options.menu(x, y) : options.menu;
+ if (items.length) this.ctx.openContextMenu?.({ x: r.x + x, y: r.y + y, items });
+ },
+ });
+ const container = new Container(surface, this.ctx, "column", options, summary);
+ build?.(container);
+ container.flush();
+ }, this.sizeOf(options, "fill"), options.bordered ?? false);
+ }
+
statusBar(options: W.StatusBarOptions & ContainerOptions): this {
return this.add((s) => {
const spans = W.drawStatusBar(s, options);
@@ -946,6 +1069,52 @@ export class Container {
return this;
}
+ /**
+ * A context menu whose state you own, drawn as a modal overlay: you keep
+ * `selected` and whether it is open, the way the emoji picker works. Most
+ * apps want `ui.contextArea` or the menu on `ui.tabs` instead, which open
+ * one the app keeps for you.
+ *
+ * Keys: Up/Down move, Enter chooses, Escape closes. The pointer highlights
+ * on hover and chooses on click; a click anywhere outside closes it.
+ */
+ contextMenu(options: W.ContextMenuOptions & {
+ /** An item was chosen. Close the menu here; `item.onSelect` is not called for you. */
+ onSelect: (index: number, item: W.ContextMenuItem) => void;
+ onClose: () => void;
+ /** The highlight moved, by key or by hover. */
+ onChange?: (selected: number) => void;
+ }): this {
+ const choose = (index: number) => {
+ const item = options.items[index];
+ if (item && !item.separator && !item.disabled) options.onSelect(index, item);
+ };
+ this.ctx.overlay((root) => {
+ const layout = W.drawContextMenu(root, options);
+ this.ctx.hit({ rect: root.hitRect(), onClick: () => options.onClose() });
+ this.ctx.hit({ rect: layout.box });
+ for (const { index, rect } of layout.rows) {
+ const item = options.items[index];
+ if (item.separator || item.disabled) continue;
+ this.ctx.hit({
+ rect,
+ onClick: (_x, _y, button) => { if (button === "left" || button === "right") choose(index); },
+ onHover: () => { if (index !== options.selected) options.onChange?.(index); },
+ });
+ }
+ }, {
+ modal: true,
+ onDismiss: options.onClose,
+ onKey: (event) => {
+ const result = W.contextMenuKey(options.items, options.selected ?? -1, event);
+ if (result.close) options.onClose();
+ else if (result.activate !== undefined) choose(result.activate);
+ else if (result.selected !== (options.selected ?? -1)) options.onChange?.(result.selected);
+ },
+ });
+ return this;
+ }
+
tooltip(options: W.TooltipOptions): this {
this.ctx.overlay((root) => W.drawTooltip(root, options));
return this;
diff --git a/packages/hqtui/src/widgets/context-menu.ts b/packages/hqtui/src/widgets/context-menu.ts
new file mode 100644
index 0000000..daf063c
--- /dev/null
+++ b/packages/hqtui/src/widgets/context-menu.ts
@@ -0,0 +1,190 @@
+/**
+ * A context menu: the small list that opens where you right-clicked.
+ *
+ * It is drawn on the root surface at a screen point and clamped inside it, so
+ * a click near the right edge or the bottom row still shows the whole menu:
+ * it slides left rather than off the screen, and opens upward rather than
+ * past the last row. Separators and disabled items are drawn but are never
+ * selected, by the keyboard or by the pointer.
+ */
+import type { Surface } from "../surface.ts";
+import type { Rect } from "../layout.ts";
+import { Attr } from "../buffer.ts";
+import type { Color } from "../color.ts";
+import { elevate } from "../theme.ts";
+import { stringWidth, truncate } from "../unicode.ts";
+import type { KeyEvent } from "../input.ts";
+import { drawShadow } from "./shadow.ts";
+
+export interface ContextMenuItem {
+ label: string;
+ /** A shortcut hint shown right-aligned and muted, e.g. "ctrl+w". */
+ hint?: string;
+ /** Drawn muted; never highlighted and never activated. */
+ disabled?: boolean;
+ /** A horizontal rule between groups. Its label is ignored. */
+ separator?: boolean;
+ /** Draw the label in the theme's danger color, for destructive actions. */
+ danger?: boolean;
+ /** Called when the item is chosen. The menu has already closed. */
+ onSelect?: () => void;
+}
+
+/** A rule between groups of items. */
+export const MENU_SEPARATOR: ContextMenuItem = Object.freeze({ label: "", separator: true });
+
+export interface ContextMenuOptions {
+ /** Screen column the menu opens at: usually where the pointer was pressed. */
+ x: number;
+ /** Screen row the menu opens at. */
+ y: number;
+ items: ContextMenuItem[];
+ /** The highlighted item, by index into `items`. -1 (the default) highlights nothing. */
+ selected?: number;
+ /** Border color. Defaults to the theme's focused border. */
+ color?: Color;
+ /** Never narrower than this many columns, borders included. */
+ minWidth?: number;
+ /** Cast a drop shadow. Default true. */
+ shadow?: boolean;
+}
+
+/** Where a drawn menu landed, so the cells that show an item are the cells that choose it. */
+export interface ContextMenuLayout {
+ /** The whole menu, borders included, in screen coordinates. */
+ box: Rect;
+ /** One row per visible item, in screen coordinates. */
+ rows: { index: number; rect: Rect }[];
+}
+
+const selectable = (item: ContextMenuItem | undefined): boolean =>
+ !!item && !item.separator && !item.disabled;
+
+/**
+ * The next selectable item from `from`, stepping by `delta` and wrapping.
+ * From -1 (nothing highlighted), down lands on the first item and up on the
+ * last. Returns -1 when nothing in the menu can be selected.
+ */
+export function contextMenuStep(items: readonly ContextMenuItem[], from: number, delta: 1 | -1): number {
+ const n = items.length;
+ if (n === 0) return -1;
+ let i = from < 0 || from >= n ? (delta > 0 ? -1 : n) : from;
+ for (let step = 0; step < n; step++) {
+ i = (i + delta + n) % n;
+ if (selectable(items[i])) return i;
+ }
+ return -1;
+}
+
+export interface ContextMenuKeyResult {
+ selected: number;
+ /** Index of the item the key chose. */
+ activate?: number;
+ /** The key dismissed the menu. */
+ close?: boolean;
+}
+
+/**
+ * What a key does to an open menu. Up/Down (and Tab) move over the selectable
+ * items, Home/End jump, Enter/Space choose, Escape and Left close, and a
+ * letter jumps to the next item whose label starts with it.
+ */
+export function contextMenuKey(
+ items: readonly ContextMenuItem[],
+ selected: number,
+ event: KeyEvent,
+): ContextMenuKeyResult {
+ switch (event.name) {
+ case "up": return { selected: contextMenuStep(items, selected, -1) };
+ case "down": return { selected: contextMenuStep(items, selected, 1) };
+ case "tab": return { selected: contextMenuStep(items, selected, event.shift ? -1 : 1) };
+ case "home": return { selected: contextMenuStep(items, -1, 1) };
+ case "end": return { selected: contextMenuStep(items, -1, -1) };
+ case "escape":
+ case "left": return { selected, close: true };
+ case "enter":
+ case "space":
+ return selectable(items[selected]) ? { selected, activate: selected } : { selected };
+ }
+ const ch = event.char?.toLowerCase();
+ if (ch && ch.length === 1 && !event.ctrl && !event.alt) {
+ for (let step = 1; step <= items.length; step++) {
+ const i = (Math.max(selected, -1) + step) % items.length;
+ if (selectable(items[i]) && items[i].label.trim().toLowerCase().startsWith(ch)) return { selected: i };
+ }
+ }
+ return { selected };
+}
+
+/** Size and place a menu inside a `width` x `height` screen without drawing it. */
+export function contextMenuLayout(
+ width: number,
+ height: number,
+ options: Pick,
+): ContextMenuLayout {
+ const items = options.items;
+ const labels = items.reduce((w, it) => (it.separator ? w : Math.max(w, stringWidth(it.label))), 0);
+ const hints = items.reduce((w, it) => (it.separator || !it.hint ? w : Math.max(w, stringWidth(it.hint))), 0);
+ // " label hint " inside a border on each side.
+ const natural = labels + (hints ? hints + 3 : 0) + 4;
+ const boxWidth = Math.max(0, Math.min(Math.max(natural, options.minWidth ?? 0, 12), width));
+ const boxHeight = Math.max(0, Math.min(items.length + 2, height));
+
+ // Open right and down from the point. Slide left at the right edge; at the
+ // bottom open upward from the point if that fits, otherwise pin to the top.
+ const x = Math.max(0, Math.min(options.x, width - boxWidth));
+ let y = options.y;
+ if (y + boxHeight > height) y = options.y - boxHeight + 1 >= 0 ? options.y - boxHeight + 1 : height - boxHeight;
+ y = Math.max(0, y);
+
+ const visible = Math.max(0, boxHeight - 2);
+ const rows = items.slice(0, visible).map((_, index) => ({
+ index,
+ rect: { x: x + 1, y: y + 1 + index, width: Math.max(0, boxWidth - 2), height: 1 },
+ }));
+ return { box: { x, y, width: boxWidth, height: boxHeight }, rows };
+}
+
+/** Draw an open context menu on the root surface. Returns where it landed. */
+export function drawContextMenu(root: Surface, options: ContextMenuOptions): ContextMenuLayout {
+ const layout = contextMenuLayout(root.width, root.height, options);
+ const { box } = layout;
+ if (box.width < 4 || box.height < 3 || root.empty) return layout;
+ const theme = root.theme;
+ const border = options.color ?? theme.borderFocused;
+ const bg = elevate(theme, 0.1);
+
+ if (options.shadow !== false) drawShadow(root, box);
+ const inner = root.sub(box.x, box.y, box.width, box.height).box({ border: "rounded", borderColor: border, bg });
+
+ layout.rows.forEach(({ index }, row) => {
+ const item = options.items[index];
+ if (item.separator) {
+ // Join the rule to the frame so the menu reads as one object.
+ root.hline(box.x + 1, box.y + 1 + row, box.width - 2, "─", { fg: theme.border, bg });
+ root.char(box.x, box.y + 1 + row, "├", { fg: border, bg });
+ root.char(box.x + box.width - 1, box.y + 1 + row, "┤", { fg: border, bg });
+ return;
+ }
+ const selected = index === (options.selected ?? -1) && selectable(item);
+ const rowBg = selected ? theme.selection : bg;
+ if (selected) inner.fillRect(0, row, inner.width, 1, { bg: rowBg });
+ const hintWidth = item.hint ? stringWidth(item.hint) : 0;
+ const labelRoom = Math.max(0, inner.width - 2 - (hintWidth ? hintWidth + 2 : 0));
+ const fg = item.disabled ? theme.muted
+ : selected ? theme.selectionText
+ : item.danger ? theme.danger : theme.foreground;
+ inner.text(1, row, truncate(item.label, labelRoom), {
+ fg,
+ bg: rowBg,
+ attrs: selected ? Attr.Bold : item.disabled ? Attr.Dim : 0,
+ });
+ if (item.hint && hintWidth + 3 < inner.width) {
+ inner.text(inner.width - hintWidth - 1, row, item.hint, {
+ fg: selected ? theme.selectionText : theme.muted,
+ bg: rowBg,
+ });
+ }
+ });
+ return layout;
+}
diff --git a/packages/hqtui/src/widgets/index.ts b/packages/hqtui/src/widgets/index.ts
index 1bea5f6..5965bc8 100644
--- a/packages/hqtui/src/widgets/index.ts
+++ b/packages/hqtui/src/widgets/index.ts
@@ -11,3 +11,4 @@ export * from "./controls.ts";
export * from "./spinner.ts";
export * from "./emoji-picker.ts";
export * from "./data-grid.ts";
+export * from "./context-menu.ts";
diff --git a/packages/hqtui/test/context-menu.test.ts b/packages/hqtui/test/context-menu.test.ts
new file mode 100644
index 0000000..1f1fc14
--- /dev/null
+++ b/packages/hqtui/test/context-menu.test.ts
@@ -0,0 +1,262 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { PassThrough } from "node:stream";
+import { App } from "../src/app.ts";
+import { renderToScreen } from "../src/testing.ts";
+import { tabMenuItems } from "../src/ui.ts";
+import {
+ contextMenuKey, contextMenuLayout, contextMenuStep, MENU_SEPARATOR, type ContextMenuItem,
+} from "../src/widgets/context-menu.ts";
+import type { KeyEvent } from "../src/input.ts";
+
+const key = (name: string, extra: Partial = {}): KeyEvent =>
+ ({ type: "key", name, key: name, ctrl: false, alt: false, shift: false, raw: "", ...extra });
+
+const ITEMS: ContextMenuItem[] = [
+ { label: "Reload from disk", hint: "ctrl+r" },
+ MENU_SEPARATOR,
+ { label: "Close tab" },
+ { label: "Close all but this tab", disabled: true },
+ { label: "Close all tabs" },
+];
+
+test("arrows skip separators and disabled items, and wrap", () => {
+ assert.equal(contextMenuStep(ITEMS, -1, 1), 0, "down from nothing lands on the first item");
+ assert.equal(contextMenuStep(ITEMS, -1, -1), 4, "up from nothing lands on the last item");
+ assert.equal(contextMenuStep(ITEMS, 0, 1), 2, "the separator is skipped");
+ assert.equal(contextMenuStep(ITEMS, 2, 1), 4, "the disabled item is skipped");
+ assert.equal(contextMenuStep(ITEMS, 4, 1), 0, "wraps");
+ assert.equal(contextMenuStep([MENU_SEPARATOR, { label: "x", disabled: true }], -1, 1), -1);
+});
+
+test("contextMenuKey: enter chooses, escape closes, a letter jumps", () => {
+ assert.deepEqual(contextMenuKey(ITEMS, -1, key("down")), { selected: 0 });
+ assert.deepEqual(contextMenuKey(ITEMS, 2, key("enter")), { selected: 2, activate: 2 });
+ assert.deepEqual(contextMenuKey(ITEMS, -1, key("enter")), { selected: -1 }, "nothing highlighted, nothing chosen");
+ assert.deepEqual(contextMenuKey(ITEMS, 2, key("escape")), { selected: 2, close: true });
+ assert.equal(contextMenuKey(ITEMS, -1, key("c", { char: "c" })).selected, 2);
+ assert.equal(contextMenuKey(ITEMS, 2, key("c", { char: "c" })).selected, 4, "the next match, past the disabled one");
+ assert.equal(contextMenuKey(ITEMS, -1, key("end")).selected, 4);
+});
+
+test("the menu is clamped inside the screen: slides left, opens upward", () => {
+ const open = contextMenuLayout(40, 12, { x: 38, y: 2, items: ITEMS });
+ assert.ok(open.box.x + open.box.width <= 40, "never past the right edge");
+ assert.equal(open.box.y, 2);
+ assert.equal(open.box.height, ITEMS.length + 2);
+
+ const low = contextMenuLayout(40, 12, { x: 3, y: 11, items: ITEMS });
+ assert.equal(low.box.y + low.box.height - 1, 11, "opens upward from the bottom row");
+
+ const tiny = contextMenuLayout(40, 4, { x: 0, y: 3, items: ITEMS });
+ assert.equal(tiny.box.y, 0);
+ assert.equal(tiny.box.height, 4);
+ assert.equal(tiny.rows.length, 2, "only the rows that fit are laid out");
+});
+
+test("drawn menu: labels, hint, a separator joined to the frame, highlight", () => {
+ const screen = renderToScreen(({ ui }) => { ui.text("background"); }, {
+ width: 50, height: 12, contextMenu: { x: 4, y: 2, items: ITEMS, selected: 2 },
+ });
+ const reload = screen.find("Reload from disk")!;
+ assert.ok(reload);
+ assert.ok(screen.line(reload.y).includes("ctrl+r"));
+ assert.ok(screen.line(reload.y + 1).includes("├"), "separator joins the left border");
+ assert.ok(screen.line(reload.y + 1).includes("┤"), "and the right one");
+ const close = screen.find("Close tab")!;
+ assert.equal(screen.cell(close.x, close.y).bg, screen.cell(close.x + 1, close.y).bg);
+ assert.notEqual(screen.cell(close.x, close.y).bg, screen.cell(reload.x, reload.y).bg, "the selected row is filled");
+});
+
+function tabsView(log: string[], extra: ContextMenuItem[] = []) {
+ return ({ ui }: { ui: Parameters[0]>[0]["ui"] }) => {
+ ui.tabs({
+ tabs: ["README.md", "notes.md", "todo.md"],
+ active: 0,
+ onSelect: (i) => log.push(`select ${i}`),
+ onClose: (i) => log.push(`close ${i}`),
+ onCloseOthers: (i) => log.push(`others ${i}`),
+ onCloseAll: () => log.push("all"),
+ menu: extra,
+ });
+ };
+}
+
+test("right-click on a tab opens the default close actions under it", () => {
+ const log: string[] = [];
+ const first = renderToScreen(tabsView(log), { width: 60, height: 12 });
+ const notes = first.find("notes.md")!;
+ assert.ok(first.click(notes.x, notes.y, { button: "right" }));
+ assert.deepEqual(log, [], "a right-click does not select the tab");
+ const menu = first.menu!;
+ assert.ok(menu, "a menu was opened");
+ assert.deepEqual(menu.items.map((i) => i.label), ["Close tab", "Close all but this tab", "Close all tabs"]);
+ assert.equal(menu.y, notes.y + 1, "just under the tab row");
+
+ const second = renderToScreen(tabsView(log), { width: 60, height: 12, contextMenu: menu });
+ const others = second.find("Close all but this tab")!;
+ assert.ok(second.click(others.x, others.y));
+ assert.deepEqual(log, ["others 1"], "the action names the right-clicked tab");
+ assert.equal(second.menu, null, "choosing closes the menu");
+});
+
+test("extra items come first, then a rule, then the close actions", () => {
+ const log: string[] = [];
+ const reload = { label: "Reload from disk", onSelect: () => log.push("reload") };
+ const items = tabMenuItems(0, 3, { onClose: () => {}, onCloseAll: () => {}, menu: [reload] });
+ assert.deepEqual(items.map((i) => i.separator ? "---" : i.label), ["Reload from disk", "---", "Close tab", "Close all tabs"]);
+ assert.equal(tabMenuItems(0, 1, { onCloseOthers: () => {} })[0].disabled, true, "nothing else to close");
+ assert.deepEqual(tabMenuItems(0, 3, { menu: (i) => [{ label: `tab ${i}` }] }).map((i) => i.label), ["tab 0"]);
+
+ const first = renderToScreen(tabsView(log, [reload]), { width: 60, height: 12 });
+ const todo = first.find("todo.md")!;
+ first.click(todo.x, todo.y, { button: "right" });
+ const second = renderToScreen(tabsView(log, [reload]), { width: 60, height: 12, contextMenu: first.menu });
+ const at = second.find("Reload from disk")!;
+ second.click(at.x, at.y);
+ assert.deepEqual(log, ["reload"]);
+});
+
+test("middle-click closes a tab; left-click still selects", () => {
+ const log: string[] = [];
+ const screen = renderToScreen(tabsView(log), { width: 60, height: 12 });
+ const todo = screen.find("todo.md")!;
+ screen.click(todo.x, todo.y, { button: "middle" });
+ screen.click(todo.x, todo.y);
+ assert.deepEqual(log, ["close 2", "select 2"]);
+ assert.equal(screen.menu, null);
+});
+
+test("tabs with no close callbacks or menu keep the old right-click behaviour", () => {
+ const log: string[] = [];
+ const screen = renderToScreen(({ ui }) => {
+ ui.tabs({ tabs: ["a", "b"], active: 0, onSelect: (i) => log.push(`select ${i}`) });
+ }, { width: 30, height: 4 });
+ const b = screen.find("b")!;
+ screen.click(b.x, b.y, { button: "right" });
+ assert.deepEqual(log, ["select 1"]);
+ assert.equal(screen.menu, null);
+});
+
+test("disabled items and separators cannot be clicked; outside closes", () => {
+ const log: string[] = [];
+ const items: ContextMenuItem[] = [
+ { label: "Copy", onSelect: () => log.push("copy") },
+ MENU_SEPARATOR,
+ { label: "Paste", disabled: true, onSelect: () => log.push("paste") },
+ ];
+ const screen = renderToScreen(() => {}, { width: 40, height: 10, contextMenu: { x: 2, y: 1, items } });
+ const paste = screen.find("Paste")!;
+ screen.click(paste.x, paste.y);
+ assert.deepEqual(log, []);
+ assert.ok(screen.menu, "still open");
+ screen.click(paste.x, paste.y - 1);
+ assert.ok(screen.menu, "the rule is part of the menu");
+ screen.click(39, 9);
+ assert.equal(screen.menu, null, "a click outside closes it");
+});
+
+test("hover moves the highlight", () => {
+ const screen = renderToScreen(() => {}, { width: 40, height: 10, contextMenu: { x: 2, y: 1, items: ITEMS } });
+ const close = screen.find("Close all tabs")!;
+ screen.hover(close.x, close.y);
+ assert.equal(screen.menu?.selected, 4);
+});
+
+test("contextArea: right-click anywhere inside opens its menu, left clicks pass through", () => {
+ const log: string[] = [];
+ const view = ({ ui }: { ui: Parameters[0]>[0]["ui"] }) => {
+ ui.panel({ title: "Files", onClick: () => log.push("panel") }, (p) => {
+ p.contextArea({ menu: (x, y) => [{ label: `New file at ${x},${y}` }] }, (area) => {
+ area.list({ size: 2, items: [{ label: "a.txt" }, { label: "b.txt" }], onSelectRow: (r) => log.push(`row ${r}`) });
+ });
+ });
+ };
+ const screen = renderToScreen(view, { width: 40, height: 10 });
+ const b = screen.find("b.txt")!;
+ screen.click(b.x, b.y);
+ assert.deepEqual(log, ["row 1"], "the list still gets left clicks");
+ screen.click(b.x, b.y, { button: "right" });
+ assert.ok(screen.menu, "a right-click on the list opens the area's menu");
+ assert.equal(screen.menu!.x, b.x);
+ assert.equal(screen.menu!.y, b.y);
+ assert.match(screen.menu!.items[0].label, /^New file at \d+,1$/);
+
+ const empty = renderToScreen(view, { width: 40, height: 10 });
+ empty.click(5, 6);
+ assert.deepEqual(log, ["row 1", "panel"], "a left click on empty space reaches the panel underneath");
+});
+
+test("a right-click on a dialog does not open the menu behind it", () => {
+ const screen = renderToScreen(({ ui }) => {
+ ui.contextArea({ menu: [{ label: "Behind" }] });
+ ui.modal({ title: "Dialog", message: "hello", width: 20 });
+ }, { width: 40, height: 12 });
+ const hello = screen.find("hello")!;
+ screen.click(hello.x, hello.y, { button: "right" });
+ assert.equal(screen.menu, null);
+ screen.click(0, 0, { button: "right" });
+ assert.equal(screen.menu, null, "not on the backdrop either");
+});
+
+function harness() {
+ const input = new PassThrough();
+ const output = new PassThrough();
+ output.resume();
+ Object.assign(output, { columns: 60, rows: 16 });
+ const app = new App({
+ input: input as unknown as NodeJS.ReadStream,
+ output: output as unknown as NodeJS.WriteStream,
+ installExitHandlers: false,
+ quitKeys: [],
+ });
+ return {
+ app,
+ async send(bytes: string) {
+ input.write(bytes);
+ await new Promise((resolve) => setTimeout(resolve, bytes === "\x1b" ? 60 : 0));
+ app.frame();
+ },
+ };
+}
+
+// SGR mouse: button 2 is right, 1 is middle; columns and rows are 1-based.
+const sgr = (button: number, x: number, y: number) => `\x1b[<${button};${x + 1};${y + 1}M`;
+
+test("in the app: right-click a tab, arrow down, Enter runs the action", async (t) => {
+ const { app, send } = harness();
+ t.after(() => app.stop());
+ let tabs = ["one.md", "two.md", "three.md"];
+ const leaked: string[] = [];
+ app.on("key", (event) => leaked.push(event.key));
+ app.render(({ ui }) => {
+ ui.tabs({
+ tabs,
+ active: 0,
+ onClose: (i) => { tabs = tabs.filter((_, j) => j !== i); },
+ onCloseOthers: (i) => { tabs = [tabs[i]]; },
+ onCloseAll: () => { tabs = []; },
+ });
+ });
+ void app.start();
+ app.frame();
+ await send(sgr(2, 10, 0)); // right-click "two.md"
+ assert.ok(app.contextMenu, "the menu is open");
+ assert.equal(app.contextMenu!.selected, -1);
+ await send("\x1b[B"); // down: Close tab
+ await send("\x1b[B"); // down: Close all but this tab
+ assert.equal(app.contextMenu!.selected, 1);
+ await send("\r");
+ assert.equal(app.contextMenu, null);
+ assert.deepEqual(tabs, ["two.md"]);
+ assert.deepEqual(leaked, [], "menu keys never reach the app");
+
+ await send(sgr(2, 3, 0));
+ assert.ok(app.contextMenu);
+ await send("\x1b");
+ assert.equal(app.contextMenu, null, "Escape closes it");
+ assert.deepEqual(tabs, ["two.md"]);
+
+ await send(sgr(1, 3, 0)); // middle-click
+ assert.deepEqual(tabs, []);
+});
diff --git a/ports/nim/hqtui.nimble b/ports/nim/hqtui.nimble
index 96d4cf1..901039e 100644
--- a/ports/nim/hqtui.nimble
+++ b/ports/nim/hqtui.nimble
@@ -1,6 +1,6 @@
# Package
-version = "0.11.1"
+version = "0.12.0"
author = "HQTUI contributors"
description = "Terminal UI widgets and the ten-screen HQTUI dashboard for Nim, over HQTUI's shared native engine (compiled from source)"
license = "MIT"
diff --git a/ports/nim/src/hqtui.nim b/ports/nim/src/hqtui.nim
index db8ca60..cec03d1 100644
--- a/ports/nim/src/hqtui.nim
+++ b/ports/nim/src/hqtui.nim
@@ -6,7 +6,7 @@
import std/[json, os, strutils]
export json
-const version* = "0.11.1"
+const version* = "0.12.0"
const
engine = currentSourcePath().parentDir / "hqtui" / "engine"