take4-console 0.30.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 +60 -0
- package/README.md +3 -2
- package/dist/Screen/Screen.d.mts +40 -0
- package/dist/Screen/Screen.d.mts.map +1 -1
- package/dist/Screen/Screen.mjs +148 -1
- package/dist/Screen/Screen.mjs.map +1 -1
- package/dist/Screen/Window.d.mts +50 -1
- package/dist/Screen/Window.d.mts.map +1 -1
- package/dist/Screen/Window.mjs +176 -19
- package/dist/Screen/Window.mjs.map +1 -1
- package/dist/Screen/WindowManager.d.mts.map +1 -1
- package/dist/Screen/WindowManager.mjs +11 -0
- 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.map +1 -1
- package/dist/Screen/controls/TextArea.mjs +11 -0
- package/dist/Screen/controls/TextArea.mjs.map +1 -1
- package/dist/Screen/controls/TextBox.d.mts.map +1 -1
- package/dist/Screen/controls/TextBox.mjs +13 -0
- package/dist/Screen/controls/TextBox.mjs.map +1 -1
- package/dist/Screen/types.d.mts +26 -0
- package/dist/Screen/types.d.mts.map +1 -1
- package/dist/index.d.mts +1 -1
- package/dist/index.d.mts.map +1 -1
- package/package.json +1 -1
- package/src/Screen/Screen.mts +148 -2
- package/src/Screen/Window.mts +165 -16
- package/src/Screen/WindowManager.mts +11 -0
- 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 +10 -0
- package/src/Screen/controls/TextBox.mts +12 -0
- package/src/Screen/types.mts +27 -0
- package/src/demo.mts +51 -0
- package/src/index.mts +1 -0
package/src/Screen/Screen.mts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { EventEmitter } from 'node:events';
|
|
2
|
-
import type { CellAttributes, ScreenFrameStats, ScreenOptions, StyleId, TerminalSize, ToastOptions, ToastPosition } 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';
|
|
@@ -43,6 +43,18 @@ export class Screen extends Window {
|
|
|
43
43
|
/** Per-toast dismiss callbacks (from `ToastOptions.onDismiss`). Kept out of
|
|
44
44
|
* the `Toast` instance so the control stays free of bookkeeping code. */
|
|
45
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;
|
|
46
58
|
|
|
47
59
|
/** Initializes the root window sized to the current terminal dimensions.
|
|
48
60
|
* Creates a fresh StyleRegistry (with built-in styles pre-registered)
|
|
@@ -68,6 +80,8 @@ export class Screen extends Window {
|
|
|
68
80
|
this.activeToasts = new Map();
|
|
69
81
|
this.toastTimers = new Map();
|
|
70
82
|
this.toastDismissHandlers = new Map();
|
|
83
|
+
this.damageTracking = options?.damageTracking ?? true;
|
|
84
|
+
this.fullInvalidate = true;
|
|
71
85
|
|
|
72
86
|
if (options?.altScreen) this.enterAltScreen();
|
|
73
87
|
if (options?.hideCursor) this.hideHardwareCursor();
|
|
@@ -340,12 +354,49 @@ export class Screen extends Window {
|
|
|
340
354
|
*/
|
|
341
355
|
public override render(): void {
|
|
342
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.
|
|
343
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
|
+
}
|
|
344
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 {
|
|
345
395
|
const reg = getRegistry();
|
|
346
396
|
const chars = this.region.getChars();
|
|
347
397
|
const styleIds = this.region.getStyleIds();
|
|
348
398
|
let output = '\x1b[H';
|
|
399
|
+
let count = 0;
|
|
349
400
|
for (let i = 0; i < chars.length; i++) {
|
|
350
401
|
const ch = chars[i];
|
|
351
402
|
// Empty-string sentinel = continuation cell of a wide character;
|
|
@@ -353,10 +404,105 @@ export class Screen extends Window {
|
|
|
353
404
|
if (ch === '') continue;
|
|
354
405
|
output += this.buildAnsiSequence(reg.get(styleIds[i]));
|
|
355
406
|
output += ch;
|
|
407
|
+
count++;
|
|
356
408
|
}
|
|
357
409
|
output += '\x1b[0m';
|
|
358
410
|
process.stdout.write(output);
|
|
359
|
-
|
|
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;
|
|
360
506
|
}
|
|
361
507
|
|
|
362
508
|
// ── Private helpers ───────────────────────────────────────────────────────
|
package/src/Screen/Window.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Cell, StyleId, BorderStyle, BorderChars, WindowBorder, WindowProperties, WriteTextOptions, WriteTextInput, WriteTextSegment, TerminalSize, LayoutMode, AlignItems, JustifyContent, Padding, PaddingSpec, Margin, MarginSpec, DimSpec } from './types.mjs';
|
|
1
|
+
import type { Cell, StyleId, BorderStyle, BorderChars, WindowBorder, WindowProperties, WriteTextOptions, WriteTextInput, WriteTextSegment, TerminalSize, LayoutMode, AlignItems, JustifyContent, Padding, PaddingSpec, Margin, MarginSpec, DimSpec, DirtyRect } from './types.mjs';
|
|
2
2
|
import { BUILTIN_TEXT, BUILTIN_TEXT_FOCUSED, BUILTIN_TEXT_DISABLED, BUILTIN_BORDER, BUILTIN_BORDER_FOCUSED, BUILTIN_BORDER_DISABLED } from './types.mjs';
|
|
3
3
|
import { Region } from './Region.mjs';
|
|
4
4
|
import { StyleRegistry } from './StyleRegistry.mjs';
|
|
@@ -156,6 +156,25 @@ export class Window {
|
|
|
156
156
|
private onFocusHandler: (() => void) | undefined;
|
|
157
157
|
/** Fires once when focused state flips from true to false. */
|
|
158
158
|
private onBlurHandler: (() => void) | undefined;
|
|
159
|
+
/** Parent window (set by `addChild`, cleared by `removeChild`). Damage
|
|
160
|
+
* tracking walks up this chain to notify the root `Screen` of geometry
|
|
161
|
+
* or topology changes that need a full repaint. */
|
|
162
|
+
protected parent: Window | null = null;
|
|
163
|
+
/** Accumulated dirty region in this window's local coordinates.
|
|
164
|
+
* - `null` — nothing changed since the last emit.
|
|
165
|
+
* - `'all'` — the whole window area needs to be re-emitted.
|
|
166
|
+
* - `DirtyRect` — bounding box of localised writes; `markDirty()` eagerly
|
|
167
|
+
* unions new rects so we only ever carry one rect per window.
|
|
168
|
+
* Windows start as `'all'` so the very first frame re-emits every cell. */
|
|
169
|
+
protected dirtyRect: DirtyRect | 'all' | null = 'all';
|
|
170
|
+
/** Depth of nested `Window.render()` calls currently executing. Increments
|
|
171
|
+
* at the top of every `render()` and decrements in a `finally`, so
|
|
172
|
+
* controls whose `render()` override re-writes content via `this.clear()`
|
|
173
|
+
* / `this.writeText(...)` do not re-mark themselves dirty every frame —
|
|
174
|
+
* render-time writes deterministically rebuild the display buffer from
|
|
175
|
+
* existing state, so marking them as "user-driven dirty" would defeat the
|
|
176
|
+
* "skip frame when nothing changed" optimisation. */
|
|
177
|
+
protected static renderingDepth: number = 0;
|
|
159
178
|
|
|
160
179
|
/** Creates a window from the given properties.
|
|
161
180
|
* For percentage-based sizes, call addChild() before writing content to the window.
|
|
@@ -209,6 +228,88 @@ export class Window {
|
|
|
209
228
|
return this.region.getSize();
|
|
210
229
|
}
|
|
211
230
|
|
|
231
|
+
// ── Damage tracking ─────────────────────────────────────────────────────
|
|
232
|
+
|
|
233
|
+
/** Marks this window (or a sub-rectangle in its local coordinates) as
|
|
234
|
+
* dirty. Subsequent `Screen.render()` calls emit ANSI only for cells
|
|
235
|
+
* inside the collected dirty rects, skipping untouched regions. When
|
|
236
|
+
* `rect` is omitted, the entire window is flagged. Render-time writes
|
|
237
|
+
* are ignored so controls' `render()` overrides do not re-mark
|
|
238
|
+
* themselves every frame. Safe to call many times between renders —
|
|
239
|
+
* new rects are unioned into the existing bounding box. */
|
|
240
|
+
public markDirty(rect?: DirtyRect): void {
|
|
241
|
+
if (Window.renderingDepth > 0) return;
|
|
242
|
+
if (this.dirtyRect === 'all') return;
|
|
243
|
+
if (rect === undefined) { this.dirtyRect = 'all'; return; }
|
|
244
|
+
if (rect.w <= 0 || rect.h <= 0) return;
|
|
245
|
+
if (this.dirtyRect === null) {
|
|
246
|
+
this.dirtyRect = { x: rect.x, y: rect.y, w: rect.w, h: rect.h };
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
const ax0 = this.dirtyRect.x;
|
|
250
|
+
const ay0 = this.dirtyRect.y;
|
|
251
|
+
const ax1 = ax0 + this.dirtyRect.w;
|
|
252
|
+
const ay1 = ay0 + this.dirtyRect.h;
|
|
253
|
+
const bx0 = rect.x;
|
|
254
|
+
const by0 = rect.y;
|
|
255
|
+
const bx1 = bx0 + rect.w;
|
|
256
|
+
const by1 = by0 + rect.h;
|
|
257
|
+
const nx0 = Math.min(ax0, bx0);
|
|
258
|
+
const ny0 = Math.min(ay0, by0);
|
|
259
|
+
const nx1 = Math.max(ax1, bx1);
|
|
260
|
+
const ny1 = Math.max(ay1, by1);
|
|
261
|
+
this.dirtyRect = { x: nx0, y: ny0, w: nx1 - nx0, h: ny1 - ny0 };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Convenience alias — flags the entire window as dirty. Equivalent to
|
|
265
|
+
* calling `markDirty()` with no argument; kept as a named entry point
|
|
266
|
+
* so custom controls can invalidate themselves without depending on
|
|
267
|
+
* the default-argument behaviour. */
|
|
268
|
+
public invalidate(): void {
|
|
269
|
+
this.markDirty();
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Walks the parent chain up to the root window. Damage tracking uses
|
|
273
|
+
* this to bubble a full-invalidation signal to the root `Screen` when
|
|
274
|
+
* geometry or tree topology changes, because the cells previously
|
|
275
|
+
* occupied by a now-moved / resized / hidden window need to be repainted
|
|
276
|
+
* from the content underneath them. `Screen` overrides it to toggle its
|
|
277
|
+
* internal full-repaint flag; plain `Window`s forward the call up. */
|
|
278
|
+
protected markFullInvalidation(): void {
|
|
279
|
+
this.parent?.markFullInvalidation();
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Appends this window's dirty rect (translated into screen coordinates
|
|
283
|
+
* via the running offset) and every descendant's dirty rects to `acc`.
|
|
284
|
+
* Hidden subtrees are skipped — invisible windows don't contribute to
|
|
285
|
+
* the emit phase, and any state change that flips visibility already
|
|
286
|
+
* escalated to a full invalidation so the vacated area repaints. */
|
|
287
|
+
protected collectDirtyRects(offsetX: number, offsetY: number, acc: DirtyRect[]): void {
|
|
288
|
+
if (!this.visible) return;
|
|
289
|
+
if (this.dirtyRect === 'all') {
|
|
290
|
+
const { width, height } = this.getSize();
|
|
291
|
+
acc.push({ x: offsetX, y: offsetY, w: width, h: height });
|
|
292
|
+
} else if (this.dirtyRect !== null) {
|
|
293
|
+
acc.push({
|
|
294
|
+
x: offsetX + this.dirtyRect.x,
|
|
295
|
+
y: offsetY + this.dirtyRect.y,
|
|
296
|
+
w: this.dirtyRect.w,
|
|
297
|
+
h: this.dirtyRect.h,
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
for (const child of this.children) {
|
|
301
|
+
child.collectDirtyRects(offsetX + child.x, offsetY + child.y, acc);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Clears the dirty flag on this window and every descendant. Called by
|
|
306
|
+
* `Screen.render()` after the emit phase so the next frame starts from
|
|
307
|
+
* a clean slate. */
|
|
308
|
+
protected clearDirtyRecursive(): void {
|
|
309
|
+
this.dirtyRect = null;
|
|
310
|
+
for (const child of this.children) child.clearDirtyRecursive();
|
|
311
|
+
}
|
|
312
|
+
|
|
212
313
|
/** Returns a snapshot of the resolved per-side margin (in cells). The parent's
|
|
213
314
|
* layout engine reads this to reserve outer spacing around the child. */
|
|
214
315
|
public getMargin(): Readonly<Margin> {
|
|
@@ -260,7 +361,9 @@ export class Window {
|
|
|
260
361
|
|
|
261
362
|
/** Sets the active state. Affects border and background appearance on next render(). */
|
|
262
363
|
public setActive(active: boolean): void {
|
|
364
|
+
if (this.active === active) return;
|
|
263
365
|
this.active = active;
|
|
366
|
+
this.markDirty();
|
|
264
367
|
}
|
|
265
368
|
|
|
266
369
|
/** Sets the focused state. Controls use this to change visual appearance on focus.
|
|
@@ -269,6 +372,7 @@ export class Window {
|
|
|
269
372
|
public setFocused(focused: boolean): void {
|
|
270
373
|
if (this.focused === focused) return;
|
|
271
374
|
this.focused = focused;
|
|
375
|
+
this.markDirty();
|
|
272
376
|
if (focused) this.onFocusHandler?.();
|
|
273
377
|
else this.onBlurHandler?.();
|
|
274
378
|
}
|
|
@@ -307,7 +411,13 @@ export class Window {
|
|
|
307
411
|
* change. Does not affect layout (flex/absolute coordinates are
|
|
308
412
|
* independent of z). */
|
|
309
413
|
public setZIndex(zIndex: number): void {
|
|
414
|
+
if (this.zIndex === zIndex) return;
|
|
310
415
|
this.zIndex = zIndex;
|
|
416
|
+
// Re-stacking can expose or occlude any cell inside the parent's
|
|
417
|
+
// bounds — escalate to a full repaint so the neighbour(s) underneath
|
|
418
|
+
// get re-emitted along with this window.
|
|
419
|
+
this.markFullInvalidation();
|
|
420
|
+
this.markDirty();
|
|
311
421
|
}
|
|
312
422
|
|
|
313
423
|
/** Returns the direct children of this window in insertion order (a
|
|
@@ -324,6 +434,7 @@ export class Window {
|
|
|
324
434
|
|
|
325
435
|
/** Sets the disabled state and deactivates the window when disabled. */
|
|
326
436
|
public setDisabled(disabled: boolean): void {
|
|
437
|
+
if (this.disabled !== disabled) this.markDirty();
|
|
327
438
|
this.disabled = disabled;
|
|
328
439
|
this.setActive(!disabled);
|
|
329
440
|
}
|
|
@@ -339,7 +450,14 @@ export class Window {
|
|
|
339
450
|
* the content buffer — previously written cells reappear verbatim on the
|
|
340
451
|
* next render after the window is shown again. */
|
|
341
452
|
public setVisible(visible: boolean): void {
|
|
453
|
+
if (this.visible === visible) return;
|
|
342
454
|
this.visible = visible;
|
|
455
|
+
// Showing / hiding a window changes what the root Screen emits in this
|
|
456
|
+
// window's old bounds (the parent below may need to repaint, or the
|
|
457
|
+
// new reveal needs its first emit). Full invalidation is the
|
|
458
|
+
// conservative, always-correct choice.
|
|
459
|
+
this.markFullInvalidation();
|
|
460
|
+
this.markDirty();
|
|
343
461
|
}
|
|
344
462
|
|
|
345
463
|
/** Returns whether this window is currently visible. Default: true. */
|
|
@@ -349,7 +467,9 @@ export class Window {
|
|
|
349
467
|
|
|
350
468
|
/** Sets the label text displayed by the control. */
|
|
351
469
|
public setLabel(label: string): void {
|
|
470
|
+
if (this.label === label) return;
|
|
352
471
|
this.label = label;
|
|
472
|
+
this.markDirty();
|
|
353
473
|
}
|
|
354
474
|
|
|
355
475
|
/** Returns the current label text. */
|
|
@@ -361,6 +481,7 @@ export class Window {
|
|
|
361
481
|
* Intended for use by subclasses that need dynamic decoration (e.g. focus-state colour). */
|
|
362
482
|
protected updateBorder(border: WindowBorder | boolean | undefined): void {
|
|
363
483
|
this.border = resolveBorder(border);
|
|
484
|
+
this.markDirty();
|
|
364
485
|
}
|
|
365
486
|
|
|
366
487
|
/** Recomputes the border color from the current focused/disabled state.
|
|
@@ -383,7 +504,13 @@ export class Window {
|
|
|
383
504
|
* geometry consistent when more children join the stack. */
|
|
384
505
|
public addChild(child: Window): void {
|
|
385
506
|
this.children.push(child);
|
|
507
|
+
child.parent = this;
|
|
386
508
|
this.runLayout();
|
|
509
|
+
// Newly attached subtrees may overlap existing cells, and their own
|
|
510
|
+
// `dirtyRect` already defaults to `'all'` — but the parent region that
|
|
511
|
+
// may be exposed by later removal needs a correct baseline on the root
|
|
512
|
+
// Screen side, so we bubble a full invalidation for the first frame.
|
|
513
|
+
this.markFullInvalidation();
|
|
387
514
|
}
|
|
388
515
|
|
|
389
516
|
/** Returns a resolved Cell (char + CellAttributes) at (x, y) from the display buffer.
|
|
@@ -404,12 +531,14 @@ export class Window {
|
|
|
404
531
|
public setChar(x: number, y: number, char: string): void {
|
|
405
532
|
this.content.setChar(x, y, char);
|
|
406
533
|
this.region.setChar(x, y, char);
|
|
534
|
+
this.markDirty({ x, y, w: 1, h: 1 });
|
|
407
535
|
}
|
|
408
536
|
|
|
409
537
|
/** Sets the character and style ID at (x, y). Throws RangeError if out of bounds. */
|
|
410
538
|
public setCell(x: number, y: number, char: string, styleId: StyleId = 0): void {
|
|
411
539
|
this.content.setCell(x, y, char, styleId);
|
|
412
540
|
this.region.setCell(x, y, char, styleId);
|
|
541
|
+
this.markDirty({ x, y, w: 1, h: 1 });
|
|
413
542
|
}
|
|
414
543
|
|
|
415
544
|
/** Merges the given style ID onto the existing style at (x, y) without changing the character.
|
|
@@ -419,18 +548,21 @@ export class Window {
|
|
|
419
548
|
const mergedRegion = this.registry.merge(this.region.getStyleId(x, y), styleId);
|
|
420
549
|
this.content.setStyleId(x, y, mergedContent);
|
|
421
550
|
this.region.setStyleId(x, y, mergedRegion);
|
|
551
|
+
this.markDirty({ x, y, w: 1, h: 1 });
|
|
422
552
|
}
|
|
423
553
|
|
|
424
554
|
/** Resets every cell to a blank space with style ID 0. */
|
|
425
555
|
public clear(): void {
|
|
426
556
|
this.content.clear();
|
|
427
557
|
this.region.clear();
|
|
558
|
+
this.markDirty();
|
|
428
559
|
}
|
|
429
560
|
|
|
430
561
|
/** Fills every cell with the given character and style ID. */
|
|
431
562
|
public fill(char: string, styleId: StyleId = 0): void {
|
|
432
563
|
this.content.fill(char, styleId);
|
|
433
564
|
this.region.fill(char, styleId);
|
|
565
|
+
this.markDirty();
|
|
434
566
|
}
|
|
435
567
|
|
|
436
568
|
/** Writes text into the window's content area starting at (x, y) (default 0, 0).
|
|
@@ -565,21 +697,26 @@ export class Window {
|
|
|
565
697
|
*/
|
|
566
698
|
public render(): void {
|
|
567
699
|
if (!this.visible) return;
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
child.
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
700
|
+
Window.renderingDepth++;
|
|
701
|
+
try {
|
|
702
|
+
this.syncBorderColor();
|
|
703
|
+
this.paintBackground();
|
|
704
|
+
this.blitContent();
|
|
705
|
+
this.paintBorder();
|
|
706
|
+
for (const child of this.orderedByZ()) {
|
|
707
|
+
if (!child.visible) continue;
|
|
708
|
+
try {
|
|
709
|
+
child.render();
|
|
710
|
+
this.blitChild(child);
|
|
711
|
+
} catch (err) {
|
|
712
|
+
this.paintErrorPlaceholder(child, err);
|
|
713
|
+
const handler = getErrorHandler();
|
|
714
|
+
if (handler) handler(err, child);
|
|
715
|
+
else throw err;
|
|
716
|
+
}
|
|
582
717
|
}
|
|
718
|
+
} finally {
|
|
719
|
+
Window.renderingDepth--;
|
|
583
720
|
}
|
|
584
721
|
}
|
|
585
722
|
|
|
@@ -748,7 +885,13 @@ export class Window {
|
|
|
748
885
|
/** Removes a previously added child window. No-op if the child is not found. */
|
|
749
886
|
public removeChild(child: Window): void {
|
|
750
887
|
const idx = this.children.indexOf(child);
|
|
751
|
-
if (idx !== -1)
|
|
888
|
+
if (idx !== -1) {
|
|
889
|
+
this.children.splice(idx, 1);
|
|
890
|
+
child.parent = null;
|
|
891
|
+
// The vacated rectangle has to be re-emitted from the content
|
|
892
|
+
// underneath — escalate so the root Screen repaints everything.
|
|
893
|
+
this.markFullInvalidation();
|
|
894
|
+
}
|
|
752
895
|
}
|
|
753
896
|
|
|
754
897
|
/** Replaces both internal regions with new ones of the given dimensions,
|
|
@@ -756,6 +899,12 @@ export class Window {
|
|
|
756
899
|
protected resizeRegions(w: number, h: number): void {
|
|
757
900
|
this.region = new Region(w, h);
|
|
758
901
|
this.content = new Region(w, h);
|
|
902
|
+
// A new region invalidates any per-cell dirty bookkeeping from the
|
|
903
|
+
// previous size — escalate to a full-window repaint and ask the
|
|
904
|
+
// Screen to emit from scratch because the old footprint on stdout
|
|
905
|
+
// may include cells that are no longer part of this window.
|
|
906
|
+
this.dirtyRect = 'all';
|
|
907
|
+
this.markFullInvalidation();
|
|
759
908
|
this.reflowChildren();
|
|
760
909
|
}
|
|
761
910
|
|
|
@@ -622,6 +622,10 @@ export class WindowManager {
|
|
|
622
622
|
this.installBlinkTimer();
|
|
623
623
|
|
|
624
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();
|
|
625
629
|
this.renderFrame();
|
|
626
630
|
}
|
|
627
631
|
}
|
|
@@ -666,6 +670,13 @@ export class WindowManager {
|
|
|
666
670
|
// Avoid drawing while paused — the stdin listener is detached
|
|
667
671
|
// and the terminal might be owned by a sub-process.
|
|
668
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();
|
|
669
680
|
this.renderFrame();
|
|
670
681
|
}, this.blinkIntervalMs);
|
|
671
682
|
// Don't keep the Node event loop alive just for cursor blinks.
|
|
@@ -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
|
}
|