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.
Files changed (64) hide show
  1. package/CHANGELOG.md +365 -0
  2. package/README.md +1 -1
  3. package/dist/Screen/InterfaceBuilder.d.mts +15 -4
  4. package/dist/Screen/InterfaceBuilder.d.mts.map +1 -1
  5. package/dist/Screen/InterfaceBuilder.mjs +104 -8
  6. package/dist/Screen/InterfaceBuilder.mjs.map +1 -1
  7. package/dist/Screen/Pos.d.mts +12 -0
  8. package/dist/Screen/Pos.d.mts.map +1 -1
  9. package/dist/Screen/Pos.mjs +23 -1
  10. package/dist/Screen/Pos.mjs.map +1 -1
  11. package/dist/Screen/Screen.d.mts +77 -3
  12. package/dist/Screen/Screen.d.mts.map +1 -1
  13. package/dist/Screen/Screen.mjs +168 -3
  14. package/dist/Screen/Screen.mjs.map +1 -1
  15. package/dist/Screen/Size.d.mts +49 -6
  16. package/dist/Screen/Size.d.mts.map +1 -1
  17. package/dist/Screen/Size.mjs +81 -7
  18. package/dist/Screen/Size.mjs.map +1 -1
  19. package/dist/Screen/Window.d.mts +131 -20
  20. package/dist/Screen/Window.d.mts.map +1 -1
  21. package/dist/Screen/Window.mjs +474 -57
  22. package/dist/Screen/Window.mjs.map +1 -1
  23. package/dist/Screen/WindowManager.d.mts +85 -5
  24. package/dist/Screen/WindowManager.d.mts.map +1 -1
  25. package/dist/Screen/WindowManager.mjs +279 -26
  26. package/dist/Screen/WindowManager.mjs.map +1 -1
  27. package/dist/Screen/controls/ListBox.d.mts +34 -12
  28. package/dist/Screen/controls/ListBox.d.mts.map +1 -1
  29. package/dist/Screen/controls/ListBox.mjs +127 -25
  30. package/dist/Screen/controls/ListBox.mjs.map +1 -1
  31. package/dist/Screen/controls/TextArea.d.mts +15 -1
  32. package/dist/Screen/controls/TextArea.d.mts.map +1 -1
  33. package/dist/Screen/controls/TextArea.mjs +74 -1
  34. package/dist/Screen/controls/TextArea.mjs.map +1 -1
  35. package/dist/Screen/controls/TextBox.d.mts +13 -1
  36. package/dist/Screen/controls/TextBox.d.mts.map +1 -1
  37. package/dist/Screen/controls/TextBox.mjs +36 -1
  38. package/dist/Screen/controls/TextBox.mjs.map +1 -1
  39. package/dist/Screen/textWidth.d.mts +13 -0
  40. package/dist/Screen/textWidth.d.mts.map +1 -0
  41. package/dist/Screen/textWidth.mjs +188 -0
  42. package/dist/Screen/textWidth.mjs.map +1 -0
  43. package/dist/Screen/types.d.mts +336 -20
  44. package/dist/Screen/types.d.mts.map +1 -1
  45. package/dist/Screen/types.mjs.map +1 -1
  46. package/dist/index.d.mts +3 -2
  47. package/dist/index.d.mts.map +1 -1
  48. package/dist/index.mjs +3 -1
  49. package/dist/index.mjs.map +1 -1
  50. package/package.json +4 -4
  51. package/src/Screen/InterfaceBuilder.mts +116 -20
  52. package/src/Screen/Pos.mts +24 -1
  53. package/src/Screen/Screen.mts +192 -4
  54. package/src/Screen/Size.mts +97 -12
  55. package/src/Screen/Window.mts +463 -63
  56. package/src/Screen/WindowManager.mts +301 -29
  57. package/src/Screen/controls/ListBox.mts +151 -32
  58. package/src/Screen/controls/TextArea.mts +82 -1
  59. package/src/Screen/controls/TextBox.mts +40 -1
  60. package/src/Screen/textWidth.mts +186 -0
  61. package/src/Screen/types.mts +328 -23
  62. package/src/demo.mts +232 -20
  63. package/src/index.mts +23 -3
  64. package/src/layout.yaml +56 -24
@@ -1,4 +1,10 @@
1
- import type { Focusable, WindowManagerOptions, TerminalMouseEvent } from './types.mjs';
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 enabled. */
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 || entries[idx].control.isDisabled()) return;
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
- process.stdout.write('\x1b[?1049h'); // switch to alternate screen buffer
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
- process.stdout.write('\x1b[?25l'); // hide cursor
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
- process.stdin.off('data', this.boundHandleInput);
292
- if (process.stdin.isTTY) {
293
- process.stdin.setRawMode(false);
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.mouseEnabled) {
299
- process.stdout.write('\x1b[?1006l\x1b[?1000l');
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
- process.stdout.write('\x1b[?25h'); // show cursor
302
- process.stdout.write('\x1b[?1049l'); // restore normal screen buffer
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
- this.onKey?.(key);
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
- this.moveFocus(1);
337
- this.renderFrame();
338
- continue;
339
- }
340
- if (key === '\x1b[Z') { // Shift-Tab
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
- /** Finds the first already-focused (or first non-disabled) control and focuses it. */
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() && !entries[i].control.isDisabled()) {
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 enabled control.
681
+ // Otherwise focus the first eligible control.
411
682
  for (let i = 0; i < entries.length; i++) {
412
- if (!entries[i].control.isDisabled()) {
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 controls. */
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.isDisabled() && attempts <= count);
710
+ } while (!this.isFocusable(entries[next].control) && attempts <= count);
439
711
 
440
- if (!entries[next].control.isDisabled()) {
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 (control.isDisabled()) continue;
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 &&