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
@@ -28,6 +28,41 @@ export const BUILTIN_TEXT_PLACEHOLDER = 'builtin:text-placeholder';
28
28
  export const BUILTIN_TEXT_CHECKED = 'builtin:text-checked';
29
29
  /** Name of the cursor highlight style (inverse) used by text input controls. */
30
30
  export const BUILTIN_CURSOR = 'builtin:cursor';
31
+ /** Name of the selection highlight style merged over selected cells in
32
+ * `TextBox` / `TextArea` (backlog P1-20). */
33
+ export const BUILTIN_TEXT_SELECTION = 'builtin:text-selection';
34
+ /** Name of the default background + text style used by `Screen.toast()`
35
+ * overlay windows (backlog P1-27). Consumers can override it via
36
+ * `Screen.setBuiltinStyle(BUILTIN_TOAST, …)` to recolour every toast at
37
+ * once without threading a per-call `style` option. */
38
+ export const BUILTIN_TOAST = 'builtin:toast';
39
+
40
+ // ── Virtual cursor ────────────────────────────────────────────────────────────
41
+
42
+ /** Blink configuration for a `VirtualCursor`.
43
+ * - `off` — cursor is never drawn (hide software cursor entirely).
44
+ * - `steady` — cursor is always drawn (no blink).
45
+ * - `slow` — 600 ms on / 600 ms off.
46
+ * - `fast` — 250 ms on / 250 ms off.
47
+ * - `irregular` — each on/off phase picks a randomised duration so the
48
+ * cursor pulses in a non-metronomic "alive" rhythm.
49
+ * - `custom` — caller-supplied on / off durations in milliseconds. */
50
+ export type CursorBlink =
51
+ | { mode: 'off' }
52
+ | { mode: 'steady' }
53
+ | { mode: 'slow' }
54
+ | { mode: 'fast' }
55
+ | { mode: 'irregular' }
56
+ | { mode: 'custom'; onMs: number; offMs: number };
57
+
58
+ /** Construction options for `VirtualCursor`. Both fields are optional;
59
+ * defaults: `symbol = '▎'`, `blink = { mode: 'steady' }`. */
60
+ export interface VirtualCursorOptions {
61
+ /** Glyph rendered for the cursor when visible. Default: `'▎'`. */
62
+ symbol?: string;
63
+ /** Blink schedule. Default: `{ mode: 'steady' }` (no blink). */
64
+ blink?: CursorBlink;
65
+ }
31
66
 
32
67
  /** Integer handle returned by StyleRegistry.register(). ID 0 always means no style (empty {}). */
33
68
  export type StyleId = number;
@@ -75,12 +110,39 @@ export interface ScreenOptions {
75
110
  * for inspection; full enforcement (frame coalescing) lands with backlog
76
111
  * item P2-47. Default: undefined (uncapped). */
77
112
  targetFps?: number;
113
+ /** Enables damage-region tracking: `Screen.render()` collects dirty rects
114
+ * propagated bottom-up from each mutated window and emits ANSI sequences
115
+ * only for the changed cells. A frame with no dirty rects is skipped
116
+ * entirely (no stdout traffic). The first frame and every frame after
117
+ * `resize()` / `invalidate()` fall back to a full repaint. Default:
118
+ * `true`. Set to `false` for the pre-0.31 behaviour (always emit the
119
+ * full buffer), useful for debugging or terminals that mis-handle
120
+ * partial cursor jumps. */
121
+ damageTracking?: boolean;
78
122
  }
79
123
 
80
124
  /** Statistics emitted by the Screen 'frame' event after each render() call. */
81
125
  export interface ScreenFrameStats {
82
126
  /** Wall-clock duration of the render() call in milliseconds. */
83
127
  ms: number;
128
+ /** Number of cells emitted to stdout during this frame (0 when the frame
129
+ * was skipped because nothing was dirty). Populated only when damage
130
+ * tracking is enabled; undefined otherwise. */
131
+ cellsEmitted?: number;
132
+ /** True when the emit path was a full-screen repaint (first frame,
133
+ * post-resize, or damage tracking disabled); false when only dirty runs
134
+ * were emitted. Undefined when the frame was skipped entirely. */
135
+ fullRepaint?: boolean;
136
+ }
137
+
138
+ /** Rectangle expressed in the owning Window's local coordinate space (for
139
+ * dirty regions on a Window) or in Screen coordinates (when collected by
140
+ * `Screen.render()` during damage tracking). */
141
+ export interface DirtyRect {
142
+ x: number;
143
+ y: number;
144
+ w: number;
145
+ h: number;
84
146
  }
85
147
 
86
148
  /** Box-drawing character style for window borders.
@@ -167,6 +229,16 @@ export interface WindowProperties {
167
229
  * padded inner area, and `getInnerSize()` / `getInnerOffset()` reflect the
168
230
  * padding as well as the border. Default: 0 on every side. */
169
231
  padding?: PaddingSpec;
232
+ /** Outer spacing reserved around the window inside its parent's layout.
233
+ * - In `row` / `column` flex layouts margin stacks with `gap`: each child
234
+ * claims `intrinsic + marginMain` cells on the main axis, and its cross
235
+ * extent is reduced by the cross margin.
236
+ * - In `grid` layout the child fits inside `cell − margin` and is offset by
237
+ * `marginTop` / `marginLeft` within the cell.
238
+ * - In `absolute` layout margin shifts the child's resolved position by
239
+ * `(marginLeft, marginTop)` without changing its size.
240
+ * Default: 0 on every side. */
241
+ margin?: MarginSpec;
170
242
  /** Number of columns for `layout: 'grid'`. Children are placed row-major,
171
243
  * so `gridColumns` implicitly determines the number of rows from the child
172
244
  * count. Default: 1. */
@@ -177,6 +249,21 @@ export interface WindowProperties {
177
249
  /** Main-axis distribution of leftover space for `layout: 'row'` / `'column'`
178
250
  * when no child consumes it via flex-grow. Default: 'start'. */
179
251
  justifyContent?: JustifyContent;
252
+ /** Optional string identifier used by `WindowManager.focusById` and for
253
+ * diagnostics. InterfaceBuilder copies the YAML `id:` into this field so
254
+ * the runtime can cross-reference programmatic focus with the builder map. */
255
+ id?: string;
256
+ /** Stacking order among siblings — windows with a higher `zIndex` render
257
+ * on top of windows with a lower value. Ties are broken by `addChild`
258
+ * insertion order, so the pre-0.26 behaviour (everything at `zIndex: 0`)
259
+ * is preserved for callers that don't opt in. Default: 0. */
260
+ zIndex?: number;
261
+ /** Fires once when this window transitions from unfocused to focused, via
262
+ * any code path (WindowManager, explicit `setFocused(true)`, focus
263
+ * inheritance on dialog open). Not fired when the state does not change. */
264
+ onFocus?: () => void;
265
+ /** Fires once when this window transitions from focused to unfocused. */
266
+ onBlur?: () => void;
180
267
  }
181
268
 
182
269
  /** Internal per-axis position spec used by Pos.
@@ -240,6 +327,20 @@ export interface Padding {
240
327
  * a partial per-side record (missing sides default to 0). */
241
328
  export type PaddingSpec = number | [number, number] | Partial<Padding>;
242
329
 
330
+ /** Resolved per-side margin values (in cells). Margin is the outer counterpart
331
+ * of padding — it pushes the window away from its parent's inner edges /
332
+ * siblings without reserving space inside the window itself. */
333
+ export interface Margin {
334
+ top: number;
335
+ right: number;
336
+ bottom: number;
337
+ left: number;
338
+ }
339
+
340
+ /** User-supplied margin: a uniform number, a [vertical, horizontal] tuple, or
341
+ * a partial per-side record (missing sides default to 0). Mirrors `PaddingSpec`. */
342
+ export type MarginSpec = number | [number, number] | Partial<Margin>;
343
+
243
344
  /** Options for Window.writeText() – position defaults to (0, 0). */
244
345
  export interface WriteTextOptions {
245
346
  /** Column to start writing at. Default: 0. */
@@ -298,6 +399,15 @@ export interface TextBoxProperties {
298
399
  * `true` to mark the key as handled — the default behaviour (inserting,
299
400
  * moving cursor, deleting, …) is then skipped. */
300
401
  onKeyDown?: (key: string) => boolean | void;
402
+ /** Virtual-cursor glyph. Overrides the default inverse-block look. When
403
+ * set, the character under the cursor is replaced by this symbol (unless
404
+ * the symbol is a zero-width string, in which case the underlying
405
+ * character stays). Default: undefined (inverse block). */
406
+ cursorSymbol?: string;
407
+ /** Blink schedule for the virtual cursor. Default: `{ mode: 'steady' }`
408
+ * (no blink). Requires `WindowManager.enableCursorBlink()` for timed
409
+ * modes to actually animate. */
410
+ cursorBlink?: CursorBlink;
301
411
  }
302
412
 
303
413
  /** Control-specific properties for the TextArea control. */
@@ -324,6 +434,10 @@ export interface TextAreaProperties {
324
434
  /** When true, Ctrl+D deletes the character to the right of the cursor
325
435
  * (or joins with the next line at end of line). Default: false. */
326
436
  ctrlDDeletesForward?: boolean;
437
+ /** Virtual-cursor glyph. Overrides the default inverse-block look. */
438
+ cursorSymbol?: string;
439
+ /** Blink schedule for the virtual cursor. Default: `{ mode: 'steady' }`. */
440
+ cursorBlink?: CursorBlink;
327
441
  }
328
442
 
329
443
  /** Control-specific properties for the Checkbox control. */
@@ -465,6 +579,43 @@ export interface SpinnerProperties {
465
579
  color?: number;
466
580
  }
467
581
 
582
+ /** Corner (or top/bottom centre) where `Screen.toast()` anchors its overlay
583
+ * windows. Toasts launched at the same position stack vertically in the
584
+ * order they were created; a dismissal shifts the remaining stack towards
585
+ * the anchor edge so the UI stays compact. */
586
+ export type ToastPosition =
587
+ | 'top-left' | 'top-center' | 'top-right'
588
+ | 'bottom-left' | 'bottom-center' | 'bottom-right';
589
+
590
+ /** Options accepted by `Screen.toast()`. All fields are optional; sensible
591
+ * defaults make `screen.toast('Saved!')` a single-call notification. */
592
+ export interface ToastOptions {
593
+ /** Auto-dismiss delay in milliseconds. Pass `0` to keep the toast sticky
594
+ * until `toast.dismiss()` is called manually. Default: `2000`. */
595
+ duration?: number;
596
+ /** Pre-registered style ID used as both the window background and the
597
+ * text style. Default: `BUILTIN_TOAST` (falls back to a high-contrast
598
+ * style registered on first use). */
599
+ style?: StyleId;
600
+ /** Corner (or top/bottom centre) the toast anchors to. Default:
601
+ * `'top-right'`. */
602
+ position?: ToastPosition;
603
+ /** Border configuration. Pass `false` for a borderless toast. Default:
604
+ * a rounded single-line border on all four sides. */
605
+ border?: WindowBorder | boolean;
606
+ /** Stacking order among Screen children. Default: `10_000` so toasts
607
+ * draw on top of every regular window without callers having to raise
608
+ * other `zIndex` values by hand. */
609
+ zIndex?: number;
610
+ /** Override the automatic text-based width (in cells, not counting the
611
+ * border). When the text is wider than this value it overflows
612
+ * normally — toasts never wrap. Default: `undefined` (auto-size). */
613
+ width?: number;
614
+ /** Fires once after the toast has been removed from the Screen
615
+ * (auto-dismiss or manual). */
616
+ onDismiss?: () => void;
617
+ }
618
+
468
619
  /** Control-specific properties for the BarChart control. */
469
620
  export interface BarChartProperties {
470
621
  /** Data values for each bar. Default: []. */
@@ -575,6 +726,10 @@ export interface YamlWindowDef {
575
726
  insertTabAsSpaces?: number;
576
727
  /** TextArea: when true, Ctrl+D deletes the character to the right of the cursor. */
577
728
  ctrlDDeletesForward?: boolean;
729
+ /** TextBox / TextArea: virtual-cursor glyph (overrides inverse-block look). */
730
+ cursorSymbol?: string;
731
+ /** TextBox / TextArea: virtual-cursor blink schedule. */
732
+ cursorBlink?: CursorBlink;
578
733
  /** LED state ('ok' | 'warn' | 'error' | 'off') — used by statusled. */
579
734
  state?: 'ok' | 'warn' | 'error' | 'off';
580
735
  /** Whether to show a percentage label over a progress bar. Default: true. */
@@ -620,6 +775,9 @@ export interface YamlWindowDef {
620
775
  /** Padding inside the border: uniform number, [vertical, horizontal] tuple,
621
776
  * or a partial per-side record. */
622
777
  padding?: number | [number, number] | { top?: number; right?: number; bottom?: number; left?: number };
778
+ /** Outer spacing reserved around this window inside its parent's layout.
779
+ * Same shape as `padding`. */
780
+ margin?: number | [number, number] | { top?: number; right?: number; bottom?: number; left?: number };
623
781
  /** Number of columns for `layout: grid`. */
624
782
  gridColumns?: number;
625
783
  /** Cross-axis alignment for `layout: row|column`. */
@@ -629,6 +787,9 @@ export interface YamlWindowDef {
629
787
  /** Free-form property bag for user-registered custom types. Built-in types
630
788
  * ignore this field; custom factories read it via `node.props`. */
631
789
  props?: Record<string, unknown>;
790
+ /** Stacking order among siblings. Higher values render on top; ties keep
791
+ * YAML declaration order. Default: 0. */
792
+ zIndex?: number;
632
793
  }
633
794
 
634
795
  /** Context passed to factories registered via `InterfaceBuilder.registerType`.
@@ -718,4 +879,10 @@ export interface WindowManagerOptions {
718
879
  onMouse?: (event: TerminalMouseEvent) => void;
719
880
  /** Enable mouse click tracking (SGR protocol). Default: false. */
720
881
  mouse?: boolean;
882
+ /** Fires when a descendant Window throws during its `render()` call (or its
883
+ * blit onto the parent). The offending subtree is replaced in-place with a
884
+ * single-line error placeholder so the rest of the frame still paints;
885
+ * the handler is invoked once per error with the thrown value and the
886
+ * Window that produced it. Default: no-op. */
887
+ onError?: (err: unknown, control: Window) => void;
721
888
  }
package/src/demo.mts CHANGED
@@ -13,7 +13,10 @@ import { Spinner } from './Screen/controls/Spinner.mjs';
13
13
  import { Sparkline } from './Screen/controls/Sparkline.mjs';
14
14
  import { ListBox } from './Screen/controls/ListBox.mjs';
15
15
  import { Tabs } from './Screen/controls/Tabs.mjs';
16
+ import { TextBox } from './Screen/controls/TextBox.mjs';
16
17
  import { Window } from './Screen/Window.mjs';
18
+ import { Pos } from './Screen/Pos.mjs';
19
+ import { Size } from './Screen/Size.mjs';
17
20
  import type { ListBoxRowSegments, WindowProperties, StyleId } from './Screen/types.mjs';
18
21
 
19
22
  /** Custom Window subclass registered with InterfaceBuilder via
@@ -73,6 +76,18 @@ const main = async (): Promise<void> => {
73
76
  process.exit(0);
74
77
  },
75
78
  mouse: true,
79
+ // P1-23 demo: whenever a descendant Window.render() raises, append a
80
+ // red row to the events list instead of tearing down the TUI.
81
+ onError: (err, control) => {
82
+ const list = result.get('eventsList') as ListBox<EventRow>;
83
+ const entry: EventRow = {
84
+ timestamp: timestamp(),
85
+ level: 'error',
86
+ message: `render fail (${control.getId() ?? 'unnamed'}): ${err instanceof Error ? err.message : String(err)}`,
87
+ count: 1,
88
+ };
89
+ list.setItems([entry, ...list.getItems()].slice(0, 20));
90
+ },
76
91
  });
77
92
 
78
93
  // 0.19.0: SIGWINCH autoresize. The Screen reflows percentage-based children
@@ -369,6 +384,55 @@ const main = async (): Promise<void> => {
369
384
  // buffer, re-hides the cursor, re-enables mouse tracking, and re-renders
370
385
  // the frame. The focus / dialog stack / key bindings stay intact across
371
386
  // the cycle.
387
+ // P1-18 demo: Ctrl+G jumps focus to the Save button via its YAML id,
388
+ // without the user having to Tab through every field in between.
389
+ wm.bindKey('ctrl+g', () => {
390
+ wm.focusById('btnSave');
391
+ screen.render();
392
+ return true;
393
+ });
394
+
395
+ // P1-22 demo: observe focus transitions on btnSave without subclassing —
396
+ // the hooks append a short trail to the events list so the user can see
397
+ // exactly when setFocus fires them.
398
+ const btnSave = result.get('btnSave')!;
399
+ btnSave.setOnFocus(() => {
400
+ const list = result.get('eventsList') as ListBox<EventRow>;
401
+ const entry: EventRow = { timestamp: timestamp(), level: 'ok', message: 'btnSave → focus', count: 1 };
402
+ list.setItems([entry, ...list.getItems()].slice(0, 20));
403
+ });
404
+ btnSave.setOnBlur(() => {
405
+ const list = result.get('eventsList') as ListBox<EventRow>;
406
+ const entry: EventRow = { timestamp: timestamp(), level: 'warn', message: 'btnSave → blur', count: 1 };
407
+ list.setItems([entry, ...list.getItems()].slice(0, 20));
408
+ });
409
+
410
+ // P1-27 demo: Ctrl+T pops a short-lived top-right toast so the overlay
411
+ // subsystem is visible at runtime. Repeated presses stack vertically and
412
+ // auto-dismiss in the same order. Ctrl+Y fires a sticky bottom-right toast
413
+ // that can only be cleared via its own dismiss button (`Enter` on focus)
414
+ // — here we close it on the next press to keep the demo self-contained.
415
+ let toastCounter = 0;
416
+ let stickyToast: ReturnType<typeof screen.toast> | null = null;
417
+ wm.bindKey('ctrl+t', () => {
418
+ toastCounter += 1;
419
+ screen.toast(` Saved #${toastCounter} at ${timestamp()} `, { duration: 2500 });
420
+ return true;
421
+ });
422
+ wm.bindKey('ctrl+y', () => {
423
+ if (stickyToast) {
424
+ stickyToast.dismiss();
425
+ stickyToast = null;
426
+ } else {
427
+ stickyToast = screen.toast(' Sticky: press Ctrl+Y again to dismiss ', {
428
+ position: 'bottom-right',
429
+ duration: 0,
430
+ onDismiss: () => { stickyToast = null; },
431
+ });
432
+ }
433
+ return true;
434
+ });
435
+
372
436
  wm.bindKey('ctrl+e', () => {
373
437
  wm.pause({ leaveAltScreen: true });
374
438
  process.stdout.write('\n--- paused take4_console TUI. Press Enter to return ---\n');
@@ -377,6 +441,73 @@ const main = async (): Promise<void> => {
377
441
  return true;
378
442
  });
379
443
 
444
+ // P2-59 demo: Ctrl+D toggles damage tracking at runtime and posts a
445
+ // sticky toast with the new state so the effect is visible. When
446
+ // tracking is on, the 'frame' event reports only the dirty cells each
447
+ // frame (watch the toast subtitle); when it's off, every frame is a
448
+ // full repaint as before.
449
+ wm.bindKey('ctrl+d', () => {
450
+ const next = !screen.isDamageTrackingEnabled();
451
+ screen.setDamageTracking(next);
452
+ screen.toast(
453
+ next ? ' Damage tracking ON — only dirty cells are emitted '
454
+ : ' Damage tracking OFF — every frame is a full repaint ',
455
+ { position: 'top-center', duration: 2500 },
456
+ );
457
+ return true;
458
+ });
459
+
460
+ // Virtual cursor: animated caret in TextBox/TextArea. The `tbEmail`
461
+ // TextBox opts into slow blink via layout.yaml; here we flip the
462
+ // `tbUsername` TextBox into the `irregular` mode so the two
463
+ // side-by-side inputs show off two different rhythms. The
464
+ // `enableCursorBlink()` call boots the periodic rerender that makes
465
+ // the blink phases actually reach the terminal.
466
+ const tbUsername = result.get('tbUsername') as TextBox | undefined;
467
+ if (tbUsername) {
468
+ tbUsername.getVirtualCursor().setSymbol('▏');
469
+ tbUsername.getVirtualCursor().setBlink({ mode: 'irregular' });
470
+ // P1-20 showcase: pre-select the initial value so the merged
471
+ // selection style is visible on boot. Shift+Left/Right, Ctrl+A,
472
+ // and plain arrows collapse it the moment the user interacts.
473
+ const text = tbUsername.getValue();
474
+ if (text.length > 0) tbUsername.setSelection(0, Math.min(3, text.length));
475
+ }
476
+ wm.enableCursorBlink(80);
477
+
478
+ // P2-59 showcase: five independent spinners with different styles,
479
+ // cadences, and positions, each driven by its own `setInterval`.
480
+ // With damage tracking ON, every tick emits only that spinner's 1–2
481
+ // cells (check the 'frame' event `cellsEmitted`); with tracking OFF
482
+ // (Ctrl+D) each tick repaints the whole screen. Placed along the
483
+ // bottom edge so they don't overlap existing widgets.
484
+ const spinnerStyles: Array<'braille' | 'dots' | 'line' | 'circle' | 'arrow'> =
485
+ ['braille', 'dots', 'line', 'circle', 'arrow'];
486
+ const spinnerCadences = [120, 180, 240, 320, 420];
487
+ const spinnerTimers: NodeJS.Timeout[] = [];
488
+ const { width: screenW, height: screenH } = screen.getSize();
489
+ for (let i = 0; i < spinnerStyles.length; i++) {
490
+ const sp = new Spinner(
491
+ { pos: new Pos(screenW - 12 - i * 3, screenH - 2), size: new Size(2, 1) },
492
+ { style: spinnerStyles[i], color: 75 + i * 10 },
493
+ );
494
+ screen.addChild(sp);
495
+ const timer = setInterval(() => {
496
+ sp.step();
497
+ screen.render();
498
+ }, spinnerCadences[i]!);
499
+ if (typeof timer === 'object' && timer !== null && 'unref' in timer
500
+ && typeof timer.unref === 'function') timer.unref();
501
+ spinnerTimers.push(timer);
502
+ }
503
+ // Ensure the spinner timers are torn down alongside the main demo
504
+ // timer when the user quits (the existing onExit handler already
505
+ // clears `demoTimer`, so extend it with ours).
506
+ const originalOnExit = () => {
507
+ for (const t of spinnerTimers) clearInterval(t);
508
+ };
509
+ process.once('exit', originalOnExit);
510
+
380
511
  wm.run();
381
512
  };
382
513
 
package/src/index.mts CHANGED
@@ -15,6 +15,7 @@ export { Window } from './Screen/Window.mjs';
15
15
  export { Region } from './Screen/Region.mjs';
16
16
  export { StyleRegistry } from './Screen/StyleRegistry.mjs';
17
17
  export { WindowManager } from './Screen/WindowManager.mjs';
18
+ export { VirtualCursor, DEFAULT_CURSOR_SYMBOL } from './Screen/VirtualCursor.mjs';
18
19
 
19
20
  // ── Geometry ──────────────────────────────────────────────────────────────────
20
21
  export { Pos } from './Screen/Pos.mjs';
@@ -39,6 +40,7 @@ export { LineChart } from './Screen/controls/LineChart.mjs';
39
40
  export { BarChart } from './Screen/controls/BarChart.mjs';
40
41
  export { Sparkline } from './Screen/controls/Sparkline.mjs';
41
42
  export { Spinner } from './Screen/controls/Spinner.mjs';
43
+ export { Toast } from './Screen/controls/Toast.mjs';
42
44
 
43
45
  // ── YAML layout builder ───────────────────────────────────────────────────────
44
46
  export { InterfaceBuilder } from './Screen/InterfaceBuilder.mjs';
@@ -58,6 +60,8 @@ export {
58
60
  BUILTIN_TEXT_PLACEHOLDER,
59
61
  BUILTIN_TEXT_CHECKED,
60
62
  BUILTIN_CURSOR,
63
+ BUILTIN_TEXT_SELECTION,
64
+ BUILTIN_TOAST,
61
65
  } from './Screen/types.mjs';
62
66
 
63
67
  // ── Public type exports ───────────────────────────────────────────────────────
@@ -70,6 +74,7 @@ export type {
70
74
  TerminalSize,
71
75
  ScreenOptions,
72
76
  ScreenFrameStats,
77
+ DirtyRect,
73
78
  AxisSpec,
74
79
  DimSpec,
75
80
  FlexBasis,
@@ -78,6 +83,8 @@ export type {
78
83
  JustifyContent,
79
84
  Padding,
80
85
  PaddingSpec,
86
+ Margin,
87
+ MarginSpec,
81
88
 
82
89
  // Window / border
83
90
  BorderStyle,
@@ -106,12 +113,18 @@ export type {
106
113
  TabsProperties,
107
114
  SparklineProperties,
108
115
  SpinnerProperties,
116
+ ToastPosition,
117
+ ToastOptions,
109
118
 
110
119
  // Focus & input
111
120
  Focusable,
112
121
  TerminalMouseEvent,
113
122
  WindowManagerOptions,
114
123
 
124
+ // Virtual cursor
125
+ CursorBlink,
126
+ VirtualCursorOptions,
127
+
115
128
  // InterfaceBuilder YAML schema
116
129
  YamlAxisValue,
117
130
  YamlPosSpec,
package/src/layout.yaml CHANGED
@@ -17,9 +17,19 @@ windows:
17
17
  type: badge
18
18
  pos: { x: 18, y: 0 }
19
19
  size: { width: 10, height: 1 }
20
+ # P1-17 demo: painted after its siblings thanks to the higher zIndex,
21
+ # so any overlapping overlay child below zIndex 10 stays underneath.
22
+ zIndex: 10
20
23
  props:
21
24
  text: "P0-10 "
22
25
  color: 220
26
+ - id: headerOverlay
27
+ pos: { x: 15, y: 0 }
28
+ size: { width: 16, height: 1 }
29
+ background: 52
30
+ # P1-17 demo: sits behind buildBadge even though declared later — the
31
+ # Window.render loop sorts siblings by (zIndex asc, insertion order).
32
+ zIndex: 1
23
33
 
24
34
  - id: statusBar
25
35
  pos: { preset: bottom }
@@ -67,6 +77,9 @@ windows:
67
77
  size: { fillWidth: 3 }
68
78
  value: "jan@example.com"
69
79
  placeholder: "e-mail"
80
+ # Virtual cursor: custom caret glyph with a slow blink.
81
+ cursorSymbol: "▎"
82
+ cursorBlink: { mode: "slow" }
70
83
  - id: cb1
71
84
  type: checkbox
72
85
  pos: { x: 1, y: 9 }
@@ -265,4 +278,7 @@ windows:
265
278
  type: button
266
279
  pos: flex
267
280
  size: { width: 11, height: 3 }
281
+ # Extra breathing room between "Cancel" and "Save" via P1-16 margin
282
+ # (horizontal cells reserved on top of layout gap).
283
+ margin: { left: 2 }
268
284
  label: " Save"