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
|
@@ -7,6 +7,7 @@ import type {
|
|
|
7
7
|
} from './types.mjs';
|
|
8
8
|
import { Screen } from './Screen.mjs';
|
|
9
9
|
import { Window } from './Window.mjs';
|
|
10
|
+
import { setErrorHandler } from './ErrorHolder.mjs';
|
|
10
11
|
|
|
11
12
|
// ── Internal types ────────────────────────────────────────────────────────────
|
|
12
13
|
|
|
@@ -182,6 +183,15 @@ export class WindowManager {
|
|
|
182
183
|
private onKey?: (key: string, ctx: KeyContext) => boolean | void;
|
|
183
184
|
private onMouse?: (event: TerminalMouseEvent) => void;
|
|
184
185
|
private mouseEnabled: boolean;
|
|
186
|
+
/** Optional sink for render-time exceptions thrown by descendant windows.
|
|
187
|
+
* Installed into the global ErrorHolder on construction (cleared on
|
|
188
|
+
* stop). When unset, `Window.render()` rethrows instead of swallowing. */
|
|
189
|
+
private onError?: (err: unknown, control: Window) => void;
|
|
190
|
+
/** When non-null, focus navigation (`focusNext/Prev/First/Last`, Tab cycle)
|
|
191
|
+
* is constrained to focusable controls whose absolute position falls
|
|
192
|
+
* inside a descendant of this window. Set by `trapFocus`; cleared by the
|
|
193
|
+
* release callback it returns. */
|
|
194
|
+
private focusTrap: Window | null = null;
|
|
185
195
|
|
|
186
196
|
/** Registered bindings mapped by raw key string.
|
|
187
197
|
* Multiple handlers for the same key fire in insertion order; the first
|
|
@@ -212,6 +222,12 @@ export class WindowManager {
|
|
|
212
222
|
/** Set by pause() when it showed a previously-hidden cursor — instructs
|
|
213
223
|
* resume() to hide it again. Cleared after resume() or stop() uses it. */
|
|
214
224
|
private pauseRestoreCursorHidden: boolean = false;
|
|
225
|
+
/** Active setInterval handle for cursor blink ticks, or null when
|
|
226
|
+
* cursor blinking is disabled (the default). */
|
|
227
|
+
private blinkTimer: ReturnType<typeof setInterval> | null = null;
|
|
228
|
+
/** Interval (ms) requested by the last `enableCursorBlink()` call.
|
|
229
|
+
* Kept so resume() can reinstall the timer with the same cadence. */
|
|
230
|
+
private blinkIntervalMs: number | null = null;
|
|
215
231
|
|
|
216
232
|
/** Creates a WindowManager for the given Screen.
|
|
217
233
|
* Does not start the input loop; call run() to begin. */
|
|
@@ -222,6 +238,11 @@ export class WindowManager {
|
|
|
222
238
|
this.onKey = options?.onKey;
|
|
223
239
|
this.onMouse = options?.onMouse;
|
|
224
240
|
this.mouseEnabled = options?.mouse ?? false;
|
|
241
|
+
this.onError = options?.onError;
|
|
242
|
+
|
|
243
|
+
if (this.onError) {
|
|
244
|
+
setErrorHandler((err, control) => this.onError?.(err, control));
|
|
245
|
+
}
|
|
225
246
|
|
|
226
247
|
this.mainEntries = [];
|
|
227
248
|
this.mainFocusIndex = -1;
|
|
@@ -263,18 +284,86 @@ export class WindowManager {
|
|
|
263
284
|
}
|
|
264
285
|
|
|
265
286
|
/** Moves focus to the given control if it belongs to the active context and is
|
|
266
|
-
* eligible (not disabled, not hidden via setVisible(false)).
|
|
287
|
+
* eligible (not disabled, not hidden via setVisible(false)). When a focus
|
|
288
|
+
* trap is installed, the candidate must also be a descendant of the trap
|
|
289
|
+
* — otherwise the call is a no-op. */
|
|
267
290
|
public setFocus(control: Focusable & Window): void {
|
|
268
291
|
const entries = this.activeEntries();
|
|
269
292
|
const idx = entries.findIndex(e => e.control === control);
|
|
270
293
|
if (idx === -1) return;
|
|
271
294
|
const candidate = entries[idx].control;
|
|
272
295
|
if (candidate.isDisabled() || !candidate.isVisible()) return;
|
|
296
|
+
if (this.focusTrap && !this.isWithinTrap(candidate)) return;
|
|
273
297
|
this.blurCurrent();
|
|
274
298
|
this.setActiveFocusIndex(idx);
|
|
275
299
|
control.setFocused(true);
|
|
276
300
|
}
|
|
277
301
|
|
|
302
|
+
/** Moves focus to the next eligible control (forward cycle), skipping
|
|
303
|
+
* disabled, hidden, and out-of-trap entries. Equivalent to the Tab key. */
|
|
304
|
+
public focusNext(): void {
|
|
305
|
+
this.moveFocus(1);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Moves focus to the previous eligible control (reverse cycle), skipping
|
|
309
|
+
* disabled, hidden, and out-of-trap entries. Equivalent to Shift-Tab. */
|
|
310
|
+
public focusPrev(): void {
|
|
311
|
+
this.moveFocus(-1);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** Focuses the first eligible control in the active context. No-op when
|
|
315
|
+
* no control is eligible. */
|
|
316
|
+
public focusFirst(): void {
|
|
317
|
+
this.focusAtEdge('first');
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Focuses the last eligible control in the active context. */
|
|
321
|
+
public focusLast(): void {
|
|
322
|
+
this.focusAtEdge('last');
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Focuses the control registered with the given `id` (copied from
|
|
326
|
+
* `WindowProperties.id` / YAML `id:`). Searches the active focus context
|
|
327
|
+
* only; returns `true` on success, `false` when no matching control is
|
|
328
|
+
* registered or the match is ineligible. */
|
|
329
|
+
public focusById(id: string): boolean {
|
|
330
|
+
const entries = this.activeEntries();
|
|
331
|
+
for (const entry of entries) {
|
|
332
|
+
if (entry.control.getId() !== id) continue;
|
|
333
|
+
if (!this.isFocusable(entry.control)) return false;
|
|
334
|
+
if (this.focusTrap && !this.isWithinTrap(entry.control)) return false;
|
|
335
|
+
this.blurCurrent();
|
|
336
|
+
this.setActiveFocusIndex(entries.indexOf(entry));
|
|
337
|
+
entry.control.setFocused(true);
|
|
338
|
+
return true;
|
|
339
|
+
}
|
|
340
|
+
return false;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** Constrains focus navigation to descendants of `within`. While active,
|
|
344
|
+
* `focusNext / focusPrev / focusFirst / focusLast / setFocus / focusById`
|
|
345
|
+
* skip any control that is not a descendant of `within`; Tab / Shift-Tab
|
|
346
|
+
* cycle within the trapped subtree only. Nested calls stack in LIFO
|
|
347
|
+
* order via the returned release function — calling the release restores
|
|
348
|
+
* the previous trap (or clears it when this was the outermost). The
|
|
349
|
+
* helper is independent of the modal dialog stack, so it can be used
|
|
350
|
+
* for composite controls that live inside the main context. */
|
|
351
|
+
public trapFocus(within: Window): () => void {
|
|
352
|
+
const previous = this.focusTrap;
|
|
353
|
+
this.focusTrap = within;
|
|
354
|
+
return () => {
|
|
355
|
+
if (this.focusTrap === within) {
|
|
356
|
+
this.focusTrap = previous;
|
|
357
|
+
}
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** Returns the Window that currently defines the focus trap, or null when
|
|
362
|
+
* no trap is active. Exposed primarily for tests and diagnostics. */
|
|
363
|
+
public getFocusTrap(): Window | null {
|
|
364
|
+
return this.focusTrap;
|
|
365
|
+
}
|
|
366
|
+
|
|
278
367
|
// ── Global key bindings (P0-4) ─────────────────────────────────────────────
|
|
279
368
|
|
|
280
369
|
/** Registers a global shortcut handler.
|
|
@@ -422,6 +511,8 @@ export class WindowManager {
|
|
|
422
511
|
this.running = false;
|
|
423
512
|
this.paused = false;
|
|
424
513
|
|
|
514
|
+
this.uninstallBlinkTimer();
|
|
515
|
+
|
|
425
516
|
if (wasRunning) {
|
|
426
517
|
if (!wasPaused) {
|
|
427
518
|
process.stdin.off('data', this.boundHandleInput);
|
|
@@ -448,6 +539,8 @@ export class WindowManager {
|
|
|
448
539
|
this.pauseRestoreCursorHidden = false;
|
|
449
540
|
}
|
|
450
541
|
|
|
542
|
+
if (this.onError) setErrorHandler(undefined);
|
|
543
|
+
|
|
451
544
|
this.onExit?.();
|
|
452
545
|
}
|
|
453
546
|
|
|
@@ -485,6 +578,10 @@ export class WindowManager {
|
|
|
485
578
|
this.screen.showHardwareCursor();
|
|
486
579
|
}
|
|
487
580
|
|
|
581
|
+
// Stop the blink timer so a spawned sub-process doesn't get its
|
|
582
|
+
// screen overwritten mid-frame. resume() reinstalls it.
|
|
583
|
+
this.uninstallBlinkTimer();
|
|
584
|
+
|
|
488
585
|
this.pauseRestoreAltScreen = false;
|
|
489
586
|
if (options?.leaveAltScreen && this.screen.isAltScreenActive()) {
|
|
490
587
|
this.screen.exitAltScreen();
|
|
@@ -521,7 +618,14 @@ export class WindowManager {
|
|
|
521
618
|
process.stdin.resume();
|
|
522
619
|
process.stdin.on('data', this.boundHandleInput);
|
|
523
620
|
|
|
621
|
+
// Restore the blink timer with its previously configured cadence.
|
|
622
|
+
this.installBlinkTimer();
|
|
623
|
+
|
|
524
624
|
if (options?.rerender !== false) {
|
|
625
|
+
// The paused interval may have reflowed via external writes
|
|
626
|
+
// (e.g. another process scribbled onto stdout), so schedule a
|
|
627
|
+
// full repaint instead of relying on cached dirty rects.
|
|
628
|
+
this.screen.invalidate();
|
|
525
629
|
this.renderFrame();
|
|
526
630
|
}
|
|
527
631
|
}
|
|
@@ -533,6 +637,63 @@ export class WindowManager {
|
|
|
533
637
|
return this.paused;
|
|
534
638
|
}
|
|
535
639
|
|
|
640
|
+
// ── Cursor blink ───────────────────────────────────────────────────────────
|
|
641
|
+
|
|
642
|
+
/** Starts a timer that periodically triggers a re-render so timed-blink
|
|
643
|
+
* `VirtualCursor` instances (used by focused `TextBox` / `TextArea`)
|
|
644
|
+
* actually animate in the terminal. Call once after `run()`; the timer
|
|
645
|
+
* automatically pauses during `pause()` and re-arms on `resume()`.
|
|
646
|
+
*
|
|
647
|
+
* The `intervalMs` parameter sets how often the timer fires — smaller
|
|
648
|
+
* values are smoother but render more frames per second. Default:
|
|
649
|
+
* `80` ms (≈12 Hz), enough to capture the `fast` preset (250/250) and
|
|
650
|
+
* the short phases of `irregular` without flooding stdout.
|
|
651
|
+
*
|
|
652
|
+
* Idempotent: calling again with a new interval replaces the existing
|
|
653
|
+
* timer. */
|
|
654
|
+
public enableCursorBlink(intervalMs: number = 80): void {
|
|
655
|
+
this.blinkIntervalMs = Math.max(16, intervalMs);
|
|
656
|
+
this.installBlinkTimer();
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/** Stops the blink timer installed by `enableCursorBlink()`. Idempotent. */
|
|
660
|
+
public disableCursorBlink(): void {
|
|
661
|
+
this.blinkIntervalMs = null;
|
|
662
|
+
this.uninstallBlinkTimer();
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/** (Re)creates the blink interval using the cached `blinkIntervalMs`. */
|
|
666
|
+
private installBlinkTimer(): void {
|
|
667
|
+
this.uninstallBlinkTimer();
|
|
668
|
+
if (this.blinkIntervalMs === null) return;
|
|
669
|
+
this.blinkTimer = setInterval(() => {
|
|
670
|
+
// Avoid drawing while paused — the stdin listener is detached
|
|
671
|
+
// and the terminal might be owned by a sub-process.
|
|
672
|
+
if (this.paused) return;
|
|
673
|
+
// A blink tick is a pure time-driven change: no user input
|
|
674
|
+
// modified any state, so damage tracking would otherwise skip
|
|
675
|
+
// the frame entirely and the cursor would never visually blink.
|
|
676
|
+
// Flag the focused control dirty (if any) so its render() path
|
|
677
|
+
// re-emits the TextBox / TextArea row with the new cursor
|
|
678
|
+
// phase; when nothing is focused the frame is still skipped.
|
|
679
|
+
this.getFocused()?.markDirty();
|
|
680
|
+
this.renderFrame();
|
|
681
|
+
}, this.blinkIntervalMs);
|
|
682
|
+
// Don't keep the Node event loop alive just for cursor blinks.
|
|
683
|
+
if (typeof this.blinkTimer === 'object' && this.blinkTimer !== null
|
|
684
|
+
&& 'unref' in this.blinkTimer && typeof this.blinkTimer.unref === 'function') {
|
|
685
|
+
this.blinkTimer.unref();
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/** Clears the active blink interval, if any. */
|
|
690
|
+
private uninstallBlinkTimer(): void {
|
|
691
|
+
if (this.blinkTimer !== null) {
|
|
692
|
+
clearInterval(this.blinkTimer);
|
|
693
|
+
this.blinkTimer = null;
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
536
697
|
// ── Input handling ─────────────────────────────────────────────────────────
|
|
537
698
|
|
|
538
699
|
/** Processes a raw stdin Buffer. Exposed as public so tests can drive it directly
|
|
@@ -660,9 +821,48 @@ export class WindowManager {
|
|
|
660
821
|
}
|
|
661
822
|
|
|
662
823
|
/** Returns true when the control is eligible to receive focus in the current
|
|
663
|
-
* context — neither disabled nor hidden via `Window.setVisible(false)
|
|
824
|
+
* context — neither disabled nor hidden via `Window.setVisible(false)`,
|
|
825
|
+
* and inside the active focus trap (if any). */
|
|
664
826
|
private isFocusable(control: Focusable & Window): boolean {
|
|
665
|
-
|
|
827
|
+
if (control.isDisabled() || !control.isVisible()) return false;
|
|
828
|
+
if (this.focusTrap && !this.isWithinTrap(control)) return false;
|
|
829
|
+
return true;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
/** Depth-first check: is `control` the trap window itself or one of its
|
|
833
|
+
* descendants? Used when `focusTrap` is installed to filter focus
|
|
834
|
+
* candidates. Cheaper than storing parent pointers because the subtree
|
|
835
|
+
* is usually small (the trap is a dialog or composite control). */
|
|
836
|
+
private isWithinTrap(control: Window): boolean {
|
|
837
|
+
if (!this.focusTrap) return true;
|
|
838
|
+
return this.isDescendant(control, this.focusTrap);
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
/** Returns true when `node` is `root` or is reachable by walking
|
|
842
|
+
* `root.getChildren()` recursively. */
|
|
843
|
+
private isDescendant(node: Window, root: Window): boolean {
|
|
844
|
+
if (node === root) return true;
|
|
845
|
+
for (const child of root.getChildren()) {
|
|
846
|
+
if (this.isDescendant(node, child)) return true;
|
|
847
|
+
}
|
|
848
|
+
return false;
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
/** Moves focus to the first or last eligible control in the active
|
|
852
|
+
* context. Shared implementation for `focusFirst` / `focusLast`. */
|
|
853
|
+
private focusAtEdge(edge: 'first' | 'last'): void {
|
|
854
|
+
const entries = this.activeEntries();
|
|
855
|
+
if (entries.length === 0) return;
|
|
856
|
+
const order = edge === 'first'
|
|
857
|
+
? entries.map((_, i) => i)
|
|
858
|
+
: entries.map((_, i) => entries.length - 1 - i);
|
|
859
|
+
for (const idx of order) {
|
|
860
|
+
if (!this.isFocusable(entries[idx]!.control)) continue;
|
|
861
|
+
this.blurCurrent();
|
|
862
|
+
this.setActiveFocusIndex(idx);
|
|
863
|
+
entries[idx]!.control.setFocused(true);
|
|
864
|
+
return;
|
|
865
|
+
}
|
|
666
866
|
}
|
|
667
867
|
|
|
668
868
|
/** Finds the first already-focused (or first eligible) control and focuses it. */
|
|
@@ -34,6 +34,7 @@ export class BarChart extends Window {
|
|
|
34
34
|
/** Sets the data values. Call render() afterwards. */
|
|
35
35
|
public setData(data: number[]): void {
|
|
36
36
|
this.data = data;
|
|
37
|
+
this.markDirty();
|
|
37
38
|
}
|
|
38
39
|
|
|
39
40
|
/** Returns the current data values. */
|
|
@@ -44,6 +45,7 @@ export class BarChart extends Window {
|
|
|
44
45
|
/** Sets the bar labels. Call render() afterwards. */
|
|
45
46
|
public setLabels(labels: string[]): void {
|
|
46
47
|
this.labels = labels;
|
|
48
|
+
this.markDirty();
|
|
47
49
|
}
|
|
48
50
|
|
|
49
51
|
/** Returns the current bar labels. */
|
|
@@ -54,6 +56,7 @@ export class BarChart extends Window {
|
|
|
54
56
|
/** Sets the maximum Y value. Pass undefined to derive from data. Call render() afterwards. */
|
|
55
57
|
public setMax(max: number | undefined): void {
|
|
56
58
|
this.max = max;
|
|
59
|
+
this.markDirty();
|
|
57
60
|
}
|
|
58
61
|
|
|
59
62
|
/** Returns the configured maximum Y value, or undefined if derived from data. */
|
|
@@ -31,7 +31,9 @@ export class Checkbox extends Window {
|
|
|
31
31
|
|
|
32
32
|
/** Toggles or sets the checked state. */
|
|
33
33
|
public setChecked(checked: boolean): void {
|
|
34
|
+
if (this.checked === checked) return;
|
|
34
35
|
this.checked = checked;
|
|
36
|
+
this.markDirty();
|
|
35
37
|
}
|
|
36
38
|
|
|
37
39
|
/** Returns the current checked state. */
|
|
@@ -44,6 +46,7 @@ export class Checkbox extends Window {
|
|
|
44
46
|
if (this.disabled) return;
|
|
45
47
|
if (key === ' ' || key === 'space') {
|
|
46
48
|
this.checked = !this.checked;
|
|
49
|
+
this.markDirty();
|
|
47
50
|
this.onChange?.(this.checked);
|
|
48
51
|
}
|
|
49
52
|
}
|
|
@@ -38,6 +38,7 @@ export class LineChart extends Window {
|
|
|
38
38
|
/** Sets the data series. Call render() afterwards. */
|
|
39
39
|
public setData(data: number[]): void {
|
|
40
40
|
this.data = data;
|
|
41
|
+
this.markDirty();
|
|
41
42
|
}
|
|
42
43
|
|
|
43
44
|
/** Returns the current data series. */
|
|
@@ -48,6 +49,7 @@ export class LineChart extends Window {
|
|
|
48
49
|
/** Sets the minimum Y value. Pass undefined to derive from data. Call render() afterwards. */
|
|
49
50
|
public setMin(min: number | undefined): void {
|
|
50
51
|
this.minValue = min;
|
|
52
|
+
this.markDirty();
|
|
51
53
|
}
|
|
52
54
|
|
|
53
55
|
/** Returns the configured minimum Y value, or undefined if derived from data. */
|
|
@@ -58,6 +60,7 @@ export class LineChart extends Window {
|
|
|
58
60
|
/** Sets the maximum Y value. Pass undefined to derive from data. Call render() afterwards. */
|
|
59
61
|
public setMax(max: number | undefined): void {
|
|
60
62
|
this.maxValue = max;
|
|
63
|
+
this.markDirty();
|
|
61
64
|
}
|
|
62
65
|
|
|
63
66
|
/** Returns the configured maximum Y value, or undefined if derived from data. */
|
|
@@ -54,6 +54,7 @@ export class ListBox<T = string> extends Window {
|
|
|
54
54
|
this.items = items;
|
|
55
55
|
this.selectedIndex = items.length > 0 ? 0 : -1;
|
|
56
56
|
this.scrollTop = 0;
|
|
57
|
+
this.markDirty();
|
|
57
58
|
}
|
|
58
59
|
|
|
59
60
|
/** Returns the current list items. */
|
|
@@ -67,7 +68,9 @@ export class ListBox<T = string> extends Window {
|
|
|
67
68
|
this.selectedIndex = -1;
|
|
68
69
|
return;
|
|
69
70
|
}
|
|
71
|
+
const prev = this.selectedIndex;
|
|
70
72
|
this.selectedIndex = Math.max(0, Math.min(this.items.length - 1, index));
|
|
73
|
+
if (prev !== this.selectedIndex) this.markDirty();
|
|
71
74
|
this.ensureVisible();
|
|
72
75
|
}
|
|
73
76
|
|
|
@@ -85,6 +88,7 @@ export class ListBox<T = string> extends Window {
|
|
|
85
88
|
/** Replaces the per-row renderer after construction. Pass undefined to restore default behaviour. */
|
|
86
89
|
public setRenderItem(fn: ((item: T, ctx: ListBoxRenderContext) => ListBoxRowSegments) | undefined): void {
|
|
87
90
|
this.renderItem = fn;
|
|
91
|
+
this.markDirty();
|
|
88
92
|
}
|
|
89
93
|
|
|
90
94
|
/** Returns the configured row height in cells. */
|
|
@@ -129,7 +133,11 @@ export class ListBox<T = string> extends Window {
|
|
|
129
133
|
return;
|
|
130
134
|
}
|
|
131
135
|
|
|
136
|
+
const prevScroll = this.scrollTop;
|
|
132
137
|
this.ensureVisible();
|
|
138
|
+
if (this.selectedIndex !== prev || this.scrollTop !== prevScroll) {
|
|
139
|
+
this.markDirty();
|
|
140
|
+
}
|
|
133
141
|
if (this.selectedIndex !== prev) {
|
|
134
142
|
this.onChange?.(this.selectedIndex, this.items[this.selectedIndex]);
|
|
135
143
|
}
|
|
@@ -37,6 +37,7 @@ export class ProgressBar extends Window {
|
|
|
37
37
|
/** Sets the current value (clamped to 0–max). Call render() afterwards. */
|
|
38
38
|
public setValue(value: number): void {
|
|
39
39
|
this.value = Math.max(0, Math.min(value, this.max));
|
|
40
|
+
this.markDirty();
|
|
40
41
|
}
|
|
41
42
|
|
|
42
43
|
/** Returns the current value. */
|
|
@@ -48,6 +49,7 @@ export class ProgressBar extends Window {
|
|
|
48
49
|
public setMax(max: number): void {
|
|
49
50
|
this.max = Math.max(1, max);
|
|
50
51
|
this.value = Math.min(this.value, this.max);
|
|
52
|
+
this.markDirty();
|
|
51
53
|
}
|
|
52
54
|
|
|
53
55
|
/** Returns the maximum value. */
|
|
@@ -31,6 +31,7 @@ export class ProgressBarV extends Window {
|
|
|
31
31
|
/** Sets the current value (clamped to 0–max). Call render() afterwards. */
|
|
32
32
|
public setValue(value: number): void {
|
|
33
33
|
this.value = Math.max(0, Math.min(value, this.max));
|
|
34
|
+
this.markDirty();
|
|
34
35
|
}
|
|
35
36
|
|
|
36
37
|
/** Returns the current value. */
|
|
@@ -42,6 +43,7 @@ export class ProgressBarV extends Window {
|
|
|
42
43
|
public setMax(max: number): void {
|
|
43
44
|
this.max = Math.max(1, max);
|
|
44
45
|
this.value = Math.min(this.value, this.max);
|
|
46
|
+
this.markDirty();
|
|
45
47
|
}
|
|
46
48
|
|
|
47
49
|
/** Returns the maximum value. */
|
|
@@ -31,7 +31,9 @@ export class Radio extends Window {
|
|
|
31
31
|
|
|
32
32
|
/** Sets the selected state. */
|
|
33
33
|
public setChecked(checked: boolean): void {
|
|
34
|
+
if (this.checked === checked) return;
|
|
34
35
|
this.checked = checked;
|
|
36
|
+
this.markDirty();
|
|
35
37
|
}
|
|
36
38
|
|
|
37
39
|
/** Returns the current selected state. */
|
|
@@ -43,7 +45,10 @@ export class Radio extends Window {
|
|
|
43
45
|
public handleKey(key: string): void {
|
|
44
46
|
if (this.disabled) return;
|
|
45
47
|
if (key === ' ' || key === 'space') {
|
|
46
|
-
this.checked
|
|
48
|
+
if (!this.checked) {
|
|
49
|
+
this.checked = true;
|
|
50
|
+
this.markDirty();
|
|
51
|
+
}
|
|
47
52
|
this.onChange?.(true);
|
|
48
53
|
}
|
|
49
54
|
}
|
|
@@ -31,6 +31,7 @@ export class Sparkline extends Window {
|
|
|
31
31
|
/** Sets the data series. Call render() afterwards. */
|
|
32
32
|
public setData(data: number[]): void {
|
|
33
33
|
this.data = data;
|
|
34
|
+
this.markDirty();
|
|
34
35
|
}
|
|
35
36
|
|
|
36
37
|
/** Returns the current data series. */
|
|
@@ -41,6 +42,7 @@ export class Sparkline extends Window {
|
|
|
41
42
|
/** Sets the minimum value. Pass undefined to derive from data. Call render() afterwards. */
|
|
42
43
|
public setMin(min: number | undefined): void {
|
|
43
44
|
this.minValue = min;
|
|
45
|
+
this.markDirty();
|
|
44
46
|
}
|
|
45
47
|
|
|
46
48
|
/** Returns the configured minimum value, or undefined if derived from data. */
|
|
@@ -51,6 +53,7 @@ export class Sparkline extends Window {
|
|
|
51
53
|
/** Sets the maximum value. Pass undefined to derive from data. Call render() afterwards. */
|
|
52
54
|
public setMax(max: number | undefined): void {
|
|
53
55
|
this.maxValue = max;
|
|
56
|
+
this.markDirty();
|
|
54
57
|
}
|
|
55
58
|
|
|
56
59
|
/** Returns the configured maximum value, or undefined if derived from data. */
|
|
@@ -52,12 +52,14 @@ export class Spinner extends Window {
|
|
|
52
52
|
public step(): void {
|
|
53
53
|
if (!this.running || this.frames.length === 0) return;
|
|
54
54
|
this.frame = (this.frame + 1) % this.frames.length;
|
|
55
|
+
this.markDirty();
|
|
55
56
|
}
|
|
56
57
|
|
|
57
58
|
/** Sets the current frame index directly. Wraps into the valid range. */
|
|
58
59
|
public setFrame(frame: number): void {
|
|
59
60
|
if (this.frames.length === 0) return;
|
|
60
61
|
this.frame = ((frame % this.frames.length) + this.frames.length) % this.frames.length;
|
|
62
|
+
this.markDirty();
|
|
61
63
|
}
|
|
62
64
|
|
|
63
65
|
/** Returns the current frame index. */
|
|
@@ -67,12 +69,16 @@ export class Spinner extends Window {
|
|
|
67
69
|
|
|
68
70
|
/** Starts or resumes the animation. step() will advance frames again. */
|
|
69
71
|
public start(): void {
|
|
72
|
+
if (this.running) return;
|
|
70
73
|
this.running = true;
|
|
74
|
+
this.markDirty();
|
|
71
75
|
}
|
|
72
76
|
|
|
73
77
|
/** Pauses the animation. step() becomes a no-op; the current frame stays visible. */
|
|
74
78
|
public stop(): void {
|
|
79
|
+
if (!this.running) return;
|
|
75
80
|
this.running = false;
|
|
81
|
+
this.markDirty();
|
|
76
82
|
}
|
|
77
83
|
|
|
78
84
|
/** Returns whether the spinner is currently animating. */
|
|
@@ -38,7 +38,9 @@ export class StatusLED extends Window {
|
|
|
38
38
|
|
|
39
39
|
/** Sets the current LED state. Call render() afterwards to update the display. */
|
|
40
40
|
public setState(state: 'ok' | 'warn' | 'error' | 'off'): void {
|
|
41
|
+
if (this.state === state) return;
|
|
41
42
|
this.state = state;
|
|
43
|
+
this.markDirty();
|
|
42
44
|
}
|
|
43
45
|
|
|
44
46
|
/** Returns the current LED state. */
|
|
@@ -48,6 +48,7 @@ export class Tabs extends Window {
|
|
|
48
48
|
public setTitles(titles: string[]): void {
|
|
49
49
|
this.titles = titles;
|
|
50
50
|
this.activeIndex = Math.max(0, Math.min(titles.length - 1, this.activeIndex));
|
|
51
|
+
this.markDirty();
|
|
51
52
|
}
|
|
52
53
|
|
|
53
54
|
/** Returns the current tab titles. */
|
|
@@ -64,6 +65,7 @@ export class Tabs extends Window {
|
|
|
64
65
|
const clamped = Math.max(0, Math.min(this.titles.length - 1, index));
|
|
65
66
|
if (clamped !== this.activeIndex) {
|
|
66
67
|
this.activeIndex = clamped;
|
|
68
|
+
this.markDirty();
|
|
67
69
|
this.onChange?.(clamped, this.titles[clamped]);
|
|
68
70
|
}
|
|
69
71
|
}
|