take4-console 0.15.0 → 0.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +365 -0
- package/README.md +1 -1
- package/dist/Screen/InterfaceBuilder.d.mts +15 -4
- package/dist/Screen/InterfaceBuilder.d.mts.map +1 -1
- package/dist/Screen/InterfaceBuilder.mjs +104 -8
- package/dist/Screen/InterfaceBuilder.mjs.map +1 -1
- package/dist/Screen/Pos.d.mts +12 -0
- package/dist/Screen/Pos.d.mts.map +1 -1
- package/dist/Screen/Pos.mjs +23 -1
- package/dist/Screen/Pos.mjs.map +1 -1
- package/dist/Screen/Screen.d.mts +77 -3
- package/dist/Screen/Screen.d.mts.map +1 -1
- package/dist/Screen/Screen.mjs +168 -3
- package/dist/Screen/Screen.mjs.map +1 -1
- package/dist/Screen/Size.d.mts +49 -6
- package/dist/Screen/Size.d.mts.map +1 -1
- package/dist/Screen/Size.mjs +81 -7
- package/dist/Screen/Size.mjs.map +1 -1
- package/dist/Screen/Window.d.mts +131 -20
- package/dist/Screen/Window.d.mts.map +1 -1
- package/dist/Screen/Window.mjs +474 -57
- package/dist/Screen/Window.mjs.map +1 -1
- package/dist/Screen/WindowManager.d.mts +85 -5
- package/dist/Screen/WindowManager.d.mts.map +1 -1
- package/dist/Screen/WindowManager.mjs +279 -26
- package/dist/Screen/WindowManager.mjs.map +1 -1
- package/dist/Screen/controls/ListBox.d.mts +34 -12
- package/dist/Screen/controls/ListBox.d.mts.map +1 -1
- package/dist/Screen/controls/ListBox.mjs +127 -25
- package/dist/Screen/controls/ListBox.mjs.map +1 -1
- package/dist/Screen/controls/TextArea.d.mts +15 -1
- package/dist/Screen/controls/TextArea.d.mts.map +1 -1
- package/dist/Screen/controls/TextArea.mjs +74 -1
- package/dist/Screen/controls/TextArea.mjs.map +1 -1
- package/dist/Screen/controls/TextBox.d.mts +13 -1
- package/dist/Screen/controls/TextBox.d.mts.map +1 -1
- package/dist/Screen/controls/TextBox.mjs +36 -1
- package/dist/Screen/controls/TextBox.mjs.map +1 -1
- package/dist/Screen/textWidth.d.mts +13 -0
- package/dist/Screen/textWidth.d.mts.map +1 -0
- package/dist/Screen/textWidth.mjs +188 -0
- package/dist/Screen/textWidth.mjs.map +1 -0
- package/dist/Screen/types.d.mts +336 -20
- package/dist/Screen/types.d.mts.map +1 -1
- package/dist/Screen/types.mjs.map +1 -1
- package/dist/index.d.mts +3 -2
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +3 -1
- package/dist/index.mjs.map +1 -1
- package/package.json +4 -4
- package/src/Screen/InterfaceBuilder.mts +116 -20
- package/src/Screen/Pos.mts +24 -1
- package/src/Screen/Screen.mts +192 -4
- package/src/Screen/Size.mts +97 -12
- package/src/Screen/Window.mts +463 -63
- package/src/Screen/WindowManager.mts +301 -29
- package/src/Screen/controls/ListBox.mts +151 -32
- package/src/Screen/controls/TextArea.mts +82 -1
- package/src/Screen/controls/TextBox.mts +40 -1
- package/src/Screen/textWidth.mts +186 -0
- package/src/Screen/types.mts +328 -23
- package/src/demo.mts +232 -20
- package/src/index.mts +23 -3
- package/src/layout.yaml +56 -24
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
Focusable,
|
|
3
|
+
WindowManagerOptions,
|
|
4
|
+
TerminalMouseEvent,
|
|
5
|
+
KeyContext,
|
|
6
|
+
KeyBindHandler,
|
|
7
|
+
} from './types.mjs';
|
|
2
8
|
import { Screen } from './Screen.mjs';
|
|
3
9
|
import { Window } from './Window.mjs';
|
|
4
10
|
|
|
@@ -114,6 +120,49 @@ function parseMouseEvent(key: string): TerminalMouseEvent | null {
|
|
|
114
120
|
};
|
|
115
121
|
}
|
|
116
122
|
|
|
123
|
+
// ── Key spec parsing ──────────────────────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
/** Lookup table of friendly key names → raw terminal key strings.
|
|
126
|
+
* Used by `bindKey` so callers can write `'ctrl+s'` or `'enter'` instead
|
|
127
|
+
* of `'\x13'` / `'\r'`. Arrow keys, function keys, etc. stay raw. */
|
|
128
|
+
const KEY_ALIASES: Record<string, string> = {
|
|
129
|
+
enter: '\r',
|
|
130
|
+
return: '\r',
|
|
131
|
+
space: ' ',
|
|
132
|
+
esc: '\x1b',
|
|
133
|
+
escape: '\x1b',
|
|
134
|
+
tab: '\t',
|
|
135
|
+
backspace: '\x7f',
|
|
136
|
+
del: '\x7f',
|
|
137
|
+
delete: '\x1b[3~',
|
|
138
|
+
up: '\x1b[A',
|
|
139
|
+
down: '\x1b[B',
|
|
140
|
+
right: '\x1b[C',
|
|
141
|
+
left: '\x1b[D',
|
|
142
|
+
home: '\x1b[H',
|
|
143
|
+
end: '\x1b[F',
|
|
144
|
+
pageup: '\x1b[5~',
|
|
145
|
+
pagedown: '\x1b[6~',
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
/** Normalises a caller-provided key spec into the raw key string emitted by
|
|
149
|
+
* the terminal. Accepts raw strings (`'\r'`, `'a'`), friendly names
|
|
150
|
+
* (`'enter'`, `'space'`, `'up'`), and `'ctrl+<letter>'` combinations.
|
|
151
|
+
* Everything else is returned unchanged. */
|
|
152
|
+
function normaliseKeySpec(spec: string): string {
|
|
153
|
+
if (spec.length <= 1) return spec;
|
|
154
|
+
const lower = spec.toLowerCase();
|
|
155
|
+
if (lower in KEY_ALIASES) return KEY_ALIASES[lower]!;
|
|
156
|
+
|
|
157
|
+
// `ctrl+<letter>` → ASCII control code (\x01 = Ctrl+A … \x1a = Ctrl+Z).
|
|
158
|
+
const ctrlMatch = /^ctrl\+([a-z])$/.exec(lower);
|
|
159
|
+
if (ctrlMatch) {
|
|
160
|
+
const letter = ctrlMatch[1]!.charCodeAt(0);
|
|
161
|
+
return String.fromCharCode(letter - 'a'.charCodeAt(0) + 1);
|
|
162
|
+
}
|
|
163
|
+
return spec;
|
|
164
|
+
}
|
|
165
|
+
|
|
117
166
|
// ── WindowManager ─────────────────────────────────────────────────────────────
|
|
118
167
|
|
|
119
168
|
/** Manages the application input loop, focus, and modal dialogs.
|
|
@@ -130,19 +179,39 @@ export class WindowManager {
|
|
|
130
179
|
private screen: Screen;
|
|
131
180
|
private exitKeys: string[];
|
|
132
181
|
private onExit?: () => void;
|
|
133
|
-
private onKey?: (key: string) => void;
|
|
182
|
+
private onKey?: (key: string, ctx: KeyContext) => boolean | void;
|
|
134
183
|
private onMouse?: (event: TerminalMouseEvent) => void;
|
|
135
184
|
private mouseEnabled: boolean;
|
|
136
185
|
|
|
186
|
+
/** Registered bindings mapped by raw key string.
|
|
187
|
+
* Multiple handlers for the same key fire in insertion order; the first
|
|
188
|
+
* one that returns `true` marks the event as consumed. */
|
|
189
|
+
private keyBindings: Map<string, KeyBindHandler[]> = new Map();
|
|
190
|
+
|
|
137
191
|
private mainEntries: FocusEntry[];
|
|
138
192
|
private mainFocusIndex: number;
|
|
139
193
|
private dialogStack: DialogLevel[];
|
|
140
194
|
private running: boolean;
|
|
195
|
+
/** True while pause() is in effect and resume() has not yet reclaimed the
|
|
196
|
+
* terminal. Distinct from `running`: a paused WindowManager is still
|
|
197
|
+
* considered running — `stop()` can finalise it, and all registered
|
|
198
|
+
* focus entries remain intact for resume(). */
|
|
199
|
+
private paused: boolean = false;
|
|
141
200
|
|
|
142
201
|
/** Bound reference kept so we can remove the listener in stop(). */
|
|
143
202
|
private boundHandleInput: (data: Buffer) => void;
|
|
144
203
|
/** Bound SIGTERM handler for clean shutdown. */
|
|
145
204
|
private boundSigterm: () => void;
|
|
205
|
+
/** True when run() entered the alt-screen on its own (so stop() owes it an exit). */
|
|
206
|
+
private ownsAltScreen: boolean = false;
|
|
207
|
+
/** True when run() hid the cursor on its own (so stop() owes it a restore). */
|
|
208
|
+
private ownsCursor: boolean = false;
|
|
209
|
+
/** Set by pause() when it exited the alternate screen buffer — instructs
|
|
210
|
+
* resume() to re-enter. Cleared after resume() or stop() uses it. */
|
|
211
|
+
private pauseRestoreAltScreen: boolean = false;
|
|
212
|
+
/** Set by pause() when it showed a previously-hidden cursor — instructs
|
|
213
|
+
* resume() to hide it again. Cleared after resume() or stop() uses it. */
|
|
214
|
+
private pauseRestoreCursorHidden: boolean = false;
|
|
146
215
|
|
|
147
216
|
/** Creates a WindowManager for the given Screen.
|
|
148
217
|
* Does not start the input loop; call run() to begin. */
|
|
@@ -193,16 +262,64 @@ export class WindowManager {
|
|
|
193
262
|
return idx >= 0 ? entries[idx].control : null;
|
|
194
263
|
}
|
|
195
264
|
|
|
196
|
-
/** Moves focus to the given control if it belongs to the active context and is
|
|
265
|
+
/** Moves focus to the given control if it belongs to the active context and is
|
|
266
|
+
* eligible (not disabled, not hidden via setVisible(false)). */
|
|
197
267
|
public setFocus(control: Focusable & Window): void {
|
|
198
268
|
const entries = this.activeEntries();
|
|
199
269
|
const idx = entries.findIndex(e => e.control === control);
|
|
200
|
-
if (idx === -1
|
|
270
|
+
if (idx === -1) return;
|
|
271
|
+
const candidate = entries[idx].control;
|
|
272
|
+
if (candidate.isDisabled() || !candidate.isVisible()) return;
|
|
201
273
|
this.blurCurrent();
|
|
202
274
|
this.setActiveFocusIndex(idx);
|
|
203
275
|
control.setFocused(true);
|
|
204
276
|
}
|
|
205
277
|
|
|
278
|
+
// ── Global key bindings (P0-4) ─────────────────────────────────────────────
|
|
279
|
+
|
|
280
|
+
/** Registers a global shortcut handler.
|
|
281
|
+
*
|
|
282
|
+
* The handler fires **before** the focused control receives the key; if it
|
|
283
|
+
* returns `true`, the key is marked as consumed (no dispatch to the
|
|
284
|
+
* focused control, no exit-key check, no focus navigation). Return
|
|
285
|
+
* `false`/`void` to let the key continue through the pipeline — useful
|
|
286
|
+
* for observer-only handlers that want to track keys without blocking.
|
|
287
|
+
*
|
|
288
|
+
* `keySpec` accepts friendly names (`'enter'`, `'space'`, `'esc'`,
|
|
289
|
+
* `'ctrl+s'`, arrow names) as well as raw terminal strings
|
|
290
|
+
* (`'\r'`, `'q'`, `'\x1b[A'`). Multiple handlers can be bound to the same
|
|
291
|
+
* key — they fire in insertion order until one consumes the event.
|
|
292
|
+
*
|
|
293
|
+
* Returns an unbind function that removes *this particular* registration. */
|
|
294
|
+
public bindKey(keySpec: string, handler: KeyBindHandler): () => void {
|
|
295
|
+
const key = normaliseKeySpec(keySpec);
|
|
296
|
+
const list = this.keyBindings.get(key);
|
|
297
|
+
if (list) list.push(handler);
|
|
298
|
+
else this.keyBindings.set(key, [handler]);
|
|
299
|
+
|
|
300
|
+
return () => this.unbindKey(keySpec, handler);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Removes a handler previously registered with `bindKey`. When `handler`
|
|
304
|
+
* is omitted, all handlers for the given key are removed. Returns `true`
|
|
305
|
+
* when at least one handler was removed. */
|
|
306
|
+
public unbindKey(keySpec: string, handler?: KeyBindHandler): boolean {
|
|
307
|
+
const key = normaliseKeySpec(keySpec);
|
|
308
|
+
const list = this.keyBindings.get(key);
|
|
309
|
+
if (!list) return false;
|
|
310
|
+
|
|
311
|
+
if (handler === undefined) {
|
|
312
|
+
this.keyBindings.delete(key);
|
|
313
|
+
return true;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const idx = list.indexOf(handler);
|
|
317
|
+
if (idx === -1) return false;
|
|
318
|
+
list.splice(idx, 1);
|
|
319
|
+
if (list.length === 0) this.keyBindings.delete(key);
|
|
320
|
+
return true;
|
|
321
|
+
}
|
|
322
|
+
|
|
206
323
|
// ── Dialog ─────────────────────────────────────────────────────────────────
|
|
207
324
|
|
|
208
325
|
/** Opens a modal dialog: blurs the current context, adds the dialog Window to
|
|
@@ -270,41 +387,152 @@ export class WindowManager {
|
|
|
270
387
|
process.stdin.on('data', this.boundHandleInput);
|
|
271
388
|
process.once('SIGTERM', this.boundSigterm);
|
|
272
389
|
|
|
273
|
-
|
|
390
|
+
// Defer alt-screen / cursor hiding to Screen so consumers using
|
|
391
|
+
// ScreenOptions don't get them double-toggled. We only "own" the toggle
|
|
392
|
+
// (and therefore have to undo it in stop()) when the Screen wasn't
|
|
393
|
+
// already in that state — for instance because the user constructed it
|
|
394
|
+
// with `altScreen: true`, in which case the alt buffer should outlive
|
|
395
|
+
// stop() until Screen.dispose() runs.
|
|
396
|
+
if (!this.screen.isAltScreenActive()) {
|
|
397
|
+
this.screen.enterAltScreen();
|
|
398
|
+
this.ownsAltScreen = true;
|
|
399
|
+
}
|
|
274
400
|
if (this.mouseEnabled) {
|
|
275
401
|
// Enable button-press tracking + SGR extended coordinates.
|
|
276
402
|
process.stdout.write('\x1b[?1000h\x1b[?1006h');
|
|
277
403
|
}
|
|
278
|
-
|
|
404
|
+
if (!this.screen.isCursorHidden()) {
|
|
405
|
+
this.screen.hideHardwareCursor();
|
|
406
|
+
this.ownsCursor = true;
|
|
407
|
+
}
|
|
279
408
|
|
|
280
409
|
this.initializeFocus();
|
|
281
410
|
this.renderFrame();
|
|
282
411
|
}
|
|
283
412
|
|
|
284
413
|
/** Stops the input loop, restores terminal state, and fires the onExit callback.
|
|
285
|
-
* Safe to call even when the loop was not started (skips terminal teardown).
|
|
414
|
+
* Safe to call even when the loop was not started (skips terminal teardown).
|
|
415
|
+
* When called while paused, the parts that pause() already released
|
|
416
|
+
* (stdin listener, raw mode, mouse tracking) are not re-teardown'd — only
|
|
417
|
+
* the remaining owned state (alt-screen, cursor, SIGTERM listener) is
|
|
418
|
+
* finalised. */
|
|
286
419
|
public stop(): void {
|
|
287
420
|
const wasRunning = this.running;
|
|
421
|
+
const wasPaused = this.paused;
|
|
288
422
|
this.running = false;
|
|
423
|
+
this.paused = false;
|
|
289
424
|
|
|
290
425
|
if (wasRunning) {
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
process.stdin.
|
|
426
|
+
if (!wasPaused) {
|
|
427
|
+
process.stdin.off('data', this.boundHandleInput);
|
|
428
|
+
if (process.stdin.isTTY) {
|
|
429
|
+
process.stdin.setRawMode(false);
|
|
430
|
+
}
|
|
431
|
+
process.stdin.pause();
|
|
432
|
+
if (this.mouseEnabled) {
|
|
433
|
+
process.stdout.write('\x1b[?1006l\x1b[?1000l');
|
|
434
|
+
}
|
|
294
435
|
}
|
|
295
|
-
process.stdin.pause();
|
|
296
436
|
process.off('SIGTERM', this.boundSigterm);
|
|
297
437
|
|
|
298
|
-
if (this.
|
|
299
|
-
|
|
438
|
+
if (this.ownsCursor) {
|
|
439
|
+
this.screen.showHardwareCursor();
|
|
440
|
+
this.ownsCursor = false;
|
|
441
|
+
}
|
|
442
|
+
if (this.ownsAltScreen) {
|
|
443
|
+
this.screen.exitAltScreen();
|
|
444
|
+
this.ownsAltScreen = false;
|
|
300
445
|
}
|
|
301
|
-
|
|
302
|
-
|
|
446
|
+
// Drop any latent pause-restore intents so a future run() starts fresh.
|
|
447
|
+
this.pauseRestoreAltScreen = false;
|
|
448
|
+
this.pauseRestoreCursorHidden = false;
|
|
303
449
|
}
|
|
304
450
|
|
|
305
451
|
this.onExit?.();
|
|
306
452
|
}
|
|
307
453
|
|
|
454
|
+
/** Suspends the input loop without tearing down the focus tree. Releases the
|
|
455
|
+
* terminal ownership the WindowManager needs for interactive mode:
|
|
456
|
+
*
|
|
457
|
+
* - detaches the stdin 'data' listener, leaves raw mode, pauses stdin;
|
|
458
|
+
* - disables mouse tracking when it was enabled;
|
|
459
|
+
* - restores the hardware cursor when it was hidden (so an external
|
|
460
|
+
* process has a visible cursor);
|
|
461
|
+
* - optionally exits the alternate screen buffer
|
|
462
|
+
* (`{ leaveAltScreen: true }`).
|
|
463
|
+
*
|
|
464
|
+
* The typical use is spawning `$EDITOR` or a shell: pause, exec, resume.
|
|
465
|
+
* All registered focus entries, dialog stack, and the onKey/bindKey
|
|
466
|
+
* bindings are preserved, so `resume()` brings the UI back without a
|
|
467
|
+
* full re-registration. No-op when the manager is not running or is
|
|
468
|
+
* already paused. */
|
|
469
|
+
public pause(options?: { leaveAltScreen?: boolean }): void {
|
|
470
|
+
if (!this.running || this.paused) return;
|
|
471
|
+
this.paused = true;
|
|
472
|
+
|
|
473
|
+
process.stdin.off('data', this.boundHandleInput);
|
|
474
|
+
if (process.stdin.isTTY) {
|
|
475
|
+
process.stdin.setRawMode(false);
|
|
476
|
+
}
|
|
477
|
+
process.stdin.pause();
|
|
478
|
+
|
|
479
|
+
if (this.mouseEnabled) {
|
|
480
|
+
process.stdout.write('\x1b[?1006l\x1b[?1000l');
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
this.pauseRestoreCursorHidden = this.screen.isCursorHidden();
|
|
484
|
+
if (this.pauseRestoreCursorHidden) {
|
|
485
|
+
this.screen.showHardwareCursor();
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
this.pauseRestoreAltScreen = false;
|
|
489
|
+
if (options?.leaveAltScreen && this.screen.isAltScreenActive()) {
|
|
490
|
+
this.screen.exitAltScreen();
|
|
491
|
+
this.pauseRestoreAltScreen = true;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/** Resumes the input loop after pause(). Re-enters the alternate screen if
|
|
496
|
+
* pause() left it, re-enables mouse tracking, re-hides the cursor when
|
|
497
|
+
* pause() showed it, restores raw mode + stdin listener, and re-renders
|
|
498
|
+
* the current frame unless `{ rerender: false }` is passed. No-op when
|
|
499
|
+
* the manager is not currently paused. */
|
|
500
|
+
public resume(options?: { rerender?: boolean }): void {
|
|
501
|
+
if (!this.paused) return;
|
|
502
|
+
this.paused = false;
|
|
503
|
+
|
|
504
|
+
if (this.pauseRestoreAltScreen) {
|
|
505
|
+
this.screen.enterAltScreen();
|
|
506
|
+
this.pauseRestoreAltScreen = false;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
if (this.mouseEnabled) {
|
|
510
|
+
process.stdout.write('\x1b[?1000h\x1b[?1006h');
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
if (this.pauseRestoreCursorHidden) {
|
|
514
|
+
this.screen.hideHardwareCursor();
|
|
515
|
+
this.pauseRestoreCursorHidden = false;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
if (process.stdin.isTTY) {
|
|
519
|
+
process.stdin.setRawMode(true);
|
|
520
|
+
}
|
|
521
|
+
process.stdin.resume();
|
|
522
|
+
process.stdin.on('data', this.boundHandleInput);
|
|
523
|
+
|
|
524
|
+
if (options?.rerender !== false) {
|
|
525
|
+
this.renderFrame();
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/** Returns true while pause() is active and resume() has not yet been
|
|
530
|
+
* called. A paused manager still has its `run()` context (focus
|
|
531
|
+
* registrations, dialog stack, exit keys) intact. */
|
|
532
|
+
public isPaused(): boolean {
|
|
533
|
+
return this.paused;
|
|
534
|
+
}
|
|
535
|
+
|
|
308
536
|
// ── Input handling ─────────────────────────────────────────────────────────
|
|
309
537
|
|
|
310
538
|
/** Processes a raw stdin Buffer. Exposed as public so tests can drive it directly
|
|
@@ -323,7 +551,22 @@ export class WindowManager {
|
|
|
323
551
|
}
|
|
324
552
|
}
|
|
325
553
|
|
|
326
|
-
|
|
554
|
+
// Build a KeyContext snapshot for global handlers. The focused
|
|
555
|
+
// control is captured before any handler fires so handlers see a
|
|
556
|
+
// consistent view even when they mutate focus.
|
|
557
|
+
const ctx: KeyContext = {
|
|
558
|
+
focusedControl: this.getFocused(),
|
|
559
|
+
inDialog: this.dialogStack.length > 0,
|
|
560
|
+
dialogDepth: this.dialogStack.length,
|
|
561
|
+
};
|
|
562
|
+
|
|
563
|
+
// Fire global shortcut handlers first (P0-4). Any handler returning
|
|
564
|
+
// `true` marks the key as consumed — exit-key check, focus
|
|
565
|
+
// navigation, and dispatch to the focused control are all skipped.
|
|
566
|
+
if (this.dispatchGlobalKey(key, ctx)) {
|
|
567
|
+
this.renderFrame();
|
|
568
|
+
continue;
|
|
569
|
+
}
|
|
327
570
|
|
|
328
571
|
// Exit keys.
|
|
329
572
|
if (this.exitKeys.includes(key)) {
|
|
@@ -332,12 +575,17 @@ export class WindowManager {
|
|
|
332
575
|
}
|
|
333
576
|
|
|
334
577
|
// Focus navigation – moveFocus handles index -1 correctly.
|
|
578
|
+
// Controls that want Tab to flow into handleKey (e.g. TextArea with
|
|
579
|
+
// insertTabAsSpaces) opt in via Focusable.capturesTab().
|
|
335
580
|
if (key === '\t') {
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
581
|
+
const focused = ctx.focusedControl;
|
|
582
|
+
if (!focused?.capturesTab?.()) {
|
|
583
|
+
this.moveFocus(1);
|
|
584
|
+
this.renderFrame();
|
|
585
|
+
continue;
|
|
586
|
+
}
|
|
587
|
+
// Fall through — let the focused control's handleKey see Tab.
|
|
588
|
+
} else if (key === '\x1b[Z') { // Shift-Tab — always cycles focus.
|
|
341
589
|
this.moveFocus(-1);
|
|
342
590
|
this.renderFrame();
|
|
343
591
|
continue;
|
|
@@ -358,6 +606,23 @@ export class WindowManager {
|
|
|
358
606
|
}
|
|
359
607
|
}
|
|
360
608
|
|
|
609
|
+
/** Runs registered `bindKey` handlers and the global `onKey` callback
|
|
610
|
+
* for the given key. Returns `true` when the event was consumed. */
|
|
611
|
+
private dispatchGlobalKey(key: string, ctx: KeyContext): boolean {
|
|
612
|
+
const bound = this.keyBindings.get(key);
|
|
613
|
+
if (bound) {
|
|
614
|
+
// Copy before iterating so an unbindKey() call from within a handler
|
|
615
|
+
// doesn't skip the next handler in the list.
|
|
616
|
+
for (const handler of bound.slice()) {
|
|
617
|
+
if (handler(ctx) === true) return true;
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
if (this.onKey) {
|
|
621
|
+
if (this.onKey(key, ctx) === true) return true;
|
|
622
|
+
}
|
|
623
|
+
return false;
|
|
624
|
+
}
|
|
625
|
+
|
|
361
626
|
// ── Private helpers ────────────────────────────────────────────────────────
|
|
362
627
|
|
|
363
628
|
/** Returns the FocusEntry array for the active context (topmost dialog or main). */
|
|
@@ -394,22 +659,28 @@ export class WindowManager {
|
|
|
394
659
|
}
|
|
395
660
|
}
|
|
396
661
|
|
|
397
|
-
/**
|
|
662
|
+
/** Returns true when the control is eligible to receive focus in the current
|
|
663
|
+
* context — neither disabled nor hidden via `Window.setVisible(false)`. */
|
|
664
|
+
private isFocusable(control: Focusable & Window): boolean {
|
|
665
|
+
return !control.isDisabled() && control.isVisible();
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/** Finds the first already-focused (or first eligible) control and focuses it. */
|
|
398
669
|
private initializeFocus(): void {
|
|
399
670
|
const entries = this.activeEntries();
|
|
400
671
|
if (entries.length === 0) return;
|
|
401
672
|
|
|
402
673
|
// Prefer a control that is already marked as focused.
|
|
403
674
|
for (let i = 0; i < entries.length; i++) {
|
|
404
|
-
if (entries[i].control.isFocused() &&
|
|
675
|
+
if (entries[i].control.isFocused() && this.isFocusable(entries[i].control)) {
|
|
405
676
|
this.setActiveFocusIndex(i);
|
|
406
677
|
return;
|
|
407
678
|
}
|
|
408
679
|
}
|
|
409
680
|
|
|
410
|
-
// Otherwise focus the first
|
|
681
|
+
// Otherwise focus the first eligible control.
|
|
411
682
|
for (let i = 0; i < entries.length; i++) {
|
|
412
|
-
if (
|
|
683
|
+
if (this.isFocusable(entries[i].control)) {
|
|
413
684
|
this.setActiveFocusIndex(i);
|
|
414
685
|
entries[i].control.setFocused(true);
|
|
415
686
|
return;
|
|
@@ -417,7 +688,8 @@ export class WindowManager {
|
|
|
417
688
|
}
|
|
418
689
|
}
|
|
419
690
|
|
|
420
|
-
/** Moves focus by delta (+1 for Tab, -1 for Shift-Tab), skipping disabled
|
|
691
|
+
/** Moves focus by delta (+1 for Tab, -1 for Shift-Tab), skipping disabled
|
|
692
|
+
* and hidden controls. */
|
|
421
693
|
private moveFocus(delta: number): void {
|
|
422
694
|
const entries = this.activeEntries();
|
|
423
695
|
if (entries.length === 0) return;
|
|
@@ -435,9 +707,9 @@ export class WindowManager {
|
|
|
435
707
|
do {
|
|
436
708
|
next = ((next + delta) % count + count) % count;
|
|
437
709
|
attempts++;
|
|
438
|
-
} while (entries[next].control
|
|
710
|
+
} while (!this.isFocusable(entries[next].control) && attempts <= count);
|
|
439
711
|
|
|
440
|
-
if (
|
|
712
|
+
if (this.isFocusable(entries[next].control)) {
|
|
441
713
|
this.setActiveFocusIndex(next);
|
|
442
714
|
entries[next].control.setFocused(true);
|
|
443
715
|
}
|
|
@@ -450,7 +722,7 @@ export class WindowManager {
|
|
|
450
722
|
const entries = this.activeEntries();
|
|
451
723
|
for (let i = 0; i < entries.length; i++) {
|
|
452
724
|
const { control, absX, absY } = entries[i];
|
|
453
|
-
if (
|
|
725
|
+
if (!this.isFocusable(control)) continue;
|
|
454
726
|
const { width, height } = control.getSize();
|
|
455
727
|
if (
|
|
456
728
|
event.x >= absX && event.x < absX + width &&
|