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.
- package/CHANGELOG.md +253 -0
- package/README.md +3 -2
- package/dist/Screen/ErrorHolder.d.mts +10 -0
- package/dist/Screen/ErrorHolder.d.mts.map +1 -0
- package/dist/Screen/ErrorHolder.mjs +14 -0
- package/dist/Screen/ErrorHolder.mjs.map +1 -0
- package/dist/Screen/InterfaceBuilder.d.mts.map +1 -1
- package/dist/Screen/InterfaceBuilder.mjs +7 -0
- package/dist/Screen/InterfaceBuilder.mjs.map +1 -1
- package/dist/Screen/Screen.d.mts +90 -1
- package/dist/Screen/Screen.d.mts.map +1 -1
- package/dist/Screen/Screen.mjs +300 -1
- package/dist/Screen/Screen.mjs.map +1 -1
- package/dist/Screen/StyleRegistry.d.mts.map +1 -1
- package/dist/Screen/StyleRegistry.mjs +3 -1
- package/dist/Screen/StyleRegistry.mjs.map +1 -1
- package/dist/Screen/VirtualCursor.d.mts +57 -0
- package/dist/Screen/VirtualCursor.d.mts.map +1 -0
- package/dist/Screen/VirtualCursor.mjs +148 -0
- package/dist/Screen/VirtualCursor.mjs.map +1 -0
- package/dist/Screen/Window.d.mts +116 -6
- package/dist/Screen/Window.d.mts.map +1 -1
- package/dist/Screen/Window.mjs +359 -34
- package/dist/Screen/Window.mjs.map +1 -1
- package/dist/Screen/WindowManager.d.mts +78 -2
- package/dist/Screen/WindowManager.d.mts.map +1 -1
- package/dist/Screen/WindowManager.mjs +197 -3
- package/dist/Screen/WindowManager.mjs.map +1 -1
- package/dist/Screen/controls/BarChart.d.mts.map +1 -1
- package/dist/Screen/controls/BarChart.mjs +3 -0
- package/dist/Screen/controls/BarChart.mjs.map +1 -1
- package/dist/Screen/controls/Checkbox.d.mts.map +1 -1
- package/dist/Screen/controls/Checkbox.mjs +4 -0
- package/dist/Screen/controls/Checkbox.mjs.map +1 -1
- package/dist/Screen/controls/LineChart.d.mts.map +1 -1
- package/dist/Screen/controls/LineChart.mjs +3 -0
- package/dist/Screen/controls/LineChart.mjs.map +1 -1
- package/dist/Screen/controls/ListBox.d.mts.map +1 -1
- package/dist/Screen/controls/ListBox.mjs +9 -0
- package/dist/Screen/controls/ListBox.mjs.map +1 -1
- package/dist/Screen/controls/ProgressBar.d.mts.map +1 -1
- package/dist/Screen/controls/ProgressBar.mjs +2 -0
- package/dist/Screen/controls/ProgressBar.mjs.map +1 -1
- package/dist/Screen/controls/ProgressBarV.d.mts.map +1 -1
- package/dist/Screen/controls/ProgressBarV.mjs +2 -0
- package/dist/Screen/controls/ProgressBarV.mjs.map +1 -1
- package/dist/Screen/controls/Radio.d.mts.map +1 -1
- package/dist/Screen/controls/Radio.mjs +7 -1
- package/dist/Screen/controls/Radio.mjs.map +1 -1
- package/dist/Screen/controls/Sparkline.d.mts.map +1 -1
- package/dist/Screen/controls/Sparkline.mjs +3 -0
- package/dist/Screen/controls/Sparkline.mjs.map +1 -1
- package/dist/Screen/controls/Spinner.d.mts.map +1 -1
- package/dist/Screen/controls/Spinner.mjs +8 -0
- package/dist/Screen/controls/Spinner.mjs.map +1 -1
- package/dist/Screen/controls/StatusLED.d.mts.map +1 -1
- package/dist/Screen/controls/StatusLED.mjs +3 -0
- package/dist/Screen/controls/StatusLED.mjs.map +1 -1
- package/dist/Screen/controls/Tabs.d.mts.map +1 -1
- package/dist/Screen/controls/Tabs.mjs +2 -0
- package/dist/Screen/controls/Tabs.mjs.map +1 -1
- package/dist/Screen/controls/TextArea.d.mts +68 -2
- package/dist/Screen/controls/TextArea.d.mts.map +1 -1
- package/dist/Screen/controls/TextArea.mjs +291 -46
- package/dist/Screen/controls/TextArea.mjs.map +1 -1
- package/dist/Screen/controls/TextBox.d.mts +52 -5
- package/dist/Screen/controls/TextBox.d.mts.map +1 -1
- package/dist/Screen/controls/TextBox.mjs +192 -10
- package/dist/Screen/controls/TextBox.mjs.map +1 -1
- package/dist/Screen/controls/Toast.d.mts +72 -0
- package/dist/Screen/controls/Toast.d.mts.map +1 -0
- package/dist/Screen/controls/Toast.mjs +112 -0
- package/dist/Screen/controls/Toast.mjs.map +1 -0
- package/dist/Screen/types.d.mts +169 -0
- package/dist/Screen/types.d.mts.map +1 -1
- package/dist/Screen/types.mjs +8 -0
- package/dist/Screen/types.mjs.map +1 -1
- package/dist/index.d.mts +4 -2
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +3 -1
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/Screen/ErrorHolder.mts +22 -0
- package/src/Screen/InterfaceBuilder.mts +12 -5
- package/src/Screen/Screen.mts +313 -2
- package/src/Screen/StyleRegistry.mts +4 -0
- package/src/Screen/VirtualCursor.mts +175 -0
- package/src/Screen/Window.mts +352 -34
- package/src/Screen/WindowManager.mts +203 -3
- package/src/Screen/controls/BarChart.mts +3 -0
- package/src/Screen/controls/Checkbox.mts +3 -0
- package/src/Screen/controls/LineChart.mts +3 -0
- package/src/Screen/controls/ListBox.mts +8 -0
- package/src/Screen/controls/ProgressBar.mts +2 -0
- package/src/Screen/controls/ProgressBarV.mts +2 -0
- package/src/Screen/controls/Radio.mts +6 -1
- package/src/Screen/controls/Sparkline.mts +3 -0
- package/src/Screen/controls/Spinner.mts +6 -0
- package/src/Screen/controls/StatusLED.mts +2 -0
- package/src/Screen/controls/Tabs.mts +2 -0
- package/src/Screen/controls/TextArea.mts +290 -41
- package/src/Screen/controls/TextBox.mts +193 -10
- package/src/Screen/controls/Toast.mts +138 -0
- package/src/Screen/types.mts +167 -0
- package/src/demo.mts +131 -0
- package/src/index.mts +13 -0
- package/src/layout.yaml +16 -0
package/src/Screen/Screen.mts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { EventEmitter } from 'node:events';
|
|
2
|
-
import type { CellAttributes, ScreenFrameStats, ScreenOptions, StyleId, TerminalSize } from './types.mjs';
|
|
2
|
+
import type { CellAttributes, DirtyRect, ScreenFrameStats, ScreenOptions, StyleId, TerminalSize, ToastOptions, ToastPosition } from './types.mjs';
|
|
3
3
|
import { StyleRegistry } from './StyleRegistry.mjs';
|
|
4
4
|
import { getRegistry, setRegistry } from './RegistryHolder.mjs';
|
|
5
5
|
import { Window } from './Window.mjs';
|
|
6
6
|
import { Pos } from './Pos.mjs';
|
|
7
7
|
import { Size } from './Size.mjs';
|
|
8
|
+
import { Toast } from './controls/Toast.mjs';
|
|
8
9
|
|
|
9
10
|
/** ANSI control sequences for the terminal lifecycle features owned by Screen. */
|
|
10
11
|
const ENTER_ALT_SCREEN = '\x1b[?1049h';
|
|
@@ -30,6 +31,30 @@ export class Screen extends Window {
|
|
|
30
31
|
private signalsInstalled: boolean;
|
|
31
32
|
/** Whether dispose() has already restored terminal state. */
|
|
32
33
|
private disposed: boolean;
|
|
34
|
+
/** Active toast overlays grouped by anchor position. Within each group the
|
|
35
|
+
* toasts are stored in the order they were created so re-layout can stack
|
|
36
|
+
* them outward from the anchor edge. A toast is removed from its group by
|
|
37
|
+
* `dismissToast` (auto-dismiss timer) or by `Toast.dismiss()`. */
|
|
38
|
+
private activeToasts: Map<ToastPosition, Toast[]>;
|
|
39
|
+
/** Per-toast auto-dismiss timers. `dismissToast` consults this map so
|
|
40
|
+
* manual dismissals cancel the pending timer and leaking timers are
|
|
41
|
+
* impossible when the caller drops their reference to the toast. */
|
|
42
|
+
private toastTimers: Map<Toast, ReturnType<typeof setTimeout>>;
|
|
43
|
+
/** Per-toast dismiss callbacks (from `ToastOptions.onDismiss`). Kept out of
|
|
44
|
+
* the `Toast` instance so the control stays free of bookkeeping code. */
|
|
45
|
+
private toastDismissHandlers: Map<Toast, () => void>;
|
|
46
|
+
/** When true, `render()` computes the union of every dirty rect propagated
|
|
47
|
+
* bottom-up from the window tree and emits ANSI sequences only for those
|
|
48
|
+
* cells. When false, every frame re-emits the full buffer (pre-0.31
|
|
49
|
+
* behaviour), which is useful for debugging or for terminals that
|
|
50
|
+
* mis-handle partial cursor jumps. Configured via `ScreenOptions.damageTracking`. */
|
|
51
|
+
private damageTracking: boolean;
|
|
52
|
+
/** Forces the next `render()` onto the full-repaint path regardless of the
|
|
53
|
+
* dirty state. Set on the first frame, after `resize()`, and bubbled up
|
|
54
|
+
* from descendants via `markFullInvalidation()` when they change
|
|
55
|
+
* geometry, visibility, or tree topology in ways that would leave
|
|
56
|
+
* stale cells on stdout. Cleared after every full-repaint emit. */
|
|
57
|
+
private fullInvalidate: boolean;
|
|
33
58
|
|
|
34
59
|
/** Initializes the root window sized to the current terminal dimensions.
|
|
35
60
|
* Creates a fresh StyleRegistry (with built-in styles pre-registered)
|
|
@@ -52,6 +77,11 @@ export class Screen extends Window {
|
|
|
52
77
|
this.boundExit = (): void => { this.restoreTerminalState(); };
|
|
53
78
|
this.signalsInstalled = false;
|
|
54
79
|
this.disposed = false;
|
|
80
|
+
this.activeToasts = new Map();
|
|
81
|
+
this.toastTimers = new Map();
|
|
82
|
+
this.toastDismissHandlers = new Map();
|
|
83
|
+
this.damageTracking = options?.damageTracking ?? true;
|
|
84
|
+
this.fullInvalidate = true;
|
|
55
85
|
|
|
56
86
|
if (options?.altScreen) this.enterAltScreen();
|
|
57
87
|
if (options?.hideCursor) this.hideHardwareCursor();
|
|
@@ -79,6 +109,146 @@ export class Screen extends Window {
|
|
|
79
109
|
return this.targetFps;
|
|
80
110
|
}
|
|
81
111
|
|
|
112
|
+
// ── Toast overlays (P1-27) ────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
/** Shows a non-modal toast overlay with the given text. Returns the
|
|
115
|
+
* underlying `Toast` window so callers can inspect it, swap its message,
|
|
116
|
+
* or dismiss it manually via `toast.dismiss()`.
|
|
117
|
+
*
|
|
118
|
+
* Toasts are stacked by anchor: every call at the same `position` adds
|
|
119
|
+
* to the visible column, and a dismissal shifts the remainder back
|
|
120
|
+
* towards the anchor edge. Auto-dismissal is driven by a `setTimeout`
|
|
121
|
+
* whose handle is tracked per-toast so manual dismissals cancel the
|
|
122
|
+
* timer. A duration of `0` disables the timer entirely (sticky toast).
|
|
123
|
+
*
|
|
124
|
+
* The method calls `render()` once so the overlay appears immediately,
|
|
125
|
+
* and again after every dismissal for the same reason. Consumers
|
|
126
|
+
* driving their own render loop can call `screen.render()` themselves —
|
|
127
|
+
* the extra render here is idempotent against the `'frame'` event. */
|
|
128
|
+
public toast(text: string, options?: ToastOptions): Toast {
|
|
129
|
+
const position = options?.position ?? 'top-right';
|
|
130
|
+
const duration = options?.duration ?? 2000;
|
|
131
|
+
const zIndex = options?.zIndex ?? 10_000;
|
|
132
|
+
const border = options?.border ?? { top: true, right: true, bottom: true, left: true, style: 'rounded' };
|
|
133
|
+
const style = options?.style ?? Toast.resolveDefaultStyle();
|
|
134
|
+
const width = options?.width;
|
|
135
|
+
|
|
136
|
+
const toast = new Toast(text, { position, style, border, zIndex, width });
|
|
137
|
+
toast.attachDismiss(() => this.dismissToast(toast));
|
|
138
|
+
|
|
139
|
+
const list = this.activeToasts.get(position) ?? [];
|
|
140
|
+
list.push(toast);
|
|
141
|
+
this.activeToasts.set(position, list);
|
|
142
|
+
|
|
143
|
+
this.addChild(toast);
|
|
144
|
+
this.relayoutToasts(position);
|
|
145
|
+
|
|
146
|
+
if (duration > 0) {
|
|
147
|
+
const timer = setTimeout(() => this.dismissToast(toast), duration);
|
|
148
|
+
if (typeof timer === 'object' && timer !== null && 'unref' in timer && typeof timer.unref === 'function') {
|
|
149
|
+
timer.unref();
|
|
150
|
+
}
|
|
151
|
+
this.toastTimers.set(toast, timer);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (options?.onDismiss) this.toastDismissHandlers.set(toast, options.onDismiss);
|
|
155
|
+
|
|
156
|
+
this.render();
|
|
157
|
+
return toast;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Removes a toast from the Screen, cancels its auto-dismiss timer (if
|
|
161
|
+
* any), reflows the remaining toasts anchored to the same corner, and
|
|
162
|
+
* fires the `onDismiss` callback the caller registered at creation
|
|
163
|
+
* time. Safe to call twice — the second call is a no-op. */
|
|
164
|
+
public dismissToast(toast: Toast): void {
|
|
165
|
+
const position = toast.getToastPosition();
|
|
166
|
+
const list = this.activeToasts.get(position);
|
|
167
|
+
if (!list) return;
|
|
168
|
+
const idx = list.indexOf(toast);
|
|
169
|
+
if (idx === -1) return;
|
|
170
|
+
list.splice(idx, 1);
|
|
171
|
+
|
|
172
|
+
const timer = this.toastTimers.get(toast);
|
|
173
|
+
if (timer !== undefined) {
|
|
174
|
+
clearTimeout(timer);
|
|
175
|
+
this.toastTimers.delete(toast);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
this.removeChild(toast);
|
|
179
|
+
this.relayoutToasts(position);
|
|
180
|
+
|
|
181
|
+
const handler = this.toastDismissHandlers.get(toast);
|
|
182
|
+
if (handler) {
|
|
183
|
+
this.toastDismissHandlers.delete(toast);
|
|
184
|
+
handler();
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
this.render();
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Returns a snapshot of the currently active toasts for the given anchor
|
|
191
|
+
* position (or every anchor when `position` is omitted). Exposed for
|
|
192
|
+
* tests and diagnostics — consumers generally keep the instance returned
|
|
193
|
+
* by `toast()` rather than walking this list. */
|
|
194
|
+
public getActiveToasts(position?: ToastPosition): readonly Toast[] {
|
|
195
|
+
if (position !== undefined) {
|
|
196
|
+
return [...(this.activeToasts.get(position) ?? [])];
|
|
197
|
+
}
|
|
198
|
+
const all: Toast[] = [];
|
|
199
|
+
for (const list of this.activeToasts.values()) all.push(...list);
|
|
200
|
+
return all;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Computes each toast's absolute position inside the Screen for the
|
|
204
|
+
* given anchor group and writes it to the toast's `x` / `y`. Called
|
|
205
|
+
* after every `addChild` / `removeChild` toast mutation and whenever
|
|
206
|
+
* the Screen is resized so the stack stays anchored. Mutates `x` / `y`
|
|
207
|
+
* directly — the Toast's `Pos.topLeft()` is only a placeholder consumed
|
|
208
|
+
* by the absolute layout pass that ran during `addChild`. */
|
|
209
|
+
private relayoutToasts(position: ToastPosition): void {
|
|
210
|
+
const list = this.activeToasts.get(position);
|
|
211
|
+
if (!list || list.length === 0) return;
|
|
212
|
+
const { width: screenW, height: screenH } = this.getSize();
|
|
213
|
+
|
|
214
|
+
const horizontalAnchor: 'left' | 'center' | 'right' =
|
|
215
|
+
position.endsWith('left') ? 'left' :
|
|
216
|
+
position.endsWith('right') ? 'right' :
|
|
217
|
+
'center';
|
|
218
|
+
const verticalAnchor: 'top' | 'bottom' =
|
|
219
|
+
position.startsWith('top') ? 'top' : 'bottom';
|
|
220
|
+
|
|
221
|
+
let offset = 0;
|
|
222
|
+
for (const toast of list) {
|
|
223
|
+
const { width: tw, height: th } = toast.getSize();
|
|
224
|
+
|
|
225
|
+
let x: number;
|
|
226
|
+
if (horizontalAnchor === 'left') x = 0;
|
|
227
|
+
else if (horizontalAnchor === 'right') x = Math.max(0, screenW - tw);
|
|
228
|
+
else x = Math.max(0, Math.floor((screenW - tw) / 2));
|
|
229
|
+
|
|
230
|
+
let y: number;
|
|
231
|
+
if (verticalAnchor === 'top') {
|
|
232
|
+
y = offset;
|
|
233
|
+
} else {
|
|
234
|
+
y = Math.max(0, screenH - offset - th);
|
|
235
|
+
}
|
|
236
|
+
offset += th;
|
|
237
|
+
|
|
238
|
+
toast.x = x;
|
|
239
|
+
toast.y = y;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Re-runs `relayoutToasts` for every non-empty anchor group. Invoked
|
|
244
|
+
* from `resize()` so terminal resizes keep the overlays pinned to their
|
|
245
|
+
* corners. */
|
|
246
|
+
private relayoutAllToasts(): void {
|
|
247
|
+
for (const position of this.activeToasts.keys()) {
|
|
248
|
+
this.relayoutToasts(position);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
82
252
|
// ── Terminal lifecycle helpers ────────────────────────────────────────────
|
|
83
253
|
|
|
84
254
|
/** Switches the terminal into the alternate screen buffer. Idempotent. */
|
|
@@ -127,6 +297,12 @@ export class Screen extends Window {
|
|
|
127
297
|
* call from both deliberate teardown and a process 'exit' handler. */
|
|
128
298
|
public dispose(): void {
|
|
129
299
|
if (this.disposed) return;
|
|
300
|
+
// Cancel every pending auto-dismiss timer so a disposed Screen does
|
|
301
|
+
// not hold the Node event loop open via a detached setTimeout.
|
|
302
|
+
for (const timer of this.toastTimers.values()) clearTimeout(timer);
|
|
303
|
+
this.toastTimers.clear();
|
|
304
|
+
this.toastDismissHandlers.clear();
|
|
305
|
+
this.activeToasts.clear();
|
|
130
306
|
this.restoreTerminalState();
|
|
131
307
|
this.uninstallSignalHandlers();
|
|
132
308
|
this.disposed = true;
|
|
@@ -143,6 +319,9 @@ export class Screen extends Window {
|
|
|
143
319
|
const w = width ?? process.stdout.columns ?? 80;
|
|
144
320
|
const h = height ?? process.stdout.rows ?? 24;
|
|
145
321
|
this.setSize(w, h);
|
|
322
|
+
// Re-anchor every active toast overlay so corner-positioned stacks
|
|
323
|
+
// follow the new terminal geometry rather than drifting out of view.
|
|
324
|
+
this.relayoutAllToasts();
|
|
146
325
|
const size: TerminalSize = { width: w, height: h };
|
|
147
326
|
this.events.emit('resize', size);
|
|
148
327
|
return size;
|
|
@@ -175,12 +354,49 @@ export class Screen extends Window {
|
|
|
175
354
|
*/
|
|
176
355
|
public override render(): void {
|
|
177
356
|
const start = Date.now();
|
|
357
|
+
|
|
358
|
+
// Fast path: damage tracking is on, nothing is dirty, no full
|
|
359
|
+
// invalidation pending — skip composition AND emit entirely. The
|
|
360
|
+
// terminal already shows the correct pixels from the previous frame.
|
|
361
|
+
if (this.damageTracking && !this.fullInvalidate) {
|
|
362
|
+
const rects: DirtyRect[] = [];
|
|
363
|
+
this.collectDirtyRects(0, 0, rects);
|
|
364
|
+
if (rects.length === 0) {
|
|
365
|
+
this.events.emit('frame', { ms: Date.now() - start, cellsEmitted: 0 });
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
// Compose first so `this.region` reflects the latest content; the
|
|
369
|
+
// emit below reads from it. We keep `rects` for the emit phase
|
|
370
|
+
// instead of re-collecting, because `super.render()` does not
|
|
371
|
+
// mutate dirty flags (render-depth guard suppresses markDirty
|
|
372
|
+
// inside render overrides).
|
|
373
|
+
super.render();
|
|
374
|
+
const stats = this.emitDirty(rects);
|
|
375
|
+
this.clearDirtyRecursive();
|
|
376
|
+
this.events.emit('frame', { ms: Date.now() - start, cellsEmitted: stats, fullRepaint: false });
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Slow / fallback path: full repaint. Always used for the first
|
|
381
|
+
// frame, after `resize()`, after `markFullInvalidation()`, and when
|
|
382
|
+
// damage tracking has been disabled by the caller.
|
|
178
383
|
super.render();
|
|
384
|
+
const cellsEmitted = this.emitFull();
|
|
385
|
+
this.fullInvalidate = false;
|
|
386
|
+
this.clearDirtyRecursive();
|
|
387
|
+
this.events.emit('frame', { ms: Date.now() - start, cellsEmitted, fullRepaint: true });
|
|
388
|
+
}
|
|
179
389
|
|
|
390
|
+
/** Emits every visible cell in `this.region` as one ANSI write, starting
|
|
391
|
+
* with `\x1b[H` (cursor home) and ending with `\x1b[0m`. Returns the
|
|
392
|
+
* number of cells whose character was actually written (skips wide-char
|
|
393
|
+
* continuation sentinels). Used by the full-repaint path. */
|
|
394
|
+
private emitFull(): number {
|
|
180
395
|
const reg = getRegistry();
|
|
181
396
|
const chars = this.region.getChars();
|
|
182
397
|
const styleIds = this.region.getStyleIds();
|
|
183
398
|
let output = '\x1b[H';
|
|
399
|
+
let count = 0;
|
|
184
400
|
for (let i = 0; i < chars.length; i++) {
|
|
185
401
|
const ch = chars[i];
|
|
186
402
|
// Empty-string sentinel = continuation cell of a wide character;
|
|
@@ -188,10 +404,105 @@ export class Screen extends Window {
|
|
|
188
404
|
if (ch === '') continue;
|
|
189
405
|
output += this.buildAnsiSequence(reg.get(styleIds[i]));
|
|
190
406
|
output += ch;
|
|
407
|
+
count++;
|
|
191
408
|
}
|
|
192
409
|
output += '\x1b[0m';
|
|
193
410
|
process.stdout.write(output);
|
|
194
|
-
|
|
411
|
+
return count;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** Coalesces the collected dirty rects into per-row horizontal intervals
|
|
415
|
+
* and emits only those cells, using `\x1b[row;colH` cursor jumps between
|
|
416
|
+
* rows. Returns the number of cells emitted so consumers of the 'frame'
|
|
417
|
+
* event can see how much work each frame did. */
|
|
418
|
+
private emitDirty(rects: DirtyRect[]): number {
|
|
419
|
+
const { width: sw, height: sh } = this.getSize();
|
|
420
|
+
if (sw === 0 || sh === 0) { process.stdout.write(''); return 0; }
|
|
421
|
+
|
|
422
|
+
// Collapse rects into per-row min/max columns. A Map keyed by row is
|
|
423
|
+
// sparse enough for the common case (few small dirty regions) and
|
|
424
|
+
// avoids allocating a dense sh-sized array when only a couple of
|
|
425
|
+
// rows are touched.
|
|
426
|
+
const rowSpans = new Map<number, { minX: number; maxX: number }>();
|
|
427
|
+
for (const r of rects) {
|
|
428
|
+
const y0 = Math.max(0, r.y);
|
|
429
|
+
const y1 = Math.min(sh - 1, r.y + r.h - 1);
|
|
430
|
+
const x0 = Math.max(0, r.x);
|
|
431
|
+
const x1 = Math.min(sw - 1, r.x + r.w - 1);
|
|
432
|
+
if (y1 < y0 || x1 < x0) continue;
|
|
433
|
+
for (let y = y0; y <= y1; y++) {
|
|
434
|
+
const existing = rowSpans.get(y);
|
|
435
|
+
if (existing) {
|
|
436
|
+
if (x0 < existing.minX) existing.minX = x0;
|
|
437
|
+
if (x1 > existing.maxX) existing.maxX = x1;
|
|
438
|
+
} else {
|
|
439
|
+
rowSpans.set(y, { minX: x0, maxX: x1 });
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
if (rowSpans.size === 0) return 0;
|
|
444
|
+
|
|
445
|
+
// Emit rows in ascending order so the terminal cursor advances
|
|
446
|
+
// forward — random access is fine (we always send an explicit cursor
|
|
447
|
+
// move) but ordered output compresses a bit better and is easier to
|
|
448
|
+
// reason about in transcripts / tests.
|
|
449
|
+
const rows = [...rowSpans.keys()].sort((a, b) => a - b);
|
|
450
|
+
const reg = getRegistry();
|
|
451
|
+
const chars = this.region.getChars();
|
|
452
|
+
const styleIds = this.region.getStyleIds();
|
|
453
|
+
let output = '';
|
|
454
|
+
let count = 0;
|
|
455
|
+
for (const y of rows) {
|
|
456
|
+
const span = rowSpans.get(y)!;
|
|
457
|
+
// ANSI cursor addressing is 1-based.
|
|
458
|
+
output += `\x1b[${y + 1};${span.minX + 1}H`;
|
|
459
|
+
for (let x = span.minX; x <= span.maxX; x++) {
|
|
460
|
+
const i = y * sw + x;
|
|
461
|
+
const ch = chars[i];
|
|
462
|
+
if (ch === '') continue;
|
|
463
|
+
output += this.buildAnsiSequence(reg.get(styleIds[i]));
|
|
464
|
+
output += ch;
|
|
465
|
+
count++;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
output += '\x1b[0m';
|
|
469
|
+
process.stdout.write(output);
|
|
470
|
+
return count;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** Overrides the parent hook so bubbled full-invalidation signals land
|
|
474
|
+
* on this Screen's own `fullInvalidate` flag instead of walking further
|
|
475
|
+
* up a non-existent parent chain. Public so WindowManager can also
|
|
476
|
+
* request a full repaint after pause/resume or alt-screen toggling. */
|
|
477
|
+
public override markFullInvalidation(): void {
|
|
478
|
+
this.fullInvalidate = true;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/** Public alias of `markFullInvalidation()` — schedules a full repaint on
|
|
482
|
+
* the next `render()` call. Use this when external state (terminal
|
|
483
|
+
* re-init, post-OS dialog, SSH reconnect, …) may have corrupted the
|
|
484
|
+
* on-screen buffer and the stored dirty rects are no longer sufficient. */
|
|
485
|
+
public invalidate(): void {
|
|
486
|
+
this.markFullInvalidation();
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/** Returns whether damage tracking is currently enabled. Useful for demo
|
|
490
|
+
* code and tests that want to assert or toggle the mode at runtime. */
|
|
491
|
+
public isDamageTrackingEnabled(): boolean {
|
|
492
|
+
return this.damageTracking;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/** Enables or disables damage tracking at runtime. Disabling forces every
|
|
496
|
+
* subsequent frame onto the full-repaint path; re-enabling resumes the
|
|
497
|
+
* dirty-rect emit on the next frame (after one final full repaint, so
|
|
498
|
+
* the terminal state matches the tree-derived baseline). */
|
|
499
|
+
public setDamageTracking(enabled: boolean): void {
|
|
500
|
+
if (this.damageTracking === enabled) return;
|
|
501
|
+
this.damageTracking = enabled;
|
|
502
|
+
// Always do one full repaint on transition so the terminal buffer
|
|
503
|
+
// matches what the window tree would produce from scratch — useful
|
|
504
|
+
// when flipping back and forth for debugging.
|
|
505
|
+
this.fullInvalidate = true;
|
|
195
506
|
}
|
|
196
507
|
|
|
197
508
|
// ── Private helpers ───────────────────────────────────────────────────────
|
|
@@ -10,6 +10,8 @@ import {
|
|
|
10
10
|
BUILTIN_TEXT_PLACEHOLDER,
|
|
11
11
|
BUILTIN_TEXT_CHECKED,
|
|
12
12
|
BUILTIN_CURSOR,
|
|
13
|
+
BUILTIN_TEXT_SELECTION,
|
|
14
|
+
BUILTIN_TOAST,
|
|
13
15
|
} from './types.mjs';
|
|
14
16
|
|
|
15
17
|
/** Central registry that maps integer style IDs to CellAttributes objects.
|
|
@@ -34,6 +36,8 @@ export class StyleRegistry {
|
|
|
34
36
|
this.registerNamed(BUILTIN_TEXT_PLACEHOLDER, { foreground: 242, italic: true });
|
|
35
37
|
this.registerNamed(BUILTIN_TEXT_CHECKED, { foreground: 76, bold: true });
|
|
36
38
|
this.registerNamed(BUILTIN_CURSOR, { inverse: true });
|
|
39
|
+
this.registerNamed(BUILTIN_TEXT_SELECTION, { background: 24, foreground: 231 });
|
|
40
|
+
this.registerNamed(BUILTIN_TOAST, { background: 24, foreground: 231, bold: true });
|
|
37
41
|
}
|
|
38
42
|
|
|
39
43
|
/** Registers a CellAttributes object and returns its stable ID.
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import type { CursorBlink, VirtualCursorOptions } from './types.mjs';
|
|
2
|
+
|
|
3
|
+
/** Default on/off durations (ms) for the named blink modes. */
|
|
4
|
+
const BLINK_PRESETS = {
|
|
5
|
+
slow: { onMs: 600, offMs: 600 },
|
|
6
|
+
fast: { onMs: 250, offMs: 250 },
|
|
7
|
+
} as const;
|
|
8
|
+
|
|
9
|
+
/** Bounds (ms) for the irregular blink mode — each phase picks a random
|
|
10
|
+
* duration from its range so the cursor feels "alive" rather than metronomic. */
|
|
11
|
+
const IRREGULAR_ON_MIN = 180;
|
|
12
|
+
const IRREGULAR_ON_MAX = 520;
|
|
13
|
+
const IRREGULAR_OFF_MIN = 90;
|
|
14
|
+
const IRREGULAR_OFF_MAX = 240;
|
|
15
|
+
|
|
16
|
+
/** Default glyph used when the consumer does not supply one — a vertical
|
|
17
|
+
* bar that reads as a text insertion caret on most monospace fonts. */
|
|
18
|
+
export const DEFAULT_CURSOR_SYMBOL = '▎';
|
|
19
|
+
|
|
20
|
+
/** Internal schedule entry used to walk the blink timeline. */
|
|
21
|
+
interface Phase {
|
|
22
|
+
/** Absolute timestamp (ms since epoch) when this phase started. */
|
|
23
|
+
startedAt: number;
|
|
24
|
+
/** True when the cursor is currently showing, false when hidden. */
|
|
25
|
+
visible: boolean;
|
|
26
|
+
/** Duration of this phase in ms. */
|
|
27
|
+
duration: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Software cursor model — owns the glyph rendered on screen and the
|
|
31
|
+
* on/off blink state. Stateless when `blink.mode === 'off'` or `'steady'`;
|
|
32
|
+
* for timed modes the class samples `Date.now()` on every `isVisible()`
|
|
33
|
+
* call and advances internal phase state as needed. A caller (typically
|
|
34
|
+
* `WindowManager`) is responsible for triggering re-renders on a timer so
|
|
35
|
+
* the visual state actually reaches the terminal. */
|
|
36
|
+
export class VirtualCursor {
|
|
37
|
+
/** Glyph rendered for the cursor when visible. `undefined` means "use
|
|
38
|
+
* the legacy inverse-block highlight over the underlying character". */
|
|
39
|
+
private symbol: string | undefined;
|
|
40
|
+
/** Blink configuration — mode and custom timings. */
|
|
41
|
+
private blink: CursorBlink;
|
|
42
|
+
/** Current phase in the blink timeline. Lazily initialised on first
|
|
43
|
+
* `isVisible()` call so construction stays free of side effects. */
|
|
44
|
+
private phase: Phase | null;
|
|
45
|
+
|
|
46
|
+
/** Creates a new cursor with the provided symbol and blink settings.
|
|
47
|
+
* Defaults: symbol = `undefined` (inverse-block), blink = `{ mode: 'steady' }`. */
|
|
48
|
+
public constructor(options?: VirtualCursorOptions) {
|
|
49
|
+
this.symbol = options?.symbol;
|
|
50
|
+
this.blink = options?.blink ?? { mode: 'steady' };
|
|
51
|
+
this.phase = null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Returns the caller-supplied glyph, or `DEFAULT_CURSOR_SYMBOL` when
|
|
55
|
+
* none was set. Use `hasCustomSymbol()` to distinguish the two cases. */
|
|
56
|
+
public getSymbol(): string {
|
|
57
|
+
return this.symbol ?? DEFAULT_CURSOR_SYMBOL;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Returns true when a custom glyph was provided; false means the
|
|
61
|
+
* caller wants the legacy inverse-block highlight behaviour. */
|
|
62
|
+
public hasCustomSymbol(): boolean {
|
|
63
|
+
return this.symbol !== undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Replaces the cursor glyph. Pass `undefined` to revert to inverse-block. */
|
|
67
|
+
public setSymbol(symbol: string | undefined): void {
|
|
68
|
+
this.symbol = symbol;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Returns the active blink configuration (for inspection / cloning). */
|
|
72
|
+
public getBlink(): CursorBlink {
|
|
73
|
+
return this.blink;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Replaces the blink configuration and resets the phase timer so the
|
|
77
|
+
* new mode starts in its "on" phase immediately. */
|
|
78
|
+
public setBlink(blink: CursorBlink): void {
|
|
79
|
+
this.blink = blink;
|
|
80
|
+
this.phase = null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Returns the fastest cadence (in ms) a re-render would need to show
|
|
84
|
+
* this cursor's blink faithfully, or `null` when the cursor is static
|
|
85
|
+
* (modes `'off'` and `'steady'`). Used by `WindowManager` to pick a
|
|
86
|
+
* timer interval. For `irregular` we return the minimum possible
|
|
87
|
+
* off-phase duration so quick flicks are not missed. */
|
|
88
|
+
public getTickHintMs(): number | null {
|
|
89
|
+
switch (this.blink.mode) {
|
|
90
|
+
case 'off':
|
|
91
|
+
case 'steady':
|
|
92
|
+
return null;
|
|
93
|
+
case 'slow':
|
|
94
|
+
return BLINK_PRESETS.slow.onMs;
|
|
95
|
+
case 'fast':
|
|
96
|
+
return BLINK_PRESETS.fast.onMs;
|
|
97
|
+
case 'irregular':
|
|
98
|
+
return IRREGULAR_OFF_MIN;
|
|
99
|
+
case 'custom':
|
|
100
|
+
return Math.min(this.blink.onMs, this.blink.offMs);
|
|
101
|
+
default:
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Returns whether the cursor should be drawn at the given moment.
|
|
107
|
+
* `now` is accepted for deterministic testing; defaults to `Date.now()`. */
|
|
108
|
+
public isVisible(now: number = Date.now()): boolean {
|
|
109
|
+
if (this.blink.mode === 'off') return false;
|
|
110
|
+
if (this.blink.mode === 'steady') return true;
|
|
111
|
+
|
|
112
|
+
if (this.phase === null) {
|
|
113
|
+
this.phase = { startedAt: now, visible: true, duration: this.firstOnDuration() };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Walk forward through phases until we land on the one that covers `now`.
|
|
117
|
+
// Doing this iteratively (vs. a single modulo) is important for
|
|
118
|
+
// `irregular` mode, where every phase has an independently sampled
|
|
119
|
+
// duration.
|
|
120
|
+
while (now - this.phase.startedAt >= this.phase.duration) {
|
|
121
|
+
const current: Phase = this.phase;
|
|
122
|
+
const nextStart: number = current.startedAt + current.duration;
|
|
123
|
+
const nextVisible: boolean = !current.visible;
|
|
124
|
+
this.phase = {
|
|
125
|
+
startedAt: nextStart,
|
|
126
|
+
visible: nextVisible,
|
|
127
|
+
duration: this.phaseDuration(nextVisible),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return this.phase.visible;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Forces the next `isVisible()` sample to begin a fresh "on" phase at
|
|
135
|
+
* `now`. Useful for keeping the cursor steady while the user types — a
|
|
136
|
+
* blink that vanishes mid-keystroke is jarring. */
|
|
137
|
+
public resetPhase(now: number = Date.now()): void {
|
|
138
|
+
this.phase = { startedAt: now, visible: true, duration: this.firstOnDuration() };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Picks the first "on" phase duration for the current blink mode.
|
|
142
|
+
* Split out so tests can assert the expected preset values. */
|
|
143
|
+
private firstOnDuration(): number {
|
|
144
|
+
return this.phaseDuration(true);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Returns the duration of the upcoming phase given its visibility flag.
|
|
148
|
+
* Implements both the named presets and the `custom` / `irregular`
|
|
149
|
+
* semantics in one place. */
|
|
150
|
+
private phaseDuration(visible: boolean): number {
|
|
151
|
+
switch (this.blink.mode) {
|
|
152
|
+
case 'off':
|
|
153
|
+
case 'steady':
|
|
154
|
+
return Number.POSITIVE_INFINITY;
|
|
155
|
+
case 'slow':
|
|
156
|
+
return visible ? BLINK_PRESETS.slow.onMs : BLINK_PRESETS.slow.offMs;
|
|
157
|
+
case 'fast':
|
|
158
|
+
return visible ? BLINK_PRESETS.fast.onMs : BLINK_PRESETS.fast.offMs;
|
|
159
|
+
case 'custom':
|
|
160
|
+
return visible ? this.blink.onMs : this.blink.offMs;
|
|
161
|
+
case 'irregular':
|
|
162
|
+
return visible
|
|
163
|
+
? randomBetween(IRREGULAR_ON_MIN, IRREGULAR_ON_MAX)
|
|
164
|
+
: randomBetween(IRREGULAR_OFF_MIN, IRREGULAR_OFF_MAX);
|
|
165
|
+
default:
|
|
166
|
+
return Number.POSITIVE_INFINITY;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Returns a uniformly distributed integer in the closed range [min, max].
|
|
172
|
+
* Kept as a module-private helper so `VirtualCursor` stays compact. */
|
|
173
|
+
function randomBetween(min: number, max: number): number {
|
|
174
|
+
return min + Math.floor(Math.random() * (max - min + 1));
|
|
175
|
+
}
|