Skip to content

Commit 8e12ef2

Browse files
Merge pull request #84 from foundersandcoders/feat/help-overlay
Add a Global Help Overlay
2 parents 85443d7 + 8af3991 commit 8e12ef2

18 files changed

Lines changed: 516 additions & 29 deletions

docs/roadmaps/v5a-2606/enhanced.md

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -265,9 +265,28 @@ registry, and a header breadcrumb.
265265

266266
## Phase C — Signature UX features
267267

268-
- [ ] **TR.C1** `feat/add-help-overlay`*(roadmap 2TI.12)* Global `?` overlay
268+
- [x] **TR.C1** `feat/add-help-overlay`*(roadmap 2TI.12)* Global `?` overlay
269269
rendered from the keymap registry, on a z-index layer over the current
270-
screen.
270+
screen. **Done:** the `Keymap` now owns the overlay lifecycle rather than
271+
screens wiring it individually — the previously-scaffolded `onHelp` option
272+
is removed from `KeymapOptions` and replaced with an always-on internal
273+
`?` binding, so all 12 screens get help for free (suppressible per screen
274+
via `disableGlobals: ['?']`). New `helpOverlay()` component
275+
(`src/tui/components/helpOverlay.ts`) renders a centred `panel` card over
276+
a full-screen backdrop, mounted on `renderer.root` (a sibling of each
277+
screen's shell root) at `zIndex: 100`. `Keymap.toHelp()` exposes
278+
display-ready rows from the live, `when`-passing bindings. `?`/ESC close
279+
the overlay; every other key (including `q`) is swallowed while open, with
280+
no fall-through to `onQuit`/`onBack`. **Bug found and fixed along the way:**
281+
`renderer.keyInput` and the focused renderable's own key handler
282+
(e.g. `SelectRenderable`) share the same underlying `InternalKeyHandler`
283+
a plain `dispatch()` return of `null` was not enough to stop arrow/enter
284+
keys reaching the focused renderable underneath the overlay; fixed by
285+
calling `key.stopPropagation()` while help is open, which the
286+
`InternalKeyHandler`'s priority ordering (global listeners before
287+
renderable handlers) then honours. Covered by regression tests in
288+
`tests/tui/utils/keymap.test.ts` and `tests/tui/components/helpOverlay.test.ts`,
289+
plus manual `tmux`-driven verification across Dashboard, About, and History.
271290
- [ ] **TR.C2** `feat/add-toast-and-confirm-overlays` — Transient toasts
272291
(success/info/error) + a real confirm modal; **replace the double-press
273292
deletes** in history & mapping-builder.

src/tui/components/helpOverlay.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
/** ====== Help Overlay Component ======
2+
* Full-screen z-index layer listing the current screen's live key bindings.
3+
* Owned and driven by Keymap (TR.C1) — mounted on renderer.root (a sibling of
4+
* each screen's shell.root) so its zIndex stacks it above the whole screen.
5+
*/
6+
import { BoxRenderable, TextRenderable, t, fg } from '@opentui/core';
7+
import type { Renderer } from '../types';
8+
import { theme } from '../../../assets/brand/theme';
9+
import { panel, type Panel } from './panel';
10+
11+
/** One display-ready shortcut row for the overlay. */
12+
export interface HelpRow {
13+
/** Pre-rendered key glyph(s), e.g. "↑↓" or "ENTER". */
14+
keys: string;
15+
/** Human-readable action label. */
16+
label: string;
17+
}
18+
19+
export interface HelpOverlayOptions {
20+
/** Overlay id, used for renderer.root.remove(). Default 'help-overlay-root'. */
21+
id?: string;
22+
/** Paint order among renderer.root siblings — must exceed screen roots. Default 100. */
23+
zIndex?: number;
24+
/** Card title. Default 'Keyboard Shortcuts'. */
25+
title?: string;
26+
}
27+
28+
export interface HelpOverlay {
29+
/** Full-screen backdrop box — add to renderer.root (sibling of the screen shell). */
30+
readonly root: BoxRenderable;
31+
/** Replace the listed shortcut rows in place. */
32+
setRows(rows: HelpRow[]): void;
33+
/** Show/hide without remounting. */
34+
setVisible(visible: boolean): void;
35+
/** Current visibility. */
36+
isVisible(): boolean;
37+
}
38+
39+
const FOOTER_HINT = '? or ESC to close';
40+
41+
export function helpOverlay(renderer: Renderer, opts: HelpOverlayOptions = {}): HelpOverlay {
42+
const root = new BoxRenderable(renderer, {
43+
id: opts.id ?? 'help-overlay-root',
44+
position: 'absolute',
45+
top: 0,
46+
left: 0,
47+
width: '100%',
48+
height: '100%',
49+
zIndex: opts.zIndex ?? 100,
50+
backgroundColor: theme.background,
51+
flexDirection: 'column',
52+
justifyContent: 'center',
53+
alignItems: 'center',
54+
visible: false,
55+
});
56+
57+
// Explicit width — the panel would otherwise size to its (initially empty)
58+
// rowsBox content and clip a title longer than the placeholder row width.
59+
const card: Panel = panel(renderer, { title: opts.title ?? 'Keyboard Shortcuts', width: 42 });
60+
61+
const rowsBox = new BoxRenderable(renderer, { flexDirection: 'column' });
62+
card.add(rowsBox);
63+
64+
const footer = new TextRenderable(renderer, { content: FOOTER_HINT, fg: theme.textMuted });
65+
card.add(footer);
66+
67+
root.add(card.box);
68+
69+
let rowIds: string[] = [];
70+
71+
return {
72+
root,
73+
setRows(rows) {
74+
for (const id of rowIds) rowsBox.remove(id);
75+
rowIds = [];
76+
77+
const maxKeysWidth = rows.reduce((max, row) => Math.max(max, row.keys.length), 0);
78+
79+
rows.forEach((row, index) => {
80+
const padding = ' '.repeat(Math.max(1, maxKeysWidth - row.keys.length + 2));
81+
const rowText = new TextRenderable(renderer, {
82+
id: `help-overlay-row-${index}`,
83+
content: t`${fg(theme.accent)(`${row.keys}${padding}`)}${fg(theme.text)(row.label)}`,
84+
});
85+
rowIds.push(rowText.id);
86+
rowsBox.add(rowText);
87+
});
88+
},
89+
setVisible(visible) {
90+
root.visible = visible;
91+
},
92+
isVisible() {
93+
return root.visible ?? false;
94+
},
95+
};
96+
}

src/tui/components/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,5 @@ export { panel } from './panel';
22
export type { Panel, PanelOptions } from './panel';
33
export { appShell } from './appShell';
44
export type { AppShell, AppShellOptions } from './appShell';
5+
export { helpOverlay } from './helpOverlay';
6+
export type { HelpOverlay, HelpOverlayOptions, HelpRow } from './helpOverlay';

src/tui/utils/keymap.ts

Lines changed: 67 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import type { KeyEvent } from '@opentui/core';
88
import { symbols } from '../../../assets/brand/theme';
99
import type { Renderer } from '../types';
10+
import { helpOverlay, type HelpOverlay, type HelpRow } from '../components/helpOverlay';
1011

1112
/** A single declarative key binding. */
1213
export interface Binding {
@@ -66,29 +67,54 @@ export function eventToKey(key: KeyEvent): string {
6667
export interface KeymapOptions {
6768
/** Per-screen bindings. Screen bindings take priority over globals. */
6869
bindings: Binding[];
69-
/** Canonical key strings to suppress from the auto-generated globals. */
70+
/** Canonical key strings to suppress from the auto-generated globals.
71+
* Include "?" to opt a screen out of the help overlay entirely. */
7072
disableGlobals?: string[];
71-
/** Handler for "?" — wired to the help overlay (TR.C1). No-op if omitted. */
72-
onHelp?: () => void;
7373
/** Handler for "q" — typically resolves quit. Omit on screens where q means back. */
7474
onQuit?: () => void;
7575
/** Handler for "escape" — typically resolves pop/back. */
7676
onBack?: () => void;
7777
}
7878

79+
const HELP_OVERLAY_ID = 'help-overlay-root';
80+
7981
export class Keymap {
8082
private readonly bindings: Binding[];
83+
private readonly helpEnabled: boolean;
8184
private attachedHandler?: (key: KeyEvent) => void;
85+
private helpOverlay?: HelpOverlay;
86+
private helpOpen = false;
8287

8388
constructor(opts: KeymapOptions) {
84-
this.bindings = [...opts.bindings, ...buildGlobals(opts)];
89+
this.helpEnabled = !(opts.disableGlobals ?? []).includes('?');
90+
this.bindings = [
91+
...opts.bindings,
92+
...buildGlobals(opts),
93+
...(this.helpEnabled
94+
? [{ keys: ['?'], hint: '?', label: 'Help', handler: () => this.toggleHelp() } as Binding]
95+
: []),
96+
];
8597
}
8698

8799
/** Find the first matching, when-passing binding and run its handler.
88100
* Returns the binding that fired, or null if none matched.
89-
* Pure and synchronous — useful for unit testing dispatch logic. */
101+
* Pure and synchronous — useful for unit testing dispatch logic.
102+
* While the help overlay is open, every key is swallowed except "?"/"escape"
103+
* (which close it) — no fall-through to other bindings (e.g. "q" won't quit).
104+
* stopPropagation() is load-bearing here: this handler runs as a "global"
105+
* listener on the shared InternalKeyHandler, ahead of the focused
106+
* renderable's own keypress handler (e.g. SelectRenderable's arrow-key
107+
* navigation) — without it, arrow/enter keys would still reach the
108+
* renderable underneath the overlay even though dispatch() ignored them. */
90109
dispatch(key: KeyEvent): Binding | null {
91110
const k = eventToKey(key);
111+
112+
if (this.helpOpen) {
113+
key.stopPropagation?.();
114+
if (k === '?' || k === 'escape') this.closeHelp();
115+
return null;
116+
}
117+
92118
for (const binding of this.bindings) {
93119
if (
94120
(binding.when?.() ?? true) &&
@@ -111,20 +137,54 @@ export class Keymap {
111137
.join(' ');
112138
}
113139

140+
/** Display-ready shortcut rows for the help overlay: visible, when-passing
141+
* bindings, excluding the Help entry itself. */
142+
toHelp(): HelpRow[] {
143+
return this.bindings
144+
.filter((b) => !b.hidden && (b.when?.() ?? true) && !b.keys.includes('?'))
145+
.map((b) => ({ keys: b.hint ?? prettyKey(b.keys[0]), label: b.label }));
146+
}
147+
148+
private toggleHelp(): void {
149+
if (this.helpOpen) this.closeHelp();
150+
else this.openHelp();
151+
}
152+
153+
private openHelp(): void {
154+
if (!this.helpOverlay) return;
155+
this.helpOverlay.setRows(this.toHelp());
156+
this.helpOverlay.setVisible(true);
157+
this.helpOpen = true;
158+
}
159+
160+
private closeHelp(): void {
161+
this.helpOverlay?.setVisible(false);
162+
this.helpOpen = false;
163+
}
164+
114165
/** Register the keypress listener on the renderer. Call inside render().
115166
* Idempotent — a prior listener is removed before registering the new one. */
116167
attach(renderer: Renderer): void {
117168
if (this.attachedHandler) this.detach(renderer);
169+
if (this.helpEnabled && !this.helpOverlay) {
170+
this.helpOverlay = helpOverlay(renderer, { id: HELP_OVERLAY_ID });
171+
renderer.root.add(this.helpOverlay.root);
172+
}
118173
this.attachedHandler = (key) => this.dispatch(key);
119174
renderer.keyInput.on('keypress', this.attachedHandler);
120175
}
121176

122-
/** Remove the keypress listener. Call inside cleanup(). */
177+
/** Remove the keypress listener and the help overlay. Call inside cleanup(). */
123178
detach(renderer: Renderer): void {
124179
if (this.attachedHandler) {
125180
renderer.keyInput.off('keypress', this.attachedHandler);
126181
this.attachedHandler = undefined;
127182
}
183+
if (this.helpOverlay) {
184+
renderer.root.remove(HELP_OVERLAY_ID);
185+
this.helpOverlay = undefined;
186+
this.helpOpen = false;
187+
}
128188
}
129189
}
130190

@@ -138,10 +198,8 @@ function buildGlobals(opts: KeymapOptions): Binding[] {
138198
if (opts.onQuit && !suppressed.has('q')) {
139199
globals.push({ keys: ['q'], hint: 'q', label: 'Quit', handler: opts.onQuit });
140200
}
141-
if (opts.onHelp && !suppressed.has('?')) {
142-
globals.push({ keys: ['?'], hint: '?', label: 'Help', handler: opts.onHelp });
143-
}
144201
// Ctrl+C deliberately omitted — createCliRenderer({ exitOnCtrlC: true }) handles it.
202+
// "?" Help is appended separately in the constructor (self-wired, TR.C1).
145203

146204
return globals;
147205
}

0 commit comments

Comments
 (0)