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
@@ -7,6 +7,7 @@ import type {
7
7
  } from './types.mjs';
8
8
  import { Screen } from './Screen.mjs';
9
9
  import { Window } from './Window.mjs';
10
+ import { setErrorHandler } from './ErrorHolder.mjs';
10
11
 
11
12
  // ── Internal types ────────────────────────────────────────────────────────────
12
13
 
@@ -182,6 +183,15 @@ export class WindowManager {
182
183
  private onKey?: (key: string, ctx: KeyContext) => boolean | void;
183
184
  private onMouse?: (event: TerminalMouseEvent) => void;
184
185
  private mouseEnabled: boolean;
186
+ /** Optional sink for render-time exceptions thrown by descendant windows.
187
+ * Installed into the global ErrorHolder on construction (cleared on
188
+ * stop). When unset, `Window.render()` rethrows instead of swallowing. */
189
+ private onError?: (err: unknown, control: Window) => void;
190
+ /** When non-null, focus navigation (`focusNext/Prev/First/Last`, Tab cycle)
191
+ * is constrained to focusable controls whose absolute position falls
192
+ * inside a descendant of this window. Set by `trapFocus`; cleared by the
193
+ * release callback it returns. */
194
+ private focusTrap: Window | null = null;
185
195
 
186
196
  /** Registered bindings mapped by raw key string.
187
197
  * Multiple handlers for the same key fire in insertion order; the first
@@ -212,6 +222,12 @@ export class WindowManager {
212
222
  /** Set by pause() when it showed a previously-hidden cursor — instructs
213
223
  * resume() to hide it again. Cleared after resume() or stop() uses it. */
214
224
  private pauseRestoreCursorHidden: boolean = false;
225
+ /** Active setInterval handle for cursor blink ticks, or null when
226
+ * cursor blinking is disabled (the default). */
227
+ private blinkTimer: ReturnType<typeof setInterval> | null = null;
228
+ /** Interval (ms) requested by the last `enableCursorBlink()` call.
229
+ * Kept so resume() can reinstall the timer with the same cadence. */
230
+ private blinkIntervalMs: number | null = null;
215
231
 
216
232
  /** Creates a WindowManager for the given Screen.
217
233
  * Does not start the input loop; call run() to begin. */
@@ -222,6 +238,11 @@ export class WindowManager {
222
238
  this.onKey = options?.onKey;
223
239
  this.onMouse = options?.onMouse;
224
240
  this.mouseEnabled = options?.mouse ?? false;
241
+ this.onError = options?.onError;
242
+
243
+ if (this.onError) {
244
+ setErrorHandler((err, control) => this.onError?.(err, control));
245
+ }
225
246
 
226
247
  this.mainEntries = [];
227
248
  this.mainFocusIndex = -1;
@@ -263,18 +284,86 @@ export class WindowManager {
263
284
  }
264
285
 
265
286
  /** Moves focus to the given control if it belongs to the active context and is
266
- * eligible (not disabled, not hidden via setVisible(false)). */
287
+ * eligible (not disabled, not hidden via setVisible(false)). When a focus
288
+ * trap is installed, the candidate must also be a descendant of the trap
289
+ * — otherwise the call is a no-op. */
267
290
  public setFocus(control: Focusable & Window): void {
268
291
  const entries = this.activeEntries();
269
292
  const idx = entries.findIndex(e => e.control === control);
270
293
  if (idx === -1) return;
271
294
  const candidate = entries[idx].control;
272
295
  if (candidate.isDisabled() || !candidate.isVisible()) return;
296
+ if (this.focusTrap && !this.isWithinTrap(candidate)) return;
273
297
  this.blurCurrent();
274
298
  this.setActiveFocusIndex(idx);
275
299
  control.setFocused(true);
276
300
  }
277
301
 
302
+ /** Moves focus to the next eligible control (forward cycle), skipping
303
+ * disabled, hidden, and out-of-trap entries. Equivalent to the Tab key. */
304
+ public focusNext(): void {
305
+ this.moveFocus(1);
306
+ }
307
+
308
+ /** Moves focus to the previous eligible control (reverse cycle), skipping
309
+ * disabled, hidden, and out-of-trap entries. Equivalent to Shift-Tab. */
310
+ public focusPrev(): void {
311
+ this.moveFocus(-1);
312
+ }
313
+
314
+ /** Focuses the first eligible control in the active context. No-op when
315
+ * no control is eligible. */
316
+ public focusFirst(): void {
317
+ this.focusAtEdge('first');
318
+ }
319
+
320
+ /** Focuses the last eligible control in the active context. */
321
+ public focusLast(): void {
322
+ this.focusAtEdge('last');
323
+ }
324
+
325
+ /** Focuses the control registered with the given `id` (copied from
326
+ * `WindowProperties.id` / YAML `id:`). Searches the active focus context
327
+ * only; returns `true` on success, `false` when no matching control is
328
+ * registered or the match is ineligible. */
329
+ public focusById(id: string): boolean {
330
+ const entries = this.activeEntries();
331
+ for (const entry of entries) {
332
+ if (entry.control.getId() !== id) continue;
333
+ if (!this.isFocusable(entry.control)) return false;
334
+ if (this.focusTrap && !this.isWithinTrap(entry.control)) return false;
335
+ this.blurCurrent();
336
+ this.setActiveFocusIndex(entries.indexOf(entry));
337
+ entry.control.setFocused(true);
338
+ return true;
339
+ }
340
+ return false;
341
+ }
342
+
343
+ /** Constrains focus navigation to descendants of `within`. While active,
344
+ * `focusNext / focusPrev / focusFirst / focusLast / setFocus / focusById`
345
+ * skip any control that is not a descendant of `within`; Tab / Shift-Tab
346
+ * cycle within the trapped subtree only. Nested calls stack in LIFO
347
+ * order via the returned release function — calling the release restores
348
+ * the previous trap (or clears it when this was the outermost). The
349
+ * helper is independent of the modal dialog stack, so it can be used
350
+ * for composite controls that live inside the main context. */
351
+ public trapFocus(within: Window): () => void {
352
+ const previous = this.focusTrap;
353
+ this.focusTrap = within;
354
+ return () => {
355
+ if (this.focusTrap === within) {
356
+ this.focusTrap = previous;
357
+ }
358
+ };
359
+ }
360
+
361
+ /** Returns the Window that currently defines the focus trap, or null when
362
+ * no trap is active. Exposed primarily for tests and diagnostics. */
363
+ public getFocusTrap(): Window | null {
364
+ return this.focusTrap;
365
+ }
366
+
278
367
  // ── Global key bindings (P0-4) ─────────────────────────────────────────────
279
368
 
280
369
  /** Registers a global shortcut handler.
@@ -422,6 +511,8 @@ export class WindowManager {
422
511
  this.running = false;
423
512
  this.paused = false;
424
513
 
514
+ this.uninstallBlinkTimer();
515
+
425
516
  if (wasRunning) {
426
517
  if (!wasPaused) {
427
518
  process.stdin.off('data', this.boundHandleInput);
@@ -448,6 +539,8 @@ export class WindowManager {
448
539
  this.pauseRestoreCursorHidden = false;
449
540
  }
450
541
 
542
+ if (this.onError) setErrorHandler(undefined);
543
+
451
544
  this.onExit?.();
452
545
  }
453
546
 
@@ -485,6 +578,10 @@ export class WindowManager {
485
578
  this.screen.showHardwareCursor();
486
579
  }
487
580
 
581
+ // Stop the blink timer so a spawned sub-process doesn't get its
582
+ // screen overwritten mid-frame. resume() reinstalls it.
583
+ this.uninstallBlinkTimer();
584
+
488
585
  this.pauseRestoreAltScreen = false;
489
586
  if (options?.leaveAltScreen && this.screen.isAltScreenActive()) {
490
587
  this.screen.exitAltScreen();
@@ -521,7 +618,14 @@ export class WindowManager {
521
618
  process.stdin.resume();
522
619
  process.stdin.on('data', this.boundHandleInput);
523
620
 
621
+ // Restore the blink timer with its previously configured cadence.
622
+ this.installBlinkTimer();
623
+
524
624
  if (options?.rerender !== false) {
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();
525
629
  this.renderFrame();
526
630
  }
527
631
  }
@@ -533,6 +637,63 @@ export class WindowManager {
533
637
  return this.paused;
534
638
  }
535
639
 
640
+ // ── Cursor blink ───────────────────────────────────────────────────────────
641
+
642
+ /** Starts a timer that periodically triggers a re-render so timed-blink
643
+ * `VirtualCursor` instances (used by focused `TextBox` / `TextArea`)
644
+ * actually animate in the terminal. Call once after `run()`; the timer
645
+ * automatically pauses during `pause()` and re-arms on `resume()`.
646
+ *
647
+ * The `intervalMs` parameter sets how often the timer fires — smaller
648
+ * values are smoother but render more frames per second. Default:
649
+ * `80` ms (≈12 Hz), enough to capture the `fast` preset (250/250) and
650
+ * the short phases of `irregular` without flooding stdout.
651
+ *
652
+ * Idempotent: calling again with a new interval replaces the existing
653
+ * timer. */
654
+ public enableCursorBlink(intervalMs: number = 80): void {
655
+ this.blinkIntervalMs = Math.max(16, intervalMs);
656
+ this.installBlinkTimer();
657
+ }
658
+
659
+ /** Stops the blink timer installed by `enableCursorBlink()`. Idempotent. */
660
+ public disableCursorBlink(): void {
661
+ this.blinkIntervalMs = null;
662
+ this.uninstallBlinkTimer();
663
+ }
664
+
665
+ /** (Re)creates the blink interval using the cached `blinkIntervalMs`. */
666
+ private installBlinkTimer(): void {
667
+ this.uninstallBlinkTimer();
668
+ if (this.blinkIntervalMs === null) return;
669
+ this.blinkTimer = setInterval(() => {
670
+ // Avoid drawing while paused — the stdin listener is detached
671
+ // and the terminal might be owned by a sub-process.
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();
680
+ this.renderFrame();
681
+ }, this.blinkIntervalMs);
682
+ // Don't keep the Node event loop alive just for cursor blinks.
683
+ if (typeof this.blinkTimer === 'object' && this.blinkTimer !== null
684
+ && 'unref' in this.blinkTimer && typeof this.blinkTimer.unref === 'function') {
685
+ this.blinkTimer.unref();
686
+ }
687
+ }
688
+
689
+ /** Clears the active blink interval, if any. */
690
+ private uninstallBlinkTimer(): void {
691
+ if (this.blinkTimer !== null) {
692
+ clearInterval(this.blinkTimer);
693
+ this.blinkTimer = null;
694
+ }
695
+ }
696
+
536
697
  // ── Input handling ─────────────────────────────────────────────────────────
537
698
 
538
699
  /** Processes a raw stdin Buffer. Exposed as public so tests can drive it directly
@@ -660,9 +821,48 @@ export class WindowManager {
660
821
  }
661
822
 
662
823
  /** Returns true when the control is eligible to receive focus in the current
663
- * context — neither disabled nor hidden via `Window.setVisible(false)`. */
824
+ * context — neither disabled nor hidden via `Window.setVisible(false)`,
825
+ * and inside the active focus trap (if any). */
664
826
  private isFocusable(control: Focusable & Window): boolean {
665
- return !control.isDisabled() && control.isVisible();
827
+ if (control.isDisabled() || !control.isVisible()) return false;
828
+ if (this.focusTrap && !this.isWithinTrap(control)) return false;
829
+ return true;
830
+ }
831
+
832
+ /** Depth-first check: is `control` the trap window itself or one of its
833
+ * descendants? Used when `focusTrap` is installed to filter focus
834
+ * candidates. Cheaper than storing parent pointers because the subtree
835
+ * is usually small (the trap is a dialog or composite control). */
836
+ private isWithinTrap(control: Window): boolean {
837
+ if (!this.focusTrap) return true;
838
+ return this.isDescendant(control, this.focusTrap);
839
+ }
840
+
841
+ /** Returns true when `node` is `root` or is reachable by walking
842
+ * `root.getChildren()` recursively. */
843
+ private isDescendant(node: Window, root: Window): boolean {
844
+ if (node === root) return true;
845
+ for (const child of root.getChildren()) {
846
+ if (this.isDescendant(node, child)) return true;
847
+ }
848
+ return false;
849
+ }
850
+
851
+ /** Moves focus to the first or last eligible control in the active
852
+ * context. Shared implementation for `focusFirst` / `focusLast`. */
853
+ private focusAtEdge(edge: 'first' | 'last'): void {
854
+ const entries = this.activeEntries();
855
+ if (entries.length === 0) return;
856
+ const order = edge === 'first'
857
+ ? entries.map((_, i) => i)
858
+ : entries.map((_, i) => entries.length - 1 - i);
859
+ for (const idx of order) {
860
+ if (!this.isFocusable(entries[idx]!.control)) continue;
861
+ this.blurCurrent();
862
+ this.setActiveFocusIndex(idx);
863
+ entries[idx]!.control.setFocused(true);
864
+ return;
865
+ }
666
866
  }
667
867
 
668
868
  /** Finds the first already-focused (or first eligible) control and focuses it. */
@@ -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
  }
@@ -31,6 +31,7 @@ export class Sparkline extends Window {
31
31
  /** Sets the data series. Call render() afterwards. */
32
32
  public setData(data: number[]): void {
33
33
  this.data = data;
34
+ this.markDirty();
34
35
  }
35
36
 
36
37
  /** Returns the current data series. */
@@ -41,6 +42,7 @@ export class Sparkline extends Window {
41
42
  /** Sets the minimum value. Pass undefined to derive from data. Call render() afterwards. */
42
43
  public setMin(min: number | undefined): void {
43
44
  this.minValue = min;
45
+ this.markDirty();
44
46
  }
45
47
 
46
48
  /** Returns the configured minimum value, or undefined if derived from data. */
@@ -51,6 +53,7 @@ export class Sparkline extends Window {
51
53
  /** Sets the maximum value. Pass undefined to derive from data. Call render() afterwards. */
52
54
  public setMax(max: number | undefined): void {
53
55
  this.maxValue = max;
56
+ this.markDirty();
54
57
  }
55
58
 
56
59
  /** Returns the configured maximum value, or undefined if derived from data. */
@@ -52,12 +52,14 @@ export class Spinner extends Window {
52
52
  public step(): void {
53
53
  if (!this.running || this.frames.length === 0) return;
54
54
  this.frame = (this.frame + 1) % this.frames.length;
55
+ this.markDirty();
55
56
  }
56
57
 
57
58
  /** Sets the current frame index directly. Wraps into the valid range. */
58
59
  public setFrame(frame: number): void {
59
60
  if (this.frames.length === 0) return;
60
61
  this.frame = ((frame % this.frames.length) + this.frames.length) % this.frames.length;
62
+ this.markDirty();
61
63
  }
62
64
 
63
65
  /** Returns the current frame index. */
@@ -67,12 +69,16 @@ export class Spinner extends Window {
67
69
 
68
70
  /** Starts or resumes the animation. step() will advance frames again. */
69
71
  public start(): void {
72
+ if (this.running) return;
70
73
  this.running = true;
74
+ this.markDirty();
71
75
  }
72
76
 
73
77
  /** Pauses the animation. step() becomes a no-op; the current frame stays visible. */
74
78
  public stop(): void {
79
+ if (!this.running) return;
75
80
  this.running = false;
81
+ this.markDirty();
76
82
  }
77
83
 
78
84
  /** Returns whether the spinner is currently animating. */
@@ -38,7 +38,9 @@ export class StatusLED extends Window {
38
38
 
39
39
  /** Sets the current LED state. Call render() afterwards to update the display. */
40
40
  public setState(state: 'ok' | 'warn' | 'error' | 'off'): void {
41
+ if (this.state === state) return;
41
42
  this.state = state;
43
+ this.markDirty();
42
44
  }
43
45
 
44
46
  /** Returns the current LED state. */
@@ -48,6 +48,7 @@ export class Tabs extends Window {
48
48
  public setTitles(titles: string[]): void {
49
49
  this.titles = titles;
50
50
  this.activeIndex = Math.max(0, Math.min(titles.length - 1, this.activeIndex));
51
+ this.markDirty();
51
52
  }
52
53
 
53
54
  /** Returns the current tab titles. */
@@ -64,6 +65,7 @@ export class Tabs extends Window {
64
65
  const clamped = Math.max(0, Math.min(this.titles.length - 1, index));
65
66
  if (clamped !== this.activeIndex) {
66
67
  this.activeIndex = clamped;
68
+ this.markDirty();
67
69
  this.onChange?.(clamped, this.titles[clamped]);
68
70
  }
69
71
  }