take4-console 0.25.0 → 0.30.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 (62) hide show
  1. package/CHANGELOG.md +193 -0
  2. package/dist/Screen/ErrorHolder.d.mts +10 -0
  3. package/dist/Screen/ErrorHolder.d.mts.map +1 -0
  4. package/dist/Screen/ErrorHolder.mjs +14 -0
  5. package/dist/Screen/ErrorHolder.mjs.map +1 -0
  6. package/dist/Screen/InterfaceBuilder.d.mts.map +1 -1
  7. package/dist/Screen/InterfaceBuilder.mjs +7 -0
  8. package/dist/Screen/InterfaceBuilder.mjs.map +1 -1
  9. package/dist/Screen/Screen.d.mts +50 -1
  10. package/dist/Screen/Screen.d.mts.map +1 -1
  11. package/dist/Screen/Screen.mjs +152 -0
  12. package/dist/Screen/Screen.mjs.map +1 -1
  13. package/dist/Screen/StyleRegistry.d.mts.map +1 -1
  14. package/dist/Screen/StyleRegistry.mjs +3 -1
  15. package/dist/Screen/StyleRegistry.mjs.map +1 -1
  16. package/dist/Screen/VirtualCursor.d.mts +57 -0
  17. package/dist/Screen/VirtualCursor.d.mts.map +1 -0
  18. package/dist/Screen/VirtualCursor.mjs +148 -0
  19. package/dist/Screen/VirtualCursor.mjs.map +1 -0
  20. package/dist/Screen/Window.d.mts +67 -6
  21. package/dist/Screen/Window.d.mts.map +1 -1
  22. package/dist/Screen/Window.mjs +195 -27
  23. package/dist/Screen/Window.mjs.map +1 -1
  24. package/dist/Screen/WindowManager.d.mts +78 -2
  25. package/dist/Screen/WindowManager.d.mts.map +1 -1
  26. package/dist/Screen/WindowManager.mjs +186 -3
  27. package/dist/Screen/WindowManager.mjs.map +1 -1
  28. package/dist/Screen/controls/TextArea.d.mts +68 -2
  29. package/dist/Screen/controls/TextArea.d.mts.map +1 -1
  30. package/dist/Screen/controls/TextArea.mjs +280 -46
  31. package/dist/Screen/controls/TextArea.mjs.map +1 -1
  32. package/dist/Screen/controls/TextBox.d.mts +52 -5
  33. package/dist/Screen/controls/TextBox.d.mts.map +1 -1
  34. package/dist/Screen/controls/TextBox.mjs +179 -10
  35. package/dist/Screen/controls/TextBox.mjs.map +1 -1
  36. package/dist/Screen/controls/Toast.d.mts +72 -0
  37. package/dist/Screen/controls/Toast.d.mts.map +1 -0
  38. package/dist/Screen/controls/Toast.mjs +112 -0
  39. package/dist/Screen/controls/Toast.mjs.map +1 -0
  40. package/dist/Screen/types.d.mts +143 -0
  41. package/dist/Screen/types.d.mts.map +1 -1
  42. package/dist/Screen/types.mjs +8 -0
  43. package/dist/Screen/types.mjs.map +1 -1
  44. package/dist/index.d.mts +4 -2
  45. package/dist/index.d.mts.map +1 -1
  46. package/dist/index.mjs +3 -1
  47. package/dist/index.mjs.map +1 -1
  48. package/package.json +1 -1
  49. package/src/Screen/ErrorHolder.mts +22 -0
  50. package/src/Screen/InterfaceBuilder.mts +12 -5
  51. package/src/Screen/Screen.mts +166 -1
  52. package/src/Screen/StyleRegistry.mts +4 -0
  53. package/src/Screen/VirtualCursor.mts +175 -0
  54. package/src/Screen/Window.mts +197 -28
  55. package/src/Screen/WindowManager.mts +192 -3
  56. package/src/Screen/controls/TextArea.mts +280 -41
  57. package/src/Screen/controls/TextBox.mts +181 -10
  58. package/src/Screen/controls/Toast.mts +138 -0
  59. package/src/Screen/types.mts +140 -0
  60. package/src/demo.mts +80 -0
  61. package/src/index.mts +12 -0
  62. package/src/layout.yaml +16 -0
@@ -1,8 +1,9 @@
1
- import type { Cell, StyleId, BorderStyle, BorderChars, WindowBorder, WindowProperties, WriteTextOptions, WriteTextInput, WriteTextSegment, TerminalSize, LayoutMode, AlignItems, JustifyContent, Padding, PaddingSpec, DimSpec } from './types.mjs';
1
+ import type { Cell, StyleId, BorderStyle, BorderChars, WindowBorder, WindowProperties, WriteTextOptions, WriteTextInput, WriteTextSegment, TerminalSize, LayoutMode, AlignItems, JustifyContent, Padding, PaddingSpec, Margin, MarginSpec, DimSpec } from './types.mjs';
2
2
  import { BUILTIN_TEXT, BUILTIN_TEXT_FOCUSED, BUILTIN_TEXT_DISABLED, BUILTIN_BORDER, BUILTIN_BORDER_FOCUSED, BUILTIN_BORDER_DISABLED } from './types.mjs';
3
3
  import { Region } from './Region.mjs';
4
4
  import { StyleRegistry } from './StyleRegistry.mjs';
5
5
  import { getRegistry } from './RegistryHolder.mjs';
6
+ import { getErrorHandler } from './ErrorHolder.mjs';
6
7
  import { charWidth, stringWidth } from './textWidth.mjs';
7
8
  import type { Pos } from './Pos.mjs';
8
9
  import { Size } from './Size.mjs';
@@ -76,6 +77,23 @@ const resolvePadding = (spec: PaddingSpec | undefined): Padding => {
76
77
  };
77
78
  };
78
79
 
80
+ /** Normalises a MarginSpec to a full Margin record (missing sides → 0).
81
+ * Shares the PaddingSpec shape so the two helpers are interchangeable. */
82
+ const resolveMargin = (spec: MarginSpec | undefined): Margin => {
83
+ if (spec === undefined) return { top: 0, right: 0, bottom: 0, left: 0 };
84
+ if (typeof spec === 'number') return { top: spec, right: spec, bottom: spec, left: spec };
85
+ if (Array.isArray(spec)) {
86
+ const [v = 0, h = 0] = spec;
87
+ return { top: v, right: h, bottom: v, left: h };
88
+ }
89
+ return {
90
+ top: spec.top ?? 0,
91
+ right: spec.right ?? 0,
92
+ bottom: spec.bottom ?? 0,
93
+ left: spec.left ?? 0,
94
+ };
95
+ };
96
+
79
97
  export class Window {
80
98
  public x: number;
81
99
  public y: number;
@@ -119,12 +137,25 @@ export class Window {
119
137
  /** Padding applied inside the border (in addition to the border inset).
120
138
  * Influences `getInnerSize()` / `getInnerOffset()`. */
121
139
  private padding: Padding;
140
+ /** Outer spacing reserved around this window inside its parent's layout.
141
+ * Consumed by the parent's layout engine (absolute/flex/grid); does not
142
+ * influence this window's inner area. */
143
+ private margin: Margin;
122
144
  /** Number of columns for `layout: 'grid'`. */
123
145
  private gridColumns: number;
124
146
  /** Cross-axis alignment for row/column layouts. */
125
147
  private alignItems: AlignItems;
126
148
  /** Main-axis distribution of leftover space when no flex-grow child consumes it. */
127
149
  private justifyContent: JustifyContent;
150
+ /** Optional identifier — copied from WindowProperties.id. Used by
151
+ * `WindowManager.focusById` and by diagnostic tooling. */
152
+ private id: string | undefined;
153
+ /** Stacking order among siblings (higher z renders on top). Default: 0. */
154
+ private zIndex: number;
155
+ /** Fires once when focused state flips from false to true. */
156
+ private onFocusHandler: (() => void) | undefined;
157
+ /** Fires once when focused state flips from true to false. */
158
+ private onBlurHandler: (() => void) | undefined;
128
159
 
129
160
  /** Creates a window from the given properties.
130
161
  * For percentage-based sizes, call addChild() before writing content to the window.
@@ -150,9 +181,14 @@ export class Window {
150
181
  this.layoutMode = wp.layout ?? 'absolute';
151
182
  this.gap = wp.gap ?? 0;
152
183
  this.padding = resolvePadding(wp.padding);
184
+ this.margin = resolveMargin(wp.margin);
153
185
  this.gridColumns = Math.max(1, wp.gridColumns ?? 1);
154
186
  this.alignItems = wp.alignItems ?? 'stretch';
155
187
  this.justifyContent = wp.justifyContent ?? 'start';
188
+ this.id = wp.id;
189
+ this.zIndex = wp.zIndex ?? 0;
190
+ this.onFocusHandler = wp.onFocus;
191
+ this.onBlurHandler = wp.onBlur;
156
192
 
157
193
  const { w, h } = size.isAbsolute() ? size.resolve(0, 0) : { w: 1, h: 1 };
158
194
  this.region = new Region(w, h);
@@ -173,6 +209,12 @@ export class Window {
173
209
  return this.region.getSize();
174
210
  }
175
211
 
212
+ /** Returns a snapshot of the resolved per-side margin (in cells). The parent's
213
+ * layout engine reads this to reserve outer spacing around the child. */
214
+ public getMargin(): Readonly<Margin> {
215
+ return { ...this.margin };
216
+ }
217
+
176
218
  /** Returns the number of cells consumed by decorations on each edge.
177
219
  * The explicit 'none' border style is treated as no border (no insets). */
178
220
  private borderInset(): { top: number; right: number; bottom: number; left: number } {
@@ -221,9 +263,58 @@ export class Window {
221
263
  this.active = active;
222
264
  }
223
265
 
224
- /** Sets the focused state. Controls use this to change visual appearance on focus. */
266
+ /** Sets the focused state. Controls use this to change visual appearance on focus.
267
+ * Fires `onFocus` / `onBlur` (configured via `WindowProperties`) when the state
268
+ * actually changes; a no-op call with the current value does not re-fire them. */
225
269
  public setFocused(focused: boolean): void {
270
+ if (this.focused === focused) return;
226
271
  this.focused = focused;
272
+ if (focused) this.onFocusHandler?.();
273
+ else this.onBlurHandler?.();
274
+ }
275
+
276
+ /** Registers (or replaces) the onFocus handler at runtime. Pass `undefined`
277
+ * to detach. Fires in `setFocused` on every false → true transition. */
278
+ public setOnFocus(handler: (() => void) | undefined): void {
279
+ this.onFocusHandler = handler;
280
+ }
281
+
282
+ /** Registers (or replaces) the onBlur handler at runtime. Pass `undefined`
283
+ * to detach. Fires in `setFocused` on every true → false transition. */
284
+ public setOnBlur(handler: (() => void) | undefined): void {
285
+ this.onBlurHandler = handler;
286
+ }
287
+
288
+ /** Returns the optional identifier set via WindowProperties.id. */
289
+ public getId(): string | undefined {
290
+ return this.id;
291
+ }
292
+
293
+ /** Sets (or clears with `undefined`) the window identifier used by
294
+ * `WindowManager.focusById` and diagnostic tooling. */
295
+ public setId(id: string | undefined): void {
296
+ this.id = id;
297
+ }
298
+
299
+ /** Returns the current stacking order. Higher values render on top of
300
+ * lower ones among siblings. Default: 0. */
301
+ public getZIndex(): number {
302
+ return this.zIndex;
303
+ }
304
+
305
+ /** Updates the stacking order. Siblings are re-sorted on the next render()
306
+ * call; call `screen.render()` (or the surrounding window) to see the
307
+ * change. Does not affect layout (flex/absolute coordinates are
308
+ * independent of z). */
309
+ public setZIndex(zIndex: number): void {
310
+ this.zIndex = zIndex;
311
+ }
312
+
313
+ /** Returns the direct children of this window in insertion order (a
314
+ * read-only view). WindowManager uses this to walk subtrees for
315
+ * `trapFocus` and `focusById` without exposing the internal array. */
316
+ public getChildren(): readonly Window[] {
317
+ return this.children;
227
318
  }
228
319
 
229
320
  /** Returns whether this window currently has keyboard focus. */
@@ -478,10 +569,53 @@ export class Window {
478
569
  this.paintBackground();
479
570
  this.blitContent();
480
571
  this.paintBorder();
481
- for (const child of this.children) {
572
+ for (const child of this.orderedByZ()) {
482
573
  if (!child.visible) continue;
483
- child.render();
574
+ try {
575
+ child.render();
576
+ this.blitChild(child);
577
+ } catch (err) {
578
+ this.paintErrorPlaceholder(child, err);
579
+ const handler = getErrorHandler();
580
+ if (handler) handler(err, child);
581
+ else throw err;
582
+ }
583
+ }
584
+ }
585
+
586
+ /** Returns direct children sorted for rendering — stable by (zIndex asc,
587
+ * insertion order). Children with higher zIndex paint last, so they
588
+ * appear on top of lower-z siblings. Layout is NOT affected: flex and
589
+ * absolute positioning run off the insertion-ordered `children` list. */
590
+ private orderedByZ(): Window[] {
591
+ if (this.children.length < 2) return this.children;
592
+ const stable = this.children.map((child, idx) => ({ child, idx }));
593
+ stable.sort((a, b) => (a.child.zIndex - b.child.zIndex) || (a.idx - b.idx));
594
+ return stable.map(e => e.child);
595
+ }
596
+
597
+ /** Renders a single-line "⚠ render error" marker over the child's
598
+ * pre-allocated region so a broken subtree never blanks the rest of the
599
+ * frame. The child is still blitted onto this window so its geometry
600
+ * (borders, siblings) remains visible in the debug layout. */
601
+ private paintErrorPlaceholder(child: Window, err: unknown): void {
602
+ const { width, height } = child.getSize();
603
+ if (width === 0 || height === 0) return;
604
+ const styleId = this.registry.register({ foreground: 196, background: 52, bold: true });
605
+ try {
606
+ child.region = new Region(width, height);
607
+ child.region.fill(' ', styleId);
608
+ const message = err instanceof Error ? err.message : String(err);
609
+ const prefix = '⚠ render error: ';
610
+ const maxLen = Math.max(0, width - prefix.length);
611
+ const text = prefix + message.slice(0, maxLen);
612
+ const chars = [...text];
613
+ for (let i = 0; i < chars.length && i < width; i++) {
614
+ child.region.setCell(i, 0, chars[i]!, styleId);
615
+ }
484
616
  this.blitChild(child);
617
+ } catch {
618
+ // Swallow — the placeholder itself must not throw further.
485
619
  }
486
620
  }
487
621
 
@@ -657,7 +791,10 @@ export class Window {
657
791
  }
658
792
 
659
793
  /** Absolute layout: each child independently resolves its Pos/Size against
660
- * the parent's inner area — the pre-flex behaviour. */
794
+ * the parent's inner area — the pre-flex behaviour. A child's `margin`
795
+ * shifts the resolved position by `(marginLeft, marginTop)` without
796
+ * altering its size, so callers can push a window away from its declared
797
+ * anchor without recomputing coordinates by hand. */
661
798
  private layoutAbsolute(pw: number, ph: number, ox: number, oy: number): void {
662
799
  for (const child of this.children) {
663
800
  if (!child.sizeSpec.isAbsolute()) {
@@ -666,8 +803,8 @@ export class Window {
666
803
  }
667
804
  const { width: cw, height: ch } = child.getSize();
668
805
  const { x, y } = child.posSpec.resolve(pw, ph, cw, ch);
669
- child.x = x + ox;
670
- child.y = y + oy;
806
+ child.x = x + ox + child.margin.left;
807
+ child.y = y + oy + child.margin.top;
671
808
  }
672
809
  }
673
810
 
@@ -681,7 +818,13 @@ export class Window {
681
818
  * distribution only when no child consumes slack via flex-grow. Invisible
682
819
  * children are skipped so `setVisible(false)` effectively removes them
683
820
  * from the stack. Children are ordered by `Pos.flex(order)` first, then
684
- * by addChild insertion (stable). */
821
+ * by addChild insertion (stable).
822
+ *
823
+ * Each child's `margin` reserves extra cells around it: the main axis
824
+ * loses `marginLeft+marginRight` (row) or `marginTop+marginBottom` (column)
825
+ * per child before distribution, and the cross axis loses `margin*` per
826
+ * child before alignment. Grow/shrink still apply to the inner size so the
827
+ * declared margin stays constant even when the child is flex-resized. */
685
828
  private layoutFlex(mode: 'row' | 'column', pw: number, ph: number, ox: number, oy: number): void {
686
829
  const ordered = this.orderedVisibleChildren();
687
830
  if (ordered.length === 0) return;
@@ -690,7 +833,14 @@ export class Window {
690
833
  const crossParent = isRow ? ph : pw;
691
834
  const gap = this.gap;
692
835
 
693
- // First pass: basis sizes per child.
836
+ // Per-child main/cross margin totals captured up-front so they stay
837
+ // constant across grow/shrink passes.
838
+ const mainMargin = (c: Window): number => isRow ? c.margin.left + c.margin.right : c.margin.top + c.margin.bottom;
839
+ const crossMargin = (c: Window): number => isRow ? c.margin.top + c.margin.bottom : c.margin.left + c.margin.right;
840
+
841
+ // First pass: basis sizes per child (inner, i.e. the child's own region
842
+ // without margin). Natural size is taken before margin so content-sized
843
+ // children keep their intrinsic footprint.
694
844
  const items = ordered.map((c): FlexItem => {
695
845
  const mainSpec = isRow ? c.sizeSpec.getWidthSpec() : c.sizeSpec.getHeightSpec();
696
846
  const crossSpec = isRow ? c.sizeSpec.getHeightSpec() : c.sizeSpec.getWidthSpec();
@@ -703,10 +853,13 @@ export class Window {
703
853
  };
704
854
  });
705
855
 
706
- // Distribute leftover main-axis space.
856
+ // Distribute leftover main-axis space. The parent sees each child's
857
+ // slot as `mainSize + mainMargin`, so margins are charged against the
858
+ // remaining space before grow/shrink.
707
859
  const totalGap = Math.max(0, items.length - 1) * gap;
860
+ const totalMargin = items.reduce((s, it) => s + mainMargin(it.child), 0);
708
861
  const totalMain = items.reduce((s, it) => s + it.mainSize, 0);
709
- let remainder = mainParent - totalMain - totalGap;
862
+ let remainder = mainParent - totalMain - totalMargin - totalGap;
710
863
  const totalGrow = items.reduce((s, it) => s + (it.mainSpec.mode === 'flex' ? it.mainSpec.grow : 0), 0);
711
864
  if (remainder > 0 && totalGrow > 0) {
712
865
  let distributed = 0;
@@ -733,17 +886,21 @@ export class Window {
733
886
  it.mainSize = Math.max(0, it.mainSize - take);
734
887
  }
735
888
  const consumed = items.reduce((s, it) => s + it.mainSize, 0);
736
- remainder = mainParent - consumed - totalGap;
889
+ remainder = mainParent - consumed - totalMargin - totalGap;
737
890
  }
738
891
  }
739
892
 
740
- // Apply cross-axis stretch where allowed.
893
+ // Apply cross-axis stretch where allowed. Cross-axis margin is charged
894
+ // before stretch/clamp so a stretched child + its margin fits within
895
+ // crossParent.
741
896
  for (const it of items) {
897
+ const cm = crossMargin(it.child);
898
+ const available = Math.max(0, crossParent - cm);
742
899
  const mode = it.crossSpec.mode;
743
900
  if (this.alignItems === 'stretch' && mode !== 'abs' && mode !== 'pct') {
744
- it.crossSize = crossParent;
901
+ it.crossSize = available;
745
902
  } else {
746
- it.crossSize = Math.min(it.crossSize, crossParent);
903
+ it.crossSize = Math.min(it.crossSize, available);
747
904
  }
748
905
  }
749
906
 
@@ -767,7 +924,10 @@ export class Window {
767
924
  }
768
925
  }
769
926
 
770
- // Write final sizes and positions.
927
+ // Write final sizes and positions. The child's main-axis starting
928
+ // coordinate is `cursor + marginLeading`; cursor then advances by
929
+ // `mainSize + mainMargin + itemSpacing` so the next slot is offset by
930
+ // this child's full footprint.
771
931
  let cursor = mainStart;
772
932
  for (const it of items) {
773
933
  const mainSize = Math.max(0, it.mainSize);
@@ -777,28 +937,34 @@ export class Window {
777
937
  if (newW !== it.child.getSize().width || newH !== it.child.getSize().height) {
778
938
  it.child.resizeRegions(newW, newH);
779
939
  }
940
+ const m = it.child.margin;
941
+ const mainLead = isRow ? m.left : m.top;
942
+ const mainTrail = isRow ? m.right : m.bottom;
943
+ const crossLead = isRow ? m.top : m.left;
944
+ const cm = crossMargin(it.child);
780
945
  let crossPos = 0;
781
946
  if (this.alignItems !== 'stretch') {
782
- const free = crossParent - crossSize;
947
+ const free = Math.max(0, crossParent - crossSize - cm);
783
948
  if (this.alignItems === 'center') crossPos = Math.floor(free / 2);
784
949
  else if (this.alignItems === 'end') crossPos = free;
785
950
  }
786
951
  if (isRow) {
787
- it.child.x = ox + cursor;
788
- it.child.y = oy + crossPos;
952
+ it.child.x = ox + cursor + mainLead;
953
+ it.child.y = oy + crossPos + crossLead;
789
954
  } else {
790
- it.child.x = ox + crossPos;
791
- it.child.y = oy + cursor;
955
+ it.child.x = ox + crossPos + crossLead;
956
+ it.child.y = oy + cursor + mainLead;
792
957
  }
793
- cursor += mainSize + itemSpacing;
958
+ cursor += mainSize + mainLead + mainTrail + itemSpacing;
794
959
  }
795
960
  }
796
961
 
797
962
  /** Grid layout: children are placed row-major into equally sized cells.
798
963
  * Each cell's width is `(innerWidth - gap * (cols - 1)) / cols` (floored);
799
964
  * heights use the same formula with the derived row count. Children are
800
- * resized to the cell dimensions and positioned at the cell's top-left.
801
- * Invisible children are skipped. */
965
+ * resized to the cell dimensions minus their own margin and positioned
966
+ * at the cell's top-left offset by `(marginLeft, marginTop)`. Invisible
967
+ * children are skipped. */
802
968
  private layoutGrid(pw: number, ph: number, ox: number, oy: number): void {
803
969
  const ordered = this.orderedVisibleChildren();
804
970
  if (ordered.length === 0) return;
@@ -811,11 +977,14 @@ export class Window {
811
977
  const child = ordered[i]!;
812
978
  const c = i % cols;
813
979
  const r = Math.floor(i / cols);
814
- if (cellW !== child.getSize().width || cellH !== child.getSize().height) {
815
- child.resizeRegions(cellW, cellH);
980
+ const m = child.margin;
981
+ const innerW = Math.max(0, cellW - m.left - m.right);
982
+ const innerH = Math.max(0, cellH - m.top - m.bottom);
983
+ if (innerW !== child.getSize().width || innerH !== child.getSize().height) {
984
+ child.resizeRegions(innerW, innerH);
816
985
  }
817
- child.x = ox + c * (cellW + gap);
818
- child.y = oy + r * (cellH + gap);
986
+ child.x = ox + c * (cellW + gap) + m.left;
987
+ child.y = oy + r * (cellH + gap) + m.top;
819
988
  }
820
989
  }
821
990
 
@@ -7,6 +7,7 @@ import type {
7
7
  } from './types.mjs';
8
8
  import { Screen } from './Screen.mjs';
9
9
  import { Window } from './Window.mjs';
10
+ import { setErrorHandler } from './ErrorHolder.mjs';
10
11
 
11
12
  // ── Internal types ────────────────────────────────────────────────────────────
12
13
 
@@ -182,6 +183,15 @@ export class WindowManager {
182
183
  private onKey?: (key: string, ctx: KeyContext) => boolean | void;
183
184
  private onMouse?: (event: TerminalMouseEvent) => void;
184
185
  private mouseEnabled: boolean;
186
+ /** Optional sink for render-time exceptions thrown by descendant windows.
187
+ * Installed into the global ErrorHolder on construction (cleared on
188
+ * stop). When unset, `Window.render()` rethrows instead of swallowing. */
189
+ private onError?: (err: unknown, control: Window) => void;
190
+ /** When non-null, focus navigation (`focusNext/Prev/First/Last`, Tab cycle)
191
+ * is constrained to focusable controls whose absolute position falls
192
+ * inside a descendant of this window. Set by `trapFocus`; cleared by the
193
+ * release callback it returns. */
194
+ private focusTrap: Window | null = null;
185
195
 
186
196
  /** Registered bindings mapped by raw key string.
187
197
  * Multiple handlers for the same key fire in insertion order; the first
@@ -212,6 +222,12 @@ export class WindowManager {
212
222
  /** Set by pause() when it showed a previously-hidden cursor — instructs
213
223
  * resume() to hide it again. Cleared after resume() or stop() uses it. */
214
224
  private pauseRestoreCursorHidden: boolean = false;
225
+ /** Active setInterval handle for cursor blink ticks, or null when
226
+ * cursor blinking is disabled (the default). */
227
+ private blinkTimer: ReturnType<typeof setInterval> | null = null;
228
+ /** Interval (ms) requested by the last `enableCursorBlink()` call.
229
+ * Kept so resume() can reinstall the timer with the same cadence. */
230
+ private blinkIntervalMs: number | null = null;
215
231
 
216
232
  /** Creates a WindowManager for the given Screen.
217
233
  * Does not start the input loop; call run() to begin. */
@@ -222,6 +238,11 @@ export class WindowManager {
222
238
  this.onKey = options?.onKey;
223
239
  this.onMouse = options?.onMouse;
224
240
  this.mouseEnabled = options?.mouse ?? false;
241
+ this.onError = options?.onError;
242
+
243
+ if (this.onError) {
244
+ setErrorHandler((err, control) => this.onError?.(err, control));
245
+ }
225
246
 
226
247
  this.mainEntries = [];
227
248
  this.mainFocusIndex = -1;
@@ -263,18 +284,86 @@ export class WindowManager {
263
284
  }
264
285
 
265
286
  /** Moves focus to the given control if it belongs to the active context and is
266
- * eligible (not disabled, not hidden via setVisible(false)). */
287
+ * eligible (not disabled, not hidden via setVisible(false)). When a focus
288
+ * trap is installed, the candidate must also be a descendant of the trap
289
+ * — otherwise the call is a no-op. */
267
290
  public setFocus(control: Focusable & Window): void {
268
291
  const entries = this.activeEntries();
269
292
  const idx = entries.findIndex(e => e.control === control);
270
293
  if (idx === -1) return;
271
294
  const candidate = entries[idx].control;
272
295
  if (candidate.isDisabled() || !candidate.isVisible()) return;
296
+ if (this.focusTrap && !this.isWithinTrap(candidate)) return;
273
297
  this.blurCurrent();
274
298
  this.setActiveFocusIndex(idx);
275
299
  control.setFocused(true);
276
300
  }
277
301
 
302
+ /** Moves focus to the next eligible control (forward cycle), skipping
303
+ * disabled, hidden, and out-of-trap entries. Equivalent to the Tab key. */
304
+ public focusNext(): void {
305
+ this.moveFocus(1);
306
+ }
307
+
308
+ /** Moves focus to the previous eligible control (reverse cycle), skipping
309
+ * disabled, hidden, and out-of-trap entries. Equivalent to Shift-Tab. */
310
+ public focusPrev(): void {
311
+ this.moveFocus(-1);
312
+ }
313
+
314
+ /** Focuses the first eligible control in the active context. No-op when
315
+ * no control is eligible. */
316
+ public focusFirst(): void {
317
+ this.focusAtEdge('first');
318
+ }
319
+
320
+ /** Focuses the last eligible control in the active context. */
321
+ public focusLast(): void {
322
+ this.focusAtEdge('last');
323
+ }
324
+
325
+ /** Focuses the control registered with the given `id` (copied from
326
+ * `WindowProperties.id` / YAML `id:`). Searches the active focus context
327
+ * only; returns `true` on success, `false` when no matching control is
328
+ * registered or the match is ineligible. */
329
+ public focusById(id: string): boolean {
330
+ const entries = this.activeEntries();
331
+ for (const entry of entries) {
332
+ if (entry.control.getId() !== id) continue;
333
+ if (!this.isFocusable(entry.control)) return false;
334
+ if (this.focusTrap && !this.isWithinTrap(entry.control)) return false;
335
+ this.blurCurrent();
336
+ this.setActiveFocusIndex(entries.indexOf(entry));
337
+ entry.control.setFocused(true);
338
+ return true;
339
+ }
340
+ return false;
341
+ }
342
+
343
+ /** Constrains focus navigation to descendants of `within`. While active,
344
+ * `focusNext / focusPrev / focusFirst / focusLast / setFocus / focusById`
345
+ * skip any control that is not a descendant of `within`; Tab / Shift-Tab
346
+ * cycle within the trapped subtree only. Nested calls stack in LIFO
347
+ * order via the returned release function — calling the release restores
348
+ * the previous trap (or clears it when this was the outermost). The
349
+ * helper is independent of the modal dialog stack, so it can be used
350
+ * for composite controls that live inside the main context. */
351
+ public trapFocus(within: Window): () => void {
352
+ const previous = this.focusTrap;
353
+ this.focusTrap = within;
354
+ return () => {
355
+ if (this.focusTrap === within) {
356
+ this.focusTrap = previous;
357
+ }
358
+ };
359
+ }
360
+
361
+ /** Returns the Window that currently defines the focus trap, or null when
362
+ * no trap is active. Exposed primarily for tests and diagnostics. */
363
+ public getFocusTrap(): Window | null {
364
+ return this.focusTrap;
365
+ }
366
+
278
367
  // ── Global key bindings (P0-4) ─────────────────────────────────────────────
279
368
 
280
369
  /** Registers a global shortcut handler.
@@ -422,6 +511,8 @@ export class WindowManager {
422
511
  this.running = false;
423
512
  this.paused = false;
424
513
 
514
+ this.uninstallBlinkTimer();
515
+
425
516
  if (wasRunning) {
426
517
  if (!wasPaused) {
427
518
  process.stdin.off('data', this.boundHandleInput);
@@ -448,6 +539,8 @@ export class WindowManager {
448
539
  this.pauseRestoreCursorHidden = false;
449
540
  }
450
541
 
542
+ if (this.onError) setErrorHandler(undefined);
543
+
451
544
  this.onExit?.();
452
545
  }
453
546
 
@@ -485,6 +578,10 @@ export class WindowManager {
485
578
  this.screen.showHardwareCursor();
486
579
  }
487
580
 
581
+ // Stop the blink timer so a spawned sub-process doesn't get its
582
+ // screen overwritten mid-frame. resume() reinstalls it.
583
+ this.uninstallBlinkTimer();
584
+
488
585
  this.pauseRestoreAltScreen = false;
489
586
  if (options?.leaveAltScreen && this.screen.isAltScreenActive()) {
490
587
  this.screen.exitAltScreen();
@@ -521,6 +618,9 @@ export class WindowManager {
521
618
  process.stdin.resume();
522
619
  process.stdin.on('data', this.boundHandleInput);
523
620
 
621
+ // Restore the blink timer with its previously configured cadence.
622
+ this.installBlinkTimer();
623
+
524
624
  if (options?.rerender !== false) {
525
625
  this.renderFrame();
526
626
  }
@@ -533,6 +633,56 @@ export class WindowManager {
533
633
  return this.paused;
534
634
  }
535
635
 
636
+ // ── Cursor blink ───────────────────────────────────────────────────────────
637
+
638
+ /** Starts a timer that periodically triggers a re-render so timed-blink
639
+ * `VirtualCursor` instances (used by focused `TextBox` / `TextArea`)
640
+ * actually animate in the terminal. Call once after `run()`; the timer
641
+ * automatically pauses during `pause()` and re-arms on `resume()`.
642
+ *
643
+ * The `intervalMs` parameter sets how often the timer fires — smaller
644
+ * values are smoother but render more frames per second. Default:
645
+ * `80` ms (≈12 Hz), enough to capture the `fast` preset (250/250) and
646
+ * the short phases of `irregular` without flooding stdout.
647
+ *
648
+ * Idempotent: calling again with a new interval replaces the existing
649
+ * timer. */
650
+ public enableCursorBlink(intervalMs: number = 80): void {
651
+ this.blinkIntervalMs = Math.max(16, intervalMs);
652
+ this.installBlinkTimer();
653
+ }
654
+
655
+ /** Stops the blink timer installed by `enableCursorBlink()`. Idempotent. */
656
+ public disableCursorBlink(): void {
657
+ this.blinkIntervalMs = null;
658
+ this.uninstallBlinkTimer();
659
+ }
660
+
661
+ /** (Re)creates the blink interval using the cached `blinkIntervalMs`. */
662
+ private installBlinkTimer(): void {
663
+ this.uninstallBlinkTimer();
664
+ if (this.blinkIntervalMs === null) return;
665
+ this.blinkTimer = setInterval(() => {
666
+ // Avoid drawing while paused — the stdin listener is detached
667
+ // and the terminal might be owned by a sub-process.
668
+ if (this.paused) return;
669
+ this.renderFrame();
670
+ }, this.blinkIntervalMs);
671
+ // Don't keep the Node event loop alive just for cursor blinks.
672
+ if (typeof this.blinkTimer === 'object' && this.blinkTimer !== null
673
+ && 'unref' in this.blinkTimer && typeof this.blinkTimer.unref === 'function') {
674
+ this.blinkTimer.unref();
675
+ }
676
+ }
677
+
678
+ /** Clears the active blink interval, if any. */
679
+ private uninstallBlinkTimer(): void {
680
+ if (this.blinkTimer !== null) {
681
+ clearInterval(this.blinkTimer);
682
+ this.blinkTimer = null;
683
+ }
684
+ }
685
+
536
686
  // ── Input handling ─────────────────────────────────────────────────────────
537
687
 
538
688
  /** Processes a raw stdin Buffer. Exposed as public so tests can drive it directly
@@ -660,9 +810,48 @@ export class WindowManager {
660
810
  }
661
811
 
662
812
  /** Returns true when the control is eligible to receive focus in the current
663
- * context — neither disabled nor hidden via `Window.setVisible(false)`. */
813
+ * context — neither disabled nor hidden via `Window.setVisible(false)`,
814
+ * and inside the active focus trap (if any). */
664
815
  private isFocusable(control: Focusable & Window): boolean {
665
- return !control.isDisabled() && control.isVisible();
816
+ if (control.isDisabled() || !control.isVisible()) return false;
817
+ if (this.focusTrap && !this.isWithinTrap(control)) return false;
818
+ return true;
819
+ }
820
+
821
+ /** Depth-first check: is `control` the trap window itself or one of its
822
+ * descendants? Used when `focusTrap` is installed to filter focus
823
+ * candidates. Cheaper than storing parent pointers because the subtree
824
+ * is usually small (the trap is a dialog or composite control). */
825
+ private isWithinTrap(control: Window): boolean {
826
+ if (!this.focusTrap) return true;
827
+ return this.isDescendant(control, this.focusTrap);
828
+ }
829
+
830
+ /** Returns true when `node` is `root` or is reachable by walking
831
+ * `root.getChildren()` recursively. */
832
+ private isDescendant(node: Window, root: Window): boolean {
833
+ if (node === root) return true;
834
+ for (const child of root.getChildren()) {
835
+ if (this.isDescendant(node, child)) return true;
836
+ }
837
+ return false;
838
+ }
839
+
840
+ /** Moves focus to the first or last eligible control in the active
841
+ * context. Shared implementation for `focusFirst` / `focusLast`. */
842
+ private focusAtEdge(edge: 'first' | 'last'): void {
843
+ const entries = this.activeEntries();
844
+ if (entries.length === 0) return;
845
+ const order = edge === 'first'
846
+ ? entries.map((_, i) => i)
847
+ : entries.map((_, i) => entries.length - 1 - i);
848
+ for (const idx of order) {
849
+ if (!this.isFocusable(entries[idx]!.control)) continue;
850
+ this.blurCurrent();
851
+ this.setActiveFocusIndex(idx);
852
+ entries[idx]!.control.setFocused(true);
853
+ return;
854
+ }
666
855
  }
667
856
 
668
857
  /** Finds the first already-focused (or first eligible) control and focuses it. */