take4-console 0.25.0 → 0.30.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 (62) hide show
  1. package/CHANGELOG.md +193 -0
  2. package/dist/Screen/ErrorHolder.d.mts +10 -0
  3. package/dist/Screen/ErrorHolder.d.mts.map +1 -0
  4. package/dist/Screen/ErrorHolder.mjs +14 -0
  5. package/dist/Screen/ErrorHolder.mjs.map +1 -0
  6. package/dist/Screen/InterfaceBuilder.d.mts.map +1 -1
  7. package/dist/Screen/InterfaceBuilder.mjs +7 -0
  8. package/dist/Screen/InterfaceBuilder.mjs.map +1 -1
  9. package/dist/Screen/Screen.d.mts +50 -1
  10. package/dist/Screen/Screen.d.mts.map +1 -1
  11. package/dist/Screen/Screen.mjs +152 -0
  12. package/dist/Screen/Screen.mjs.map +1 -1
  13. package/dist/Screen/StyleRegistry.d.mts.map +1 -1
  14. package/dist/Screen/StyleRegistry.mjs +3 -1
  15. package/dist/Screen/StyleRegistry.mjs.map +1 -1
  16. package/dist/Screen/VirtualCursor.d.mts +57 -0
  17. package/dist/Screen/VirtualCursor.d.mts.map +1 -0
  18. package/dist/Screen/VirtualCursor.mjs +148 -0
  19. package/dist/Screen/VirtualCursor.mjs.map +1 -0
  20. package/dist/Screen/Window.d.mts +67 -6
  21. package/dist/Screen/Window.d.mts.map +1 -1
  22. package/dist/Screen/Window.mjs +195 -27
  23. package/dist/Screen/Window.mjs.map +1 -1
  24. package/dist/Screen/WindowManager.d.mts +78 -2
  25. package/dist/Screen/WindowManager.d.mts.map +1 -1
  26. package/dist/Screen/WindowManager.mjs +186 -3
  27. package/dist/Screen/WindowManager.mjs.map +1 -1
  28. package/dist/Screen/controls/TextArea.d.mts +68 -2
  29. package/dist/Screen/controls/TextArea.d.mts.map +1 -1
  30. package/dist/Screen/controls/TextArea.mjs +280 -46
  31. package/dist/Screen/controls/TextArea.mjs.map +1 -1
  32. package/dist/Screen/controls/TextBox.d.mts +52 -5
  33. package/dist/Screen/controls/TextBox.d.mts.map +1 -1
  34. package/dist/Screen/controls/TextBox.mjs +179 -10
  35. package/dist/Screen/controls/TextBox.mjs.map +1 -1
  36. package/dist/Screen/controls/Toast.d.mts +72 -0
  37. package/dist/Screen/controls/Toast.d.mts.map +1 -0
  38. package/dist/Screen/controls/Toast.mjs +112 -0
  39. package/dist/Screen/controls/Toast.mjs.map +1 -0
  40. package/dist/Screen/types.d.mts +143 -0
  41. package/dist/Screen/types.d.mts.map +1 -1
  42. package/dist/Screen/types.mjs +8 -0
  43. package/dist/Screen/types.mjs.map +1 -1
  44. package/dist/index.d.mts +4 -2
  45. package/dist/index.d.mts.map +1 -1
  46. package/dist/index.mjs +3 -1
  47. package/dist/index.mjs.map +1 -1
  48. package/package.json +1 -1
  49. package/src/Screen/ErrorHolder.mts +22 -0
  50. package/src/Screen/InterfaceBuilder.mts +12 -5
  51. package/src/Screen/Screen.mts +166 -1
  52. package/src/Screen/StyleRegistry.mts +4 -0
  53. package/src/Screen/VirtualCursor.mts +175 -0
  54. package/src/Screen/Window.mts +197 -28
  55. package/src/Screen/WindowManager.mts +192 -3
  56. package/src/Screen/controls/TextArea.mts +280 -41
  57. package/src/Screen/controls/TextBox.mts +181 -10
  58. package/src/Screen/controls/Toast.mts +138 -0
  59. package/src/Screen/types.mts +140 -0
  60. package/src/demo.mts +80 -0
  61. package/src/index.mts +12 -0
  62. package/src/layout.yaml +16 -0
@@ -1,7 +1,8 @@
1
1
  import type { TextBoxProperties, WindowProperties, StyleId } from '../types.mjs';
2
- import { BUILTIN_TEXT_PLACEHOLDER, BUILTIN_CURSOR } from '../types.mjs';
2
+ import { BUILTIN_TEXT_PLACEHOLDER, BUILTIN_CURSOR, BUILTIN_TEXT_SELECTION } from '../types.mjs';
3
3
  import { Window } from '../Window.mjs';
4
4
  import { getRegistry } from '../RegistryHolder.mjs';
5
+ import { VirtualCursor } from '../VirtualCursor.mjs';
5
6
 
6
7
  /** A single-line text-input widget with scrolling, cursor display, and placeholder support.
7
8
  * Wraps content area inside a single-line border (total height 3 by default).
@@ -9,10 +10,17 @@ import { getRegistry } from '../RegistryHolder.mjs';
9
10
  export class TextBox extends Window {
10
11
  private value: string;
11
12
  private cursor: number;
13
+ /** Anchor of the active selection, or null when no selection is active.
14
+ * Selection spans the half-open range [min(anchor, cursor), max(anchor, cursor)). */
15
+ private selectionAnchor: number | null;
12
16
  private scrollOffset: number;
13
17
  private placeholder: string;
14
18
  private placeholderStyleId: StyleId;
15
19
  private cursorStyleId: StyleId;
20
+ private selectionStyleId: StyleId;
21
+ /** Software-cursor model: blink state + glyph. Always present so the
22
+ * caller can reconfigure at runtime via `getVirtualCursor()`. */
23
+ private virtualCursor: VirtualCursor;
16
24
 
17
25
  /** Invoked after every value change driven by handleKey. Not called by setValue. */
18
26
  private onChange?: (value: string) => void;
@@ -35,6 +43,7 @@ export class TextBox extends Window {
35
43
  this.cursor = cp?.cursor !== undefined
36
44
  ? Math.max(0, Math.min(cp.cursor, this.value.length))
37
45
  : this.value.length;
46
+ this.selectionAnchor = null;
38
47
 
39
48
  this.onChange = cp?.onChange;
40
49
  this.onSubmit = cp?.onSubmit;
@@ -43,17 +52,75 @@ export class TextBox extends Window {
43
52
  const reg = getRegistry();
44
53
  this.placeholderStyleId = reg.getNamed(BUILTIN_TEXT_PLACEHOLDER)!;
45
54
  this.cursorStyleId = reg.getNamed(BUILTIN_CURSOR)!;
55
+ this.selectionStyleId = reg.getNamed(BUILTIN_TEXT_SELECTION)!;
56
+
57
+ this.virtualCursor = new VirtualCursor({
58
+ symbol: cp?.cursorSymbol,
59
+ blink: cp?.cursorBlink,
60
+ });
46
61
 
47
62
  this.clampScroll();
48
63
  }
49
64
 
50
- /** Replaces the current value; clamps cursor and scroll to fit. Does NOT fire onChange. */
65
+ /** Returns the VirtualCursor model so callers can tweak symbol/blink at runtime. */
66
+ public getVirtualCursor(): VirtualCursor {
67
+ return this.virtualCursor;
68
+ }
69
+
70
+ /** Replaces the current value; clamps cursor and scroll to fit. Does NOT fire onChange.
71
+ * Any active selection is cleared because the old anchor no longer maps
72
+ * onto the new string. */
51
73
  public setValue(value: string): void {
52
74
  this.value = value;
53
75
  this.cursor = Math.min(this.cursor, value.length);
76
+ this.selectionAnchor = null;
77
+ this.clampScroll();
78
+ }
79
+
80
+ /** Returns the normalized selection range `{ start, end }` (half-open)
81
+ * when a selection is active and non-empty, otherwise `null`. */
82
+ public getSelection(): { start: number; end: number } | null {
83
+ if (this.selectionAnchor === null) return null;
84
+ const a = this.selectionAnchor;
85
+ const b = this.cursor;
86
+ if (a === b) return null;
87
+ return a < b ? { start: a, end: b } : { start: b, end: a };
88
+ }
89
+
90
+ /** Returns the currently selected substring, or '' when no selection. */
91
+ public getSelectedText(): string {
92
+ const sel = this.getSelection();
93
+ return sel === null ? '' : this.value.slice(sel.start, sel.end);
94
+ }
95
+
96
+ /** Replaces the current selection: sets anchor and cursor, clamping both
97
+ * to the current value length. Passing identical indices clears the
98
+ * selection; callers that want an empty anchor should use `clearSelection()`. */
99
+ public setSelection(anchor: number, cursor: number): void {
100
+ const len = this.value.length;
101
+ const a = Math.max(0, Math.min(anchor, len));
102
+ const c = Math.max(0, Math.min(cursor, len));
103
+ this.selectionAnchor = a === c ? null : a;
104
+ this.cursor = c;
54
105
  this.clampScroll();
55
106
  }
56
107
 
108
+ /** Selects every character in the current value. No-op when the value is empty. */
109
+ public selectAll(): void {
110
+ if (this.value.length === 0) {
111
+ this.selectionAnchor = null;
112
+ return;
113
+ }
114
+ this.selectionAnchor = 0;
115
+ this.cursor = this.value.length;
116
+ this.clampScroll();
117
+ }
118
+
119
+ /** Drops any active selection without moving the cursor. */
120
+ public clearSelection(): void {
121
+ this.selectionAnchor = null;
122
+ }
123
+
57
124
  /** Replaces the onChange callback (passing undefined clears it). */
58
125
  public setOnChange(fn?: (value: string) => void): void {
59
126
  this.onChange = fn;
@@ -74,9 +141,12 @@ export class TextBox extends Window {
74
141
  return this.value;
75
142
  }
76
143
 
77
- /** Sets the cursor to the given character index (clamped to valid range). */
144
+ /** Sets the cursor to the given character index (clamped to valid range).
145
+ * Any active selection is dropped — callers that want to adjust the
146
+ * cursor while keeping the selection must use `setSelection()`. */
78
147
  public setCursor(pos: number): void {
79
148
  this.cursor = Math.max(0, Math.min(pos, this.value.length));
149
+ this.selectionAnchor = null;
80
150
  this.clampScroll();
81
151
  }
82
152
 
@@ -87,9 +157,13 @@ export class TextBox extends Window {
87
157
 
88
158
  /** Processes a key string from the terminal input loop and updates value/cursor.
89
159
  * Supported special keys: backspace (\x7f), delete (\x1b[3~), left (\x1b[D),
90
- * right (\x1b[C), home (\x1b[H), end (\x1b[F).
91
- * Human-readable aliases: 'backspace', 'delete', 'left', 'right', 'home', 'end'.
92
- * Any single printable character is inserted at the cursor position. */
160
+ * right (\x1b[C), home (\x1b[H), end (\x1b[F), plus their shift-modified
161
+ * CSI variants (`\x1b[1;2D` etc.) which extend the selection, and
162
+ * Ctrl+A (`\x01`) which selects the entire value.
163
+ * Human-readable aliases: 'backspace', 'delete', 'left', 'right', 'home',
164
+ * 'end', 'shift+left', 'shift+right', 'shift+home', 'shift+end', 'ctrl+a'.
165
+ * Any single printable character is inserted at the cursor position and
166
+ * replaces the active selection (if any). */
93
167
  public handleKey(key: string): void {
94
168
  if (this.disabled) return;
95
169
 
@@ -100,44 +174,119 @@ export class TextBox extends Window {
100
174
  }
101
175
 
102
176
  const before = this.value;
177
+
178
+ // ── Selection-extending motion (shift + arrow / home / end) ─────────
179
+ if (key === '\x1b[1;2D' || key === 'shift+left') {
180
+ this.ensureAnchor();
181
+ if (this.cursor > 0) this.cursor--;
182
+ this.finishKey(before);
183
+ return;
184
+ }
185
+ if (key === '\x1b[1;2C' || key === 'shift+right') {
186
+ this.ensureAnchor();
187
+ if (this.cursor < this.value.length) this.cursor++;
188
+ this.finishKey(before);
189
+ return;
190
+ }
191
+ if (key === '\x1b[1;2H' || key === 'shift+home') {
192
+ this.ensureAnchor();
193
+ this.cursor = 0;
194
+ this.finishKey(before);
195
+ return;
196
+ }
197
+ if (key === '\x1b[1;2F' || key === 'shift+end') {
198
+ this.ensureAnchor();
199
+ this.cursor = this.value.length;
200
+ this.finishKey(before);
201
+ return;
202
+ }
203
+ if (key === '\x01' || key === 'ctrl+a') {
204
+ this.selectAll();
205
+ this.finishKey(before);
206
+ return;
207
+ }
208
+
103
209
  switch (key) {
104
210
  case '\r': case '\n': case 'enter':
105
211
  this.onSubmit?.(this.value);
106
212
  this.clampScroll();
107
213
  return; // Enter never mutates value by itself in TextBox.
108
214
  case '\x7f': case '\b': case 'backspace':
215
+ if (this.deleteSelection()) break;
109
216
  if (this.cursor > 0) {
110
217
  this.value = this.value.slice(0, this.cursor - 1) + this.value.slice(this.cursor);
111
218
  this.cursor--;
112
219
  }
113
220
  break;
114
221
  case '\x1b[3~': case 'delete':
222
+ if (this.deleteSelection()) break;
115
223
  if (this.cursor < this.value.length) {
116
224
  this.value = this.value.slice(0, this.cursor) + this.value.slice(this.cursor + 1);
117
225
  }
118
226
  break;
119
227
  case '\x1b[D': case 'left':
228
+ // Non-shift left collapses an active selection to its start.
229
+ if (this.collapseSelection('start')) break;
120
230
  if (this.cursor > 0) this.cursor--;
121
231
  break;
122
232
  case '\x1b[C': case 'right':
233
+ if (this.collapseSelection('end')) break;
123
234
  if (this.cursor < this.value.length) this.cursor++;
124
235
  break;
125
236
  case '\x1b[H': case 'home':
237
+ this.selectionAnchor = null;
126
238
  this.cursor = 0;
127
239
  break;
128
240
  case '\x1b[F': case 'end':
241
+ this.selectionAnchor = null;
129
242
  this.cursor = this.value.length;
130
243
  break;
131
244
  default:
132
245
  if (key.length === 1 && key >= ' ') {
246
+ this.deleteSelection();
133
247
  this.value = this.value.slice(0, this.cursor) + key + this.value.slice(this.cursor);
134
248
  this.cursor++;
135
249
  }
136
250
  }
251
+ this.finishKey(before);
252
+ }
253
+
254
+ /** Shared post-key housekeeping: clamp scroll, reset blink phase, fire
255
+ * onChange when the value actually changed. */
256
+ private finishKey(before: string): void {
137
257
  this.clampScroll();
258
+ this.virtualCursor.resetPhase();
138
259
  if (this.value !== before) this.onChange?.(this.value);
139
260
  }
140
261
 
262
+ /** Starts a selection at the current cursor if none is active, so that a
263
+ * subsequent cursor move extends the selection. */
264
+ private ensureAnchor(): void {
265
+ if (this.selectionAnchor === null) this.selectionAnchor = this.cursor;
266
+ }
267
+
268
+ /** Removes the selected text when a selection is active; cursor is placed
269
+ * at the start of the deleted range. Returns true when a deletion
270
+ * happened, false otherwise. */
271
+ private deleteSelection(): boolean {
272
+ const sel = this.getSelection();
273
+ if (sel === null) return false;
274
+ this.value = this.value.slice(0, sel.start) + this.value.slice(sel.end);
275
+ this.cursor = sel.start;
276
+ this.selectionAnchor = null;
277
+ return true;
278
+ }
279
+
280
+ /** Drops an active selection, moving the cursor to the selection's
281
+ * `start` or `end` edge. Returns true when it did so. */
282
+ private collapseSelection(edge: 'start' | 'end'): boolean {
283
+ const sel = this.getSelection();
284
+ if (sel === null) return false;
285
+ this.cursor = edge === 'start' ? sel.start : sel.end;
286
+ this.selectionAnchor = null;
287
+ return true;
288
+ }
289
+
141
290
  /** Rebuilds the TextBox: renders text or placeholder, draws cursor. */
142
291
  public override render(): void {
143
292
  this.clear();
@@ -153,12 +302,34 @@ export class TextBox extends Window {
153
302
  this.writeText(visible, { style: this.disabled ? undefined : this.normalStyleId });
154
303
  }
155
304
 
156
- // Draw cursor when focused.
157
- if (this.focused && !this.disabled) {
305
+ // Paint the selection highlight over any cells that fall inside the
306
+ // active selection range — run before the cursor so the caret still
307
+ // shows on top of the highlight.
308
+ const sel = this.focused && !this.disabled ? this.getSelection() : null;
309
+ if (sel !== null) {
310
+ const base = this.normalStyleId;
311
+ for (let i = sel.start; i < sel.end; i++) {
312
+ const col = i - this.scrollOffset;
313
+ if (col < 0 || col >= width) continue;
314
+ const merged = this.registry.merge(base, this.selectionStyleId);
315
+ const ch = this.value[i] ?? ' ';
316
+ this.writeText(ch, { x: col, y: 0, style: merged });
317
+ }
318
+ }
319
+
320
+ // Draw cursor when focused and the virtual-cursor blink says "on".
321
+ if (this.focused && !this.disabled && this.virtualCursor.isVisible()) {
158
322
  const cursorX = this.cursor - this.scrollOffset;
159
323
  if (cursorX >= 0 && cursorX < width) {
160
- const cursorChar = this.value[this.cursor] ?? ' ';
161
- const cursorStyle = this.registry.merge(this.normalStyleId, this.cursorStyleId);
324
+ const useSymbol = this.virtualCursor.hasCustomSymbol();
325
+ // With a custom glyph we draw the glyph itself (styled with the
326
+ // normal text style so the symbol's colours stay predictable);
327
+ // without one we fall back to the legacy inverse-block highlight
328
+ // painted over the character under the caret.
329
+ const cursorChar = useSymbol ? this.virtualCursor.getSymbol() : (this.value[this.cursor] ?? ' ');
330
+ const cursorStyle = useSymbol
331
+ ? this.normalStyleId
332
+ : this.registry.merge(this.normalStyleId, this.cursorStyleId);
162
333
  this.writeText(cursorChar, { x: cursorX, y: 0, style: cursorStyle });
163
334
  }
164
335
  }
@@ -0,0 +1,138 @@
1
+ import type { StyleId, ToastPosition, WindowBorder } from '../types.mjs';
2
+ import { BUILTIN_TOAST } from '../types.mjs';
3
+ import { Window } from '../Window.mjs';
4
+ import { Pos } from '../Pos.mjs';
5
+ import { Size } from '../Size.mjs';
6
+ import { getRegistry } from '../RegistryHolder.mjs';
7
+ import { stringWidth } from '../textWidth.mjs';
8
+
9
+ /** Resolved option bag consumed by the `Toast` constructor. `Screen.toast()`
10
+ * fills every field so the control itself never has to re-apply defaults. */
11
+ export interface ToastConstructorOptions {
12
+ /** Anchor corner (or top/bottom centre). Stored on the instance so the
13
+ * enclosing Screen can group toasts by anchor for stacking / re-layout. */
14
+ position: ToastPosition;
15
+ /** Pre-registered style used both as the window background and as the
16
+ * base style for the toast text. */
17
+ style: StyleId;
18
+ /** Border configuration. `false` yields a borderless toast. */
19
+ border: WindowBorder | boolean;
20
+ /** Stacking order relative to regular Screen children. */
21
+ zIndex: number;
22
+ /** Optional width override for the text row (inner area, without border).
23
+ * When undefined the toast auto-sizes to the measured string width. */
24
+ width: number | undefined;
25
+ }
26
+
27
+ /** Converts a border option (`true` / `false` / `WindowBorder`) into the
28
+ * normalised `{ top, right, bottom, left }` record we need to compute the
29
+ * toast's final width and height at construction time. */
30
+ const resolveBorderSides = (border: WindowBorder | boolean): { top: boolean; right: boolean; bottom: boolean; left: boolean } => {
31
+ if (border === false) return { top: false, right: false, bottom: false, left: false };
32
+ if (border === true) return { top: true, right: true, bottom: true, left: true };
33
+ const style = border.style;
34
+ if (style === 'none') return { top: false, right: false, bottom: false, left: false };
35
+ return {
36
+ top: border.top ?? false,
37
+ right: border.right ?? false,
38
+ bottom: border.bottom ?? false,
39
+ left: border.left ?? false,
40
+ };
41
+ };
42
+
43
+ /** A transient overlay window produced by `Screen.toast()`. Holds the
44
+ * message text and the anchor position so the Screen can stack, re-flow,
45
+ * and dismiss toasts without leaking that bookkeeping into every caller.
46
+ *
47
+ * The toast is auto-sized: the inner text row is `max(stringWidth(text), 1)`
48
+ * cells wide (or `options.width` when explicitly provided), plus one cell
49
+ * of horizontal padding on each side and the border insets. Height is
50
+ * always one text row plus the vertical border insets — toasts do not
51
+ * wrap or stack text vertically. */
52
+ export class Toast extends Window {
53
+ /** Message rendered inside the toast body. Stored so `setMessage` can
54
+ * redraw without re-asking the caller for the original text. */
55
+ private message: string;
56
+ /** Anchor corner (or top/bottom centre). Read by `Screen` when re-flowing
57
+ * the active-toast stack after a dismissal or terminal resize. */
58
+ private toastPosition: ToastPosition;
59
+ /** Style shared between the background fill and the text row. Kept so
60
+ * `setMessage` uses the same colour combination the toast launched with. */
61
+ private toastStyle: StyleId;
62
+ /** Screen-supplied dismissal hook. Registered after construction via
63
+ * `attachDismiss()` so `Toast` does not need a circular import on
64
+ * `Screen` to invoke `screen.dismissToast(this)`. */
65
+ private dismissHandler: (() => void) | undefined;
66
+
67
+ /** Builds a Toast with a final size derived from the supplied text and
68
+ * option bag. The caller (`Screen.toast()`) is responsible for placing
69
+ * the toast inside the Screen via `addChild` and setting the final x/y. */
70
+ public constructor(text: string, options: ToastConstructorOptions) {
71
+ const sides = resolveBorderSides(options.border);
72
+ const borderW = (sides.left ? 1 : 0) + (sides.right ? 1 : 0);
73
+ const borderH = (sides.top ? 1 : 0) + (sides.bottom ? 1 : 0);
74
+ const innerWidth = Math.max(1, options.width ?? stringWidth(text));
75
+ const width = innerWidth + 2 + borderW; // 1-cell horizontal padding each side.
76
+ const height = 1 + borderH;
77
+
78
+ super({
79
+ pos: Pos.topLeft(),
80
+ size: new Size(width, height),
81
+ background: options.style,
82
+ border: options.border,
83
+ zIndex: options.zIndex,
84
+ padding: { left: 1, right: 1, top: 0, bottom: 0 },
85
+ });
86
+
87
+ this.message = text;
88
+ this.toastPosition = options.position;
89
+ this.toastStyle = options.style;
90
+
91
+ this.writeText(text, { x: 0, y: 0, style: options.style });
92
+ }
93
+
94
+ /** Returns the currently displayed message. */
95
+ public getMessage(): string {
96
+ return this.message;
97
+ }
98
+
99
+ /** Returns the resolved style id used for both the background fill and
100
+ * the text row. Exposed so tests can verify the toast's paint without
101
+ * re-resolving BUILTIN_TOAST by hand. */
102
+ public getToastStyle(): StyleId {
103
+ return this.toastStyle;
104
+ }
105
+
106
+ /** Returns the anchor corner the toast was created with. `Screen` uses
107
+ * this to group sibling toasts when re-flowing the active stack. */
108
+ public getToastPosition(): ToastPosition {
109
+ return this.toastPosition;
110
+ }
111
+
112
+ /** Registers the dismiss callback wired up by `Screen.toast()`. Called
113
+ * once immediately after construction so `toast.dismiss()` can trigger
114
+ * the full Screen-side teardown (timer cancel, re-flow, onDismiss
115
+ * callback) without the Toast needing a direct Screen reference. */
116
+ public attachDismiss(handler: () => void): void {
117
+ this.dismissHandler = handler;
118
+ }
119
+
120
+ /** Removes the toast from the Screen it was shown on. Equivalent to
121
+ * calling `screen.dismissToast(this)` — the overload exists so callers
122
+ * that only kept the Toast reference do not need to remember the owning
123
+ * Screen. Safe to call before `attachDismiss()` (no-op) or twice
124
+ * (subsequent calls are no-ops because the Screen guards re-dismissal). */
125
+ public dismiss(): void {
126
+ this.dismissHandler?.();
127
+ }
128
+
129
+ /** Builds a fallback toast style when the registry did not already have
130
+ * `BUILTIN_TOAST` mapped. Extracted as a static helper so `Screen.toast`
131
+ * can resolve the style before it knows whether construction will fail. */
132
+ public static resolveDefaultStyle(): StyleId {
133
+ const reg = getRegistry();
134
+ const existing = reg.getNamed(BUILTIN_TOAST);
135
+ if (existing !== undefined) return existing;
136
+ return reg.registerNamed(BUILTIN_TOAST, { background: 24, foreground: 231, bold: true });
137
+ }
138
+ }
@@ -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;
@@ -167,6 +202,16 @@ export interface WindowProperties {
167
202
  * padded inner area, and `getInnerSize()` / `getInnerOffset()` reflect the
168
203
  * padding as well as the border. Default: 0 on every side. */
169
204
  padding?: PaddingSpec;
205
+ /** Outer spacing reserved around the window inside its parent's layout.
206
+ * - In `row` / `column` flex layouts margin stacks with `gap`: each child
207
+ * claims `intrinsic + marginMain` cells on the main axis, and its cross
208
+ * extent is reduced by the cross margin.
209
+ * - In `grid` layout the child fits inside `cell − margin` and is offset by
210
+ * `marginTop` / `marginLeft` within the cell.
211
+ * - In `absolute` layout margin shifts the child's resolved position by
212
+ * `(marginLeft, marginTop)` without changing its size.
213
+ * Default: 0 on every side. */
214
+ margin?: MarginSpec;
170
215
  /** Number of columns for `layout: 'grid'`. Children are placed row-major,
171
216
  * so `gridColumns` implicitly determines the number of rows from the child
172
217
  * count. Default: 1. */
@@ -177,6 +222,21 @@ export interface WindowProperties {
177
222
  /** Main-axis distribution of leftover space for `layout: 'row'` / `'column'`
178
223
  * when no child consumes it via flex-grow. Default: 'start'. */
179
224
  justifyContent?: JustifyContent;
225
+ /** Optional string identifier used by `WindowManager.focusById` and for
226
+ * diagnostics. InterfaceBuilder copies the YAML `id:` into this field so
227
+ * the runtime can cross-reference programmatic focus with the builder map. */
228
+ id?: string;
229
+ /** Stacking order among siblings — windows with a higher `zIndex` render
230
+ * on top of windows with a lower value. Ties are broken by `addChild`
231
+ * insertion order, so the pre-0.26 behaviour (everything at `zIndex: 0`)
232
+ * is preserved for callers that don't opt in. Default: 0. */
233
+ zIndex?: number;
234
+ /** Fires once when this window transitions from unfocused to focused, via
235
+ * any code path (WindowManager, explicit `setFocused(true)`, focus
236
+ * inheritance on dialog open). Not fired when the state does not change. */
237
+ onFocus?: () => void;
238
+ /** Fires once when this window transitions from focused to unfocused. */
239
+ onBlur?: () => void;
180
240
  }
181
241
 
182
242
  /** Internal per-axis position spec used by Pos.
@@ -240,6 +300,20 @@ export interface Padding {
240
300
  * a partial per-side record (missing sides default to 0). */
241
301
  export type PaddingSpec = number | [number, number] | Partial<Padding>;
242
302
 
303
+ /** Resolved per-side margin values (in cells). Margin is the outer counterpart
304
+ * of padding — it pushes the window away from its parent's inner edges /
305
+ * siblings without reserving space inside the window itself. */
306
+ export interface Margin {
307
+ top: number;
308
+ right: number;
309
+ bottom: number;
310
+ left: number;
311
+ }
312
+
313
+ /** User-supplied margin: a uniform number, a [vertical, horizontal] tuple, or
314
+ * a partial per-side record (missing sides default to 0). Mirrors `PaddingSpec`. */
315
+ export type MarginSpec = number | [number, number] | Partial<Margin>;
316
+
243
317
  /** Options for Window.writeText() – position defaults to (0, 0). */
244
318
  export interface WriteTextOptions {
245
319
  /** Column to start writing at. Default: 0. */
@@ -298,6 +372,15 @@ export interface TextBoxProperties {
298
372
  * `true` to mark the key as handled — the default behaviour (inserting,
299
373
  * moving cursor, deleting, …) is then skipped. */
300
374
  onKeyDown?: (key: string) => boolean | void;
375
+ /** Virtual-cursor glyph. Overrides the default inverse-block look. When
376
+ * set, the character under the cursor is replaced by this symbol (unless
377
+ * the symbol is a zero-width string, in which case the underlying
378
+ * character stays). Default: undefined (inverse block). */
379
+ cursorSymbol?: string;
380
+ /** Blink schedule for the virtual cursor. Default: `{ mode: 'steady' }`
381
+ * (no blink). Requires `WindowManager.enableCursorBlink()` for timed
382
+ * modes to actually animate. */
383
+ cursorBlink?: CursorBlink;
301
384
  }
302
385
 
303
386
  /** Control-specific properties for the TextArea control. */
@@ -324,6 +407,10 @@ export interface TextAreaProperties {
324
407
  /** When true, Ctrl+D deletes the character to the right of the cursor
325
408
  * (or joins with the next line at end of line). Default: false. */
326
409
  ctrlDDeletesForward?: boolean;
410
+ /** Virtual-cursor glyph. Overrides the default inverse-block look. */
411
+ cursorSymbol?: string;
412
+ /** Blink schedule for the virtual cursor. Default: `{ mode: 'steady' }`. */
413
+ cursorBlink?: CursorBlink;
327
414
  }
328
415
 
329
416
  /** Control-specific properties for the Checkbox control. */
@@ -465,6 +552,43 @@ export interface SpinnerProperties {
465
552
  color?: number;
466
553
  }
467
554
 
555
+ /** Corner (or top/bottom centre) where `Screen.toast()` anchors its overlay
556
+ * windows. Toasts launched at the same position stack vertically in the
557
+ * order they were created; a dismissal shifts the remaining stack towards
558
+ * the anchor edge so the UI stays compact. */
559
+ export type ToastPosition =
560
+ | 'top-left' | 'top-center' | 'top-right'
561
+ | 'bottom-left' | 'bottom-center' | 'bottom-right';
562
+
563
+ /** Options accepted by `Screen.toast()`. All fields are optional; sensible
564
+ * defaults make `screen.toast('Saved!')` a single-call notification. */
565
+ export interface ToastOptions {
566
+ /** Auto-dismiss delay in milliseconds. Pass `0` to keep the toast sticky
567
+ * until `toast.dismiss()` is called manually. Default: `2000`. */
568
+ duration?: number;
569
+ /** Pre-registered style ID used as both the window background and the
570
+ * text style. Default: `BUILTIN_TOAST` (falls back to a high-contrast
571
+ * style registered on first use). */
572
+ style?: StyleId;
573
+ /** Corner (or top/bottom centre) the toast anchors to. Default:
574
+ * `'top-right'`. */
575
+ position?: ToastPosition;
576
+ /** Border configuration. Pass `false` for a borderless toast. Default:
577
+ * a rounded single-line border on all four sides. */
578
+ border?: WindowBorder | boolean;
579
+ /** Stacking order among Screen children. Default: `10_000` so toasts
580
+ * draw on top of every regular window without callers having to raise
581
+ * other `zIndex` values by hand. */
582
+ zIndex?: number;
583
+ /** Override the automatic text-based width (in cells, not counting the
584
+ * border). When the text is wider than this value it overflows
585
+ * normally — toasts never wrap. Default: `undefined` (auto-size). */
586
+ width?: number;
587
+ /** Fires once after the toast has been removed from the Screen
588
+ * (auto-dismiss or manual). */
589
+ onDismiss?: () => void;
590
+ }
591
+
468
592
  /** Control-specific properties for the BarChart control. */
469
593
  export interface BarChartProperties {
470
594
  /** Data values for each bar. Default: []. */
@@ -575,6 +699,10 @@ export interface YamlWindowDef {
575
699
  insertTabAsSpaces?: number;
576
700
  /** TextArea: when true, Ctrl+D deletes the character to the right of the cursor. */
577
701
  ctrlDDeletesForward?: boolean;
702
+ /** TextBox / TextArea: virtual-cursor glyph (overrides inverse-block look). */
703
+ cursorSymbol?: string;
704
+ /** TextBox / TextArea: virtual-cursor blink schedule. */
705
+ cursorBlink?: CursorBlink;
578
706
  /** LED state ('ok' | 'warn' | 'error' | 'off') — used by statusled. */
579
707
  state?: 'ok' | 'warn' | 'error' | 'off';
580
708
  /** Whether to show a percentage label over a progress bar. Default: true. */
@@ -620,6 +748,9 @@ export interface YamlWindowDef {
620
748
  /** Padding inside the border: uniform number, [vertical, horizontal] tuple,
621
749
  * or a partial per-side record. */
622
750
  padding?: number | [number, number] | { top?: number; right?: number; bottom?: number; left?: number };
751
+ /** Outer spacing reserved around this window inside its parent's layout.
752
+ * Same shape as `padding`. */
753
+ margin?: number | [number, number] | { top?: number; right?: number; bottom?: number; left?: number };
623
754
  /** Number of columns for `layout: grid`. */
624
755
  gridColumns?: number;
625
756
  /** Cross-axis alignment for `layout: row|column`. */
@@ -629,6 +760,9 @@ export interface YamlWindowDef {
629
760
  /** Free-form property bag for user-registered custom types. Built-in types
630
761
  * ignore this field; custom factories read it via `node.props`. */
631
762
  props?: Record<string, unknown>;
763
+ /** Stacking order among siblings. Higher values render on top; ties keep
764
+ * YAML declaration order. Default: 0. */
765
+ zIndex?: number;
632
766
  }
633
767
 
634
768
  /** Context passed to factories registered via `InterfaceBuilder.registerType`.
@@ -718,4 +852,10 @@ export interface WindowManagerOptions {
718
852
  onMouse?: (event: TerminalMouseEvent) => void;
719
853
  /** Enable mouse click tracking (SGR protocol). Default: false. */
720
854
  mouse?: boolean;
855
+ /** Fires when a descendant Window throws during its `render()` call (or its
856
+ * blit onto the parent). The offending subtree is replaced in-place with a
857
+ * single-line error placeholder so the rest of the frame still paints;
858
+ * the handler is invoked once per error with the thrown value and the
859
+ * Window that produced it. Default: no-op. */
860
+ onError?: (err: unknown, control: Window) => void;
721
861
  }