take4-console 0.30.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 (76) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/README.md +3 -2
  3. package/dist/Screen/Screen.d.mts +40 -0
  4. package/dist/Screen/Screen.d.mts.map +1 -1
  5. package/dist/Screen/Screen.mjs +148 -1
  6. package/dist/Screen/Screen.mjs.map +1 -1
  7. package/dist/Screen/Window.d.mts +50 -1
  8. package/dist/Screen/Window.d.mts.map +1 -1
  9. package/dist/Screen/Window.mjs +176 -19
  10. package/dist/Screen/Window.mjs.map +1 -1
  11. package/dist/Screen/WindowManager.d.mts.map +1 -1
  12. package/dist/Screen/WindowManager.mjs +11 -0
  13. package/dist/Screen/WindowManager.mjs.map +1 -1
  14. package/dist/Screen/controls/BarChart.d.mts.map +1 -1
  15. package/dist/Screen/controls/BarChart.mjs +3 -0
  16. package/dist/Screen/controls/BarChart.mjs.map +1 -1
  17. package/dist/Screen/controls/Checkbox.d.mts.map +1 -1
  18. package/dist/Screen/controls/Checkbox.mjs +4 -0
  19. package/dist/Screen/controls/Checkbox.mjs.map +1 -1
  20. package/dist/Screen/controls/LineChart.d.mts.map +1 -1
  21. package/dist/Screen/controls/LineChart.mjs +3 -0
  22. package/dist/Screen/controls/LineChart.mjs.map +1 -1
  23. package/dist/Screen/controls/ListBox.d.mts.map +1 -1
  24. package/dist/Screen/controls/ListBox.mjs +9 -0
  25. package/dist/Screen/controls/ListBox.mjs.map +1 -1
  26. package/dist/Screen/controls/ProgressBar.d.mts.map +1 -1
  27. package/dist/Screen/controls/ProgressBar.mjs +2 -0
  28. package/dist/Screen/controls/ProgressBar.mjs.map +1 -1
  29. package/dist/Screen/controls/ProgressBarV.d.mts.map +1 -1
  30. package/dist/Screen/controls/ProgressBarV.mjs +2 -0
  31. package/dist/Screen/controls/ProgressBarV.mjs.map +1 -1
  32. package/dist/Screen/controls/Radio.d.mts.map +1 -1
  33. package/dist/Screen/controls/Radio.mjs +7 -1
  34. package/dist/Screen/controls/Radio.mjs.map +1 -1
  35. package/dist/Screen/controls/Sparkline.d.mts.map +1 -1
  36. package/dist/Screen/controls/Sparkline.mjs +3 -0
  37. package/dist/Screen/controls/Sparkline.mjs.map +1 -1
  38. package/dist/Screen/controls/Spinner.d.mts.map +1 -1
  39. package/dist/Screen/controls/Spinner.mjs +8 -0
  40. package/dist/Screen/controls/Spinner.mjs.map +1 -1
  41. package/dist/Screen/controls/StatusLED.d.mts.map +1 -1
  42. package/dist/Screen/controls/StatusLED.mjs +3 -0
  43. package/dist/Screen/controls/StatusLED.mjs.map +1 -1
  44. package/dist/Screen/controls/Tabs.d.mts.map +1 -1
  45. package/dist/Screen/controls/Tabs.mjs +2 -0
  46. package/dist/Screen/controls/Tabs.mjs.map +1 -1
  47. package/dist/Screen/controls/TextArea.d.mts.map +1 -1
  48. package/dist/Screen/controls/TextArea.mjs +11 -0
  49. package/dist/Screen/controls/TextArea.mjs.map +1 -1
  50. package/dist/Screen/controls/TextBox.d.mts.map +1 -1
  51. package/dist/Screen/controls/TextBox.mjs +13 -0
  52. package/dist/Screen/controls/TextBox.mjs.map +1 -1
  53. package/dist/Screen/types.d.mts +26 -0
  54. package/dist/Screen/types.d.mts.map +1 -1
  55. package/dist/index.d.mts +1 -1
  56. package/dist/index.d.mts.map +1 -1
  57. package/package.json +1 -1
  58. package/src/Screen/Screen.mts +148 -2
  59. package/src/Screen/Window.mts +165 -16
  60. package/src/Screen/WindowManager.mts +11 -0
  61. package/src/Screen/controls/BarChart.mts +3 -0
  62. package/src/Screen/controls/Checkbox.mts +3 -0
  63. package/src/Screen/controls/LineChart.mts +3 -0
  64. package/src/Screen/controls/ListBox.mts +8 -0
  65. package/src/Screen/controls/ProgressBar.mts +2 -0
  66. package/src/Screen/controls/ProgressBarV.mts +2 -0
  67. package/src/Screen/controls/Radio.mts +6 -1
  68. package/src/Screen/controls/Sparkline.mts +3 -0
  69. package/src/Screen/controls/Spinner.mts +6 -0
  70. package/src/Screen/controls/StatusLED.mts +2 -0
  71. package/src/Screen/controls/Tabs.mts +2 -0
  72. package/src/Screen/controls/TextArea.mts +10 -0
  73. package/src/Screen/controls/TextBox.mts +12 -0
  74. package/src/Screen/types.mts +27 -0
  75. package/src/demo.mts +51 -0
  76. package/src/index.mts +1 -0
@@ -1,5 +1,5 @@
1
1
  import { EventEmitter } from 'node:events';
2
- import type { CellAttributes, ScreenFrameStats, ScreenOptions, StyleId, TerminalSize, ToastOptions, ToastPosition } from './types.mjs';
2
+ import type { CellAttributes, DirtyRect, ScreenFrameStats, ScreenOptions, StyleId, TerminalSize, ToastOptions, ToastPosition } from './types.mjs';
3
3
  import { StyleRegistry } from './StyleRegistry.mjs';
4
4
  import { getRegistry, setRegistry } from './RegistryHolder.mjs';
5
5
  import { Window } from './Window.mjs';
@@ -43,6 +43,18 @@ export class Screen extends Window {
43
43
  /** Per-toast dismiss callbacks (from `ToastOptions.onDismiss`). Kept out of
44
44
  * the `Toast` instance so the control stays free of bookkeeping code. */
45
45
  private toastDismissHandlers: Map<Toast, () => void>;
46
+ /** When true, `render()` computes the union of every dirty rect propagated
47
+ * bottom-up from the window tree and emits ANSI sequences only for those
48
+ * cells. When false, every frame re-emits the full buffer (pre-0.31
49
+ * behaviour), which is useful for debugging or for terminals that
50
+ * mis-handle partial cursor jumps. Configured via `ScreenOptions.damageTracking`. */
51
+ private damageTracking: boolean;
52
+ /** Forces the next `render()` onto the full-repaint path regardless of the
53
+ * dirty state. Set on the first frame, after `resize()`, and bubbled up
54
+ * from descendants via `markFullInvalidation()` when they change
55
+ * geometry, visibility, or tree topology in ways that would leave
56
+ * stale cells on stdout. Cleared after every full-repaint emit. */
57
+ private fullInvalidate: boolean;
46
58
 
47
59
  /** Initializes the root window sized to the current terminal dimensions.
48
60
  * Creates a fresh StyleRegistry (with built-in styles pre-registered)
@@ -68,6 +80,8 @@ export class Screen extends Window {
68
80
  this.activeToasts = new Map();
69
81
  this.toastTimers = new Map();
70
82
  this.toastDismissHandlers = new Map();
83
+ this.damageTracking = options?.damageTracking ?? true;
84
+ this.fullInvalidate = true;
71
85
 
72
86
  if (options?.altScreen) this.enterAltScreen();
73
87
  if (options?.hideCursor) this.hideHardwareCursor();
@@ -340,12 +354,49 @@ export class Screen extends Window {
340
354
  */
341
355
  public override render(): void {
342
356
  const start = Date.now();
357
+
358
+ // Fast path: damage tracking is on, nothing is dirty, no full
359
+ // invalidation pending — skip composition AND emit entirely. The
360
+ // terminal already shows the correct pixels from the previous frame.
361
+ if (this.damageTracking && !this.fullInvalidate) {
362
+ const rects: DirtyRect[] = [];
363
+ this.collectDirtyRects(0, 0, rects);
364
+ if (rects.length === 0) {
365
+ this.events.emit('frame', { ms: Date.now() - start, cellsEmitted: 0 });
366
+ return;
367
+ }
368
+ // Compose first so `this.region` reflects the latest content; the
369
+ // emit below reads from it. We keep `rects` for the emit phase
370
+ // instead of re-collecting, because `super.render()` does not
371
+ // mutate dirty flags (render-depth guard suppresses markDirty
372
+ // inside render overrides).
373
+ super.render();
374
+ const stats = this.emitDirty(rects);
375
+ this.clearDirtyRecursive();
376
+ this.events.emit('frame', { ms: Date.now() - start, cellsEmitted: stats, fullRepaint: false });
377
+ return;
378
+ }
379
+
380
+ // Slow / fallback path: full repaint. Always used for the first
381
+ // frame, after `resize()`, after `markFullInvalidation()`, and when
382
+ // damage tracking has been disabled by the caller.
343
383
  super.render();
384
+ const cellsEmitted = this.emitFull();
385
+ this.fullInvalidate = false;
386
+ this.clearDirtyRecursive();
387
+ this.events.emit('frame', { ms: Date.now() - start, cellsEmitted, fullRepaint: true });
388
+ }
344
389
 
390
+ /** Emits every visible cell in `this.region` as one ANSI write, starting
391
+ * with `\x1b[H` (cursor home) and ending with `\x1b[0m`. Returns the
392
+ * number of cells whose character was actually written (skips wide-char
393
+ * continuation sentinels). Used by the full-repaint path. */
394
+ private emitFull(): number {
345
395
  const reg = getRegistry();
346
396
  const chars = this.region.getChars();
347
397
  const styleIds = this.region.getStyleIds();
348
398
  let output = '\x1b[H';
399
+ let count = 0;
349
400
  for (let i = 0; i < chars.length; i++) {
350
401
  const ch = chars[i];
351
402
  // Empty-string sentinel = continuation cell of a wide character;
@@ -353,10 +404,105 @@ export class Screen extends Window {
353
404
  if (ch === '') continue;
354
405
  output += this.buildAnsiSequence(reg.get(styleIds[i]));
355
406
  output += ch;
407
+ count++;
356
408
  }
357
409
  output += '\x1b[0m';
358
410
  process.stdout.write(output);
359
- this.events.emit('frame', { ms: Date.now() - start });
411
+ return count;
412
+ }
413
+
414
+ /** Coalesces the collected dirty rects into per-row horizontal intervals
415
+ * and emits only those cells, using `\x1b[row;colH` cursor jumps between
416
+ * rows. Returns the number of cells emitted so consumers of the 'frame'
417
+ * event can see how much work each frame did. */
418
+ private emitDirty(rects: DirtyRect[]): number {
419
+ const { width: sw, height: sh } = this.getSize();
420
+ if (sw === 0 || sh === 0) { process.stdout.write(''); return 0; }
421
+
422
+ // Collapse rects into per-row min/max columns. A Map keyed by row is
423
+ // sparse enough for the common case (few small dirty regions) and
424
+ // avoids allocating a dense sh-sized array when only a couple of
425
+ // rows are touched.
426
+ const rowSpans = new Map<number, { minX: number; maxX: number }>();
427
+ for (const r of rects) {
428
+ const y0 = Math.max(0, r.y);
429
+ const y1 = Math.min(sh - 1, r.y + r.h - 1);
430
+ const x0 = Math.max(0, r.x);
431
+ const x1 = Math.min(sw - 1, r.x + r.w - 1);
432
+ if (y1 < y0 || x1 < x0) continue;
433
+ for (let y = y0; y <= y1; y++) {
434
+ const existing = rowSpans.get(y);
435
+ if (existing) {
436
+ if (x0 < existing.minX) existing.minX = x0;
437
+ if (x1 > existing.maxX) existing.maxX = x1;
438
+ } else {
439
+ rowSpans.set(y, { minX: x0, maxX: x1 });
440
+ }
441
+ }
442
+ }
443
+ if (rowSpans.size === 0) return 0;
444
+
445
+ // Emit rows in ascending order so the terminal cursor advances
446
+ // forward — random access is fine (we always send an explicit cursor
447
+ // move) but ordered output compresses a bit better and is easier to
448
+ // reason about in transcripts / tests.
449
+ const rows = [...rowSpans.keys()].sort((a, b) => a - b);
450
+ const reg = getRegistry();
451
+ const chars = this.region.getChars();
452
+ const styleIds = this.region.getStyleIds();
453
+ let output = '';
454
+ let count = 0;
455
+ for (const y of rows) {
456
+ const span = rowSpans.get(y)!;
457
+ // ANSI cursor addressing is 1-based.
458
+ output += `\x1b[${y + 1};${span.minX + 1}H`;
459
+ for (let x = span.minX; x <= span.maxX; x++) {
460
+ const i = y * sw + x;
461
+ const ch = chars[i];
462
+ if (ch === '') continue;
463
+ output += this.buildAnsiSequence(reg.get(styleIds[i]));
464
+ output += ch;
465
+ count++;
466
+ }
467
+ }
468
+ output += '\x1b[0m';
469
+ process.stdout.write(output);
470
+ return count;
471
+ }
472
+
473
+ /** Overrides the parent hook so bubbled full-invalidation signals land
474
+ * on this Screen's own `fullInvalidate` flag instead of walking further
475
+ * up a non-existent parent chain. Public so WindowManager can also
476
+ * request a full repaint after pause/resume or alt-screen toggling. */
477
+ public override markFullInvalidation(): void {
478
+ this.fullInvalidate = true;
479
+ }
480
+
481
+ /** Public alias of `markFullInvalidation()` — schedules a full repaint on
482
+ * the next `render()` call. Use this when external state (terminal
483
+ * re-init, post-OS dialog, SSH reconnect, …) may have corrupted the
484
+ * on-screen buffer and the stored dirty rects are no longer sufficient. */
485
+ public invalidate(): void {
486
+ this.markFullInvalidation();
487
+ }
488
+
489
+ /** Returns whether damage tracking is currently enabled. Useful for demo
490
+ * code and tests that want to assert or toggle the mode at runtime. */
491
+ public isDamageTrackingEnabled(): boolean {
492
+ return this.damageTracking;
493
+ }
494
+
495
+ /** Enables or disables damage tracking at runtime. Disabling forces every
496
+ * subsequent frame onto the full-repaint path; re-enabling resumes the
497
+ * dirty-rect emit on the next frame (after one final full repaint, so
498
+ * the terminal state matches the tree-derived baseline). */
499
+ public setDamageTracking(enabled: boolean): void {
500
+ if (this.damageTracking === enabled) return;
501
+ this.damageTracking = enabled;
502
+ // Always do one full repaint on transition so the terminal buffer
503
+ // matches what the window tree would produce from scratch — useful
504
+ // when flipping back and forth for debugging.
505
+ this.fullInvalidate = true;
360
506
  }
361
507
 
362
508
  // ── Private helpers ───────────────────────────────────────────────────────
@@ -1,4 +1,4 @@
1
- import type { Cell, StyleId, BorderStyle, BorderChars, WindowBorder, WindowProperties, WriteTextOptions, WriteTextInput, WriteTextSegment, TerminalSize, LayoutMode, AlignItems, JustifyContent, Padding, PaddingSpec, Margin, MarginSpec, 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';
@@ -156,6 +156,25 @@ export class Window {
156
156
  private onFocusHandler: (() => void) | undefined;
157
157
  /** Fires once when focused state flips from true to false. */
158
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;
159
178
 
160
179
  /** Creates a window from the given properties.
161
180
  * For percentage-based sizes, call addChild() before writing content to the window.
@@ -209,6 +228,88 @@ export class Window {
209
228
  return this.region.getSize();
210
229
  }
211
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
+
212
313
  /** Returns a snapshot of the resolved per-side margin (in cells). The parent's
213
314
  * layout engine reads this to reserve outer spacing around the child. */
214
315
  public getMargin(): Readonly<Margin> {
@@ -260,7 +361,9 @@ export class Window {
260
361
 
261
362
  /** Sets the active state. Affects border and background appearance on next render(). */
262
363
  public setActive(active: boolean): void {
364
+ if (this.active === active) return;
263
365
  this.active = active;
366
+ this.markDirty();
264
367
  }
265
368
 
266
369
  /** Sets the focused state. Controls use this to change visual appearance on focus.
@@ -269,6 +372,7 @@ export class Window {
269
372
  public setFocused(focused: boolean): void {
270
373
  if (this.focused === focused) return;
271
374
  this.focused = focused;
375
+ this.markDirty();
272
376
  if (focused) this.onFocusHandler?.();
273
377
  else this.onBlurHandler?.();
274
378
  }
@@ -307,7 +411,13 @@ export class Window {
307
411
  * change. Does not affect layout (flex/absolute coordinates are
308
412
  * independent of z). */
309
413
  public setZIndex(zIndex: number): void {
414
+ if (this.zIndex === zIndex) return;
310
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();
311
421
  }
312
422
 
313
423
  /** Returns the direct children of this window in insertion order (a
@@ -324,6 +434,7 @@ export class Window {
324
434
 
325
435
  /** Sets the disabled state and deactivates the window when disabled. */
326
436
  public setDisabled(disabled: boolean): void {
437
+ if (this.disabled !== disabled) this.markDirty();
327
438
  this.disabled = disabled;
328
439
  this.setActive(!disabled);
329
440
  }
@@ -339,7 +450,14 @@ export class Window {
339
450
  * the content buffer — previously written cells reappear verbatim on the
340
451
  * next render after the window is shown again. */
341
452
  public setVisible(visible: boolean): void {
453
+ if (this.visible === visible) return;
342
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();
343
461
  }
344
462
 
345
463
  /** Returns whether this window is currently visible. Default: true. */
@@ -349,7 +467,9 @@ export class Window {
349
467
 
350
468
  /** Sets the label text displayed by the control. */
351
469
  public setLabel(label: string): void {
470
+ if (this.label === label) return;
352
471
  this.label = label;
472
+ this.markDirty();
353
473
  }
354
474
 
355
475
  /** Returns the current label text. */
@@ -361,6 +481,7 @@ export class Window {
361
481
  * Intended for use by subclasses that need dynamic decoration (e.g. focus-state colour). */
362
482
  protected updateBorder(border: WindowBorder | boolean | undefined): void {
363
483
  this.border = resolveBorder(border);
484
+ this.markDirty();
364
485
  }
365
486
 
366
487
  /** Recomputes the border color from the current focused/disabled state.
@@ -383,7 +504,13 @@ export class Window {
383
504
  * geometry consistent when more children join the stack. */
384
505
  public addChild(child: Window): void {
385
506
  this.children.push(child);
507
+ child.parent = this;
386
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();
387
514
  }
388
515
 
389
516
  /** Returns a resolved Cell (char + CellAttributes) at (x, y) from the display buffer.
@@ -404,12 +531,14 @@ export class Window {
404
531
  public setChar(x: number, y: number, char: string): void {
405
532
  this.content.setChar(x, y, char);
406
533
  this.region.setChar(x, y, char);
534
+ this.markDirty({ x, y, w: 1, h: 1 });
407
535
  }
408
536
 
409
537
  /** Sets the character and style ID at (x, y). Throws RangeError if out of bounds. */
410
538
  public setCell(x: number, y: number, char: string, styleId: StyleId = 0): void {
411
539
  this.content.setCell(x, y, char, styleId);
412
540
  this.region.setCell(x, y, char, styleId);
541
+ this.markDirty({ x, y, w: 1, h: 1 });
413
542
  }
414
543
 
415
544
  /** Merges the given style ID onto the existing style at (x, y) without changing the character.
@@ -419,18 +548,21 @@ export class Window {
419
548
  const mergedRegion = this.registry.merge(this.region.getStyleId(x, y), styleId);
420
549
  this.content.setStyleId(x, y, mergedContent);
421
550
  this.region.setStyleId(x, y, mergedRegion);
551
+ this.markDirty({ x, y, w: 1, h: 1 });
422
552
  }
423
553
 
424
554
  /** Resets every cell to a blank space with style ID 0. */
425
555
  public clear(): void {
426
556
  this.content.clear();
427
557
  this.region.clear();
558
+ this.markDirty();
428
559
  }
429
560
 
430
561
  /** Fills every cell with the given character and style ID. */
431
562
  public fill(char: string, styleId: StyleId = 0): void {
432
563
  this.content.fill(char, styleId);
433
564
  this.region.fill(char, styleId);
565
+ this.markDirty();
434
566
  }
435
567
 
436
568
  /** Writes text into the window's content area starting at (x, y) (default 0, 0).
@@ -565,21 +697,26 @@ export class Window {
565
697
  */
566
698
  public render(): void {
567
699
  if (!this.visible) return;
568
- this.syncBorderColor();
569
- this.paintBackground();
570
- this.blitContent();
571
- this.paintBorder();
572
- for (const child of this.orderedByZ()) {
573
- if (!child.visible) continue;
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;
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
+ }
582
717
  }
718
+ } finally {
719
+ Window.renderingDepth--;
583
720
  }
584
721
  }
585
722
 
@@ -748,7 +885,13 @@ export class Window {
748
885
  /** Removes a previously added child window. No-op if the child is not found. */
749
886
  public removeChild(child: Window): void {
750
887
  const idx = this.children.indexOf(child);
751
- 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
+ }
752
895
  }
753
896
 
754
897
  /** Replaces both internal regions with new ones of the given dimensions,
@@ -756,6 +899,12 @@ export class Window {
756
899
  protected resizeRegions(w: number, h: number): void {
757
900
  this.region = new Region(w, h);
758
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();
759
908
  this.reflowChildren();
760
909
  }
761
910
 
@@ -622,6 +622,10 @@ export class WindowManager {
622
622
  this.installBlinkTimer();
623
623
 
624
624
  if (options?.rerender !== false) {
625
+ // The paused interval may have reflowed via external writes
626
+ // (e.g. another process scribbled onto stdout), so schedule a
627
+ // full repaint instead of relying on cached dirty rects.
628
+ this.screen.invalidate();
625
629
  this.renderFrame();
626
630
  }
627
631
  }
@@ -666,6 +670,13 @@ export class WindowManager {
666
670
  // Avoid drawing while paused — the stdin listener is detached
667
671
  // and the terminal might be owned by a sub-process.
668
672
  if (this.paused) return;
673
+ // A blink tick is a pure time-driven change: no user input
674
+ // modified any state, so damage tracking would otherwise skip
675
+ // the frame entirely and the cursor would never visually blink.
676
+ // Flag the focused control dirty (if any) so its render() path
677
+ // re-emits the TextBox / TextArea row with the new cursor
678
+ // phase; when nothing is focused the frame is still skipped.
679
+ this.getFocused()?.markDirty();
669
680
  this.renderFrame();
670
681
  }, this.blinkIntervalMs);
671
682
  // Don't keep the Node event loop alive just for cursor blinks.
@@ -34,6 +34,7 @@ export class BarChart extends Window {
34
34
  /** Sets the data values. Call render() afterwards. */
35
35
  public setData(data: number[]): void {
36
36
  this.data = data;
37
+ this.markDirty();
37
38
  }
38
39
 
39
40
  /** Returns the current data values. */
@@ -44,6 +45,7 @@ export class BarChart extends Window {
44
45
  /** Sets the bar labels. Call render() afterwards. */
45
46
  public setLabels(labels: string[]): void {
46
47
  this.labels = labels;
48
+ this.markDirty();
47
49
  }
48
50
 
49
51
  /** Returns the current bar labels. */
@@ -54,6 +56,7 @@ export class BarChart extends Window {
54
56
  /** Sets the maximum Y value. Pass undefined to derive from data. Call render() afterwards. */
55
57
  public setMax(max: number | undefined): void {
56
58
  this.max = max;
59
+ this.markDirty();
57
60
  }
58
61
 
59
62
  /** Returns the configured maximum Y value, or undefined if derived from data. */
@@ -31,7 +31,9 @@ export class Checkbox extends Window {
31
31
 
32
32
  /** Toggles or sets the checked state. */
33
33
  public setChecked(checked: boolean): void {
34
+ if (this.checked === checked) return;
34
35
  this.checked = checked;
36
+ this.markDirty();
35
37
  }
36
38
 
37
39
  /** Returns the current checked state. */
@@ -44,6 +46,7 @@ export class Checkbox extends Window {
44
46
  if (this.disabled) return;
45
47
  if (key === ' ' || key === 'space') {
46
48
  this.checked = !this.checked;
49
+ this.markDirty();
47
50
  this.onChange?.(this.checked);
48
51
  }
49
52
  }
@@ -38,6 +38,7 @@ export class LineChart extends Window {
38
38
  /** Sets the data series. Call render() afterwards. */
39
39
  public setData(data: number[]): void {
40
40
  this.data = data;
41
+ this.markDirty();
41
42
  }
42
43
 
43
44
  /** Returns the current data series. */
@@ -48,6 +49,7 @@ export class LineChart extends Window {
48
49
  /** Sets the minimum Y value. Pass undefined to derive from data. Call render() afterwards. */
49
50
  public setMin(min: number | undefined): void {
50
51
  this.minValue = min;
52
+ this.markDirty();
51
53
  }
52
54
 
53
55
  /** Returns the configured minimum Y value, or undefined if derived from data. */
@@ -58,6 +60,7 @@ export class LineChart extends Window {
58
60
  /** Sets the maximum Y value. Pass undefined to derive from data. Call render() afterwards. */
59
61
  public setMax(max: number | undefined): void {
60
62
  this.maxValue = max;
63
+ this.markDirty();
61
64
  }
62
65
 
63
66
  /** Returns the configured maximum Y value, or undefined if derived from data. */
@@ -54,6 +54,7 @@ export class ListBox<T = string> extends Window {
54
54
  this.items = items;
55
55
  this.selectedIndex = items.length > 0 ? 0 : -1;
56
56
  this.scrollTop = 0;
57
+ this.markDirty();
57
58
  }
58
59
 
59
60
  /** Returns the current list items. */
@@ -67,7 +68,9 @@ export class ListBox<T = string> extends Window {
67
68
  this.selectedIndex = -1;
68
69
  return;
69
70
  }
71
+ const prev = this.selectedIndex;
70
72
  this.selectedIndex = Math.max(0, Math.min(this.items.length - 1, index));
73
+ if (prev !== this.selectedIndex) this.markDirty();
71
74
  this.ensureVisible();
72
75
  }
73
76
 
@@ -85,6 +88,7 @@ export class ListBox<T = string> extends Window {
85
88
  /** Replaces the per-row renderer after construction. Pass undefined to restore default behaviour. */
86
89
  public setRenderItem(fn: ((item: T, ctx: ListBoxRenderContext) => ListBoxRowSegments) | undefined): void {
87
90
  this.renderItem = fn;
91
+ this.markDirty();
88
92
  }
89
93
 
90
94
  /** Returns the configured row height in cells. */
@@ -129,7 +133,11 @@ export class ListBox<T = string> extends Window {
129
133
  return;
130
134
  }
131
135
 
136
+ const prevScroll = this.scrollTop;
132
137
  this.ensureVisible();
138
+ if (this.selectedIndex !== prev || this.scrollTop !== prevScroll) {
139
+ this.markDirty();
140
+ }
133
141
  if (this.selectedIndex !== prev) {
134
142
  this.onChange?.(this.selectedIndex, this.items[this.selectedIndex]);
135
143
  }
@@ -37,6 +37,7 @@ export class ProgressBar extends Window {
37
37
  /** Sets the current value (clamped to 0–max). Call render() afterwards. */
38
38
  public setValue(value: number): void {
39
39
  this.value = Math.max(0, Math.min(value, this.max));
40
+ this.markDirty();
40
41
  }
41
42
 
42
43
  /** Returns the current value. */
@@ -48,6 +49,7 @@ export class ProgressBar extends Window {
48
49
  public setMax(max: number): void {
49
50
  this.max = Math.max(1, max);
50
51
  this.value = Math.min(this.value, this.max);
52
+ this.markDirty();
51
53
  }
52
54
 
53
55
  /** Returns the maximum value. */
@@ -31,6 +31,7 @@ export class ProgressBarV extends Window {
31
31
  /** Sets the current value (clamped to 0–max). Call render() afterwards. */
32
32
  public setValue(value: number): void {
33
33
  this.value = Math.max(0, Math.min(value, this.max));
34
+ this.markDirty();
34
35
  }
35
36
 
36
37
  /** Returns the current value. */
@@ -42,6 +43,7 @@ export class ProgressBarV extends Window {
42
43
  public setMax(max: number): void {
43
44
  this.max = Math.max(1, max);
44
45
  this.value = Math.min(this.value, this.max);
46
+ this.markDirty();
45
47
  }
46
48
 
47
49
  /** Returns the maximum value. */
@@ -31,7 +31,9 @@ export class Radio extends Window {
31
31
 
32
32
  /** Sets the selected state. */
33
33
  public setChecked(checked: boolean): void {
34
+ if (this.checked === checked) return;
34
35
  this.checked = checked;
36
+ this.markDirty();
35
37
  }
36
38
 
37
39
  /** Returns the current selected state. */
@@ -43,7 +45,10 @@ export class Radio extends Window {
43
45
  public handleKey(key: string): void {
44
46
  if (this.disabled) return;
45
47
  if (key === ' ' || key === 'space') {
46
- this.checked = true;
48
+ if (!this.checked) {
49
+ this.checked = true;
50
+ this.markDirty();
51
+ }
47
52
  this.onChange?.(true);
48
53
  }
49
54
  }