take4-console 0.25.0 → 0.31.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 (107) hide show
  1. package/CHANGELOG.md +253 -0
  2. package/README.md +3 -2
  3. package/dist/Screen/ErrorHolder.d.mts +10 -0
  4. package/dist/Screen/ErrorHolder.d.mts.map +1 -0
  5. package/dist/Screen/ErrorHolder.mjs +14 -0
  6. package/dist/Screen/ErrorHolder.mjs.map +1 -0
  7. package/dist/Screen/InterfaceBuilder.d.mts.map +1 -1
  8. package/dist/Screen/InterfaceBuilder.mjs +7 -0
  9. package/dist/Screen/InterfaceBuilder.mjs.map +1 -1
  10. package/dist/Screen/Screen.d.mts +90 -1
  11. package/dist/Screen/Screen.d.mts.map +1 -1
  12. package/dist/Screen/Screen.mjs +300 -1
  13. package/dist/Screen/Screen.mjs.map +1 -1
  14. package/dist/Screen/StyleRegistry.d.mts.map +1 -1
  15. package/dist/Screen/StyleRegistry.mjs +3 -1
  16. package/dist/Screen/StyleRegistry.mjs.map +1 -1
  17. package/dist/Screen/VirtualCursor.d.mts +57 -0
  18. package/dist/Screen/VirtualCursor.d.mts.map +1 -0
  19. package/dist/Screen/VirtualCursor.mjs +148 -0
  20. package/dist/Screen/VirtualCursor.mjs.map +1 -0
  21. package/dist/Screen/Window.d.mts +116 -6
  22. package/dist/Screen/Window.d.mts.map +1 -1
  23. package/dist/Screen/Window.mjs +359 -34
  24. package/dist/Screen/Window.mjs.map +1 -1
  25. package/dist/Screen/WindowManager.d.mts +78 -2
  26. package/dist/Screen/WindowManager.d.mts.map +1 -1
  27. package/dist/Screen/WindowManager.mjs +197 -3
  28. package/dist/Screen/WindowManager.mjs.map +1 -1
  29. package/dist/Screen/controls/BarChart.d.mts.map +1 -1
  30. package/dist/Screen/controls/BarChart.mjs +3 -0
  31. package/dist/Screen/controls/BarChart.mjs.map +1 -1
  32. package/dist/Screen/controls/Checkbox.d.mts.map +1 -1
  33. package/dist/Screen/controls/Checkbox.mjs +4 -0
  34. package/dist/Screen/controls/Checkbox.mjs.map +1 -1
  35. package/dist/Screen/controls/LineChart.d.mts.map +1 -1
  36. package/dist/Screen/controls/LineChart.mjs +3 -0
  37. package/dist/Screen/controls/LineChart.mjs.map +1 -1
  38. package/dist/Screen/controls/ListBox.d.mts.map +1 -1
  39. package/dist/Screen/controls/ListBox.mjs +9 -0
  40. package/dist/Screen/controls/ListBox.mjs.map +1 -1
  41. package/dist/Screen/controls/ProgressBar.d.mts.map +1 -1
  42. package/dist/Screen/controls/ProgressBar.mjs +2 -0
  43. package/dist/Screen/controls/ProgressBar.mjs.map +1 -1
  44. package/dist/Screen/controls/ProgressBarV.d.mts.map +1 -1
  45. package/dist/Screen/controls/ProgressBarV.mjs +2 -0
  46. package/dist/Screen/controls/ProgressBarV.mjs.map +1 -1
  47. package/dist/Screen/controls/Radio.d.mts.map +1 -1
  48. package/dist/Screen/controls/Radio.mjs +7 -1
  49. package/dist/Screen/controls/Radio.mjs.map +1 -1
  50. package/dist/Screen/controls/Sparkline.d.mts.map +1 -1
  51. package/dist/Screen/controls/Sparkline.mjs +3 -0
  52. package/dist/Screen/controls/Sparkline.mjs.map +1 -1
  53. package/dist/Screen/controls/Spinner.d.mts.map +1 -1
  54. package/dist/Screen/controls/Spinner.mjs +8 -0
  55. package/dist/Screen/controls/Spinner.mjs.map +1 -1
  56. package/dist/Screen/controls/StatusLED.d.mts.map +1 -1
  57. package/dist/Screen/controls/StatusLED.mjs +3 -0
  58. package/dist/Screen/controls/StatusLED.mjs.map +1 -1
  59. package/dist/Screen/controls/Tabs.d.mts.map +1 -1
  60. package/dist/Screen/controls/Tabs.mjs +2 -0
  61. package/dist/Screen/controls/Tabs.mjs.map +1 -1
  62. package/dist/Screen/controls/TextArea.d.mts +68 -2
  63. package/dist/Screen/controls/TextArea.d.mts.map +1 -1
  64. package/dist/Screen/controls/TextArea.mjs +291 -46
  65. package/dist/Screen/controls/TextArea.mjs.map +1 -1
  66. package/dist/Screen/controls/TextBox.d.mts +52 -5
  67. package/dist/Screen/controls/TextBox.d.mts.map +1 -1
  68. package/dist/Screen/controls/TextBox.mjs +192 -10
  69. package/dist/Screen/controls/TextBox.mjs.map +1 -1
  70. package/dist/Screen/controls/Toast.d.mts +72 -0
  71. package/dist/Screen/controls/Toast.d.mts.map +1 -0
  72. package/dist/Screen/controls/Toast.mjs +112 -0
  73. package/dist/Screen/controls/Toast.mjs.map +1 -0
  74. package/dist/Screen/types.d.mts +169 -0
  75. package/dist/Screen/types.d.mts.map +1 -1
  76. package/dist/Screen/types.mjs +8 -0
  77. package/dist/Screen/types.mjs.map +1 -1
  78. package/dist/index.d.mts +4 -2
  79. package/dist/index.d.mts.map +1 -1
  80. package/dist/index.mjs +3 -1
  81. package/dist/index.mjs.map +1 -1
  82. package/package.json +1 -1
  83. package/src/Screen/ErrorHolder.mts +22 -0
  84. package/src/Screen/InterfaceBuilder.mts +12 -5
  85. package/src/Screen/Screen.mts +313 -2
  86. package/src/Screen/StyleRegistry.mts +4 -0
  87. package/src/Screen/VirtualCursor.mts +175 -0
  88. package/src/Screen/Window.mts +352 -34
  89. package/src/Screen/WindowManager.mts +203 -3
  90. package/src/Screen/controls/BarChart.mts +3 -0
  91. package/src/Screen/controls/Checkbox.mts +3 -0
  92. package/src/Screen/controls/LineChart.mts +3 -0
  93. package/src/Screen/controls/ListBox.mts +8 -0
  94. package/src/Screen/controls/ProgressBar.mts +2 -0
  95. package/src/Screen/controls/ProgressBarV.mts +2 -0
  96. package/src/Screen/controls/Radio.mts +6 -1
  97. package/src/Screen/controls/Sparkline.mts +3 -0
  98. package/src/Screen/controls/Spinner.mts +6 -0
  99. package/src/Screen/controls/StatusLED.mts +2 -0
  100. package/src/Screen/controls/Tabs.mts +2 -0
  101. package/src/Screen/controls/TextArea.mts +290 -41
  102. package/src/Screen/controls/TextBox.mts +193 -10
  103. package/src/Screen/controls/Toast.mts +138 -0
  104. package/src/Screen/types.mts +167 -0
  105. package/src/demo.mts +131 -0
  106. package/src/index.mts +13 -0
  107. 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, DirtyRect } 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,44 @@ 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;
159
+ /** Parent window (set by `addChild`, cleared by `removeChild`). Damage
160
+ * tracking walks up this chain to notify the root `Screen` of geometry
161
+ * or topology changes that need a full repaint. */
162
+ protected parent: Window | null = null;
163
+ /** Accumulated dirty region in this window's local coordinates.
164
+ * - `null` — nothing changed since the last emit.
165
+ * - `'all'` — the whole window area needs to be re-emitted.
166
+ * - `DirtyRect` — bounding box of localised writes; `markDirty()` eagerly
167
+ * unions new rects so we only ever carry one rect per window.
168
+ * Windows start as `'all'` so the very first frame re-emits every cell. */
169
+ protected dirtyRect: DirtyRect | 'all' | null = 'all';
170
+ /** Depth of nested `Window.render()` calls currently executing. Increments
171
+ * at the top of every `render()` and decrements in a `finally`, so
172
+ * controls whose `render()` override re-writes content via `this.clear()`
173
+ * / `this.writeText(...)` do not re-mark themselves dirty every frame —
174
+ * render-time writes deterministically rebuild the display buffer from
175
+ * existing state, so marking them as "user-driven dirty" would defeat the
176
+ * "skip frame when nothing changed" optimisation. */
177
+ protected static renderingDepth: number = 0;
128
178
 
129
179
  /** Creates a window from the given properties.
130
180
  * For percentage-based sizes, call addChild() before writing content to the window.
@@ -150,9 +200,14 @@ export class Window {
150
200
  this.layoutMode = wp.layout ?? 'absolute';
151
201
  this.gap = wp.gap ?? 0;
152
202
  this.padding = resolvePadding(wp.padding);
203
+ this.margin = resolveMargin(wp.margin);
153
204
  this.gridColumns = Math.max(1, wp.gridColumns ?? 1);
154
205
  this.alignItems = wp.alignItems ?? 'stretch';
155
206
  this.justifyContent = wp.justifyContent ?? 'start';
207
+ this.id = wp.id;
208
+ this.zIndex = wp.zIndex ?? 0;
209
+ this.onFocusHandler = wp.onFocus;
210
+ this.onBlurHandler = wp.onBlur;
156
211
 
157
212
  const { w, h } = size.isAbsolute() ? size.resolve(0, 0) : { w: 1, h: 1 };
158
213
  this.region = new Region(w, h);
@@ -173,6 +228,94 @@ export class Window {
173
228
  return this.region.getSize();
174
229
  }
175
230
 
231
+ // ── Damage tracking ─────────────────────────────────────────────────────
232
+
233
+ /** Marks this window (or a sub-rectangle in its local coordinates) as
234
+ * dirty. Subsequent `Screen.render()` calls emit ANSI only for cells
235
+ * inside the collected dirty rects, skipping untouched regions. When
236
+ * `rect` is omitted, the entire window is flagged. Render-time writes
237
+ * are ignored so controls' `render()` overrides do not re-mark
238
+ * themselves every frame. Safe to call many times between renders —
239
+ * new rects are unioned into the existing bounding box. */
240
+ public markDirty(rect?: DirtyRect): void {
241
+ if (Window.renderingDepth > 0) return;
242
+ if (this.dirtyRect === 'all') return;
243
+ if (rect === undefined) { this.dirtyRect = 'all'; return; }
244
+ if (rect.w <= 0 || rect.h <= 0) return;
245
+ if (this.dirtyRect === null) {
246
+ this.dirtyRect = { x: rect.x, y: rect.y, w: rect.w, h: rect.h };
247
+ return;
248
+ }
249
+ const ax0 = this.dirtyRect.x;
250
+ const ay0 = this.dirtyRect.y;
251
+ const ax1 = ax0 + this.dirtyRect.w;
252
+ const ay1 = ay0 + this.dirtyRect.h;
253
+ const bx0 = rect.x;
254
+ const by0 = rect.y;
255
+ const bx1 = bx0 + rect.w;
256
+ const by1 = by0 + rect.h;
257
+ const nx0 = Math.min(ax0, bx0);
258
+ const ny0 = Math.min(ay0, by0);
259
+ const nx1 = Math.max(ax1, bx1);
260
+ const ny1 = Math.max(ay1, by1);
261
+ this.dirtyRect = { x: nx0, y: ny0, w: nx1 - nx0, h: ny1 - ny0 };
262
+ }
263
+
264
+ /** Convenience alias — flags the entire window as dirty. Equivalent to
265
+ * calling `markDirty()` with no argument; kept as a named entry point
266
+ * so custom controls can invalidate themselves without depending on
267
+ * the default-argument behaviour. */
268
+ public invalidate(): void {
269
+ this.markDirty();
270
+ }
271
+
272
+ /** Walks the parent chain up to the root window. Damage tracking uses
273
+ * this to bubble a full-invalidation signal to the root `Screen` when
274
+ * geometry or tree topology changes, because the cells previously
275
+ * occupied by a now-moved / resized / hidden window need to be repainted
276
+ * from the content underneath them. `Screen` overrides it to toggle its
277
+ * internal full-repaint flag; plain `Window`s forward the call up. */
278
+ protected markFullInvalidation(): void {
279
+ this.parent?.markFullInvalidation();
280
+ }
281
+
282
+ /** Appends this window's dirty rect (translated into screen coordinates
283
+ * via the running offset) and every descendant's dirty rects to `acc`.
284
+ * Hidden subtrees are skipped — invisible windows don't contribute to
285
+ * the emit phase, and any state change that flips visibility already
286
+ * escalated to a full invalidation so the vacated area repaints. */
287
+ protected collectDirtyRects(offsetX: number, offsetY: number, acc: DirtyRect[]): void {
288
+ if (!this.visible) return;
289
+ if (this.dirtyRect === 'all') {
290
+ const { width, height } = this.getSize();
291
+ acc.push({ x: offsetX, y: offsetY, w: width, h: height });
292
+ } else if (this.dirtyRect !== null) {
293
+ acc.push({
294
+ x: offsetX + this.dirtyRect.x,
295
+ y: offsetY + this.dirtyRect.y,
296
+ w: this.dirtyRect.w,
297
+ h: this.dirtyRect.h,
298
+ });
299
+ }
300
+ for (const child of this.children) {
301
+ child.collectDirtyRects(offsetX + child.x, offsetY + child.y, acc);
302
+ }
303
+ }
304
+
305
+ /** Clears the dirty flag on this window and every descendant. Called by
306
+ * `Screen.render()` after the emit phase so the next frame starts from
307
+ * a clean slate. */
308
+ protected clearDirtyRecursive(): void {
309
+ this.dirtyRect = null;
310
+ for (const child of this.children) child.clearDirtyRecursive();
311
+ }
312
+
313
+ /** Returns a snapshot of the resolved per-side margin (in cells). The parent's
314
+ * layout engine reads this to reserve outer spacing around the child. */
315
+ public getMargin(): Readonly<Margin> {
316
+ return { ...this.margin };
317
+ }
318
+
176
319
  /** Returns the number of cells consumed by decorations on each edge.
177
320
  * The explicit 'none' border style is treated as no border (no insets). */
178
321
  private borderInset(): { top: number; right: number; bottom: number; left: number } {
@@ -218,12 +361,70 @@ export class Window {
218
361
 
219
362
  /** Sets the active state. Affects border and background appearance on next render(). */
220
363
  public setActive(active: boolean): void {
364
+ if (this.active === active) return;
221
365
  this.active = active;
366
+ this.markDirty();
222
367
  }
223
368
 
224
- /** Sets the focused state. Controls use this to change visual appearance on focus. */
369
+ /** Sets the focused state. Controls use this to change visual appearance on focus.
370
+ * Fires `onFocus` / `onBlur` (configured via `WindowProperties`) when the state
371
+ * actually changes; a no-op call with the current value does not re-fire them. */
225
372
  public setFocused(focused: boolean): void {
373
+ if (this.focused === focused) return;
226
374
  this.focused = focused;
375
+ this.markDirty();
376
+ if (focused) this.onFocusHandler?.();
377
+ else this.onBlurHandler?.();
378
+ }
379
+
380
+ /** Registers (or replaces) the onFocus handler at runtime. Pass `undefined`
381
+ * to detach. Fires in `setFocused` on every false → true transition. */
382
+ public setOnFocus(handler: (() => void) | undefined): void {
383
+ this.onFocusHandler = handler;
384
+ }
385
+
386
+ /** Registers (or replaces) the onBlur handler at runtime. Pass `undefined`
387
+ * to detach. Fires in `setFocused` on every true → false transition. */
388
+ public setOnBlur(handler: (() => void) | undefined): void {
389
+ this.onBlurHandler = handler;
390
+ }
391
+
392
+ /** Returns the optional identifier set via WindowProperties.id. */
393
+ public getId(): string | undefined {
394
+ return this.id;
395
+ }
396
+
397
+ /** Sets (or clears with `undefined`) the window identifier used by
398
+ * `WindowManager.focusById` and diagnostic tooling. */
399
+ public setId(id: string | undefined): void {
400
+ this.id = id;
401
+ }
402
+
403
+ /** Returns the current stacking order. Higher values render on top of
404
+ * lower ones among siblings. Default: 0. */
405
+ public getZIndex(): number {
406
+ return this.zIndex;
407
+ }
408
+
409
+ /** Updates the stacking order. Siblings are re-sorted on the next render()
410
+ * call; call `screen.render()` (or the surrounding window) to see the
411
+ * change. Does not affect layout (flex/absolute coordinates are
412
+ * independent of z). */
413
+ public setZIndex(zIndex: number): void {
414
+ if (this.zIndex === zIndex) return;
415
+ this.zIndex = zIndex;
416
+ // Re-stacking can expose or occlude any cell inside the parent's
417
+ // bounds — escalate to a full repaint so the neighbour(s) underneath
418
+ // get re-emitted along with this window.
419
+ this.markFullInvalidation();
420
+ this.markDirty();
421
+ }
422
+
423
+ /** Returns the direct children of this window in insertion order (a
424
+ * read-only view). WindowManager uses this to walk subtrees for
425
+ * `trapFocus` and `focusById` without exposing the internal array. */
426
+ public getChildren(): readonly Window[] {
427
+ return this.children;
227
428
  }
228
429
 
229
430
  /** Returns whether this window currently has keyboard focus. */
@@ -233,6 +434,7 @@ export class Window {
233
434
 
234
435
  /** Sets the disabled state and deactivates the window when disabled. */
235
436
  public setDisabled(disabled: boolean): void {
437
+ if (this.disabled !== disabled) this.markDirty();
236
438
  this.disabled = disabled;
237
439
  this.setActive(!disabled);
238
440
  }
@@ -248,7 +450,14 @@ export class Window {
248
450
  * the content buffer — previously written cells reappear verbatim on the
249
451
  * next render after the window is shown again. */
250
452
  public setVisible(visible: boolean): void {
453
+ if (this.visible === visible) return;
251
454
  this.visible = visible;
455
+ // Showing / hiding a window changes what the root Screen emits in this
456
+ // window's old bounds (the parent below may need to repaint, or the
457
+ // new reveal needs its first emit). Full invalidation is the
458
+ // conservative, always-correct choice.
459
+ this.markFullInvalidation();
460
+ this.markDirty();
252
461
  }
253
462
 
254
463
  /** Returns whether this window is currently visible. Default: true. */
@@ -258,7 +467,9 @@ export class Window {
258
467
 
259
468
  /** Sets the label text displayed by the control. */
260
469
  public setLabel(label: string): void {
470
+ if (this.label === label) return;
261
471
  this.label = label;
472
+ this.markDirty();
262
473
  }
263
474
 
264
475
  /** Returns the current label text. */
@@ -270,6 +481,7 @@ export class Window {
270
481
  * Intended for use by subclasses that need dynamic decoration (e.g. focus-state colour). */
271
482
  protected updateBorder(border: WindowBorder | boolean | undefined): void {
272
483
  this.border = resolveBorder(border);
484
+ this.markDirty();
273
485
  }
274
486
 
275
487
  /** Recomputes the border color from the current focused/disabled state.
@@ -292,7 +504,13 @@ export class Window {
292
504
  * geometry consistent when more children join the stack. */
293
505
  public addChild(child: Window): void {
294
506
  this.children.push(child);
507
+ child.parent = this;
295
508
  this.runLayout();
509
+ // Newly attached subtrees may overlap existing cells, and their own
510
+ // `dirtyRect` already defaults to `'all'` — but the parent region that
511
+ // may be exposed by later removal needs a correct baseline on the root
512
+ // Screen side, so we bubble a full invalidation for the first frame.
513
+ this.markFullInvalidation();
296
514
  }
297
515
 
298
516
  /** Returns a resolved Cell (char + CellAttributes) at (x, y) from the display buffer.
@@ -313,12 +531,14 @@ export class Window {
313
531
  public setChar(x: number, y: number, char: string): void {
314
532
  this.content.setChar(x, y, char);
315
533
  this.region.setChar(x, y, char);
534
+ this.markDirty({ x, y, w: 1, h: 1 });
316
535
  }
317
536
 
318
537
  /** Sets the character and style ID at (x, y). Throws RangeError if out of bounds. */
319
538
  public setCell(x: number, y: number, char: string, styleId: StyleId = 0): void {
320
539
  this.content.setCell(x, y, char, styleId);
321
540
  this.region.setCell(x, y, char, styleId);
541
+ this.markDirty({ x, y, w: 1, h: 1 });
322
542
  }
323
543
 
324
544
  /** Merges the given style ID onto the existing style at (x, y) without changing the character.
@@ -328,18 +548,21 @@ export class Window {
328
548
  const mergedRegion = this.registry.merge(this.region.getStyleId(x, y), styleId);
329
549
  this.content.setStyleId(x, y, mergedContent);
330
550
  this.region.setStyleId(x, y, mergedRegion);
551
+ this.markDirty({ x, y, w: 1, h: 1 });
331
552
  }
332
553
 
333
554
  /** Resets every cell to a blank space with style ID 0. */
334
555
  public clear(): void {
335
556
  this.content.clear();
336
557
  this.region.clear();
558
+ this.markDirty();
337
559
  }
338
560
 
339
561
  /** Fills every cell with the given character and style ID. */
340
562
  public fill(char: string, styleId: StyleId = 0): void {
341
563
  this.content.fill(char, styleId);
342
564
  this.region.fill(char, styleId);
565
+ this.markDirty();
343
566
  }
344
567
 
345
568
  /** Writes text into the window's content area starting at (x, y) (default 0, 0).
@@ -474,14 +697,62 @@ export class Window {
474
697
  */
475
698
  public render(): void {
476
699
  if (!this.visible) return;
477
- this.syncBorderColor();
478
- this.paintBackground();
479
- this.blitContent();
480
- this.paintBorder();
481
- for (const child of this.children) {
482
- if (!child.visible) continue;
483
- child.render();
700
+ Window.renderingDepth++;
701
+ try {
702
+ this.syncBorderColor();
703
+ this.paintBackground();
704
+ this.blitContent();
705
+ this.paintBorder();
706
+ for (const child of this.orderedByZ()) {
707
+ if (!child.visible) continue;
708
+ try {
709
+ child.render();
710
+ this.blitChild(child);
711
+ } catch (err) {
712
+ this.paintErrorPlaceholder(child, err);
713
+ const handler = getErrorHandler();
714
+ if (handler) handler(err, child);
715
+ else throw err;
716
+ }
717
+ }
718
+ } finally {
719
+ Window.renderingDepth--;
720
+ }
721
+ }
722
+
723
+ /** Returns direct children sorted for rendering — stable by (zIndex asc,
724
+ * insertion order). Children with higher zIndex paint last, so they
725
+ * appear on top of lower-z siblings. Layout is NOT affected: flex and
726
+ * absolute positioning run off the insertion-ordered `children` list. */
727
+ private orderedByZ(): Window[] {
728
+ if (this.children.length < 2) return this.children;
729
+ const stable = this.children.map((child, idx) => ({ child, idx }));
730
+ stable.sort((a, b) => (a.child.zIndex - b.child.zIndex) || (a.idx - b.idx));
731
+ return stable.map(e => e.child);
732
+ }
733
+
734
+ /** Renders a single-line "⚠ render error" marker over the child's
735
+ * pre-allocated region so a broken subtree never blanks the rest of the
736
+ * frame. The child is still blitted onto this window so its geometry
737
+ * (borders, siblings) remains visible in the debug layout. */
738
+ private paintErrorPlaceholder(child: Window, err: unknown): void {
739
+ const { width, height } = child.getSize();
740
+ if (width === 0 || height === 0) return;
741
+ const styleId = this.registry.register({ foreground: 196, background: 52, bold: true });
742
+ try {
743
+ child.region = new Region(width, height);
744
+ child.region.fill(' ', styleId);
745
+ const message = err instanceof Error ? err.message : String(err);
746
+ const prefix = '⚠ render error: ';
747
+ const maxLen = Math.max(0, width - prefix.length);
748
+ const text = prefix + message.slice(0, maxLen);
749
+ const chars = [...text];
750
+ for (let i = 0; i < chars.length && i < width; i++) {
751
+ child.region.setCell(i, 0, chars[i]!, styleId);
752
+ }
484
753
  this.blitChild(child);
754
+ } catch {
755
+ // Swallow — the placeholder itself must not throw further.
485
756
  }
486
757
  }
487
758
 
@@ -614,7 +885,13 @@ export class Window {
614
885
  /** Removes a previously added child window. No-op if the child is not found. */
615
886
  public removeChild(child: Window): void {
616
887
  const idx = this.children.indexOf(child);
617
- if (idx !== -1) this.children.splice(idx, 1);
888
+ if (idx !== -1) {
889
+ this.children.splice(idx, 1);
890
+ child.parent = null;
891
+ // The vacated rectangle has to be re-emitted from the content
892
+ // underneath — escalate so the root Screen repaints everything.
893
+ this.markFullInvalidation();
894
+ }
618
895
  }
619
896
 
620
897
  /** Replaces both internal regions with new ones of the given dimensions,
@@ -622,6 +899,12 @@ export class Window {
622
899
  protected resizeRegions(w: number, h: number): void {
623
900
  this.region = new Region(w, h);
624
901
  this.content = new Region(w, h);
902
+ // A new region invalidates any per-cell dirty bookkeeping from the
903
+ // previous size — escalate to a full-window repaint and ask the
904
+ // Screen to emit from scratch because the old footprint on stdout
905
+ // may include cells that are no longer part of this window.
906
+ this.dirtyRect = 'all';
907
+ this.markFullInvalidation();
625
908
  this.reflowChildren();
626
909
  }
627
910
 
@@ -657,7 +940,10 @@ export class Window {
657
940
  }
658
941
 
659
942
  /** Absolute layout: each child independently resolves its Pos/Size against
660
- * the parent's inner area — the pre-flex behaviour. */
943
+ * the parent's inner area — the pre-flex behaviour. A child's `margin`
944
+ * shifts the resolved position by `(marginLeft, marginTop)` without
945
+ * altering its size, so callers can push a window away from its declared
946
+ * anchor without recomputing coordinates by hand. */
661
947
  private layoutAbsolute(pw: number, ph: number, ox: number, oy: number): void {
662
948
  for (const child of this.children) {
663
949
  if (!child.sizeSpec.isAbsolute()) {
@@ -666,8 +952,8 @@ export class Window {
666
952
  }
667
953
  const { width: cw, height: ch } = child.getSize();
668
954
  const { x, y } = child.posSpec.resolve(pw, ph, cw, ch);
669
- child.x = x + ox;
670
- child.y = y + oy;
955
+ child.x = x + ox + child.margin.left;
956
+ child.y = y + oy + child.margin.top;
671
957
  }
672
958
  }
673
959
 
@@ -681,7 +967,13 @@ export class Window {
681
967
  * distribution only when no child consumes slack via flex-grow. Invisible
682
968
  * children are skipped so `setVisible(false)` effectively removes them
683
969
  * from the stack. Children are ordered by `Pos.flex(order)` first, then
684
- * by addChild insertion (stable). */
970
+ * by addChild insertion (stable).
971
+ *
972
+ * Each child's `margin` reserves extra cells around it: the main axis
973
+ * loses `marginLeft+marginRight` (row) or `marginTop+marginBottom` (column)
974
+ * per child before distribution, and the cross axis loses `margin*` per
975
+ * child before alignment. Grow/shrink still apply to the inner size so the
976
+ * declared margin stays constant even when the child is flex-resized. */
685
977
  private layoutFlex(mode: 'row' | 'column', pw: number, ph: number, ox: number, oy: number): void {
686
978
  const ordered = this.orderedVisibleChildren();
687
979
  if (ordered.length === 0) return;
@@ -690,7 +982,14 @@ export class Window {
690
982
  const crossParent = isRow ? ph : pw;
691
983
  const gap = this.gap;
692
984
 
693
- // First pass: basis sizes per child.
985
+ // Per-child main/cross margin totals captured up-front so they stay
986
+ // constant across grow/shrink passes.
987
+ const mainMargin = (c: Window): number => isRow ? c.margin.left + c.margin.right : c.margin.top + c.margin.bottom;
988
+ const crossMargin = (c: Window): number => isRow ? c.margin.top + c.margin.bottom : c.margin.left + c.margin.right;
989
+
990
+ // First pass: basis sizes per child (inner, i.e. the child's own region
991
+ // without margin). Natural size is taken before margin so content-sized
992
+ // children keep their intrinsic footprint.
694
993
  const items = ordered.map((c): FlexItem => {
695
994
  const mainSpec = isRow ? c.sizeSpec.getWidthSpec() : c.sizeSpec.getHeightSpec();
696
995
  const crossSpec = isRow ? c.sizeSpec.getHeightSpec() : c.sizeSpec.getWidthSpec();
@@ -703,10 +1002,13 @@ export class Window {
703
1002
  };
704
1003
  });
705
1004
 
706
- // Distribute leftover main-axis space.
1005
+ // Distribute leftover main-axis space. The parent sees each child's
1006
+ // slot as `mainSize + mainMargin`, so margins are charged against the
1007
+ // remaining space before grow/shrink.
707
1008
  const totalGap = Math.max(0, items.length - 1) * gap;
1009
+ const totalMargin = items.reduce((s, it) => s + mainMargin(it.child), 0);
708
1010
  const totalMain = items.reduce((s, it) => s + it.mainSize, 0);
709
- let remainder = mainParent - totalMain - totalGap;
1011
+ let remainder = mainParent - totalMain - totalMargin - totalGap;
710
1012
  const totalGrow = items.reduce((s, it) => s + (it.mainSpec.mode === 'flex' ? it.mainSpec.grow : 0), 0);
711
1013
  if (remainder > 0 && totalGrow > 0) {
712
1014
  let distributed = 0;
@@ -733,17 +1035,21 @@ export class Window {
733
1035
  it.mainSize = Math.max(0, it.mainSize - take);
734
1036
  }
735
1037
  const consumed = items.reduce((s, it) => s + it.mainSize, 0);
736
- remainder = mainParent - consumed - totalGap;
1038
+ remainder = mainParent - consumed - totalMargin - totalGap;
737
1039
  }
738
1040
  }
739
1041
 
740
- // Apply cross-axis stretch where allowed.
1042
+ // Apply cross-axis stretch where allowed. Cross-axis margin is charged
1043
+ // before stretch/clamp so a stretched child + its margin fits within
1044
+ // crossParent.
741
1045
  for (const it of items) {
1046
+ const cm = crossMargin(it.child);
1047
+ const available = Math.max(0, crossParent - cm);
742
1048
  const mode = it.crossSpec.mode;
743
1049
  if (this.alignItems === 'stretch' && mode !== 'abs' && mode !== 'pct') {
744
- it.crossSize = crossParent;
1050
+ it.crossSize = available;
745
1051
  } else {
746
- it.crossSize = Math.min(it.crossSize, crossParent);
1052
+ it.crossSize = Math.min(it.crossSize, available);
747
1053
  }
748
1054
  }
749
1055
 
@@ -767,7 +1073,10 @@ export class Window {
767
1073
  }
768
1074
  }
769
1075
 
770
- // Write final sizes and positions.
1076
+ // Write final sizes and positions. The child's main-axis starting
1077
+ // coordinate is `cursor + marginLeading`; cursor then advances by
1078
+ // `mainSize + mainMargin + itemSpacing` so the next slot is offset by
1079
+ // this child's full footprint.
771
1080
  let cursor = mainStart;
772
1081
  for (const it of items) {
773
1082
  const mainSize = Math.max(0, it.mainSize);
@@ -777,28 +1086,34 @@ export class Window {
777
1086
  if (newW !== it.child.getSize().width || newH !== it.child.getSize().height) {
778
1087
  it.child.resizeRegions(newW, newH);
779
1088
  }
1089
+ const m = it.child.margin;
1090
+ const mainLead = isRow ? m.left : m.top;
1091
+ const mainTrail = isRow ? m.right : m.bottom;
1092
+ const crossLead = isRow ? m.top : m.left;
1093
+ const cm = crossMargin(it.child);
780
1094
  let crossPos = 0;
781
1095
  if (this.alignItems !== 'stretch') {
782
- const free = crossParent - crossSize;
1096
+ const free = Math.max(0, crossParent - crossSize - cm);
783
1097
  if (this.alignItems === 'center') crossPos = Math.floor(free / 2);
784
1098
  else if (this.alignItems === 'end') crossPos = free;
785
1099
  }
786
1100
  if (isRow) {
787
- it.child.x = ox + cursor;
788
- it.child.y = oy + crossPos;
1101
+ it.child.x = ox + cursor + mainLead;
1102
+ it.child.y = oy + crossPos + crossLead;
789
1103
  } else {
790
- it.child.x = ox + crossPos;
791
- it.child.y = oy + cursor;
1104
+ it.child.x = ox + crossPos + crossLead;
1105
+ it.child.y = oy + cursor + mainLead;
792
1106
  }
793
- cursor += mainSize + itemSpacing;
1107
+ cursor += mainSize + mainLead + mainTrail + itemSpacing;
794
1108
  }
795
1109
  }
796
1110
 
797
1111
  /** Grid layout: children are placed row-major into equally sized cells.
798
1112
  * Each cell's width is `(innerWidth - gap * (cols - 1)) / cols` (floored);
799
1113
  * 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. */
1114
+ * resized to the cell dimensions minus their own margin and positioned
1115
+ * at the cell's top-left offset by `(marginLeft, marginTop)`. Invisible
1116
+ * children are skipped. */
802
1117
  private layoutGrid(pw: number, ph: number, ox: number, oy: number): void {
803
1118
  const ordered = this.orderedVisibleChildren();
804
1119
  if (ordered.length === 0) return;
@@ -811,11 +1126,14 @@ export class Window {
811
1126
  const child = ordered[i]!;
812
1127
  const c = i % cols;
813
1128
  const r = Math.floor(i / cols);
814
- if (cellW !== child.getSize().width || cellH !== child.getSize().height) {
815
- child.resizeRegions(cellW, cellH);
1129
+ const m = child.margin;
1130
+ const innerW = Math.max(0, cellW - m.left - m.right);
1131
+ const innerH = Math.max(0, cellH - m.top - m.bottom);
1132
+ if (innerW !== child.getSize().width || innerH !== child.getSize().height) {
1133
+ child.resizeRegions(innerW, innerH);
816
1134
  }
817
- child.x = ox + c * (cellW + gap);
818
- child.y = oy + r * (cellH + gap);
1135
+ child.x = ox + c * (cellW + gap) + m.left;
1136
+ child.y = oy + r * (cellH + gap) + m.top;
819
1137
  }
820
1138
  }
821
1139