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,10 +1,11 @@
1
1
  import { EventEmitter } from 'node:events';
2
- import type { CellAttributes, ScreenFrameStats, ScreenOptions, StyleId, TerminalSize } 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';
6
6
  import { Pos } from './Pos.mjs';
7
7
  import { Size } from './Size.mjs';
8
+ import { Toast } from './controls/Toast.mjs';
8
9
 
9
10
  /** ANSI control sequences for the terminal lifecycle features owned by Screen. */
10
11
  const ENTER_ALT_SCREEN = '\x1b[?1049h';
@@ -30,6 +31,30 @@ export class Screen extends Window {
30
31
  private signalsInstalled: boolean;
31
32
  /** Whether dispose() has already restored terminal state. */
32
33
  private disposed: boolean;
34
+ /** Active toast overlays grouped by anchor position. Within each group the
35
+ * toasts are stored in the order they were created so re-layout can stack
36
+ * them outward from the anchor edge. A toast is removed from its group by
37
+ * `dismissToast` (auto-dismiss timer) or by `Toast.dismiss()`. */
38
+ private activeToasts: Map<ToastPosition, Toast[]>;
39
+ /** Per-toast auto-dismiss timers. `dismissToast` consults this map so
40
+ * manual dismissals cancel the pending timer and leaking timers are
41
+ * impossible when the caller drops their reference to the toast. */
42
+ private toastTimers: Map<Toast, ReturnType<typeof setTimeout>>;
43
+ /** Per-toast dismiss callbacks (from `ToastOptions.onDismiss`). Kept out of
44
+ * the `Toast` instance so the control stays free of bookkeeping code. */
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;
33
58
 
34
59
  /** Initializes the root window sized to the current terminal dimensions.
35
60
  * Creates a fresh StyleRegistry (with built-in styles pre-registered)
@@ -52,6 +77,11 @@ export class Screen extends Window {
52
77
  this.boundExit = (): void => { this.restoreTerminalState(); };
53
78
  this.signalsInstalled = false;
54
79
  this.disposed = false;
80
+ this.activeToasts = new Map();
81
+ this.toastTimers = new Map();
82
+ this.toastDismissHandlers = new Map();
83
+ this.damageTracking = options?.damageTracking ?? true;
84
+ this.fullInvalidate = true;
55
85
 
56
86
  if (options?.altScreen) this.enterAltScreen();
57
87
  if (options?.hideCursor) this.hideHardwareCursor();
@@ -79,6 +109,146 @@ export class Screen extends Window {
79
109
  return this.targetFps;
80
110
  }
81
111
 
112
+ // ── Toast overlays (P1-27) ────────────────────────────────────────────────
113
+
114
+ /** Shows a non-modal toast overlay with the given text. Returns the
115
+ * underlying `Toast` window so callers can inspect it, swap its message,
116
+ * or dismiss it manually via `toast.dismiss()`.
117
+ *
118
+ * Toasts are stacked by anchor: every call at the same `position` adds
119
+ * to the visible column, and a dismissal shifts the remainder back
120
+ * towards the anchor edge. Auto-dismissal is driven by a `setTimeout`
121
+ * whose handle is tracked per-toast so manual dismissals cancel the
122
+ * timer. A duration of `0` disables the timer entirely (sticky toast).
123
+ *
124
+ * The method calls `render()` once so the overlay appears immediately,
125
+ * and again after every dismissal for the same reason. Consumers
126
+ * driving their own render loop can call `screen.render()` themselves —
127
+ * the extra render here is idempotent against the `'frame'` event. */
128
+ public toast(text: string, options?: ToastOptions): Toast {
129
+ const position = options?.position ?? 'top-right';
130
+ const duration = options?.duration ?? 2000;
131
+ const zIndex = options?.zIndex ?? 10_000;
132
+ const border = options?.border ?? { top: true, right: true, bottom: true, left: true, style: 'rounded' };
133
+ const style = options?.style ?? Toast.resolveDefaultStyle();
134
+ const width = options?.width;
135
+
136
+ const toast = new Toast(text, { position, style, border, zIndex, width });
137
+ toast.attachDismiss(() => this.dismissToast(toast));
138
+
139
+ const list = this.activeToasts.get(position) ?? [];
140
+ list.push(toast);
141
+ this.activeToasts.set(position, list);
142
+
143
+ this.addChild(toast);
144
+ this.relayoutToasts(position);
145
+
146
+ if (duration > 0) {
147
+ const timer = setTimeout(() => this.dismissToast(toast), duration);
148
+ if (typeof timer === 'object' && timer !== null && 'unref' in timer && typeof timer.unref === 'function') {
149
+ timer.unref();
150
+ }
151
+ this.toastTimers.set(toast, timer);
152
+ }
153
+
154
+ if (options?.onDismiss) this.toastDismissHandlers.set(toast, options.onDismiss);
155
+
156
+ this.render();
157
+ return toast;
158
+ }
159
+
160
+ /** Removes a toast from the Screen, cancels its auto-dismiss timer (if
161
+ * any), reflows the remaining toasts anchored to the same corner, and
162
+ * fires the `onDismiss` callback the caller registered at creation
163
+ * time. Safe to call twice — the second call is a no-op. */
164
+ public dismissToast(toast: Toast): void {
165
+ const position = toast.getToastPosition();
166
+ const list = this.activeToasts.get(position);
167
+ if (!list) return;
168
+ const idx = list.indexOf(toast);
169
+ if (idx === -1) return;
170
+ list.splice(idx, 1);
171
+
172
+ const timer = this.toastTimers.get(toast);
173
+ if (timer !== undefined) {
174
+ clearTimeout(timer);
175
+ this.toastTimers.delete(toast);
176
+ }
177
+
178
+ this.removeChild(toast);
179
+ this.relayoutToasts(position);
180
+
181
+ const handler = this.toastDismissHandlers.get(toast);
182
+ if (handler) {
183
+ this.toastDismissHandlers.delete(toast);
184
+ handler();
185
+ }
186
+
187
+ this.render();
188
+ }
189
+
190
+ /** Returns a snapshot of the currently active toasts for the given anchor
191
+ * position (or every anchor when `position` is omitted). Exposed for
192
+ * tests and diagnostics — consumers generally keep the instance returned
193
+ * by `toast()` rather than walking this list. */
194
+ public getActiveToasts(position?: ToastPosition): readonly Toast[] {
195
+ if (position !== undefined) {
196
+ return [...(this.activeToasts.get(position) ?? [])];
197
+ }
198
+ const all: Toast[] = [];
199
+ for (const list of this.activeToasts.values()) all.push(...list);
200
+ return all;
201
+ }
202
+
203
+ /** Computes each toast's absolute position inside the Screen for the
204
+ * given anchor group and writes it to the toast's `x` / `y`. Called
205
+ * after every `addChild` / `removeChild` toast mutation and whenever
206
+ * the Screen is resized so the stack stays anchored. Mutates `x` / `y`
207
+ * directly — the Toast's `Pos.topLeft()` is only a placeholder consumed
208
+ * by the absolute layout pass that ran during `addChild`. */
209
+ private relayoutToasts(position: ToastPosition): void {
210
+ const list = this.activeToasts.get(position);
211
+ if (!list || list.length === 0) return;
212
+ const { width: screenW, height: screenH } = this.getSize();
213
+
214
+ const horizontalAnchor: 'left' | 'center' | 'right' =
215
+ position.endsWith('left') ? 'left' :
216
+ position.endsWith('right') ? 'right' :
217
+ 'center';
218
+ const verticalAnchor: 'top' | 'bottom' =
219
+ position.startsWith('top') ? 'top' : 'bottom';
220
+
221
+ let offset = 0;
222
+ for (const toast of list) {
223
+ const { width: tw, height: th } = toast.getSize();
224
+
225
+ let x: number;
226
+ if (horizontalAnchor === 'left') x = 0;
227
+ else if (horizontalAnchor === 'right') x = Math.max(0, screenW - tw);
228
+ else x = Math.max(0, Math.floor((screenW - tw) / 2));
229
+
230
+ let y: number;
231
+ if (verticalAnchor === 'top') {
232
+ y = offset;
233
+ } else {
234
+ y = Math.max(0, screenH - offset - th);
235
+ }
236
+ offset += th;
237
+
238
+ toast.x = x;
239
+ toast.y = y;
240
+ }
241
+ }
242
+
243
+ /** Re-runs `relayoutToasts` for every non-empty anchor group. Invoked
244
+ * from `resize()` so terminal resizes keep the overlays pinned to their
245
+ * corners. */
246
+ private relayoutAllToasts(): void {
247
+ for (const position of this.activeToasts.keys()) {
248
+ this.relayoutToasts(position);
249
+ }
250
+ }
251
+
82
252
  // ── Terminal lifecycle helpers ────────────────────────────────────────────
83
253
 
84
254
  /** Switches the terminal into the alternate screen buffer. Idempotent. */
@@ -127,6 +297,12 @@ export class Screen extends Window {
127
297
  * call from both deliberate teardown and a process 'exit' handler. */
128
298
  public dispose(): void {
129
299
  if (this.disposed) return;
300
+ // Cancel every pending auto-dismiss timer so a disposed Screen does
301
+ // not hold the Node event loop open via a detached setTimeout.
302
+ for (const timer of this.toastTimers.values()) clearTimeout(timer);
303
+ this.toastTimers.clear();
304
+ this.toastDismissHandlers.clear();
305
+ this.activeToasts.clear();
130
306
  this.restoreTerminalState();
131
307
  this.uninstallSignalHandlers();
132
308
  this.disposed = true;
@@ -143,6 +319,9 @@ export class Screen extends Window {
143
319
  const w = width ?? process.stdout.columns ?? 80;
144
320
  const h = height ?? process.stdout.rows ?? 24;
145
321
  this.setSize(w, h);
322
+ // Re-anchor every active toast overlay so corner-positioned stacks
323
+ // follow the new terminal geometry rather than drifting out of view.
324
+ this.relayoutAllToasts();
146
325
  const size: TerminalSize = { width: w, height: h };
147
326
  this.events.emit('resize', size);
148
327
  return size;
@@ -175,12 +354,49 @@ export class Screen extends Window {
175
354
  */
176
355
  public override render(): void {
177
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.
178
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
+ }
179
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 {
180
395
  const reg = getRegistry();
181
396
  const chars = this.region.getChars();
182
397
  const styleIds = this.region.getStyleIds();
183
398
  let output = '\x1b[H';
399
+ let count = 0;
184
400
  for (let i = 0; i < chars.length; i++) {
185
401
  const ch = chars[i];
186
402
  // Empty-string sentinel = continuation cell of a wide character;
@@ -188,10 +404,105 @@ export class Screen extends Window {
188
404
  if (ch === '') continue;
189
405
  output += this.buildAnsiSequence(reg.get(styleIds[i]));
190
406
  output += ch;
407
+ count++;
191
408
  }
192
409
  output += '\x1b[0m';
193
410
  process.stdout.write(output);
194
- 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;
195
506
  }
196
507
 
197
508
  // ── Private helpers ───────────────────────────────────────────────────────
@@ -10,6 +10,8 @@ import {
10
10
  BUILTIN_TEXT_PLACEHOLDER,
11
11
  BUILTIN_TEXT_CHECKED,
12
12
  BUILTIN_CURSOR,
13
+ BUILTIN_TEXT_SELECTION,
14
+ BUILTIN_TOAST,
13
15
  } from './types.mjs';
14
16
 
15
17
  /** Central registry that maps integer style IDs to CellAttributes objects.
@@ -34,6 +36,8 @@ export class StyleRegistry {
34
36
  this.registerNamed(BUILTIN_TEXT_PLACEHOLDER, { foreground: 242, italic: true });
35
37
  this.registerNamed(BUILTIN_TEXT_CHECKED, { foreground: 76, bold: true });
36
38
  this.registerNamed(BUILTIN_CURSOR, { inverse: true });
39
+ this.registerNamed(BUILTIN_TEXT_SELECTION, { background: 24, foreground: 231 });
40
+ this.registerNamed(BUILTIN_TOAST, { background: 24, foreground: 231, bold: true });
37
41
  }
38
42
 
39
43
  /** Registers a CellAttributes object and returns its stable ID.
@@ -0,0 +1,175 @@
1
+ import type { CursorBlink, VirtualCursorOptions } from './types.mjs';
2
+
3
+ /** Default on/off durations (ms) for the named blink modes. */
4
+ const BLINK_PRESETS = {
5
+ slow: { onMs: 600, offMs: 600 },
6
+ fast: { onMs: 250, offMs: 250 },
7
+ } as const;
8
+
9
+ /** Bounds (ms) for the irregular blink mode — each phase picks a random
10
+ * duration from its range so the cursor feels "alive" rather than metronomic. */
11
+ const IRREGULAR_ON_MIN = 180;
12
+ const IRREGULAR_ON_MAX = 520;
13
+ const IRREGULAR_OFF_MIN = 90;
14
+ const IRREGULAR_OFF_MAX = 240;
15
+
16
+ /** Default glyph used when the consumer does not supply one — a vertical
17
+ * bar that reads as a text insertion caret on most monospace fonts. */
18
+ export const DEFAULT_CURSOR_SYMBOL = '▎';
19
+
20
+ /** Internal schedule entry used to walk the blink timeline. */
21
+ interface Phase {
22
+ /** Absolute timestamp (ms since epoch) when this phase started. */
23
+ startedAt: number;
24
+ /** True when the cursor is currently showing, false when hidden. */
25
+ visible: boolean;
26
+ /** Duration of this phase in ms. */
27
+ duration: number;
28
+ }
29
+
30
+ /** Software cursor model — owns the glyph rendered on screen and the
31
+ * on/off blink state. Stateless when `blink.mode === 'off'` or `'steady'`;
32
+ * for timed modes the class samples `Date.now()` on every `isVisible()`
33
+ * call and advances internal phase state as needed. A caller (typically
34
+ * `WindowManager`) is responsible for triggering re-renders on a timer so
35
+ * the visual state actually reaches the terminal. */
36
+ export class VirtualCursor {
37
+ /** Glyph rendered for the cursor when visible. `undefined` means "use
38
+ * the legacy inverse-block highlight over the underlying character". */
39
+ private symbol: string | undefined;
40
+ /** Blink configuration — mode and custom timings. */
41
+ private blink: CursorBlink;
42
+ /** Current phase in the blink timeline. Lazily initialised on first
43
+ * `isVisible()` call so construction stays free of side effects. */
44
+ private phase: Phase | null;
45
+
46
+ /** Creates a new cursor with the provided symbol and blink settings.
47
+ * Defaults: symbol = `undefined` (inverse-block), blink = `{ mode: 'steady' }`. */
48
+ public constructor(options?: VirtualCursorOptions) {
49
+ this.symbol = options?.symbol;
50
+ this.blink = options?.blink ?? { mode: 'steady' };
51
+ this.phase = null;
52
+ }
53
+
54
+ /** Returns the caller-supplied glyph, or `DEFAULT_CURSOR_SYMBOL` when
55
+ * none was set. Use `hasCustomSymbol()` to distinguish the two cases. */
56
+ public getSymbol(): string {
57
+ return this.symbol ?? DEFAULT_CURSOR_SYMBOL;
58
+ }
59
+
60
+ /** Returns true when a custom glyph was provided; false means the
61
+ * caller wants the legacy inverse-block highlight behaviour. */
62
+ public hasCustomSymbol(): boolean {
63
+ return this.symbol !== undefined;
64
+ }
65
+
66
+ /** Replaces the cursor glyph. Pass `undefined` to revert to inverse-block. */
67
+ public setSymbol(symbol: string | undefined): void {
68
+ this.symbol = symbol;
69
+ }
70
+
71
+ /** Returns the active blink configuration (for inspection / cloning). */
72
+ public getBlink(): CursorBlink {
73
+ return this.blink;
74
+ }
75
+
76
+ /** Replaces the blink configuration and resets the phase timer so the
77
+ * new mode starts in its "on" phase immediately. */
78
+ public setBlink(blink: CursorBlink): void {
79
+ this.blink = blink;
80
+ this.phase = null;
81
+ }
82
+
83
+ /** Returns the fastest cadence (in ms) a re-render would need to show
84
+ * this cursor's blink faithfully, or `null` when the cursor is static
85
+ * (modes `'off'` and `'steady'`). Used by `WindowManager` to pick a
86
+ * timer interval. For `irregular` we return the minimum possible
87
+ * off-phase duration so quick flicks are not missed. */
88
+ public getTickHintMs(): number | null {
89
+ switch (this.blink.mode) {
90
+ case 'off':
91
+ case 'steady':
92
+ return null;
93
+ case 'slow':
94
+ return BLINK_PRESETS.slow.onMs;
95
+ case 'fast':
96
+ return BLINK_PRESETS.fast.onMs;
97
+ case 'irregular':
98
+ return IRREGULAR_OFF_MIN;
99
+ case 'custom':
100
+ return Math.min(this.blink.onMs, this.blink.offMs);
101
+ default:
102
+ return null;
103
+ }
104
+ }
105
+
106
+ /** Returns whether the cursor should be drawn at the given moment.
107
+ * `now` is accepted for deterministic testing; defaults to `Date.now()`. */
108
+ public isVisible(now: number = Date.now()): boolean {
109
+ if (this.blink.mode === 'off') return false;
110
+ if (this.blink.mode === 'steady') return true;
111
+
112
+ if (this.phase === null) {
113
+ this.phase = { startedAt: now, visible: true, duration: this.firstOnDuration() };
114
+ }
115
+
116
+ // Walk forward through phases until we land on the one that covers `now`.
117
+ // Doing this iteratively (vs. a single modulo) is important for
118
+ // `irregular` mode, where every phase has an independently sampled
119
+ // duration.
120
+ while (now - this.phase.startedAt >= this.phase.duration) {
121
+ const current: Phase = this.phase;
122
+ const nextStart: number = current.startedAt + current.duration;
123
+ const nextVisible: boolean = !current.visible;
124
+ this.phase = {
125
+ startedAt: nextStart,
126
+ visible: nextVisible,
127
+ duration: this.phaseDuration(nextVisible),
128
+ };
129
+ }
130
+
131
+ return this.phase.visible;
132
+ }
133
+
134
+ /** Forces the next `isVisible()` sample to begin a fresh "on" phase at
135
+ * `now`. Useful for keeping the cursor steady while the user types — a
136
+ * blink that vanishes mid-keystroke is jarring. */
137
+ public resetPhase(now: number = Date.now()): void {
138
+ this.phase = { startedAt: now, visible: true, duration: this.firstOnDuration() };
139
+ }
140
+
141
+ /** Picks the first "on" phase duration for the current blink mode.
142
+ * Split out so tests can assert the expected preset values. */
143
+ private firstOnDuration(): number {
144
+ return this.phaseDuration(true);
145
+ }
146
+
147
+ /** Returns the duration of the upcoming phase given its visibility flag.
148
+ * Implements both the named presets and the `custom` / `irregular`
149
+ * semantics in one place. */
150
+ private phaseDuration(visible: boolean): number {
151
+ switch (this.blink.mode) {
152
+ case 'off':
153
+ case 'steady':
154
+ return Number.POSITIVE_INFINITY;
155
+ case 'slow':
156
+ return visible ? BLINK_PRESETS.slow.onMs : BLINK_PRESETS.slow.offMs;
157
+ case 'fast':
158
+ return visible ? BLINK_PRESETS.fast.onMs : BLINK_PRESETS.fast.offMs;
159
+ case 'custom':
160
+ return visible ? this.blink.onMs : this.blink.offMs;
161
+ case 'irregular':
162
+ return visible
163
+ ? randomBetween(IRREGULAR_ON_MIN, IRREGULAR_ON_MAX)
164
+ : randomBetween(IRREGULAR_OFF_MIN, IRREGULAR_OFF_MAX);
165
+ default:
166
+ return Number.POSITIVE_INFINITY;
167
+ }
168
+ }
169
+ }
170
+
171
+ /** Returns a uniformly distributed integer in the closed range [min, max].
172
+ * Kept as a module-private helper so `VirtualCursor` stays compact. */
173
+ function randomBetween(min: number, max: number): number {
174
+ return min + Math.floor(Math.random() * (max - min + 1));
175
+ }