77import type { KeyEvent } from '@opentui/core' ;
88import { symbols } from '../../../assets/brand/theme' ;
99import type { Renderer } from '../types' ;
10+ import { helpOverlay , type HelpOverlay , type HelpRow } from '../components/helpOverlay' ;
1011
1112/** A single declarative key binding. */
1213export interface Binding {
@@ -66,29 +67,54 @@ export function eventToKey(key: KeyEvent): string {
6667export 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+
7981export 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