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,6 +1,7 @@
1
1
  import { BUILTIN_TEXT, BUILTIN_TEXT_FOCUSED, BUILTIN_TEXT_DISABLED, BUILTIN_BORDER, BUILTIN_BORDER_FOCUSED, BUILTIN_BORDER_DISABLED } from './types.mjs';
2
2
  import { Region } from './Region.mjs';
3
3
  import { getRegistry } from './RegistryHolder.mjs';
4
+ import { getErrorHandler } from './ErrorHolder.mjs';
4
5
  import { charWidth, stringWidth } from './textWidth.mjs';
5
6
  import { Size } from './Size.mjs';
6
7
  /** Glyph table for each visual border style. The 'none' style is handled
@@ -63,6 +64,24 @@ const resolvePadding = (spec) => {
63
64
  left: spec.left ?? 0,
64
65
  };
65
66
  };
67
+ /** Normalises a MarginSpec to a full Margin record (missing sides → 0).
68
+ * Shares the PaddingSpec shape so the two helpers are interchangeable. */
69
+ const resolveMargin = (spec) => {
70
+ if (spec === undefined)
71
+ return { top: 0, right: 0, bottom: 0, left: 0 };
72
+ if (typeof spec === 'number')
73
+ return { top: spec, right: spec, bottom: spec, left: spec };
74
+ if (Array.isArray(spec)) {
75
+ const [v = 0, h = 0] = spec;
76
+ return { top: v, right: h, bottom: v, left: h };
77
+ }
78
+ return {
79
+ top: spec.top ?? 0,
80
+ right: spec.right ?? 0,
81
+ bottom: spec.bottom ?? 0,
82
+ left: spec.left ?? 0,
83
+ };
84
+ };
66
85
  export class Window {
67
86
  x;
68
87
  y;
@@ -105,12 +124,44 @@ export class Window {
105
124
  /** Padding applied inside the border (in addition to the border inset).
106
125
  * Influences `getInnerSize()` / `getInnerOffset()`. */
107
126
  padding;
127
+ /** Outer spacing reserved around this window inside its parent's layout.
128
+ * Consumed by the parent's layout engine (absolute/flex/grid); does not
129
+ * influence this window's inner area. */
130
+ margin;
108
131
  /** Number of columns for `layout: 'grid'`. */
109
132
  gridColumns;
110
133
  /** Cross-axis alignment for row/column layouts. */
111
134
  alignItems;
112
135
  /** Main-axis distribution of leftover space when no flex-grow child consumes it. */
113
136
  justifyContent;
137
+ /** Optional identifier — copied from WindowProperties.id. Used by
138
+ * `WindowManager.focusById` and by diagnostic tooling. */
139
+ id;
140
+ /** Stacking order among siblings (higher z renders on top). Default: 0. */
141
+ zIndex;
142
+ /** Fires once when focused state flips from false to true. */
143
+ onFocusHandler;
144
+ /** Fires once when focused state flips from true to false. */
145
+ onBlurHandler;
146
+ /** Parent window (set by `addChild`, cleared by `removeChild`). Damage
147
+ * tracking walks up this chain to notify the root `Screen` of geometry
148
+ * or topology changes that need a full repaint. */
149
+ parent = null;
150
+ /** Accumulated dirty region in this window's local coordinates.
151
+ * - `null` — nothing changed since the last emit.
152
+ * - `'all'` — the whole window area needs to be re-emitted.
153
+ * - `DirtyRect` — bounding box of localised writes; `markDirty()` eagerly
154
+ * unions new rects so we only ever carry one rect per window.
155
+ * Windows start as `'all'` so the very first frame re-emits every cell. */
156
+ dirtyRect = 'all';
157
+ /** Depth of nested `Window.render()` calls currently executing. Increments
158
+ * at the top of every `render()` and decrements in a `finally`, so
159
+ * controls whose `render()` override re-writes content via `this.clear()`
160
+ * / `this.writeText(...)` do not re-mark themselves dirty every frame —
161
+ * render-time writes deterministically rebuild the display buffer from
162
+ * existing state, so marking them as "user-driven dirty" would defeat the
163
+ * "skip frame when nothing changed" optimisation. */
164
+ static renderingDepth = 0;
114
165
  /** Creates a window from the given properties.
115
166
  * For percentage-based sizes, call addChild() before writing content to the window.
116
167
  * Uses the global StyleRegistry set by the Screen constructor. */
@@ -134,9 +185,14 @@ export class Window {
134
185
  this.layoutMode = wp.layout ?? 'absolute';
135
186
  this.gap = wp.gap ?? 0;
136
187
  this.padding = resolvePadding(wp.padding);
188
+ this.margin = resolveMargin(wp.margin);
137
189
  this.gridColumns = Math.max(1, wp.gridColumns ?? 1);
138
190
  this.alignItems = wp.alignItems ?? 'stretch';
139
191
  this.justifyContent = wp.justifyContent ?? 'start';
192
+ this.id = wp.id;
193
+ this.zIndex = wp.zIndex ?? 0;
194
+ this.onFocusHandler = wp.onFocus;
195
+ this.onBlurHandler = wp.onBlur;
140
196
  const { w, h } = size.isAbsolute() ? size.resolve(0, 0) : { w: 1, h: 1 };
141
197
  this.region = new Region(w, h);
142
198
  this.content = new Region(w, h);
@@ -154,6 +210,96 @@ export class Window {
154
210
  getSize() {
155
211
  return this.region.getSize();
156
212
  }
213
+ // ── Damage tracking ─────────────────────────────────────────────────────
214
+ /** Marks this window (or a sub-rectangle in its local coordinates) as
215
+ * dirty. Subsequent `Screen.render()` calls emit ANSI only for cells
216
+ * inside the collected dirty rects, skipping untouched regions. When
217
+ * `rect` is omitted, the entire window is flagged. Render-time writes
218
+ * are ignored so controls' `render()` overrides do not re-mark
219
+ * themselves every frame. Safe to call many times between renders —
220
+ * new rects are unioned into the existing bounding box. */
221
+ markDirty(rect) {
222
+ if (Window.renderingDepth > 0)
223
+ return;
224
+ if (this.dirtyRect === 'all')
225
+ return;
226
+ if (rect === undefined) {
227
+ this.dirtyRect = 'all';
228
+ return;
229
+ }
230
+ if (rect.w <= 0 || rect.h <= 0)
231
+ return;
232
+ if (this.dirtyRect === null) {
233
+ this.dirtyRect = { x: rect.x, y: rect.y, w: rect.w, h: rect.h };
234
+ return;
235
+ }
236
+ const ax0 = this.dirtyRect.x;
237
+ const ay0 = this.dirtyRect.y;
238
+ const ax1 = ax0 + this.dirtyRect.w;
239
+ const ay1 = ay0 + this.dirtyRect.h;
240
+ const bx0 = rect.x;
241
+ const by0 = rect.y;
242
+ const bx1 = bx0 + rect.w;
243
+ const by1 = by0 + rect.h;
244
+ const nx0 = Math.min(ax0, bx0);
245
+ const ny0 = Math.min(ay0, by0);
246
+ const nx1 = Math.max(ax1, bx1);
247
+ const ny1 = Math.max(ay1, by1);
248
+ this.dirtyRect = { x: nx0, y: ny0, w: nx1 - nx0, h: ny1 - ny0 };
249
+ }
250
+ /** Convenience alias — flags the entire window as dirty. Equivalent to
251
+ * calling `markDirty()` with no argument; kept as a named entry point
252
+ * so custom controls can invalidate themselves without depending on
253
+ * the default-argument behaviour. */
254
+ invalidate() {
255
+ this.markDirty();
256
+ }
257
+ /** Walks the parent chain up to the root window. Damage tracking uses
258
+ * this to bubble a full-invalidation signal to the root `Screen` when
259
+ * geometry or tree topology changes, because the cells previously
260
+ * occupied by a now-moved / resized / hidden window need to be repainted
261
+ * from the content underneath them. `Screen` overrides it to toggle its
262
+ * internal full-repaint flag; plain `Window`s forward the call up. */
263
+ markFullInvalidation() {
264
+ this.parent?.markFullInvalidation();
265
+ }
266
+ /** Appends this window's dirty rect (translated into screen coordinates
267
+ * via the running offset) and every descendant's dirty rects to `acc`.
268
+ * Hidden subtrees are skipped — invisible windows don't contribute to
269
+ * the emit phase, and any state change that flips visibility already
270
+ * escalated to a full invalidation so the vacated area repaints. */
271
+ collectDirtyRects(offsetX, offsetY, acc) {
272
+ if (!this.visible)
273
+ return;
274
+ if (this.dirtyRect === 'all') {
275
+ const { width, height } = this.getSize();
276
+ acc.push({ x: offsetX, y: offsetY, w: width, h: height });
277
+ }
278
+ else if (this.dirtyRect !== null) {
279
+ acc.push({
280
+ x: offsetX + this.dirtyRect.x,
281
+ y: offsetY + this.dirtyRect.y,
282
+ w: this.dirtyRect.w,
283
+ h: this.dirtyRect.h,
284
+ });
285
+ }
286
+ for (const child of this.children) {
287
+ child.collectDirtyRects(offsetX + child.x, offsetY + child.y, acc);
288
+ }
289
+ }
290
+ /** Clears the dirty flag on this window and every descendant. Called by
291
+ * `Screen.render()` after the emit phase so the next frame starts from
292
+ * a clean slate. */
293
+ clearDirtyRecursive() {
294
+ this.dirtyRect = null;
295
+ for (const child of this.children)
296
+ child.clearDirtyRecursive();
297
+ }
298
+ /** Returns a snapshot of the resolved per-side margin (in cells). The parent's
299
+ * layout engine reads this to reserve outer spacing around the child. */
300
+ getMargin() {
301
+ return { ...this.margin };
302
+ }
157
303
  /** Returns the number of cells consumed by decorations on each edge.
158
304
  * The explicit 'none' border style is treated as no border (no insets). */
159
305
  borderInset() {
@@ -196,11 +342,67 @@ export class Window {
196
342
  }
197
343
  /** Sets the active state. Affects border and background appearance on next render(). */
198
344
  setActive(active) {
345
+ if (this.active === active)
346
+ return;
199
347
  this.active = active;
348
+ this.markDirty();
200
349
  }
201
- /** Sets the focused state. Controls use this to change visual appearance on focus. */
350
+ /** Sets the focused state. Controls use this to change visual appearance on focus.
351
+ * Fires `onFocus` / `onBlur` (configured via `WindowProperties`) when the state
352
+ * actually changes; a no-op call with the current value does not re-fire them. */
202
353
  setFocused(focused) {
354
+ if (this.focused === focused)
355
+ return;
203
356
  this.focused = focused;
357
+ this.markDirty();
358
+ if (focused)
359
+ this.onFocusHandler?.();
360
+ else
361
+ this.onBlurHandler?.();
362
+ }
363
+ /** Registers (or replaces) the onFocus handler at runtime. Pass `undefined`
364
+ * to detach. Fires in `setFocused` on every false → true transition. */
365
+ setOnFocus(handler) {
366
+ this.onFocusHandler = handler;
367
+ }
368
+ /** Registers (or replaces) the onBlur handler at runtime. Pass `undefined`
369
+ * to detach. Fires in `setFocused` on every true → false transition. */
370
+ setOnBlur(handler) {
371
+ this.onBlurHandler = handler;
372
+ }
373
+ /** Returns the optional identifier set via WindowProperties.id. */
374
+ getId() {
375
+ return this.id;
376
+ }
377
+ /** Sets (or clears with `undefined`) the window identifier used by
378
+ * `WindowManager.focusById` and diagnostic tooling. */
379
+ setId(id) {
380
+ this.id = id;
381
+ }
382
+ /** Returns the current stacking order. Higher values render on top of
383
+ * lower ones among siblings. Default: 0. */
384
+ getZIndex() {
385
+ return this.zIndex;
386
+ }
387
+ /** Updates the stacking order. Siblings are re-sorted on the next render()
388
+ * call; call `screen.render()` (or the surrounding window) to see the
389
+ * change. Does not affect layout (flex/absolute coordinates are
390
+ * independent of z). */
391
+ setZIndex(zIndex) {
392
+ if (this.zIndex === zIndex)
393
+ return;
394
+ this.zIndex = zIndex;
395
+ // Re-stacking can expose or occlude any cell inside the parent's
396
+ // bounds — escalate to a full repaint so the neighbour(s) underneath
397
+ // get re-emitted along with this window.
398
+ this.markFullInvalidation();
399
+ this.markDirty();
400
+ }
401
+ /** Returns the direct children of this window in insertion order (a
402
+ * read-only view). WindowManager uses this to walk subtrees for
403
+ * `trapFocus` and `focusById` without exposing the internal array. */
404
+ getChildren() {
405
+ return this.children;
204
406
  }
205
407
  /** Returns whether this window currently has keyboard focus. */
206
408
  isFocused() {
@@ -208,6 +410,8 @@ export class Window {
208
410
  }
209
411
  /** Sets the disabled state and deactivates the window when disabled. */
210
412
  setDisabled(disabled) {
413
+ if (this.disabled !== disabled)
414
+ this.markDirty();
211
415
  this.disabled = disabled;
212
416
  this.setActive(!disabled);
213
417
  }
@@ -221,7 +425,15 @@ export class Window {
221
425
  * the content buffer — previously written cells reappear verbatim on the
222
426
  * next render after the window is shown again. */
223
427
  setVisible(visible) {
428
+ if (this.visible === visible)
429
+ return;
224
430
  this.visible = visible;
431
+ // Showing / hiding a window changes what the root Screen emits in this
432
+ // window's old bounds (the parent below may need to repaint, or the
433
+ // new reveal needs its first emit). Full invalidation is the
434
+ // conservative, always-correct choice.
435
+ this.markFullInvalidation();
436
+ this.markDirty();
225
437
  }
226
438
  /** Returns whether this window is currently visible. Default: true. */
227
439
  isVisible() {
@@ -229,7 +441,10 @@ export class Window {
229
441
  }
230
442
  /** Sets the label text displayed by the control. */
231
443
  setLabel(label) {
444
+ if (this.label === label)
445
+ return;
232
446
  this.label = label;
447
+ this.markDirty();
233
448
  }
234
449
  /** Returns the current label text. */
235
450
  getLabel() {
@@ -239,6 +454,7 @@ export class Window {
239
454
  * Intended for use by subclasses that need dynamic decoration (e.g. focus-state colour). */
240
455
  updateBorder(border) {
241
456
  this.border = resolveBorder(border);
457
+ this.markDirty();
242
458
  }
243
459
  /** Recomputes the border color from the current focused/disabled state.
244
460
  * Called automatically at the start of render() so subclasses never need to do it manually.
@@ -260,7 +476,13 @@ export class Window {
260
476
  * geometry consistent when more children join the stack. */
261
477
  addChild(child) {
262
478
  this.children.push(child);
479
+ child.parent = this;
263
480
  this.runLayout();
481
+ // Newly attached subtrees may overlap existing cells, and their own
482
+ // `dirtyRect` already defaults to `'all'` — but the parent region that
483
+ // may be exposed by later removal needs a correct baseline on the root
484
+ // Screen side, so we bubble a full invalidation for the first frame.
485
+ this.markFullInvalidation();
264
486
  }
265
487
  /** Returns a resolved Cell (char + CellAttributes) at (x, y) from the display buffer.
266
488
  * Throws RangeError if out of bounds. Throws Error when the window is
@@ -279,11 +501,13 @@ export class Window {
279
501
  setChar(x, y, char) {
280
502
  this.content.setChar(x, y, char);
281
503
  this.region.setChar(x, y, char);
504
+ this.markDirty({ x, y, w: 1, h: 1 });
282
505
  }
283
506
  /** Sets the character and style ID at (x, y). Throws RangeError if out of bounds. */
284
507
  setCell(x, y, char, styleId = 0) {
285
508
  this.content.setCell(x, y, char, styleId);
286
509
  this.region.setCell(x, y, char, styleId);
510
+ this.markDirty({ x, y, w: 1, h: 1 });
287
511
  }
288
512
  /** Merges the given style ID onto the existing style at (x, y) without changing the character.
289
513
  * Throws RangeError if out of bounds. */
@@ -292,16 +516,19 @@ export class Window {
292
516
  const mergedRegion = this.registry.merge(this.region.getStyleId(x, y), styleId);
293
517
  this.content.setStyleId(x, y, mergedContent);
294
518
  this.region.setStyleId(x, y, mergedRegion);
519
+ this.markDirty({ x, y, w: 1, h: 1 });
295
520
  }
296
521
  /** Resets every cell to a blank space with style ID 0. */
297
522
  clear() {
298
523
  this.content.clear();
299
524
  this.region.clear();
525
+ this.markDirty();
300
526
  }
301
527
  /** Fills every cell with the given character and style ID. */
302
528
  fill(char, styleId = 0) {
303
529
  this.content.fill(char, styleId);
304
530
  this.region.fill(char, styleId);
531
+ this.markDirty();
305
532
  }
306
533
  /** Writes text into the window's content area starting at (x, y) (default 0, 0).
307
534
  * Coordinates are relative to the inner content area (i.e. decorations such as borders are excluded).
@@ -447,16 +674,69 @@ export class Window {
447
674
  render() {
448
675
  if (!this.visible)
449
676
  return;
450
- this.syncBorderColor();
451
- this.paintBackground();
452
- this.blitContent();
453
- this.paintBorder();
454
- for (const child of this.children) {
455
- if (!child.visible)
456
- continue;
457
- child.render();
677
+ Window.renderingDepth++;
678
+ try {
679
+ this.syncBorderColor();
680
+ this.paintBackground();
681
+ this.blitContent();
682
+ this.paintBorder();
683
+ for (const child of this.orderedByZ()) {
684
+ if (!child.visible)
685
+ continue;
686
+ try {
687
+ child.render();
688
+ this.blitChild(child);
689
+ }
690
+ catch (err) {
691
+ this.paintErrorPlaceholder(child, err);
692
+ const handler = getErrorHandler();
693
+ if (handler)
694
+ handler(err, child);
695
+ else
696
+ throw err;
697
+ }
698
+ }
699
+ }
700
+ finally {
701
+ Window.renderingDepth--;
702
+ }
703
+ }
704
+ /** Returns direct children sorted for rendering — stable by (zIndex asc,
705
+ * insertion order). Children with higher zIndex paint last, so they
706
+ * appear on top of lower-z siblings. Layout is NOT affected: flex and
707
+ * absolute positioning run off the insertion-ordered `children` list. */
708
+ orderedByZ() {
709
+ if (this.children.length < 2)
710
+ return this.children;
711
+ const stable = this.children.map((child, idx) => ({ child, idx }));
712
+ stable.sort((a, b) => (a.child.zIndex - b.child.zIndex) || (a.idx - b.idx));
713
+ return stable.map(e => e.child);
714
+ }
715
+ /** Renders a single-line "⚠ render error" marker over the child's
716
+ * pre-allocated region so a broken subtree never blanks the rest of the
717
+ * frame. The child is still blitted onto this window so its geometry
718
+ * (borders, siblings) remains visible in the debug layout. */
719
+ paintErrorPlaceholder(child, err) {
720
+ const { width, height } = child.getSize();
721
+ if (width === 0 || height === 0)
722
+ return;
723
+ const styleId = this.registry.register({ foreground: 196, background: 52, bold: true });
724
+ try {
725
+ child.region = new Region(width, height);
726
+ child.region.fill(' ', styleId);
727
+ const message = err instanceof Error ? err.message : String(err);
728
+ const prefix = '⚠ render error: ';
729
+ const maxLen = Math.max(0, width - prefix.length);
730
+ const text = prefix + message.slice(0, maxLen);
731
+ const chars = [...text];
732
+ for (let i = 0; i < chars.length && i < width; i++) {
733
+ child.region.setCell(i, 0, chars[i], styleId);
734
+ }
458
735
  this.blitChild(child);
459
736
  }
737
+ catch {
738
+ // Swallow — the placeholder itself must not throw further.
739
+ }
460
740
  }
461
741
  /** Fills the display buffer with the background style. When inactive, adds dim to every cell.
462
742
  * No-op when background is 0 (transparent). */
@@ -588,14 +868,25 @@ export class Window {
588
868
  /** Removes a previously added child window. No-op if the child is not found. */
589
869
  removeChild(child) {
590
870
  const idx = this.children.indexOf(child);
591
- if (idx !== -1)
871
+ if (idx !== -1) {
592
872
  this.children.splice(idx, 1);
873
+ child.parent = null;
874
+ // The vacated rectangle has to be re-emitted from the content
875
+ // underneath — escalate so the root Screen repaints everything.
876
+ this.markFullInvalidation();
877
+ }
593
878
  }
594
879
  /** Replaces both internal regions with new ones of the given dimensions,
595
880
  * then re-resolves sizes and positions of all direct children against the updated inner area. */
596
881
  resizeRegions(w, h) {
597
882
  this.region = new Region(w, h);
598
883
  this.content = new Region(w, h);
884
+ // A new region invalidates any per-cell dirty bookkeeping from the
885
+ // previous size — escalate to a full-window repaint and ask the
886
+ // Screen to emit from scratch because the old footprint on stdout
887
+ // may include cells that are no longer part of this window.
888
+ this.dirtyRect = 'all';
889
+ this.markFullInvalidation();
599
890
  this.reflowChildren();
600
891
  }
601
892
  /** Resizes this window to the given absolute dimensions and reflows every
@@ -633,7 +924,10 @@ export class Window {
633
924
  }
634
925
  }
635
926
  /** Absolute layout: each child independently resolves its Pos/Size against
636
- * the parent's inner area — the pre-flex behaviour. */
927
+ * the parent's inner area — the pre-flex behaviour. A child's `margin`
928
+ * shifts the resolved position by `(marginLeft, marginTop)` without
929
+ * altering its size, so callers can push a window away from its declared
930
+ * anchor without recomputing coordinates by hand. */
637
931
  layoutAbsolute(pw, ph, ox, oy) {
638
932
  for (const child of this.children) {
639
933
  if (!child.sizeSpec.isAbsolute()) {
@@ -642,8 +936,8 @@ export class Window {
642
936
  }
643
937
  const { width: cw, height: ch } = child.getSize();
644
938
  const { x, y } = child.posSpec.resolve(pw, ph, cw, ch);
645
- child.x = x + ox;
646
- child.y = y + oy;
939
+ child.x = x + ox + child.margin.left;
940
+ child.y = y + oy + child.margin.top;
647
941
  }
648
942
  }
649
943
  /** Row / column flex layout. The main axis (width for 'row', height for
@@ -656,7 +950,13 @@ export class Window {
656
950
  * distribution only when no child consumes slack via flex-grow. Invisible
657
951
  * children are skipped so `setVisible(false)` effectively removes them
658
952
  * from the stack. Children are ordered by `Pos.flex(order)` first, then
659
- * by addChild insertion (stable). */
953
+ * by addChild insertion (stable).
954
+ *
955
+ * Each child's `margin` reserves extra cells around it: the main axis
956
+ * loses `marginLeft+marginRight` (row) or `marginTop+marginBottom` (column)
957
+ * per child before distribution, and the cross axis loses `margin*` per
958
+ * child before alignment. Grow/shrink still apply to the inner size so the
959
+ * declared margin stays constant even when the child is flex-resized. */
660
960
  layoutFlex(mode, pw, ph, ox, oy) {
661
961
  const ordered = this.orderedVisibleChildren();
662
962
  if (ordered.length === 0)
@@ -665,7 +965,13 @@ export class Window {
665
965
  const mainParent = isRow ? pw : ph;
666
966
  const crossParent = isRow ? ph : pw;
667
967
  const gap = this.gap;
668
- // First pass: basis sizes per child.
968
+ // Per-child main/cross margin totals captured up-front so they stay
969
+ // constant across grow/shrink passes.
970
+ const mainMargin = (c) => isRow ? c.margin.left + c.margin.right : c.margin.top + c.margin.bottom;
971
+ const crossMargin = (c) => isRow ? c.margin.top + c.margin.bottom : c.margin.left + c.margin.right;
972
+ // First pass: basis sizes per child (inner, i.e. the child's own region
973
+ // without margin). Natural size is taken before margin so content-sized
974
+ // children keep their intrinsic footprint.
669
975
  const items = ordered.map((c) => {
670
976
  const mainSpec = isRow ? c.sizeSpec.getWidthSpec() : c.sizeSpec.getHeightSpec();
671
977
  const crossSpec = isRow ? c.sizeSpec.getHeightSpec() : c.sizeSpec.getWidthSpec();
@@ -677,10 +983,13 @@ export class Window {
677
983
  crossSize: resolveFlexBasis(crossSpec, crossParent, isRow ? c.getSize().height : c.getSize().width),
678
984
  };
679
985
  });
680
- // Distribute leftover main-axis space.
986
+ // Distribute leftover main-axis space. The parent sees each child's
987
+ // slot as `mainSize + mainMargin`, so margins are charged against the
988
+ // remaining space before grow/shrink.
681
989
  const totalGap = Math.max(0, items.length - 1) * gap;
990
+ const totalMargin = items.reduce((s, it) => s + mainMargin(it.child), 0);
682
991
  const totalMain = items.reduce((s, it) => s + it.mainSize, 0);
683
- let remainder = mainParent - totalMain - totalGap;
992
+ let remainder = mainParent - totalMain - totalMargin - totalGap;
684
993
  const totalGrow = items.reduce((s, it) => s + (it.mainSpec.mode === 'flex' ? it.mainSpec.grow : 0), 0);
685
994
  if (remainder > 0 && totalGrow > 0) {
686
995
  let distributed = 0;
@@ -713,17 +1022,21 @@ export class Window {
713
1022
  it.mainSize = Math.max(0, it.mainSize - take);
714
1023
  }
715
1024
  const consumed = items.reduce((s, it) => s + it.mainSize, 0);
716
- remainder = mainParent - consumed - totalGap;
1025
+ remainder = mainParent - consumed - totalMargin - totalGap;
717
1026
  }
718
1027
  }
719
- // Apply cross-axis stretch where allowed.
1028
+ // Apply cross-axis stretch where allowed. Cross-axis margin is charged
1029
+ // before stretch/clamp so a stretched child + its margin fits within
1030
+ // crossParent.
720
1031
  for (const it of items) {
1032
+ const cm = crossMargin(it.child);
1033
+ const available = Math.max(0, crossParent - cm);
721
1034
  const mode = it.crossSpec.mode;
722
1035
  if (this.alignItems === 'stretch' && mode !== 'abs' && mode !== 'pct') {
723
- it.crossSize = crossParent;
1036
+ it.crossSize = available;
724
1037
  }
725
1038
  else {
726
- it.crossSize = Math.min(it.crossSize, crossParent);
1039
+ it.crossSize = Math.min(it.crossSize, available);
727
1040
  }
728
1041
  }
729
1042
  // justifyContent kicks in only when there is positive slack with no grow.
@@ -750,7 +1063,10 @@ export class Window {
750
1063
  break;
751
1064
  }
752
1065
  }
753
- // Write final sizes and positions.
1066
+ // Write final sizes and positions. The child's main-axis starting
1067
+ // coordinate is `cursor + marginLeading`; cursor then advances by
1068
+ // `mainSize + mainMargin + itemSpacing` so the next slot is offset by
1069
+ // this child's full footprint.
754
1070
  let cursor = mainStart;
755
1071
  for (const it of items) {
756
1072
  const mainSize = Math.max(0, it.mainSize);
@@ -760,30 +1076,36 @@ export class Window {
760
1076
  if (newW !== it.child.getSize().width || newH !== it.child.getSize().height) {
761
1077
  it.child.resizeRegions(newW, newH);
762
1078
  }
1079
+ const m = it.child.margin;
1080
+ const mainLead = isRow ? m.left : m.top;
1081
+ const mainTrail = isRow ? m.right : m.bottom;
1082
+ const crossLead = isRow ? m.top : m.left;
1083
+ const cm = crossMargin(it.child);
763
1084
  let crossPos = 0;
764
1085
  if (this.alignItems !== 'stretch') {
765
- const free = crossParent - crossSize;
1086
+ const free = Math.max(0, crossParent - crossSize - cm);
766
1087
  if (this.alignItems === 'center')
767
1088
  crossPos = Math.floor(free / 2);
768
1089
  else if (this.alignItems === 'end')
769
1090
  crossPos = free;
770
1091
  }
771
1092
  if (isRow) {
772
- it.child.x = ox + cursor;
773
- it.child.y = oy + crossPos;
1093
+ it.child.x = ox + cursor + mainLead;
1094
+ it.child.y = oy + crossPos + crossLead;
774
1095
  }
775
1096
  else {
776
- it.child.x = ox + crossPos;
777
- it.child.y = oy + cursor;
1097
+ it.child.x = ox + crossPos + crossLead;
1098
+ it.child.y = oy + cursor + mainLead;
778
1099
  }
779
- cursor += mainSize + itemSpacing;
1100
+ cursor += mainSize + mainLead + mainTrail + itemSpacing;
780
1101
  }
781
1102
  }
782
1103
  /** Grid layout: children are placed row-major into equally sized cells.
783
1104
  * Each cell's width is `(innerWidth - gap * (cols - 1)) / cols` (floored);
784
1105
  * heights use the same formula with the derived row count. Children are
785
- * resized to the cell dimensions and positioned at the cell's top-left.
786
- * Invisible children are skipped. */
1106
+ * resized to the cell dimensions minus their own margin and positioned
1107
+ * at the cell's top-left offset by `(marginLeft, marginTop)`. Invisible
1108
+ * children are skipped. */
787
1109
  layoutGrid(pw, ph, ox, oy) {
788
1110
  const ordered = this.orderedVisibleChildren();
789
1111
  if (ordered.length === 0)
@@ -797,11 +1119,14 @@ export class Window {
797
1119
  const child = ordered[i];
798
1120
  const c = i % cols;
799
1121
  const r = Math.floor(i / cols);
800
- if (cellW !== child.getSize().width || cellH !== child.getSize().height) {
801
- child.resizeRegions(cellW, cellH);
1122
+ const m = child.margin;
1123
+ const innerW = Math.max(0, cellW - m.left - m.right);
1124
+ const innerH = Math.max(0, cellH - m.top - m.bottom);
1125
+ if (innerW !== child.getSize().width || innerH !== child.getSize().height) {
1126
+ child.resizeRegions(innerW, innerH);
802
1127
  }
803
- child.x = ox + c * (cellW + gap);
804
- child.y = oy + r * (cellH + gap);
1128
+ child.x = ox + c * (cellW + gap) + m.left;
1129
+ child.y = oy + r * (cellH + gap) + m.top;
805
1130
  }
806
1131
  }
807
1132
  /** Returns visible children sorted by Pos.flex(order) ascending; children