@gajae-code/tui 0.16.3 → 0.16.4
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 +6 -0
- package/README.md +54 -0
- package/dist/types/components/settings-list.d.ts +1 -0
- package/dist/types/tui.d.ts +12 -0
- package/package.json +3 -3
- package/src/components/settings-list.ts +5 -0
- package/src/tui.ts +309 -66
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.16.4] - 2026-09-05
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Added cancellable, one-shot `enqueueBeforeRender` preparation on the existing frame scheduler and a single-owner `setRenderPreparationLifecycleCallbacks` seam for invalidation and restart preparation. Stop, terminal loss, and disposal cancel stale work; restart preparation runs before the first forced frame without adding another streaming timer.
|
|
10
|
+
|
|
5
11
|
## [0.16.3] - 2026-09-04
|
|
6
12
|
|
|
7
13
|
## [0.16.2] - 2026-09-04
|
package/README.md
CHANGED
|
@@ -44,6 +44,60 @@ tui.start();
|
|
|
44
44
|
|
|
45
45
|
Main container that manages components and rendering.
|
|
46
46
|
|
|
47
|
+
#### Frame preparation
|
|
48
|
+
|
|
49
|
+
`tui.enqueueBeforeRender(callback: () => void): () => void` registers one synchronous,
|
|
50
|
+
one-shot preparation and returns an idempotent cancellation function. It requests a
|
|
51
|
+
normal full-mutation render on the existing 16 ms frame clock; it adds no timer and
|
|
52
|
+
does not force or expedite a frame. Normal, forced, and input-priority frames drain
|
|
53
|
+
the same snapshot before capturing render generations or reading layout, components,
|
|
54
|
+
viewport sources, or caches. Ordinary render requests made by preparation join that
|
|
55
|
+
frame without a request-only follow-up paint. Registrations made while draining run
|
|
56
|
+
in a later frame; their generations cannot commit before that preparation executes,
|
|
57
|
+
even when a newer ordinary request commits first or terminal output is queued.
|
|
58
|
+
|
|
59
|
+
Cancellation removes callback references, including entries already captured but
|
|
60
|
+
not yet invoked. Retained cancellation handles release their reference to the TUI
|
|
61
|
+
and callback on cancellation, invocation (including failure), or lifecycle
|
|
62
|
+
invalidation. Later cancellation calls are no-ops; an executing callback remains
|
|
63
|
+
live until its synchronous invocation returns. Preparation exceptions are logged, unrelated callbacks continue,
|
|
64
|
+
and the failed preparation's generation is not reported as successfully committed.
|
|
65
|
+
Throwing or lifecycle-invalidated preparations immediately resolve their existing
|
|
66
|
+
commit waiters `false`; late waiters for those generations also resolve `false`,
|
|
67
|
+
even after a newer successful frame. Retired generations are stored as merged,
|
|
68
|
+
sorted failure ranges, separate from live pending-frame exclusions. Contiguous
|
|
69
|
+
failures compact into one range; separated failures retain distinct ranges to
|
|
70
|
+
preserve exact outcomes. Historical failure ranges are not copied or scanned on
|
|
71
|
+
each frame (waiter lookup uses binary search).
|
|
72
|
+
History has no arbitrary expiry: its numeric storage is O(disjoint failed ranges),
|
|
73
|
+
not bounded over an indefinitely alternating failure/success session. Empty active
|
|
74
|
+
pending and hole sets do not imply bounded historical storage or prove leak freedom.
|
|
75
|
+
Callbacks must be synchronous (do not pass async functions).
|
|
76
|
+
|
|
77
|
+
The cancellation lifetime regression inspects Bun's heap snapshot for outgoing
|
|
78
|
+
local closure/entry paths to the owner, callback and payload, while all targets
|
|
79
|
+
remain deliberately rooted. Pending handles and an intentionally retaining arrow
|
|
80
|
+
are positive controls; retired handles must lose those paths. This checks reference
|
|
81
|
+
release without assuming when GC collects an object. It does not prove global leak
|
|
82
|
+
freedom or exclude unrelated module, runtime or test-runner roots.
|
|
83
|
+
|
|
84
|
+
`tui.setRenderPreparationLifecycleCallbacks(callbacks: { invalidate: () => void;
|
|
85
|
+
beforeStart: () => void } | undefined): void` installs a **single** preparation owner.
|
|
86
|
+
Replacing or clearing the owner invalidates its old queued and captured work and
|
|
87
|
+
calls its `invalidate` synchronously. Stop, terminal loss, and disposal do the same;
|
|
88
|
+
exceptions are logged without preventing cancellation. Disposal also clears the
|
|
89
|
+
owner. Enqueue while stopped, unavailable, invalidating, or disposed retains no work.
|
|
90
|
+
Disposal cancels pending render/width-settle timers and render requests; later normal,
|
|
91
|
+
forced, input-priority, and resize requests cannot rearm the disposed renderer.
|
|
92
|
+
|
|
93
|
+
After successful terminal setup, each `start()` calls `beforeStart` synchronously
|
|
94
|
+
before its first forced render request. The owner can enqueue fresh preparation
|
|
95
|
+
from its current authoritative state there, so restarting does not require another
|
|
96
|
+
provider event. Failed setup does not rearm work; a throwing `beforeStart` is logged
|
|
97
|
+
and its queued work is invalidated. Stop/start during a drain cannot revive that
|
|
98
|
+
drain's old snapshot. This API is only a presentation-preparation lifecycle seam,
|
|
99
|
+
not a general lifecycle event bus.
|
|
100
|
+
|
|
47
101
|
```typescript
|
|
48
102
|
const tui = new TUI(terminal);
|
|
49
103
|
tui.addChild(component);
|
|
@@ -23,6 +23,7 @@ export interface SettingsListTheme {
|
|
|
23
23
|
export declare class SettingsList implements Component {
|
|
24
24
|
#private;
|
|
25
25
|
constructor(items: SettingItem[], maxVisible: number, theme: SettingsListTheme, onChange: (id: string, newValue: string) => void, onCancel: () => void, onSelectionChange?: (item: SettingItem | undefined) => void, descriptionRows?: number);
|
|
26
|
+
get navigationLocked(): boolean;
|
|
26
27
|
/** Update an item's currentValue */
|
|
27
28
|
updateValue(id: string, newValue: string): void;
|
|
28
29
|
/**
|
package/dist/types/tui.d.ts
CHANGED
|
@@ -373,6 +373,11 @@ export declare class TUI extends Container {
|
|
|
373
373
|
onDebug?: () => void;
|
|
374
374
|
static resetRenderCountersForTest(): void;
|
|
375
375
|
static getRenderCountersForTest(): TuiRenderCounterSnapshot;
|
|
376
|
+
getRenderPreparationStateForTest(): {
|
|
377
|
+
pending: number;
|
|
378
|
+
holes: number;
|
|
379
|
+
failedRanges: number;
|
|
380
|
+
};
|
|
376
381
|
overlayStack: {
|
|
377
382
|
component: Component;
|
|
378
383
|
options?: OverlayOptions;
|
|
@@ -495,6 +500,13 @@ export declare class TUI extends Container {
|
|
|
495
500
|
*/
|
|
496
501
|
requestResizeRender(): void;
|
|
497
502
|
requestRender(force?: boolean, source?: string): void;
|
|
503
|
+
/** Queue one synchronous preparation on the existing frame clock. Cancellation is idempotent. */
|
|
504
|
+
enqueueBeforeRender(callback: () => void): () => void;
|
|
505
|
+
/** One owner only: replacing or clearing it invalidates all old preparation work. */
|
|
506
|
+
setRenderPreparationLifecycleCallbacks(callbacks: {
|
|
507
|
+
invalidate: () => void;
|
|
508
|
+
beforeStart: () => void;
|
|
509
|
+
} | undefined): void;
|
|
498
510
|
/** Request a frame whose mutation is known to be outside the viewport-anchor subtree. */
|
|
499
511
|
requestLayoutRender(source?: string): void;
|
|
500
512
|
requestRenderWithGeneration(force?: boolean, source?: string): number;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@gajae-code/tui",
|
|
4
|
-
"version": "0.16.
|
|
4
|
+
"version": "0.16.4",
|
|
5
5
|
"description": "Terminal User Interface library with differential rendering for efficient text-based applications",
|
|
6
6
|
"homepage": "https://gajae-code.com",
|
|
7
7
|
"author": "Yeachan-Heo and Gajae Code Contributors",
|
|
@@ -36,8 +36,8 @@
|
|
|
36
36
|
"fmt": "biome format --write ."
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@gajae-code/natives": "0.16.
|
|
40
|
-
"@gajae-code/utils": "0.16.
|
|
39
|
+
"@gajae-code/natives": "0.16.4",
|
|
40
|
+
"@gajae-code/utils": "0.16.4",
|
|
41
41
|
"lru-cache": "11.3.6",
|
|
42
42
|
"marked": "18.0.6"
|
|
43
43
|
},
|
|
@@ -62,6 +62,11 @@ export class SettingsList implements Component {
|
|
|
62
62
|
this.#notifySelectionChange();
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
get navigationLocked(): boolean {
|
|
66
|
+
const submenu = this.#submenuComponent as (Component & { navigationLocked?: boolean }) | null;
|
|
67
|
+
return submenu?.navigationLocked === true;
|
|
68
|
+
}
|
|
69
|
+
|
|
65
70
|
#clampSelectedIndex(): void {
|
|
66
71
|
if (this.#items.length === 0) {
|
|
67
72
|
this.#selectedIndex = 0;
|
package/src/tui.ts
CHANGED
|
@@ -960,6 +960,20 @@ function hasDistinctPostContractionRows(
|
|
|
960
960
|
return false;
|
|
961
961
|
}
|
|
962
962
|
|
|
963
|
+
interface RenderPreparation {
|
|
964
|
+
callback?: () => void;
|
|
965
|
+
generation: number;
|
|
966
|
+
cancel?: () => void;
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
// Keep the public handle outside the enqueue scope: it must capture only the entry,
|
|
970
|
+
// not the TUI or the original callback after the entry has been released.
|
|
971
|
+
function createPreparationCancellation(entry: RenderPreparation): () => void {
|
|
972
|
+
return () => entry.cancel?.();
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
function cancelNoPreparation(): void {}
|
|
976
|
+
|
|
963
977
|
/**
|
|
964
978
|
* TUI - Main class for managing terminal UI with differential rendering
|
|
965
979
|
*/
|
|
@@ -1009,6 +1023,21 @@ export class TUI extends Container {
|
|
|
1009
1023
|
#nextRenderGeneration = 0;
|
|
1010
1024
|
#renderRequestedGeneration = 0;
|
|
1011
1025
|
#committedRenderGeneration = 0;
|
|
1026
|
+
#preparations = new Set<RenderPreparation>();
|
|
1027
|
+
#preparationSnapshot: Set<RenderPreparation> | undefined;
|
|
1028
|
+
#preparationEpoch = 0;
|
|
1029
|
+
#preparationDrainEpoch = -1;
|
|
1030
|
+
#preparationDisposed = false;
|
|
1031
|
+
#preparationInvalidating = false;
|
|
1032
|
+
#preparationLifecycle: { invalidate: () => void; beforeStart: () => void } | undefined;
|
|
1033
|
+
// A newer ordinary request may commit while an older nested preparation is still pending.
|
|
1034
|
+
// Keep holes in the high-water mark, including for waiters registered after that commit.
|
|
1035
|
+
#preparationBlocked = new Set<number>();
|
|
1036
|
+
#commitHoles = new Set<number>();
|
|
1037
|
+
// Terminal failures are sorted, disjoint inclusive ranges, not per-frame exclusions.
|
|
1038
|
+
#failedPreparationRanges: Array<[number, number]> = [];
|
|
1039
|
+
// Captured by queued terminal writes; never consult the mutable queue at write settlement.
|
|
1040
|
+
#frameCommitExclusions = new Set<number>();
|
|
1012
1041
|
#renderCommitWaiters = new Map<number, Set<RenderCommitWaiter>>();
|
|
1013
1042
|
#lastRenderWriteSucceeded = false;
|
|
1014
1043
|
/** Generation whose render path is currently capturing terminal output. */
|
|
@@ -1161,6 +1190,14 @@ export class TUI extends Container {
|
|
|
1161
1190
|
return { ...TUI.#renderCounters };
|
|
1162
1191
|
}
|
|
1163
1192
|
|
|
1193
|
+
getRenderPreparationStateForTest(): { pending: number; holes: number; failedRanges: number } {
|
|
1194
|
+
return {
|
|
1195
|
+
pending: this.#preparationBlocked.size,
|
|
1196
|
+
holes: this.#commitHoles.size,
|
|
1197
|
+
failedRanges: this.#failedPreparationRanges.length,
|
|
1198
|
+
};
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1164
1201
|
static #readDebugRedrawFlag(): boolean {
|
|
1165
1202
|
TUI.#renderCounters.debugRedrawEnvReads += 1;
|
|
1166
1203
|
return $pickflag("GJC_DEBUG_REDRAW", "PI_DEBUG_REDRAW");
|
|
@@ -1249,6 +1286,29 @@ export class TUI extends Container {
|
|
|
1249
1286
|
}
|
|
1250
1287
|
|
|
1251
1288
|
override dispose(): void {
|
|
1289
|
+
if (this.#preparationDisposed) return;
|
|
1290
|
+
this.#preparationDisposed = true;
|
|
1291
|
+
this.#renderRequested = false;
|
|
1292
|
+
this.#renderRequestedGeneration = 0;
|
|
1293
|
+
this.#inputRenderPending = false;
|
|
1294
|
+
this.#resizeRenderQueued = false;
|
|
1295
|
+
this.#resizeRenderMutationQueued = false;
|
|
1296
|
+
this.#renderMutationQueued = false;
|
|
1297
|
+
this.#widthSettleRenderQueued = false;
|
|
1298
|
+
this.#forcedRenderQueued = false;
|
|
1299
|
+
this.#clearSixelProbeState();
|
|
1300
|
+
if (this.#renderTimer) {
|
|
1301
|
+
clearTimeout(this.#renderTimer);
|
|
1302
|
+
this.#renderTimer = undefined;
|
|
1303
|
+
if (renderMetrics.enabled) renderMetrics.setTimerGauge("tui.renderTimer", 0);
|
|
1304
|
+
}
|
|
1305
|
+
if (this.#widthSettleTimer) {
|
|
1306
|
+
clearTimeout(this.#widthSettleTimer);
|
|
1307
|
+
this.#widthSettleTimer = undefined;
|
|
1308
|
+
}
|
|
1309
|
+
this.#invalidatePreparations();
|
|
1310
|
+
this.#preparationLifecycle = undefined;
|
|
1311
|
+
this.#settleRenderCommitWaiters(false);
|
|
1252
1312
|
this.#unsubscribeTabWidthChange?.();
|
|
1253
1313
|
this.#unsubscribeTabWidthChange = undefined;
|
|
1254
1314
|
this.#finalizeRasterLeases("terminal-loss");
|
|
@@ -2314,6 +2374,7 @@ export class TUI extends Container {
|
|
|
2314
2374
|
return true;
|
|
2315
2375
|
}
|
|
2316
2376
|
start(): void {
|
|
2377
|
+
if (this.#preparationDisposed) return;
|
|
2317
2378
|
this.#stopped = false;
|
|
2318
2379
|
this.#terminalUnavailable = false;
|
|
2319
2380
|
this.#clearMouseSelection();
|
|
@@ -2321,31 +2382,36 @@ export class TUI extends Container {
|
|
|
2321
2382
|
// Seed the observed width so a spurious post-start resize event (iTerm2 tab
|
|
2322
2383
|
// activation, the self-sent SIGWINCH after resume) is not read as a reflow.
|
|
2323
2384
|
this.#lastObservedWidth = this.terminal.columns;
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
2385
|
+
try {
|
|
2386
|
+
this.terminal.setMouseEnabled?.(this.options.enableMouse === true);
|
|
2387
|
+
this.terminal.start(
|
|
2388
|
+
data => this.#handleInput(data),
|
|
2389
|
+
() => {
|
|
2390
|
+
const hadRasterLease = this.#rasterLeases.size > 0;
|
|
2391
|
+
this.#revokeRasterLeases("resize");
|
|
2392
|
+
// Only a pet raster lease needs refreshed cell metrics on resize; a
|
|
2393
|
+
// plain resize keeps the historical byte stream (no cell query).
|
|
2394
|
+
if (hadRasterLease) this.#queryCellSize(true);
|
|
2395
|
+
this.invalidate();
|
|
2396
|
+
if (this.#pendingTerminalCleanup.length > 0 || this.#rasterCleanup.size > 0) {
|
|
2397
|
+
// Retained pet/cleanup output must be flushed before the resize
|
|
2398
|
+
// repaint so it cannot interleave behind the new frame.
|
|
2399
|
+
this.notifyTerminalLifecycle({
|
|
2400
|
+
kind: "explicit-cleanup",
|
|
2401
|
+
source: "tui",
|
|
2402
|
+
terminalGeneration: this.#terminalGeneration,
|
|
2403
|
+
}).then(result => {
|
|
2404
|
+
if (result.stillPending === 0) this.requestResizeRender();
|
|
2405
|
+
});
|
|
2406
|
+
} else {
|
|
2407
|
+
this.requestResizeRender();
|
|
2408
|
+
}
|
|
2409
|
+
},
|
|
2410
|
+
);
|
|
2411
|
+
} catch (error) {
|
|
2412
|
+
this.#markTerminalUnavailable();
|
|
2413
|
+
throw error;
|
|
2414
|
+
}
|
|
2349
2415
|
if (this.#pendingTerminalCleanup.length > 0 || this.#rasterCleanup.size > 0) {
|
|
2350
2416
|
void this.notifyTerminalLifecycle({
|
|
2351
2417
|
kind: "availability-restored",
|
|
@@ -2356,6 +2422,14 @@ export class TUI extends Container {
|
|
|
2356
2422
|
this.#hideCursor();
|
|
2357
2423
|
this.#querySixelSupport();
|
|
2358
2424
|
this.#queryCellSize();
|
|
2425
|
+
if (this.#stopped || !this.terminalAvailable) return;
|
|
2426
|
+
const epoch = this.#preparationEpoch;
|
|
2427
|
+
if (!this.#callPreparation(this.#preparationLifecycle?.beforeStart, "beforeStart")) {
|
|
2428
|
+
this.#invalidatePreparations();
|
|
2429
|
+
if (!this.#stopped && this.terminalAvailable && !this.#preparationDisposed) this.requestRender(true);
|
|
2430
|
+
return;
|
|
2431
|
+
}
|
|
2432
|
+
if (epoch !== this.#preparationEpoch || this.#stopped || !this.terminalAvailable) return;
|
|
2359
2433
|
this.requestRender(true);
|
|
2360
2434
|
}
|
|
2361
2435
|
|
|
@@ -2368,8 +2442,10 @@ export class TUI extends Container {
|
|
|
2368
2442
|
* holding a session operation behind a dead renderer.
|
|
2369
2443
|
*/
|
|
2370
2444
|
waitForRenderCommit(generation: number, timeoutMs = 250): Promise<boolean> {
|
|
2371
|
-
if (
|
|
2372
|
-
if (this.#
|
|
2445
|
+
if (this.#isFailedPreparation(generation)) return Promise.resolve(false);
|
|
2446
|
+
if (generation <= 0 || (generation <= this.#committedRenderGeneration && !this.#commitHoles.has(generation)))
|
|
2447
|
+
return Promise.resolve(true);
|
|
2448
|
+
if (this.#stopped || !this.terminalAvailable || this.#preparationDisposed) return Promise.resolve(false);
|
|
2373
2449
|
return new Promise<boolean>(resolve => {
|
|
2374
2450
|
const waiter: RenderCommitWaiter = {
|
|
2375
2451
|
resolve,
|
|
@@ -2392,14 +2468,31 @@ export class TUI extends Container {
|
|
|
2392
2468
|
});
|
|
2393
2469
|
}
|
|
2394
2470
|
|
|
2395
|
-
#settleRenderCommitWaiters(
|
|
2396
|
-
|
|
2471
|
+
#settleRenderCommitWaiters(
|
|
2472
|
+
committed: boolean,
|
|
2473
|
+
generation = Number.POSITIVE_INFINITY,
|
|
2474
|
+
exclusions = this.#frameCommitExclusions,
|
|
2475
|
+
): void {
|
|
2476
|
+
if (committed) {
|
|
2477
|
+
for (const blocked of exclusions) {
|
|
2478
|
+
if (
|
|
2479
|
+
blocked <= generation &&
|
|
2480
|
+
blocked > this.#committedRenderGeneration &&
|
|
2481
|
+
!this.#isFailedPreparation(blocked)
|
|
2482
|
+
)
|
|
2483
|
+
this.#commitHoles.add(blocked);
|
|
2484
|
+
}
|
|
2485
|
+
for (const hole of this.#commitHoles) {
|
|
2486
|
+
if (hole <= generation && !exclusions.has(hole)) this.#commitHoles.delete(hole);
|
|
2487
|
+
}
|
|
2488
|
+
this.#committedRenderGeneration = Math.max(this.#committedRenderGeneration, generation);
|
|
2489
|
+
}
|
|
2397
2490
|
for (const [waiterGeneration, waiters] of this.#renderCommitWaiters) {
|
|
2398
|
-
if (committed && waiterGeneration > generation) continue;
|
|
2491
|
+
if (committed && (waiterGeneration > generation || exclusions.has(waiterGeneration))) continue;
|
|
2399
2492
|
this.#renderCommitWaiters.delete(waiterGeneration);
|
|
2400
2493
|
for (const waiter of waiters) {
|
|
2401
2494
|
clearTimeout(waiter.timer);
|
|
2402
|
-
waiter.resolve(committed);
|
|
2495
|
+
waiter.resolve(committed && !this.#isFailedPreparation(waiterGeneration));
|
|
2403
2496
|
}
|
|
2404
2497
|
}
|
|
2405
2498
|
}
|
|
@@ -2440,6 +2533,7 @@ export class TUI extends Container {
|
|
|
2440
2533
|
this.#rasterLeases.clear();
|
|
2441
2534
|
}
|
|
2442
2535
|
#markTerminalUnavailable(settleRenderWaiters = true): void {
|
|
2536
|
+
this.#invalidatePreparations();
|
|
2443
2537
|
this.#terminalGeneration++;
|
|
2444
2538
|
for (const record of this.#rasterCleanup.values()) record.terminalGeneration = this.#terminalGeneration;
|
|
2445
2539
|
this.#revokeRasterLeases("terminal-loss");
|
|
@@ -2676,6 +2770,7 @@ export class TUI extends Container {
|
|
|
2676
2770
|
}
|
|
2677
2771
|
|
|
2678
2772
|
stop(): void {
|
|
2773
|
+
this.#invalidatePreparations();
|
|
2679
2774
|
// Invalidate every raster-queue body captured under the running epoch
|
|
2680
2775
|
// before any teardown: nothing queued before stop may write after
|
|
2681
2776
|
// restoration. Synchronous stop cleanup below writes directly.
|
|
@@ -2785,6 +2880,7 @@ export class TUI extends Container {
|
|
|
2785
2880
|
* the last committed frame.
|
|
2786
2881
|
*/
|
|
2787
2882
|
requestResizeRender(): void {
|
|
2883
|
+
if (this.#preparationDisposed) return;
|
|
2788
2884
|
// Width is tracked against the last OBSERVED terminal width, not against
|
|
2789
2885
|
// #previousWidth (the last committed frame). Those diverge whenever resize
|
|
2790
2886
|
// events coalesce inside one frame budget: a 100->90->100 burst would leave
|
|
@@ -2844,8 +2940,180 @@ export class TUI extends Container {
|
|
|
2844
2940
|
this.requestRenderWithGeneration(force, source);
|
|
2845
2941
|
}
|
|
2846
2942
|
|
|
2943
|
+
/** Queue one synchronous preparation on the existing frame clock. Cancellation is idempotent. */
|
|
2944
|
+
enqueueBeforeRender(callback: () => void): () => void {
|
|
2945
|
+
if (this.#preparationDisposed || this.#preparationInvalidating || this.#stopped || !this.terminalAvailable)
|
|
2946
|
+
return cancelNoPreparation;
|
|
2947
|
+
const entry: RenderPreparation = { callback, generation: this.#nextRenderGeneration + 1 };
|
|
2948
|
+
entry.cancel = () => {
|
|
2949
|
+
entry.callback = undefined;
|
|
2950
|
+
entry.cancel = undefined;
|
|
2951
|
+
this.#preparations.delete(entry);
|
|
2952
|
+
this.#preparationSnapshot?.delete(entry);
|
|
2953
|
+
this.#preparationBlocked.delete(entry.generation);
|
|
2954
|
+
};
|
|
2955
|
+
this.#preparations.add(entry);
|
|
2956
|
+
this.#preparationBlocked.add(entry.generation);
|
|
2957
|
+
this.requestRenderWithGeneration(false, "preparation");
|
|
2958
|
+
return createPreparationCancellation(entry);
|
|
2959
|
+
}
|
|
2960
|
+
|
|
2961
|
+
/** One owner only: replacing or clearing it invalidates all old preparation work. */
|
|
2962
|
+
setRenderPreparationLifecycleCallbacks(
|
|
2963
|
+
callbacks: { invalidate: () => void; beforeStart: () => void } | undefined,
|
|
2964
|
+
): void {
|
|
2965
|
+
if (this.#preparationLifecycle === callbacks) return;
|
|
2966
|
+
this.#invalidatePreparations();
|
|
2967
|
+
this.#preparationLifecycle = this.#preparationDisposed ? undefined : callbacks;
|
|
2968
|
+
}
|
|
2969
|
+
|
|
2970
|
+
#callPreparation(callback: (() => void) | undefined, where: string): boolean {
|
|
2971
|
+
try {
|
|
2972
|
+
callback?.();
|
|
2973
|
+
return true;
|
|
2974
|
+
} catch (error) {
|
|
2975
|
+
logger.error("Render preparation failed", {
|
|
2976
|
+
where,
|
|
2977
|
+
error: error instanceof Error ? error.message : String(error),
|
|
2978
|
+
stack: error instanceof Error ? error.stack : undefined,
|
|
2979
|
+
});
|
|
2980
|
+
return false;
|
|
2981
|
+
}
|
|
2982
|
+
}
|
|
2983
|
+
|
|
2984
|
+
#invalidatePreparations(): void {
|
|
2985
|
+
this.#preparationEpoch++;
|
|
2986
|
+
for (const generation of this.#preparationBlocked) this.#retirePreparation(generation);
|
|
2987
|
+
for (const entry of this.#preparations) {
|
|
2988
|
+
entry.callback = undefined;
|
|
2989
|
+
entry.cancel = undefined;
|
|
2990
|
+
}
|
|
2991
|
+
for (const entry of this.#preparationSnapshot ?? []) {
|
|
2992
|
+
entry.callback = undefined;
|
|
2993
|
+
entry.cancel = undefined;
|
|
2994
|
+
}
|
|
2995
|
+
this.#preparations.clear();
|
|
2996
|
+
this.#preparationSnapshot?.clear();
|
|
2997
|
+
if (this.#preparationInvalidating) return;
|
|
2998
|
+
this.#preparationInvalidating = true;
|
|
2999
|
+
try {
|
|
3000
|
+
this.#callPreparation(this.#preparationLifecycle?.invalidate, "invalidate");
|
|
3001
|
+
} finally {
|
|
3002
|
+
this.#preparationInvalidating = false;
|
|
3003
|
+
}
|
|
3004
|
+
}
|
|
3005
|
+
|
|
3006
|
+
#isFailedPreparation(generation: number): boolean {
|
|
3007
|
+
let low = 0;
|
|
3008
|
+
let high = this.#failedPreparationRanges.length;
|
|
3009
|
+
while (low < high) {
|
|
3010
|
+
const middle = (low + high) >>> 1;
|
|
3011
|
+
const [start, end] = this.#failedPreparationRanges[middle];
|
|
3012
|
+
if (generation < start) high = middle;
|
|
3013
|
+
else if (generation > end) low = middle + 1;
|
|
3014
|
+
else return true;
|
|
3015
|
+
}
|
|
3016
|
+
return false;
|
|
3017
|
+
}
|
|
3018
|
+
|
|
3019
|
+
#retirePreparation(generation: number): void {
|
|
3020
|
+
this.#preparationBlocked.delete(generation);
|
|
3021
|
+
this.#commitHoles.delete(generation);
|
|
3022
|
+
const ranges = this.#failedPreparationRanges;
|
|
3023
|
+
let low = 0;
|
|
3024
|
+
let high = ranges.length;
|
|
3025
|
+
while (low < high) {
|
|
3026
|
+
const middle = (low + high) >>> 1;
|
|
3027
|
+
if (ranges[middle][1] < generation - 1) low = middle + 1;
|
|
3028
|
+
else high = middle;
|
|
3029
|
+
}
|
|
3030
|
+
let start = generation;
|
|
3031
|
+
let end = generation;
|
|
3032
|
+
let next = low;
|
|
3033
|
+
while (next < ranges.length && ranges[next][0] <= end + 1) {
|
|
3034
|
+
start = Math.min(start, ranges[next][0]);
|
|
3035
|
+
end = Math.max(end, ranges[next][1]);
|
|
3036
|
+
next++;
|
|
3037
|
+
}
|
|
3038
|
+
ranges.splice(low, next - low, [start, end]);
|
|
3039
|
+
const waiters = this.#renderCommitWaiters.get(generation);
|
|
3040
|
+
this.#renderCommitWaiters.delete(generation);
|
|
3041
|
+
for (const waiter of waiters ?? []) {
|
|
3042
|
+
clearTimeout(waiter.timer);
|
|
3043
|
+
waiter.resolve(false);
|
|
3044
|
+
}
|
|
3045
|
+
}
|
|
3046
|
+
|
|
3047
|
+
/** The only preparation boundary, before generation capture and all renderer reads. */
|
|
3048
|
+
#renderPreparedFrame(): void {
|
|
3049
|
+
if (this.#stopped || this.#preparationDisposed) return;
|
|
3050
|
+
if (!this.terminalAvailable) {
|
|
3051
|
+
this.#markTerminalUnavailable();
|
|
3052
|
+
return;
|
|
3053
|
+
}
|
|
3054
|
+
const epoch = this.#preparationEpoch;
|
|
3055
|
+
const snapshot = this.#preparations;
|
|
3056
|
+
this.#preparations = new Set();
|
|
3057
|
+
this.#preparationSnapshot = snapshot;
|
|
3058
|
+
this.#preparationDrainEpoch = epoch;
|
|
3059
|
+
try {
|
|
3060
|
+
for (const entry of snapshot) {
|
|
3061
|
+
if (epoch !== this.#preparationEpoch || this.#stopped || !this.terminalAvailable) break;
|
|
3062
|
+
const callback = entry.callback;
|
|
3063
|
+
entry.callback = undefined;
|
|
3064
|
+
entry.cancel = undefined;
|
|
3065
|
+
if (
|
|
3066
|
+
callback &&
|
|
3067
|
+
this.#callPreparation(callback, "beforeRender") &&
|
|
3068
|
+
epoch === this.#preparationEpoch &&
|
|
3069
|
+
!this.#stopped &&
|
|
3070
|
+
this.terminalAvailable
|
|
3071
|
+
) {
|
|
3072
|
+
this.#preparationBlocked.delete(entry.generation);
|
|
3073
|
+
} else if (callback) {
|
|
3074
|
+
this.#retirePreparation(entry.generation);
|
|
3075
|
+
}
|
|
3076
|
+
}
|
|
3077
|
+
} finally {
|
|
3078
|
+
for (const entry of snapshot) {
|
|
3079
|
+
entry.callback = undefined;
|
|
3080
|
+
entry.cancel = undefined;
|
|
3081
|
+
}
|
|
3082
|
+
snapshot.clear();
|
|
3083
|
+
this.#preparationSnapshot = undefined;
|
|
3084
|
+
}
|
|
3085
|
+
if (!this.terminalAvailable && !this.#stopped) this.#markTerminalUnavailable();
|
|
3086
|
+
if (epoch !== this.#preparationEpoch || this.#stopped || !this.terminalAvailable || this.#preparationDisposed) {
|
|
3087
|
+
if (!this.#stopped && this.terminalAvailable && !this.#preparationDisposed) this.#scheduleRender();
|
|
3088
|
+
return;
|
|
3089
|
+
}
|
|
3090
|
+
const requestedGeneration = this.#renderRequestedGeneration;
|
|
3091
|
+
this.#renderRequestedGeneration = 0;
|
|
3092
|
+
this.#renderRequested = false;
|
|
3093
|
+
this.#lastRenderAt = performance.now();
|
|
3094
|
+
this.#lastRenderWriteSucceeded = false;
|
|
3095
|
+
this.#frameCommitExclusions = new Set(this.#preparationBlocked);
|
|
3096
|
+
const t0 = renderMetrics.now();
|
|
3097
|
+
try {
|
|
3098
|
+
this.#renderGenerationInProgress = requestedGeneration;
|
|
3099
|
+
this.#doRender();
|
|
3100
|
+
this.#commitRenderGeneration(requestedGeneration);
|
|
3101
|
+
} finally {
|
|
3102
|
+
this.#renderGenerationInProgress = 0;
|
|
3103
|
+
this.#frameCommitExclusions = new Set();
|
|
3104
|
+
if (renderMetrics.enabled) renderMetrics.recordRender(renderMetrics.now() - t0);
|
|
3105
|
+
if (this.#preparations.size > 0 && epoch === this.#preparationEpoch) {
|
|
3106
|
+
for (const entry of this.#preparations)
|
|
3107
|
+
this.#renderRequestedGeneration = Math.max(this.#renderRequestedGeneration, entry.generation);
|
|
3108
|
+
this.#renderRequested = true;
|
|
3109
|
+
this.#scheduleRender();
|
|
3110
|
+
}
|
|
3111
|
+
}
|
|
3112
|
+
}
|
|
3113
|
+
|
|
2847
3114
|
#requestRenderWithScope(force: boolean, source: string, scope: "full" | "layout"): number {
|
|
2848
3115
|
const generation = ++this.#nextRenderGeneration;
|
|
3116
|
+
if (this.#preparationDisposed) return generation;
|
|
2849
3117
|
this.#renderRequestedGeneration = Math.max(this.#renderRequestedGeneration, generation);
|
|
2850
3118
|
if (scope === "full") this.#renderScope = "full";
|
|
2851
3119
|
this.#requestRenderCore(force, source, generation);
|
|
@@ -2863,6 +3131,7 @@ export class TUI extends Container {
|
|
|
2863
3131
|
}
|
|
2864
3132
|
|
|
2865
3133
|
#requestRenderCore(force: boolean, source: string, generation: number): void {
|
|
3134
|
+
if (this.#preparationDisposed) return;
|
|
2866
3135
|
if (!this.terminalAvailable) {
|
|
2867
3136
|
this.#markTerminalUnavailable();
|
|
2868
3137
|
return;
|
|
@@ -2905,6 +3174,7 @@ export class TUI extends Container {
|
|
|
2905
3174
|
this.#viewportTopRow = 0;
|
|
2906
3175
|
this.#maxLinesRendered = 0;
|
|
2907
3176
|
}
|
|
3177
|
+
if (this.#preparationSnapshot && this.#preparationDrainEpoch === this.#preparationEpoch) return;
|
|
2908
3178
|
if (this.#renderTimer) {
|
|
2909
3179
|
clearTimeout(this.#renderTimer);
|
|
2910
3180
|
this.#renderTimer = undefined;
|
|
@@ -2916,20 +3186,12 @@ export class TUI extends Container {
|
|
|
2916
3186
|
this.#settleRenderCommitWaiters(false, generation);
|
|
2917
3187
|
return;
|
|
2918
3188
|
}
|
|
2919
|
-
|
|
2920
|
-
this.#renderRequestedGeneration = 0;
|
|
2921
|
-
this.#renderRequested = false;
|
|
2922
|
-
this.#lastRenderAt = performance.now();
|
|
2923
|
-
this.#lastRenderWriteSucceeded = false;
|
|
2924
|
-
const t0 = renderMetrics.now();
|
|
2925
|
-
this.#renderGenerationInProgress = requestedGeneration;
|
|
2926
|
-
this.#doRender();
|
|
2927
|
-
this.#renderGenerationInProgress = 0;
|
|
2928
|
-
this.#commitRenderGeneration(requestedGeneration);
|
|
2929
|
-
if (renderMetrics.enabled) renderMetrics.recordRender(renderMetrics.now() - t0);
|
|
3189
|
+
this.#renderPreparedFrame();
|
|
2930
3190
|
});
|
|
2931
3191
|
return;
|
|
2932
3192
|
}
|
|
3193
|
+
// Preparation mutations join this frame; nested registrations get the next normal frame.
|
|
3194
|
+
if (this.#preparationSnapshot && this.#preparationDrainEpoch === this.#preparationEpoch) return;
|
|
2933
3195
|
// Input-priority path: expedite so the keystroke echoes within the next tick
|
|
2934
3196
|
// instead of waiting for (or behind) the frame-budget timer. Re-entrant input
|
|
2935
3197
|
// requests in the same turn coalesce via #inputRenderPending, so at most one
|
|
@@ -2950,7 +3212,7 @@ export class TUI extends Container {
|
|
|
2950
3212
|
}
|
|
2951
3213
|
|
|
2952
3214
|
#scheduleRender(): void {
|
|
2953
|
-
if (this.#stopped || this.#renderTimer || !this.#renderRequested) {
|
|
3215
|
+
if (this.#preparationDisposed || this.#stopped || this.#renderTimer || !this.#renderRequested) {
|
|
2954
3216
|
return;
|
|
2955
3217
|
}
|
|
2956
3218
|
const elapsed = performance.now() - this.#lastRenderAt;
|
|
@@ -2961,17 +3223,7 @@ export class TUI extends Container {
|
|
|
2961
3223
|
if (this.#stopped || !this.#renderRequested) {
|
|
2962
3224
|
return;
|
|
2963
3225
|
}
|
|
2964
|
-
|
|
2965
|
-
this.#renderRequestedGeneration = 0;
|
|
2966
|
-
this.#renderRequested = false;
|
|
2967
|
-
this.#lastRenderAt = performance.now();
|
|
2968
|
-
this.#lastRenderWriteSucceeded = false;
|
|
2969
|
-
const t0 = renderMetrics.now();
|
|
2970
|
-
this.#renderGenerationInProgress = requestedGeneration;
|
|
2971
|
-
this.#doRender();
|
|
2972
|
-
this.#renderGenerationInProgress = 0;
|
|
2973
|
-
this.#commitRenderGeneration(requestedGeneration);
|
|
2974
|
-
if (renderMetrics.enabled) renderMetrics.recordRender(renderMetrics.now() - t0);
|
|
3226
|
+
this.#renderPreparedFrame();
|
|
2975
3227
|
if (this.#renderRequested) {
|
|
2976
3228
|
this.#scheduleRender();
|
|
2977
3229
|
}
|
|
@@ -2993,17 +3245,7 @@ export class TUI extends Container {
|
|
|
2993
3245
|
this.#renderTimer = undefined;
|
|
2994
3246
|
if (renderMetrics.enabled) renderMetrics.setTimerGauge("tui.renderTimer", 0);
|
|
2995
3247
|
}
|
|
2996
|
-
|
|
2997
|
-
this.#renderRequestedGeneration = 0;
|
|
2998
|
-
this.#renderRequested = false;
|
|
2999
|
-
this.#lastRenderAt = performance.now();
|
|
3000
|
-
this.#lastRenderWriteSucceeded = false;
|
|
3001
|
-
const t0 = renderMetrics.now();
|
|
3002
|
-
this.#renderGenerationInProgress = requestedGeneration;
|
|
3003
|
-
this.#doRender();
|
|
3004
|
-
this.#renderGenerationInProgress = 0;
|
|
3005
|
-
this.#commitRenderGeneration(requestedGeneration);
|
|
3006
|
-
if (renderMetrics.enabled) renderMetrics.recordRender(renderMetrics.now() - t0);
|
|
3248
|
+
this.#renderPreparedFrame();
|
|
3007
3249
|
}
|
|
3008
3250
|
|
|
3009
3251
|
#handleInput(data: string): void {
|
|
@@ -5607,11 +5849,12 @@ export class TUI extends Container {
|
|
|
5607
5849
|
? (bytes: string) => this.#writeRasterPreservingRenderIngress(bytes)
|
|
5608
5850
|
: (bytes: string) => this.#writeProtectedRenderIngress(bytes);
|
|
5609
5851
|
const renderGeneration = this.#renderGenerationInProgress;
|
|
5852
|
+
const commitExclusions = this.#frameCommitExclusions;
|
|
5610
5853
|
const write = () => {
|
|
5611
5854
|
if (!writeIngress(buffer)) return false;
|
|
5612
5855
|
onBufferWritten?.();
|
|
5613
5856
|
this.#lastRenderWriteSucceeded = true;
|
|
5614
|
-
if (renderGeneration > 0) this.#settleRenderCommitWaiters(true, renderGeneration);
|
|
5857
|
+
if (renderGeneration > 0) this.#settleRenderCommitWaiters(true, renderGeneration, commitExclusions);
|
|
5615
5858
|
const emission = this.#postRenderEmitter?.();
|
|
5616
5859
|
if (emission) {
|
|
5617
5860
|
const overlay = typeof emission === "string" ? emission : emission.payload;
|