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.
- package/CHANGELOG.md +193 -0
- 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 +50 -1
- package/dist/Screen/Screen.d.mts.map +1 -1
- package/dist/Screen/Screen.mjs +152 -0
- 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 +67 -6
- package/dist/Screen/Window.d.mts.map +1 -1
- package/dist/Screen/Window.mjs +195 -27
- 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 +186 -3
- package/dist/Screen/WindowManager.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 +280 -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 +179 -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 +143 -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 +166 -1
- package/src/Screen/StyleRegistry.mts +4 -0
- package/src/Screen/VirtualCursor.mts +175 -0
- package/src/Screen/Window.mts +197 -28
- package/src/Screen/WindowManager.mts +192 -3
- package/src/Screen/controls/TextArea.mts +280 -41
- package/src/Screen/controls/TextBox.mts +181 -10
- package/src/Screen/controls/Toast.mts +138 -0
- package/src/Screen/types.mts +140 -0
- package/src/demo.mts +80 -0
- package/src/index.mts +12 -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, 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
|
+
}
|