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,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,15 +52,78 @@ 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
+ this.markDirty();
79
+ }
80
+
81
+ /** Returns the normalized selection range `{ start, end }` (half-open)
82
+ * when a selection is active and non-empty, otherwise `null`. */
83
+ public getSelection(): { start: number; end: number } | null {
84
+ if (this.selectionAnchor === null) return null;
85
+ const a = this.selectionAnchor;
86
+ const b = this.cursor;
87
+ if (a === b) return null;
88
+ return a < b ? { start: a, end: b } : { start: b, end: a };
89
+ }
90
+
91
+ /** Returns the currently selected substring, or '' when no selection. */
92
+ public getSelectedText(): string {
93
+ const sel = this.getSelection();
94
+ return sel === null ? '' : this.value.slice(sel.start, sel.end);
95
+ }
96
+
97
+ /** Replaces the current selection: sets anchor and cursor, clamping both
98
+ * to the current value length. Passing identical indices clears the
99
+ * selection; callers that want an empty anchor should use `clearSelection()`. */
100
+ public setSelection(anchor: number, cursor: number): void {
101
+ const len = this.value.length;
102
+ const a = Math.max(0, Math.min(anchor, len));
103
+ const c = Math.max(0, Math.min(cursor, len));
104
+ this.selectionAnchor = a === c ? null : a;
105
+ this.cursor = c;
54
106
  this.clampScroll();
107
+ this.markDirty();
108
+ }
109
+
110
+ /** Selects every character in the current value. No-op when the value is empty. */
111
+ public selectAll(): void {
112
+ if (this.value.length === 0) {
113
+ this.selectionAnchor = null;
114
+ return;
115
+ }
116
+ this.selectionAnchor = 0;
117
+ this.cursor = this.value.length;
118
+ this.clampScroll();
119
+ this.markDirty();
120
+ }
121
+
122
+ /** Drops any active selection without moving the cursor. */
123
+ public clearSelection(): void {
124
+ if (this.selectionAnchor === null) return;
125
+ this.selectionAnchor = null;
126
+ this.markDirty();
55
127
  }
56
128
 
57
129
  /** Replaces the onChange callback (passing undefined clears it). */
@@ -74,10 +146,14 @@ export class TextBox extends Window {
74
146
  return this.value;
75
147
  }
76
148
 
77
- /** Sets the cursor to the given character index (clamped to valid range). */
149
+ /** Sets the cursor to the given character index (clamped to valid range).
150
+ * Any active selection is dropped — callers that want to adjust the
151
+ * cursor while keeping the selection must use `setSelection()`. */
78
152
  public setCursor(pos: number): void {
79
153
  this.cursor = Math.max(0, Math.min(pos, this.value.length));
154
+ this.selectionAnchor = null;
80
155
  this.clampScroll();
156
+ this.markDirty();
81
157
  }
82
158
 
83
159
  /** Returns the current cursor character index. */
@@ -87,9 +163,13 @@ export class TextBox extends Window {
87
163
 
88
164
  /** Processes a key string from the terminal input loop and updates value/cursor.
89
165
  * 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. */
166
+ * right (\x1b[C), home (\x1b[H), end (\x1b[F), plus their shift-modified
167
+ * CSI variants (`\x1b[1;2D` etc.) which extend the selection, and
168
+ * Ctrl+A (`\x01`) which selects the entire value.
169
+ * Human-readable aliases: 'backspace', 'delete', 'left', 'right', 'home',
170
+ * 'end', 'shift+left', 'shift+right', 'shift+home', 'shift+end', 'ctrl+a'.
171
+ * Any single printable character is inserted at the cursor position and
172
+ * replaces the active selection (if any). */
93
173
  public handleKey(key: string): void {
94
174
  if (this.disabled) return;
95
175
 
@@ -100,44 +180,125 @@ export class TextBox extends Window {
100
180
  }
101
181
 
102
182
  const before = this.value;
183
+
184
+ // ── Selection-extending motion (shift + arrow / home / end) ─────────
185
+ if (key === '\x1b[1;2D' || key === 'shift+left') {
186
+ this.ensureAnchor();
187
+ if (this.cursor > 0) this.cursor--;
188
+ this.finishKey(before);
189
+ return;
190
+ }
191
+ if (key === '\x1b[1;2C' || key === 'shift+right') {
192
+ this.ensureAnchor();
193
+ if (this.cursor < this.value.length) this.cursor++;
194
+ this.finishKey(before);
195
+ return;
196
+ }
197
+ if (key === '\x1b[1;2H' || key === 'shift+home') {
198
+ this.ensureAnchor();
199
+ this.cursor = 0;
200
+ this.finishKey(before);
201
+ return;
202
+ }
203
+ if (key === '\x1b[1;2F' || key === 'shift+end') {
204
+ this.ensureAnchor();
205
+ this.cursor = this.value.length;
206
+ this.finishKey(before);
207
+ return;
208
+ }
209
+ if (key === '\x01' || key === 'ctrl+a') {
210
+ this.selectAll();
211
+ this.finishKey(before);
212
+ return;
213
+ }
214
+
103
215
  switch (key) {
104
216
  case '\r': case '\n': case 'enter':
105
217
  this.onSubmit?.(this.value);
106
218
  this.clampScroll();
107
219
  return; // Enter never mutates value by itself in TextBox.
108
220
  case '\x7f': case '\b': case 'backspace':
221
+ if (this.deleteSelection()) break;
109
222
  if (this.cursor > 0) {
110
223
  this.value = this.value.slice(0, this.cursor - 1) + this.value.slice(this.cursor);
111
224
  this.cursor--;
112
225
  }
113
226
  break;
114
227
  case '\x1b[3~': case 'delete':
228
+ if (this.deleteSelection()) break;
115
229
  if (this.cursor < this.value.length) {
116
230
  this.value = this.value.slice(0, this.cursor) + this.value.slice(this.cursor + 1);
117
231
  }
118
232
  break;
119
233
  case '\x1b[D': case 'left':
234
+ // Non-shift left collapses an active selection to its start.
235
+ if (this.collapseSelection('start')) break;
120
236
  if (this.cursor > 0) this.cursor--;
121
237
  break;
122
238
  case '\x1b[C': case 'right':
239
+ if (this.collapseSelection('end')) break;
123
240
  if (this.cursor < this.value.length) this.cursor++;
124
241
  break;
125
242
  case '\x1b[H': case 'home':
243
+ this.selectionAnchor = null;
126
244
  this.cursor = 0;
127
245
  break;
128
246
  case '\x1b[F': case 'end':
247
+ this.selectionAnchor = null;
129
248
  this.cursor = this.value.length;
130
249
  break;
131
250
  default:
132
251
  if (key.length === 1 && key >= ' ') {
252
+ this.deleteSelection();
133
253
  this.value = this.value.slice(0, this.cursor) + key + this.value.slice(this.cursor);
134
254
  this.cursor++;
135
255
  }
136
256
  }
257
+ this.finishKey(before);
258
+ }
259
+
260
+ /** Shared post-key housekeeping: clamp scroll, reset blink phase, fire
261
+ * onChange when the value actually changed. */
262
+ private finishKey(before: string): void {
137
263
  this.clampScroll();
264
+ this.virtualCursor.resetPhase();
265
+ // Every key dispatch is a potential content / selection / cursor
266
+ // change — flag the window dirty unconditionally so the next
267
+ // Screen.render() re-emits it. The per-key equality checks in
268
+ // individual branches are not worth the code; the bounding box is
269
+ // cheap and we're going to emit at most the whole window.
270
+ this.markDirty();
138
271
  if (this.value !== before) this.onChange?.(this.value);
139
272
  }
140
273
 
274
+ /** Starts a selection at the current cursor if none is active, so that a
275
+ * subsequent cursor move extends the selection. */
276
+ private ensureAnchor(): void {
277
+ if (this.selectionAnchor === null) this.selectionAnchor = this.cursor;
278
+ }
279
+
280
+ /** Removes the selected text when a selection is active; cursor is placed
281
+ * at the start of the deleted range. Returns true when a deletion
282
+ * happened, false otherwise. */
283
+ private deleteSelection(): boolean {
284
+ const sel = this.getSelection();
285
+ if (sel === null) return false;
286
+ this.value = this.value.slice(0, sel.start) + this.value.slice(sel.end);
287
+ this.cursor = sel.start;
288
+ this.selectionAnchor = null;
289
+ return true;
290
+ }
291
+
292
+ /** Drops an active selection, moving the cursor to the selection's
293
+ * `start` or `end` edge. Returns true when it did so. */
294
+ private collapseSelection(edge: 'start' | 'end'): boolean {
295
+ const sel = this.getSelection();
296
+ if (sel === null) return false;
297
+ this.cursor = edge === 'start' ? sel.start : sel.end;
298
+ this.selectionAnchor = null;
299
+ return true;
300
+ }
301
+
141
302
  /** Rebuilds the TextBox: renders text or placeholder, draws cursor. */
142
303
  public override render(): void {
143
304
  this.clear();
@@ -153,12 +314,34 @@ export class TextBox extends Window {
153
314
  this.writeText(visible, { style: this.disabled ? undefined : this.normalStyleId });
154
315
  }
155
316
 
156
- // Draw cursor when focused.
157
- if (this.focused && !this.disabled) {
317
+ // Paint the selection highlight over any cells that fall inside the
318
+ // active selection range — run before the cursor so the caret still
319
+ // shows on top of the highlight.
320
+ const sel = this.focused && !this.disabled ? this.getSelection() : null;
321
+ if (sel !== null) {
322
+ const base = this.normalStyleId;
323
+ for (let i = sel.start; i < sel.end; i++) {
324
+ const col = i - this.scrollOffset;
325
+ if (col < 0 || col >= width) continue;
326
+ const merged = this.registry.merge(base, this.selectionStyleId);
327
+ const ch = this.value[i] ?? ' ';
328
+ this.writeText(ch, { x: col, y: 0, style: merged });
329
+ }
330
+ }
331
+
332
+ // Draw cursor when focused and the virtual-cursor blink says "on".
333
+ if (this.focused && !this.disabled && this.virtualCursor.isVisible()) {
158
334
  const cursorX = this.cursor - this.scrollOffset;
159
335
  if (cursorX >= 0 && cursorX < width) {
160
- const cursorChar = this.value[this.cursor] ?? ' ';
161
- const cursorStyle = this.registry.merge(this.normalStyleId, this.cursorStyleId);
336
+ const useSymbol = this.virtualCursor.hasCustomSymbol();
337
+ // With a custom glyph we draw the glyph itself (styled with the
338
+ // normal text style so the symbol's colours stay predictable);
339
+ // without one we fall back to the legacy inverse-block highlight
340
+ // painted over the character under the caret.
341
+ const cursorChar = useSymbol ? this.virtualCursor.getSymbol() : (this.value[this.cursor] ?? ' ');
342
+ const cursorStyle = useSymbol
343
+ ? this.normalStyleId
344
+ : this.registry.merge(this.normalStyleId, this.cursorStyleId);
162
345
  this.writeText(cursorChar, { x: cursorX, y: 0, style: cursorStyle });
163
346
  }
164
347
  }
@@ -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
+ }