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.
- package/CHANGELOG.md +193 -0
- package/dist/Screen/ErrorHolder.d.mts +10 -0
- package/dist/Screen/ErrorHolder.d.mts.map +1 -0
- package/dist/Screen/ErrorHolder.mjs +14 -0
- package/dist/Screen/ErrorHolder.mjs.map +1 -0
- package/dist/Screen/InterfaceBuilder.d.mts.map +1 -1
- package/dist/Screen/InterfaceBuilder.mjs +7 -0
- package/dist/Screen/InterfaceBuilder.mjs.map +1 -1
- package/dist/Screen/Screen.d.mts +50 -1
- package/dist/Screen/Screen.d.mts.map +1 -1
- package/dist/Screen/Screen.mjs +152 -0
- package/dist/Screen/Screen.mjs.map +1 -1
- package/dist/Screen/StyleRegistry.d.mts.map +1 -1
- package/dist/Screen/StyleRegistry.mjs +3 -1
- package/dist/Screen/StyleRegistry.mjs.map +1 -1
- package/dist/Screen/VirtualCursor.d.mts +57 -0
- package/dist/Screen/VirtualCursor.d.mts.map +1 -0
- package/dist/Screen/VirtualCursor.mjs +148 -0
- package/dist/Screen/VirtualCursor.mjs.map +1 -0
- package/dist/Screen/Window.d.mts +67 -6
- package/dist/Screen/Window.d.mts.map +1 -1
- package/dist/Screen/Window.mjs +195 -27
- package/dist/Screen/Window.mjs.map +1 -1
- package/dist/Screen/WindowManager.d.mts +78 -2
- package/dist/Screen/WindowManager.d.mts.map +1 -1
- package/dist/Screen/WindowManager.mjs +186 -3
- package/dist/Screen/WindowManager.mjs.map +1 -1
- package/dist/Screen/controls/TextArea.d.mts +68 -2
- package/dist/Screen/controls/TextArea.d.mts.map +1 -1
- package/dist/Screen/controls/TextArea.mjs +280 -46
- package/dist/Screen/controls/TextArea.mjs.map +1 -1
- package/dist/Screen/controls/TextBox.d.mts +52 -5
- package/dist/Screen/controls/TextBox.d.mts.map +1 -1
- package/dist/Screen/controls/TextBox.mjs +179 -10
- package/dist/Screen/controls/TextBox.mjs.map +1 -1
- package/dist/Screen/controls/Toast.d.mts +72 -0
- package/dist/Screen/controls/Toast.d.mts.map +1 -0
- package/dist/Screen/controls/Toast.mjs +112 -0
- package/dist/Screen/controls/Toast.mjs.map +1 -0
- package/dist/Screen/types.d.mts +143 -0
- package/dist/Screen/types.d.mts.map +1 -1
- package/dist/Screen/types.mjs +8 -0
- package/dist/Screen/types.mjs.map +1 -1
- package/dist/index.d.mts +4 -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 +1 -1
- package/src/Screen/ErrorHolder.mts +22 -0
- package/src/Screen/InterfaceBuilder.mts +12 -5
- package/src/Screen/Screen.mts +166 -1
- package/src/Screen/StyleRegistry.mts +4 -0
- package/src/Screen/VirtualCursor.mts +175 -0
- package/src/Screen/Window.mts +197 -28
- package/src/Screen/WindowManager.mts +192 -3
- package/src/Screen/controls/TextArea.mts +280 -41
- package/src/Screen/controls/TextBox.mts +181 -10
- package/src/Screen/controls/Toast.mts +138 -0
- package/src/Screen/types.mts +140 -0
- package/src/demo.mts +80 -0
- package/src/index.mts +12 -0
- package/src/layout.yaml +16 -0
package/src/Screen/Window.mts
CHANGED
|
@@ -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.
|
|
572
|
+
for (const child of this.orderedByZ()) {
|
|
482
573
|
if (!child.visible) continue;
|
|
483
|
-
|
|
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
|
-
//
|
|
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 =
|
|
901
|
+
it.crossSize = available;
|
|
745
902
|
} else {
|
|
746
|
-
it.crossSize = Math.min(it.crossSize,
|
|
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
|
|
801
|
-
*
|
|
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
|
-
|
|
815
|
-
|
|
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
|
-
|
|
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. */
|