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,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, 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,18 @@ 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>;
33
46
 
34
47
  /** Initializes the root window sized to the current terminal dimensions.
35
48
  * Creates a fresh StyleRegistry (with built-in styles pre-registered)
@@ -52,6 +65,9 @@ export class Screen extends Window {
52
65
  this.boundExit = (): void => { this.restoreTerminalState(); };
53
66
  this.signalsInstalled = false;
54
67
  this.disposed = false;
68
+ this.activeToasts = new Map();
69
+ this.toastTimers = new Map();
70
+ this.toastDismissHandlers = new Map();
55
71
 
56
72
  if (options?.altScreen) this.enterAltScreen();
57
73
  if (options?.hideCursor) this.hideHardwareCursor();
@@ -79,6 +95,146 @@ export class Screen extends Window {
79
95
  return this.targetFps;
80
96
  }
81
97
 
98
+ // ── Toast overlays (P1-27) ────────────────────────────────────────────────
99
+
100
+ /** Shows a non-modal toast overlay with the given text. Returns the
101
+ * underlying `Toast` window so callers can inspect it, swap its message,
102
+ * or dismiss it manually via `toast.dismiss()`.
103
+ *
104
+ * Toasts are stacked by anchor: every call at the same `position` adds
105
+ * to the visible column, and a dismissal shifts the remainder back
106
+ * towards the anchor edge. Auto-dismissal is driven by a `setTimeout`
107
+ * whose handle is tracked per-toast so manual dismissals cancel the
108
+ * timer. A duration of `0` disables the timer entirely (sticky toast).
109
+ *
110
+ * The method calls `render()` once so the overlay appears immediately,
111
+ * and again after every dismissal for the same reason. Consumers
112
+ * driving their own render loop can call `screen.render()` themselves —
113
+ * the extra render here is idempotent against the `'frame'` event. */
114
+ public toast(text: string, options?: ToastOptions): Toast {
115
+ const position = options?.position ?? 'top-right';
116
+ const duration = options?.duration ?? 2000;
117
+ const zIndex = options?.zIndex ?? 10_000;
118
+ const border = options?.border ?? { top: true, right: true, bottom: true, left: true, style: 'rounded' };
119
+ const style = options?.style ?? Toast.resolveDefaultStyle();
120
+ const width = options?.width;
121
+
122
+ const toast = new Toast(text, { position, style, border, zIndex, width });
123
+ toast.attachDismiss(() => this.dismissToast(toast));
124
+
125
+ const list = this.activeToasts.get(position) ?? [];
126
+ list.push(toast);
127
+ this.activeToasts.set(position, list);
128
+
129
+ this.addChild(toast);
130
+ this.relayoutToasts(position);
131
+
132
+ if (duration > 0) {
133
+ const timer = setTimeout(() => this.dismissToast(toast), duration);
134
+ if (typeof timer === 'object' && timer !== null && 'unref' in timer && typeof timer.unref === 'function') {
135
+ timer.unref();
136
+ }
137
+ this.toastTimers.set(toast, timer);
138
+ }
139
+
140
+ if (options?.onDismiss) this.toastDismissHandlers.set(toast, options.onDismiss);
141
+
142
+ this.render();
143
+ return toast;
144
+ }
145
+
146
+ /** Removes a toast from the Screen, cancels its auto-dismiss timer (if
147
+ * any), reflows the remaining toasts anchored to the same corner, and
148
+ * fires the `onDismiss` callback the caller registered at creation
149
+ * time. Safe to call twice — the second call is a no-op. */
150
+ public dismissToast(toast: Toast): void {
151
+ const position = toast.getToastPosition();
152
+ const list = this.activeToasts.get(position);
153
+ if (!list) return;
154
+ const idx = list.indexOf(toast);
155
+ if (idx === -1) return;
156
+ list.splice(idx, 1);
157
+
158
+ const timer = this.toastTimers.get(toast);
159
+ if (timer !== undefined) {
160
+ clearTimeout(timer);
161
+ this.toastTimers.delete(toast);
162
+ }
163
+
164
+ this.removeChild(toast);
165
+ this.relayoutToasts(position);
166
+
167
+ const handler = this.toastDismissHandlers.get(toast);
168
+ if (handler) {
169
+ this.toastDismissHandlers.delete(toast);
170
+ handler();
171
+ }
172
+
173
+ this.render();
174
+ }
175
+
176
+ /** Returns a snapshot of the currently active toasts for the given anchor
177
+ * position (or every anchor when `position` is omitted). Exposed for
178
+ * tests and diagnostics — consumers generally keep the instance returned
179
+ * by `toast()` rather than walking this list. */
180
+ public getActiveToasts(position?: ToastPosition): readonly Toast[] {
181
+ if (position !== undefined) {
182
+ return [...(this.activeToasts.get(position) ?? [])];
183
+ }
184
+ const all: Toast[] = [];
185
+ for (const list of this.activeToasts.values()) all.push(...list);
186
+ return all;
187
+ }
188
+
189
+ /** Computes each toast's absolute position inside the Screen for the
190
+ * given anchor group and writes it to the toast's `x` / `y`. Called
191
+ * after every `addChild` / `removeChild` toast mutation and whenever
192
+ * the Screen is resized so the stack stays anchored. Mutates `x` / `y`
193
+ * directly — the Toast's `Pos.topLeft()` is only a placeholder consumed
194
+ * by the absolute layout pass that ran during `addChild`. */
195
+ private relayoutToasts(position: ToastPosition): void {
196
+ const list = this.activeToasts.get(position);
197
+ if (!list || list.length === 0) return;
198
+ const { width: screenW, height: screenH } = this.getSize();
199
+
200
+ const horizontalAnchor: 'left' | 'center' | 'right' =
201
+ position.endsWith('left') ? 'left' :
202
+ position.endsWith('right') ? 'right' :
203
+ 'center';
204
+ const verticalAnchor: 'top' | 'bottom' =
205
+ position.startsWith('top') ? 'top' : 'bottom';
206
+
207
+ let offset = 0;
208
+ for (const toast of list) {
209
+ const { width: tw, height: th } = toast.getSize();
210
+
211
+ let x: number;
212
+ if (horizontalAnchor === 'left') x = 0;
213
+ else if (horizontalAnchor === 'right') x = Math.max(0, screenW - tw);
214
+ else x = Math.max(0, Math.floor((screenW - tw) / 2));
215
+
216
+ let y: number;
217
+ if (verticalAnchor === 'top') {
218
+ y = offset;
219
+ } else {
220
+ y = Math.max(0, screenH - offset - th);
221
+ }
222
+ offset += th;
223
+
224
+ toast.x = x;
225
+ toast.y = y;
226
+ }
227
+ }
228
+
229
+ /** Re-runs `relayoutToasts` for every non-empty anchor group. Invoked
230
+ * from `resize()` so terminal resizes keep the overlays pinned to their
231
+ * corners. */
232
+ private relayoutAllToasts(): void {
233
+ for (const position of this.activeToasts.keys()) {
234
+ this.relayoutToasts(position);
235
+ }
236
+ }
237
+
82
238
  // ── Terminal lifecycle helpers ────────────────────────────────────────────
83
239
 
84
240
  /** Switches the terminal into the alternate screen buffer. Idempotent. */
@@ -127,6 +283,12 @@ export class Screen extends Window {
127
283
  * call from both deliberate teardown and a process 'exit' handler. */
128
284
  public dispose(): void {
129
285
  if (this.disposed) return;
286
+ // Cancel every pending auto-dismiss timer so a disposed Screen does
287
+ // not hold the Node event loop open via a detached setTimeout.
288
+ for (const timer of this.toastTimers.values()) clearTimeout(timer);
289
+ this.toastTimers.clear();
290
+ this.toastDismissHandlers.clear();
291
+ this.activeToasts.clear();
130
292
  this.restoreTerminalState();
131
293
  this.uninstallSignalHandlers();
132
294
  this.disposed = true;
@@ -143,6 +305,9 @@ export class Screen extends Window {
143
305
  const w = width ?? process.stdout.columns ?? 80;
144
306
  const h = height ?? process.stdout.rows ?? 24;
145
307
  this.setSize(w, h);
308
+ // Re-anchor every active toast overlay so corner-positioned stacks
309
+ // follow the new terminal geometry rather than drifting out of view.
310
+ this.relayoutAllToasts();
146
311
  const size: TerminalSize = { width: w, height: h };
147
312
  this.events.emit('resize', size);
148
313
  return size;
@@ -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
+ }