@cplieger/web-terminal-engine 3.5.0 → 3.7.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/README.md +2 -2
- package/jsr.json +1 -1
- package/package.json +3 -3
- package/src/connection.ts +647 -9
- package/src/index.ts +2 -1
- package/src/intervals.ts +81 -0
- package/src/render.ts +899 -48
- package/src/scroll.ts +229 -25
- package/src/store.ts +1443 -68
- package/src/types.ts +28 -0
- package/src/wire-binary.ts +9 -1
package/src/render.ts
CHANGED
|
@@ -16,9 +16,10 @@
|
|
|
16
16
|
// when real history commits — never oscillating mid-redraw.
|
|
17
17
|
|
|
18
18
|
import type { ScreenMessage, ScrollMessage, WireRun } from "./types.js";
|
|
19
|
-
import { LineStore } from "./store.js";
|
|
19
|
+
import { LineStore, PAGE_SIZE, PREFETCH_THRESHOLD } from "./store.js";
|
|
20
20
|
import * as scroll from "./scroll.js";
|
|
21
21
|
import { isReverseVideo } from "./modes.js";
|
|
22
|
+
import { interiorGaps } from "./intervals.js";
|
|
22
23
|
|
|
23
24
|
// --- Width cache (two-tier, xterm.js style) ---
|
|
24
25
|
const WIDTH_FLAT_SIZE = 256;
|
|
@@ -29,6 +30,12 @@ const widthMap = new Map<string, number>();
|
|
|
29
30
|
const VARIANT_REGULAR = 0;
|
|
30
31
|
const VARIANT_BOLD = 1;
|
|
31
32
|
const VARIANT_ITALIC = 2;
|
|
33
|
+
// widthMap holds one entry per unique (bold, italic, glyph) measured — bounded
|
|
34
|
+
// by the rendered repertoire in practice, but a long CJK/emoji-heavy session
|
|
35
|
+
// can accumulate tens of thousands of keys that survive eviction, reset, and
|
|
36
|
+
// tab close (only a font change clears it). Cap it: a clear on overflow costs
|
|
37
|
+
// an occasional re-measure and changes no rendered output.
|
|
38
|
+
const WIDTH_MAP_MAX = 20_000;
|
|
32
39
|
const variantCtx: (CanvasRenderingContext2D | null)[] = [null, null, null, null];
|
|
33
40
|
let fontString = "";
|
|
34
41
|
|
|
@@ -88,6 +95,9 @@ function measureChar(ch: string, bold: boolean, italic: boolean): number {
|
|
|
88
95
|
variant |= VARIANT_ITALIC;
|
|
89
96
|
}
|
|
90
97
|
const w = variantContext(variant).measureText(ch).width;
|
|
98
|
+
if (widthMap.size >= WIDTH_MAP_MAX) {
|
|
99
|
+
widthMap.clear();
|
|
100
|
+
}
|
|
91
101
|
widthMap.set(key, w);
|
|
92
102
|
return w;
|
|
93
103
|
}
|
|
@@ -124,6 +134,25 @@ const rowEls = new Map<number, HTMLDivElement>();
|
|
|
124
134
|
// as a module ref so it is reused rather than recreated each flush.
|
|
125
135
|
let trimMarkerEl: HTMLDivElement | null = null;
|
|
126
136
|
|
|
137
|
+
/**
|
|
138
|
+
* Per-gap "earlier output not loaded" markers, keyed by the gap's LOW index.
|
|
139
|
+
*
|
|
140
|
+
* Distinct from the singleton trim marker above in two ways that matter. Each
|
|
141
|
+
* one carries its gap's low index as `data-abs`, so `insertRowInOrder` and the
|
|
142
|
+
* read-anchor binary searches keep their monotonic-`data-abs` invariant with
|
|
143
|
+
* the marker in place — the trim marker can be a `data-abs`-less first child
|
|
144
|
+
* precisely because it only ever sits at the very top, and an INTERIOR marker
|
|
145
|
+
* cannot. And a marker here is a PROJECTION of the store's gap geometry: it is
|
|
146
|
+
* re-derived (value and position) whenever either edge of its gap moves, so a
|
|
147
|
+
* gap healing from its low edge carries its marker up with it, and it is
|
|
148
|
+
* removed when the gap closes (docs/paged-scrollback.md §5.4).
|
|
149
|
+
*/
|
|
150
|
+
const gapMarkerEls = new Map<number, HTMLDivElement>();
|
|
151
|
+
|
|
152
|
+
/** Transport seams for demand paging, wired by the consumer at init. */
|
|
153
|
+
let requestHistoryFn: ((fromAbs: number, maxLines: number) => boolean) | null = null;
|
|
154
|
+
let historyBudgetFn: (() => number) | null = null;
|
|
155
|
+
|
|
127
156
|
// Rows awaiting a DOM (re)build, processed in budgeted batches across frames.
|
|
128
157
|
// A session restore (kiro-cli's /chat), a post-resize transcript reprint, or a
|
|
129
158
|
// `cat bigfile` dumps thousands of lines in one wire frame; building them all
|
|
@@ -205,6 +234,23 @@ function termPadding(): { padL: number; padT: number } {
|
|
|
205
234
|
let flushDrainedThisPass = 0;
|
|
206
235
|
let renderNoProgressStreak = 0;
|
|
207
236
|
const MAX_RENDER_NO_PROGRESS_RETRIES = 3;
|
|
237
|
+
// Whether the CURRENT flush performed a full reset (server restart / epoch
|
|
238
|
+
// change). Read by restoreReadAnchor after the flush: a re-anchor across a
|
|
239
|
+
// reset would match unrelated content from the new index space (R2 review).
|
|
240
|
+
let fullResetThisPass = false;
|
|
241
|
+
// The ED3 base observed since the last flush, or -1. Set by handleScreen from
|
|
242
|
+
// the frame itself; consumed (and reset) by the pass that applies it. Renderer-
|
|
243
|
+
// local on purpose: the renderer is the caller of every path that discards a
|
|
244
|
+
// REGION rather than trimming the cap, so nothing in the store's change set has
|
|
245
|
+
// to grow a field for it (docs/scroll-position-fidelity.md §5).
|
|
246
|
+
let discardedBelowPending = -1;
|
|
247
|
+
let discardedBelowThisPass = -1;
|
|
248
|
+
// Whether THIS pass removed rows from the DOM. Only a pass that did can have
|
|
249
|
+
// caused a clamp, and announcing one that did not is unsound on Safari, which
|
|
250
|
+
// updates scrollTop PAST the maximum during an overscroll bounce: the settle back
|
|
251
|
+
// from a rubber-band is a downward move in value with no content change at all,
|
|
252
|
+
// and arming for it would hand the user's own gesture a pass-through.
|
|
253
|
+
let removedRowsThisPass = false;
|
|
208
254
|
|
|
209
255
|
/**
|
|
210
256
|
* Initialize the renderer by attaching it to a pair of DOM elements: the
|
|
@@ -214,23 +260,67 @@ const MAX_RENDER_NO_PROGRESS_RETRIES = 3;
|
|
|
214
260
|
* @param opts.output Inner element that holds row children.
|
|
215
261
|
* @param opts.termWrap Outer scroll container.
|
|
216
262
|
* @param opts.onCursorMove Optional callback invoked when the cursor moves.
|
|
263
|
+
* @param opts.maxLines Optional retained-line cap for the implicit store
|
|
264
|
+
* (default 5000) — a HISTORY budget floored at the
|
|
265
|
+
* live screen (the store never evicts the current
|
|
266
|
+
* window, so a cap at or below the terminal height
|
|
267
|
+
* keeps the full screen with no scrollback). Governs
|
|
268
|
+
* how many absolute-index lines — and therefore DOM
|
|
269
|
+
* rows — the client keeps; a memory-constrained
|
|
270
|
+
* consumer (a phone) passes a smaller budget. A
|
|
271
|
+
* consumer that manages stores itself (a tabbed
|
|
272
|
+
* shell) constructs each `LineStore` with its own cap
|
|
273
|
+
* and `bind()`s it AFTER init; init always installs a
|
|
274
|
+
* fresh implicit store, so call it before any bind.
|
|
275
|
+
* For the batched eviction to help, choose a cap
|
|
276
|
+
* comfortably above the terminal height (near or
|
|
277
|
+
* below it, eviction degrades to per-line — the
|
|
278
|
+
* screen stays correct, but the churn returns).
|
|
279
|
+
* Non-positive or non-integer values warn and are
|
|
280
|
+
* ignored (the default applies).
|
|
217
281
|
*/
|
|
218
282
|
export function init(opts: {
|
|
219
283
|
output: HTMLElement;
|
|
220
284
|
termWrap: HTMLElement;
|
|
221
285
|
onCursorMove?: () => void;
|
|
286
|
+
maxLines?: number;
|
|
287
|
+
/** Ask the transport for a page of history. Wired by the consumer to
|
|
288
|
+
* `connection.requestHistory`; absent means paging is not available and the
|
|
289
|
+
* controller stays dormant (docs/paged-scrollback.md §5.4). */
|
|
290
|
+
requestHistory?: (fromAbs: number, maxLines: number) => boolean;
|
|
291
|
+
/** The transport's current adaptive request budget
|
|
292
|
+
* (`connection.historyBudget`). Read at FIRE time, and used for both the
|
|
293
|
+
* request's length and its anchor: a shrunken length with a full-size anchor
|
|
294
|
+
* serves a range that ends far from the reader. */
|
|
295
|
+
historyBudget?: () => number;
|
|
222
296
|
}): void {
|
|
223
297
|
output = opts.output;
|
|
224
298
|
termWrap = opts.termWrap;
|
|
225
299
|
onCursorMove = opts.onCursorMove ?? null;
|
|
300
|
+
requestHistoryFn = opts.requestHistory ?? null;
|
|
301
|
+
historyBudgetFn = opts.historyBudget ?? null;
|
|
226
302
|
// New attach target: the cached padding belongs to the previous termWrap.
|
|
227
303
|
padValid = false;
|
|
228
|
-
// Fresh attach:
|
|
229
|
-
//
|
|
230
|
-
|
|
304
|
+
// Fresh attach: ALWAYS install a fresh implicit store, so no cap — a
|
|
305
|
+
// consumer-configured one, or a bound per-tab store's — leaks from a
|
|
306
|
+
// previous attachment into this one (init is the attachment boundary;
|
|
307
|
+
// vitest's non-isolated module reuse relies on the same reset).
|
|
308
|
+
const validCap =
|
|
309
|
+
opts.maxLines !== undefined && Number.isInteger(opts.maxLines) && opts.maxLines > 0;
|
|
310
|
+
if (opts.maxLines !== undefined && !validCap) {
|
|
311
|
+
console.warn(`vterm: ignoring invalid maxLines ${String(opts.maxLines)}`);
|
|
312
|
+
}
|
|
313
|
+
// An absent OR invalid cap is forwarded as absent, which is how the store
|
|
314
|
+
// spells "engine's choice"; a value here would be indistinguishable from a
|
|
315
|
+
// consumer decision and would pin the tail at the compatibility cap forever.
|
|
316
|
+
store = new LineStore(validCap ? opts.maxLines : undefined);
|
|
231
317
|
rowEls.clear();
|
|
232
318
|
renderQueue.clear();
|
|
233
319
|
trimMarkerEl = null;
|
|
320
|
+
for (const el of gapMarkerEls.values()) {
|
|
321
|
+
el.remove();
|
|
322
|
+
}
|
|
323
|
+
gapMarkerEls.clear();
|
|
234
324
|
// Drop the predicted-cursor + caret overlays (re-created lazily against the
|
|
235
325
|
// new termWrap) so re-init starts clean.
|
|
236
326
|
if (predCursorEl) {
|
|
@@ -249,6 +339,14 @@ export function init(opts: {
|
|
|
249
339
|
}
|
|
250
340
|
flushDrainedThisPass = 0;
|
|
251
341
|
renderNoProgressStreak = 0;
|
|
342
|
+
// Attachment boundary: a view restore armed against the PREVIOUS surface must
|
|
343
|
+
// not survive it, or its timer outlives teardown and writes scrollTop on a
|
|
344
|
+
// detached element. Same discipline scroll.init applies to its own arms.
|
|
345
|
+
bindGen++;
|
|
346
|
+
clearPendingRestore();
|
|
347
|
+
discardedBelowPending = -1;
|
|
348
|
+
discardedBelowThisPass = -1;
|
|
349
|
+
lastInboundMs = 0;
|
|
252
350
|
syncCursorBlink();
|
|
253
351
|
}
|
|
254
352
|
|
|
@@ -277,10 +375,68 @@ export function resetScrollback(): void {
|
|
|
277
375
|
* active tab's cached LineStore (design sections 5, 6, 8). The DOM is wiped and
|
|
278
376
|
* repainted viewport-first from the new store; this is local, so the last-known
|
|
279
377
|
* screen paints without a network round-trip.
|
|
378
|
+
*
|
|
379
|
+
* `opts.view` is the per-view scroll memory `captureViewMemory()` returned when
|
|
380
|
+
* the consumer last left this store, and passing it makes the swap ATOMIC. Both
|
|
381
|
+
* halves matter and they land at different times on purpose:
|
|
382
|
+
*
|
|
383
|
+
* - the FOLLOW half is adopted SYNCHRONOUSLY, before the wipe, so the very
|
|
384
|
+
* first flush's bottom pin is gated on the INCOMING view's state. The
|
|
385
|
+
* follow flag is global (one per kernel), so without this the first frame
|
|
386
|
+
* after a switch is gated on the state of the tab the user just LEFT —
|
|
387
|
+
* binding a following tab while holding in another rendered the cached
|
|
388
|
+
* screen above the viewport and left a black gap until a touch re-engaged
|
|
389
|
+
* follow. It is applied through restoreView at the CURRENT offset, which
|
|
390
|
+
* sets the flag without moving the position.
|
|
391
|
+
* - the POSITION half is armed and re-asserted across the rebuild's frames
|
|
392
|
+
* (see applyPendingRestore), because the row it names is usually not built
|
|
393
|
+
* yet. A view left FOLLOWING arms nothing: the per-flush bottom pin already
|
|
394
|
+
* is the correct answer, which keeps the common path free of any restore.
|
|
280
395
|
*/
|
|
281
|
-
export function bind(next: LineStore): void {
|
|
396
|
+
export function bind(next: LineStore, opts?: { view?: ViewMemory | null }): void {
|
|
397
|
+
const view = opts?.view ?? null;
|
|
398
|
+
// Passing `opts` at all is a statement that this caller owns the view. A NULL
|
|
399
|
+
// view is therefore not "no opinion" but "no memory" — a tab never visited, or
|
|
400
|
+
// one left on the alternate screen, where there is no absolute index worth
|
|
401
|
+
// remembering — and the right follow state for no memory is the tail. Without
|
|
402
|
+
// this, those tabs inherited the OUTGOING tab's follow flag, which is the
|
|
403
|
+
// stale-global-flag bug this seam exists to close, just narrowed to the tabs
|
|
404
|
+
// that have nothing saved. A caller that passes no `opts` keeps the pre-3.7
|
|
405
|
+
// behavior and its follow state is left alone.
|
|
406
|
+
if (opts !== undefined) {
|
|
407
|
+
scroll.restoreView({
|
|
408
|
+
top: scroll.currentScrollTop(),
|
|
409
|
+
following: view === null ? true : view.following,
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
// The OUTGOING store cannot have a request in flight: the socket that issued it
|
|
413
|
+
// is being switched away with it. Closing its solicited window here is what
|
|
414
|
+
// stops it stranding open, because the transport's own `clearSolicited` lands on
|
|
415
|
+
// whichever store is bound WHEN IT FIRES — and the order of `bind` against the
|
|
416
|
+
// consumer's `setSession`/reconnect teardown is the consumer's choice, which
|
|
417
|
+
// neither module constrains. Bound after the switch, that clear closes the
|
|
418
|
+
// INCOMING store's (empty) window and leaves the outgoing one permanently open:
|
|
419
|
+
// a standing exemption from apply-line guard 2 for that index range, with no
|
|
420
|
+
// socket and no timer left to close it, so any later frame in that range can
|
|
421
|
+
// resurrect an evicted row and be classified as browse cache. The sibling
|
|
422
|
+
// pendingRestore arm already had a generation guard for the same hazard; this
|
|
423
|
+
// one had none.
|
|
424
|
+
store.clearSolicited();
|
|
282
425
|
store = next;
|
|
426
|
+
// A new bind invalidates any restore armed for the previous one: cancel, then
|
|
427
|
+
// arm, one slot, so a second switch mid-drain cannot land the first tab's
|
|
428
|
+
// anchor into this store.
|
|
429
|
+
bindGen++;
|
|
430
|
+
clearPendingRestore();
|
|
283
431
|
rebuild();
|
|
432
|
+
if (view !== null && !view.following) {
|
|
433
|
+
pendingRestore = {
|
|
434
|
+
view,
|
|
435
|
+
gen: bindGen,
|
|
436
|
+
lastWrote: scroll.currentScrollTop(),
|
|
437
|
+
deadline: Date.now() + RESTORE_MAX_MS,
|
|
438
|
+
};
|
|
439
|
+
}
|
|
284
440
|
}
|
|
285
441
|
|
|
286
442
|
/**
|
|
@@ -331,10 +487,19 @@ function queueRowsViewportFirst(): void {
|
|
|
331
487
|
* force a full repaint of the current store.
|
|
332
488
|
*/
|
|
333
489
|
export function rebuild(): void {
|
|
490
|
+
// The wipe is the largest content shrink this module performs, and it happens
|
|
491
|
+
// OUTSIDE a flush (bind calls it synchronously), so the flush's own
|
|
492
|
+
// announcement cannot cover it. Unannounced, the clamp it causes falls back to
|
|
493
|
+
// the epsilon — exactly the inference §4 exists to stop relying on.
|
|
494
|
+
const scrollTopBeforeWipe = scroll.currentScrollTop();
|
|
334
495
|
output.replaceChildren();
|
|
335
496
|
rowEls.clear();
|
|
336
497
|
renderQueue.clear();
|
|
337
498
|
trimMarkerEl = null;
|
|
499
|
+
for (const el of gapMarkerEls.values()) {
|
|
500
|
+
el.remove();
|
|
501
|
+
}
|
|
502
|
+
gapMarkerEls.clear();
|
|
338
503
|
cursorAbs = -1;
|
|
339
504
|
altRendered = false;
|
|
340
505
|
altPrevRows = [];
|
|
@@ -346,6 +511,7 @@ export function rebuild(): void {
|
|
|
346
511
|
if (!store.isAlt()) {
|
|
347
512
|
queueRowsViewportFirst();
|
|
348
513
|
}
|
|
514
|
+
scroll.noteContentShrink(scrollTopBeforeWipe);
|
|
349
515
|
scheduleFlush();
|
|
350
516
|
}
|
|
351
517
|
|
|
@@ -370,6 +536,118 @@ export function noteResumeBounds(committed: number, oldest: number): void {
|
|
|
370
536
|
scheduleFlush();
|
|
371
537
|
}
|
|
372
538
|
|
|
539
|
+
// --- demand-paged scrollback: the consumer's seams (docs/paged-scrollback.md) ---
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Apply a correlated history page and, when the reply proved history is gone,
|
|
543
|
+
* raise the paging floor so nothing below it is requested again.
|
|
544
|
+
*
|
|
545
|
+
* Wired to `connection`'s `onHistoryReply`. The viewport index is supplied here
|
|
546
|
+
* rather than by the caller because the renderer is the only layer that knows
|
|
547
|
+
* it, and the store's eviction needs it to decide which end of the cache is
|
|
548
|
+
* safe to drop.
|
|
549
|
+
*/
|
|
550
|
+
export function handleHistoryReply(msg: ScrollMessage, raiseFloorTo: number | null): void {
|
|
551
|
+
if (raiseFloorTo !== null) {
|
|
552
|
+
store.raisePagingFloor(raiseFloorTo);
|
|
553
|
+
}
|
|
554
|
+
store.applyHistoryScroll(msg, viewportAbs());
|
|
555
|
+
scheduleFlush();
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* Run the store's single resume-ack transition, supplying the viewport the
|
|
560
|
+
* store cannot see. Wired to `connection`'s `onResumeTransition`.
|
|
561
|
+
*/
|
|
562
|
+
export function applyResumeTransition(ack: {
|
|
563
|
+
epochChanged: boolean;
|
|
564
|
+
committed: number | null;
|
|
565
|
+
serverOldest: number | null;
|
|
566
|
+
paging: boolean;
|
|
567
|
+
sentHaveThrough: number;
|
|
568
|
+
sentReplayMax: number | null;
|
|
569
|
+
}): void {
|
|
570
|
+
// A tab switch RECONNECTS (connection.setSession -> reconnectNow), so this
|
|
571
|
+
// transition routinely runs while the surface is mid-rebuild — at a clamped
|
|
572
|
+
// offset, over rows that are still being built. The live measurement there is
|
|
573
|
+
// a transient that belongs to neither the outgoing nor the incoming view, and
|
|
574
|
+
// the store's reclassify pass uses it to decide which rows survive: reading
|
|
575
|
+
// the transient can evict the very rows an armed restore is about to bring
|
|
576
|
+
// back. An armed restore names the position the user is REGAINING, so it is
|
|
577
|
+
// the answer to "which rows must survive" (docs/scroll-position-fidelity.md
|
|
578
|
+
// §7.2, §7.3). Every other viewportAbs consumer asks about NOW and keeps the
|
|
579
|
+
// live value.
|
|
580
|
+
const pending = pendingRestoreAbs();
|
|
581
|
+
// The store no longer infers whether the reader is following; this layer knows.
|
|
582
|
+
// `isUserScrolledUp()` is the one authoritative source, and an ARMED RESTORE
|
|
583
|
+
// overrides it outright: mid-rebuild the browser has clamped the offset, so the
|
|
584
|
+
// live flag describes the transient rather than the view the reader is about to
|
|
585
|
+
// regain — and a restore is armed only for a position in history.
|
|
586
|
+
const pendingRestoreArmed = pending !== null;
|
|
587
|
+
store.applyResumeAck({
|
|
588
|
+
...ack,
|
|
589
|
+
viewportAbs: pending ?? viewportAbs(),
|
|
590
|
+
following: !pendingRestoreArmed && !scroll.isUserScrolledUp(),
|
|
591
|
+
});
|
|
592
|
+
scheduleFlush();
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/** Record the window of a request going out (the store port's half). */
|
|
596
|
+
export function noteSolicited(fromAbs: number, end: number): void {
|
|
597
|
+
store.noteSolicited(fromAbs, end);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/** Release the in-flight window (reply applied, timed out, socket gone). */
|
|
601
|
+
export function clearSolicited(): void {
|
|
602
|
+
store.clearSolicited();
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* Drop the browse cache, supplying the viewport the skip rule needs. The
|
|
607
|
+
* consumer owns the TTL and the visibility state; the engine has no notion of
|
|
608
|
+
* tabs or page visibility.
|
|
609
|
+
*/
|
|
610
|
+
export function dropBrowseCache(pageVisible: boolean): void {
|
|
611
|
+
store.dropBrowseCache(viewportAbs(), pageVisible);
|
|
612
|
+
scheduleFlush();
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/** When the browse cache was last created or refreshed, for a consumer's TTL. */
|
|
616
|
+
export function lastBrowseActivityMs(): number {
|
|
617
|
+
return store.lastBrowseActivityMs();
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/** Lines currently held as disposable browse cache (diagnostics, tests). */
|
|
621
|
+
export function browseCacheSize(): number {
|
|
622
|
+
return store.browseCacheSize();
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* The resume replay bound to send: the client's own residency minus the window
|
|
627
|
+
* it is about to be sent anyway, so an attach does not download rows the cap
|
|
628
|
+
* would immediately trim. Wired to `connection`'s `getReplayMax`.
|
|
629
|
+
*/
|
|
630
|
+
export function replayMaxForResume(): number {
|
|
631
|
+
return Math.max(1, store.tailCap() - store.getWindow().height);
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* How many rows are queued for a DOM (re)build but not yet built. Non-zero
|
|
636
|
+
* means the surface is still materializing content the store already holds: a
|
|
637
|
+
* resume replay, a wipe-and-rebuild after a store swap, or any burst larger
|
|
638
|
+
* than one frame's build budget.
|
|
639
|
+
*
|
|
640
|
+
* Exposed so a consumer can surface a "still catching up" affordance during a
|
|
641
|
+
* large restore. It is a RENDER-side measure, not a transport one: it reaches
|
|
642
|
+
* zero between the server's replay chunks, so on its own it is not a
|
|
643
|
+
* restore-complete signal. Pair it with the resumeAck bounds (the store's
|
|
644
|
+
* highestIndex reaching the reported `committed`) to tell "this frame's backlog
|
|
645
|
+
* is drained" from "the restore has finished arriving".
|
|
646
|
+
*/
|
|
647
|
+
export function pendingRowCount(): number {
|
|
648
|
+
return renderQueue.size;
|
|
649
|
+
}
|
|
650
|
+
|
|
373
651
|
// --- Color helpers ---
|
|
374
652
|
function colorHex(c: number | undefined): string | null {
|
|
375
653
|
if (c === undefined || c < 0) {
|
|
@@ -649,6 +927,17 @@ function buildRowSpans(runs: readonly WireRun[]): (HTMLSpanElement | HTMLAnchorE
|
|
|
649
927
|
* index, so no client-side frame coalescing is needed.
|
|
650
928
|
*/
|
|
651
929
|
export function handleScreen(msg: ScreenMessage): void {
|
|
930
|
+
lastInboundMs = Date.now();
|
|
931
|
+
if (msg.scrollbackCleared) {
|
|
932
|
+
// ED3: the application discarded its saved lines, so everything the store
|
|
933
|
+
// holds below this base is about to go. Recorded HERE, at the one place that
|
|
934
|
+
// sees the frame before the store consumes it, so restoreReadAnchor can tell
|
|
935
|
+
// a region DISCARD from an ordinary cap trim — the two need opposite
|
|
936
|
+
// recoveries and only the frame knows which happened
|
|
937
|
+
// (docs/scroll-position-fidelity.md §5). Max, because several frames can
|
|
938
|
+
// land between flushes.
|
|
939
|
+
discardedBelowPending = Math.max(discardedBelowPending, msg.base);
|
|
940
|
+
}
|
|
652
941
|
store.applyScreen(msg);
|
|
653
942
|
scheduleFlush();
|
|
654
943
|
}
|
|
@@ -658,6 +947,7 @@ export function handleScreen(msg: ScreenMessage): void {
|
|
|
658
947
|
* index, then schedule a render flush.
|
|
659
948
|
*/
|
|
660
949
|
export function handleScroll(msg: ScrollMessage): void {
|
|
950
|
+
lastInboundMs = Date.now();
|
|
661
951
|
store.applyScroll(msg);
|
|
662
952
|
scheduleFlush();
|
|
663
953
|
}
|
|
@@ -688,16 +978,42 @@ function scheduleFlush(): void {
|
|
|
688
978
|
// all, because the bottom pin owns its position.
|
|
689
979
|
interface ReadAnchor {
|
|
690
980
|
el: HTMLElement;
|
|
981
|
+
/** The anchor row's absolute index, so an eviction that removes the element
|
|
982
|
+
* itself (batched cap eviction frees up to a whole batch at once) can
|
|
983
|
+
* re-resolve to the nearest surviving row instead of giving up. */
|
|
984
|
+
abs: number;
|
|
691
985
|
/** Where the row sat ON SCREEN: offsetTop minus the scroll offset. Measuring
|
|
692
986
|
* the screen position rather than the container offset is what makes the
|
|
693
987
|
* correction idempotent — see restoreReadAnchor. */
|
|
694
988
|
screenTop: number;
|
|
695
989
|
}
|
|
696
990
|
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
991
|
+
// rowAtViewportTop returns the first CONTENT row at or below the viewport top —
|
|
992
|
+
// the ONE row-selection primitive every "where is the reader" question resolves
|
|
993
|
+
// through: the read anchor, the paging trigger's viewportAbs, and the per-view
|
|
994
|
+
// memory a tabbed consumer saves. One definition because two would drift, and
|
|
995
|
+
// they would drift exactly during a rebuild, which is when the answer matters
|
|
996
|
+
// most (docs/scroll-position-fidelity.md §7.2).
|
|
997
|
+
//
|
|
998
|
+
// Children are in document order with monotonic offsetTop, so this is a binary
|
|
999
|
+
// search: ~13 reads for a full 5000-row buffer. Non-content children are NEVER a
|
|
1000
|
+
// reading position and are skipped: a marker is an annotation ABOUT a hole, so
|
|
1001
|
+
// holding one in place would pin the annotation rather than the text the reader
|
|
1002
|
+
// is looking at.
|
|
1003
|
+
//
|
|
1004
|
+
// "Content row" is decided by IDENTITY in rowEls, not by the presence of a
|
|
1005
|
+
// data-abs attribute. A gap marker carries one — updateGapMarkers sets it to the
|
|
1006
|
+
// gap's LOW index so insertRowInOrder keeps the container monotonic — so an
|
|
1007
|
+
// attribute test skips only the trim marker and happily returns a gap marker as
|
|
1008
|
+
// a reading position. Its index is the first ABSENT line, which rowEls can never
|
|
1009
|
+
// resolve, so a restore anchored there could never land and a viewportAbs built
|
|
1010
|
+
// on it would name a line the store does not hold.
|
|
1011
|
+
function isContentRow(el: HTMLElement): boolean {
|
|
1012
|
+
const abs = rowAbs(el);
|
|
1013
|
+
return abs >= 0 && rowEls.get(abs) === el;
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
function rowAtViewportTop(): HTMLElement | null {
|
|
701
1017
|
const kids = output.children;
|
|
702
1018
|
if (kids.length === 0) {
|
|
703
1019
|
return null;
|
|
@@ -716,15 +1032,123 @@ function captureReadAnchor(): ReadAnchor | null {
|
|
|
716
1032
|
lo = mid + 1;
|
|
717
1033
|
}
|
|
718
1034
|
}
|
|
719
|
-
//
|
|
720
|
-
//
|
|
721
|
-
|
|
722
|
-
|
|
1035
|
+
// Walk past any markers the search landed on. The bound is O(gaps + 1), not 1:
|
|
1036
|
+
// two gap markers CAN be adjacent siblings, and can be the last two children —
|
|
1037
|
+
// measured once predictReplayJump retires the window, since emptyWindow()'s
|
|
1038
|
+
// base of 0 makes the drain ascending. Cheap either way (a handful of gaps at
|
|
1039
|
+
// most), but do not assume a single step.
|
|
1040
|
+
while (found !== null && !isContentRow(found)) {
|
|
1041
|
+
found = found.nextElementSibling as HTMLElement | null;
|
|
1042
|
+
}
|
|
1043
|
+
return found;
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
function captureReadAnchor(): ReadAnchor | null {
|
|
1047
|
+
if (!scroll.isUserScrolledUp()) {
|
|
1048
|
+
return null; // following: stickToBottom owns the position
|
|
1049
|
+
}
|
|
1050
|
+
const el = rowAtViewportTop();
|
|
1051
|
+
if (el === null) {
|
|
1052
|
+
// Nothing at or below the viewport top. Reaching this with a container that
|
|
1053
|
+
// CLAMPS scrollTop needs the viewport to be shorter than a single row, so it
|
|
1054
|
+
// is effectively unreachable in a browser; the previous last-row fallback
|
|
1055
|
+
// was reachable mainly under a test fixture whose scrollTop setter did not
|
|
1056
|
+
// clamp. Standing down is the right answer either way: using the TAIL as a
|
|
1057
|
+
// proxy reading position is what turned a large content shrink into a
|
|
1058
|
+
// tail-drag (docs/scroll-position-fidelity.md §1.2).
|
|
1059
|
+
return null;
|
|
1060
|
+
}
|
|
1061
|
+
return { el, abs: rowAbs(el), screenTop: el.offsetTop - scroll.currentScrollTop() };
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
// firstRowAtOrAfter returns the first output child whose absolute index is
|
|
1065
|
+
// >= abs — the nearest surviving CONTENT at or below a lost reading position.
|
|
1066
|
+
// Binary search over the children, mirroring rowAtViewportTop: they are kept in
|
|
1067
|
+
// ascending data-abs order.
|
|
1068
|
+
//
|
|
1069
|
+
// Markers are skipped by identity (isContentRow), not by reading -1 from a
|
|
1070
|
+
// missing data-abs. That older property held only while the trim marker was the
|
|
1071
|
+
// single non-row child; a per-gap marker carries its gap's LOW index, so an
|
|
1072
|
+
// attribute test would return the marker as the "nearest surviving row" and the
|
|
1073
|
+
// anchor would pin an annotation about a hole to the reader's screen position.
|
|
1074
|
+
// (~13 reads at the 5000-row cap; this runs inside the eviction flush, the
|
|
1075
|
+
// frame's most expensive pass already.)
|
|
1076
|
+
function firstRowAtOrAfter(abs: number): HTMLElement | null {
|
|
1077
|
+
const kids = output.children;
|
|
1078
|
+
let lo = 0;
|
|
1079
|
+
let hi = kids.length - 1;
|
|
1080
|
+
let found: HTMLElement | null = null;
|
|
1081
|
+
while (lo <= hi) {
|
|
1082
|
+
const mid = (lo + hi) >> 1;
|
|
1083
|
+
const el = kids[mid] as HTMLElement;
|
|
1084
|
+
if (rowAbs(el) >= abs) {
|
|
1085
|
+
found = el;
|
|
1086
|
+
hi = mid - 1;
|
|
1087
|
+
} else {
|
|
1088
|
+
lo = mid + 1;
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
while (found !== null && !isContentRow(found)) {
|
|
1092
|
+
found = found.nextElementSibling as HTMLElement | null;
|
|
1093
|
+
}
|
|
1094
|
+
return found;
|
|
723
1095
|
}
|
|
724
1096
|
|
|
725
1097
|
function restoreReadAnchor(anchor: ReadAnchor | null): void {
|
|
726
|
-
if (anchor
|
|
727
|
-
return; //
|
|
1098
|
+
if (anchor === null) {
|
|
1099
|
+
return; // following: stickToBottom owns the position
|
|
1100
|
+
}
|
|
1101
|
+
let el: HTMLElement | null = anchor.el;
|
|
1102
|
+
if (el.parentElement !== output) {
|
|
1103
|
+
if (fullResetThisPass) {
|
|
1104
|
+
// Server restart / epoch change: absolute indices restarted from 0, so
|
|
1105
|
+
// a child with a matching data-abs is UNRELATED content from the new
|
|
1106
|
+
// session — re-anchoring on it would pull an arbitrary row to the old
|
|
1107
|
+
// reading position. Stand down; the fresh session's own follow state
|
|
1108
|
+
// owns the viewport.
|
|
1109
|
+
return;
|
|
1110
|
+
}
|
|
1111
|
+
// The anchor row itself was evicted out from under the reader: batched
|
|
1112
|
+
// cap eviction frees up to a whole batch (256 at the default cap) in one
|
|
1113
|
+
// pass, so a reader parked within a batch of the buffer top loses the
|
|
1114
|
+
// anchored ELEMENT while rows they had not read yet survive below it.
|
|
1115
|
+
// Re-resolve to the first surviving row at or after the anchored index
|
|
1116
|
+
// and hold THAT at the anchor's screen position — the view resumes at
|
|
1117
|
+
// the nearest surviving content instead of silently jumping past up to
|
|
1118
|
+
// a batch of unread lines (WebKit has no native anchoring to catch it).
|
|
1119
|
+
el = firstRowAtOrAfter(anchor.abs);
|
|
1120
|
+
if (el === null) {
|
|
1121
|
+
return; // nothing survives (reset/clear): nothing to hold
|
|
1122
|
+
}
|
|
1123
|
+
// A region DISCARD, not a cap trim: the application erased its saved lines
|
|
1124
|
+
// (ED3), so nothing surviving is guaranteed ADJACENT to what the reader was
|
|
1125
|
+
// looking at. On an inline TUI that reprints on resize the same text comes
|
|
1126
|
+
// straight back at new indices, so the nearest surviving row is unrelated
|
|
1127
|
+
// content — and holding it at the reader's screen position is the "random jump
|
|
1128
|
+
// on resize" symptom. A cap trim is the opposite case and keeps the re-resolve
|
|
1129
|
+
// above: it removes the oldest contiguous run, so the survivor really is the
|
|
1130
|
+
// reader's neighbour (docs/scroll-position-fidelity.md §1.2, §5).
|
|
1131
|
+
//
|
|
1132
|
+
// The test is the ANCHOR's index. An earlier version also required the
|
|
1133
|
+
// SURVIVOR to sit at or above the discard base, which never held for the shape
|
|
1134
|
+
// this exists for: the reprint re-delivers lines below that base in the same
|
|
1135
|
+
// frame, so the survivor looked adjacent and the correction fired anyway.
|
|
1136
|
+
//
|
|
1137
|
+
// The remaining `anchor.abs <` clause is a GUARD, not a live path, and the
|
|
1138
|
+
// reachability argument is worth recording because it is not obvious. For a
|
|
1139
|
+
// single well-formed frame it cannot fire: `msg.base` is both the discard bound
|
|
1140
|
+
// and the new window base, so an anchor at or above it sits inside the live
|
|
1141
|
+
// window, which cap eviction protects and `truncateBelowWindow` only trims
|
|
1142
|
+
// above. Across several frames in one batch (ED3 at a low base, the window
|
|
1143
|
+
// settling high) the anchor's ELEMENT normally survives instead — the store
|
|
1144
|
+
// coalesces an evict-then-reapply of the same index, so `upsertRow` reuses the
|
|
1145
|
+
// element and this whole branch is skipped, leaving the ordinary drift
|
|
1146
|
+
// correction to handle the history the discard removed above the reader, which
|
|
1147
|
+
// is right. Measured: that path applies a legitimate -1700px correction for
|
|
1148
|
+
// 100 discarded rows, and it is NOT this branch.
|
|
1149
|
+
if (discardedBelowThisPass >= 0 && anchor.abs < discardedBelowThisPass) {
|
|
1150
|
+
return;
|
|
1151
|
+
}
|
|
728
1152
|
}
|
|
729
1153
|
// Correct by how far the row DRIFTED ON SCREEN, not by how much the content
|
|
730
1154
|
// above it changed. The two differ exactly when the browser already did this
|
|
@@ -734,14 +1158,16 @@ function restoreReadAnchor(anchor: ReadAnchor | null): void {
|
|
|
734
1158
|
// the content delta instead would double-compensate there and throw the view
|
|
735
1159
|
// the other way. On Safari, which has no native anchoring, the drift is the
|
|
736
1160
|
// whole content delta and this is the only thing that fixes it.
|
|
737
|
-
const drift =
|
|
1161
|
+
const drift = el.offsetTop - scroll.currentScrollTop() - anchor.screenTop;
|
|
738
1162
|
scroll.adjustForContentShift(drift);
|
|
739
1163
|
}
|
|
740
1164
|
|
|
741
1165
|
function flushRender(): void {
|
|
742
1166
|
pendingFrame = undefined;
|
|
743
|
-
//
|
|
744
|
-
//
|
|
1167
|
+
// Read BEFORE any mutation, twice over: the anchor needs the pre-mutation
|
|
1168
|
+
// screen position, and noteContentShrink needs the pre-mutation offset to tell
|
|
1169
|
+
// whether this pass's row removals actually moved the viewport.
|
|
1170
|
+
const scrollTopBefore = scroll.currentScrollTop();
|
|
745
1171
|
const anchor = captureReadAnchor();
|
|
746
1172
|
try {
|
|
747
1173
|
flushRenderInner();
|
|
@@ -776,14 +1202,45 @@ function flushRender(): void {
|
|
|
776
1202
|
console.error("vterm: giving up render retry after repeated no-progress errors");
|
|
777
1203
|
}
|
|
778
1204
|
}
|
|
779
|
-
//
|
|
780
|
-
//
|
|
781
|
-
//
|
|
782
|
-
//
|
|
783
|
-
//
|
|
784
|
-
|
|
1205
|
+
// Tell the scroll controller whether this pass's row removals moved the
|
|
1206
|
+
// viewport. Must run BEFORE any write of our own below, or the comparison
|
|
1207
|
+
// measures our write instead of the browser's clamp. Announcing the clamp is
|
|
1208
|
+
// what keeps a content shrink from reading as "the user scrolled up" and
|
|
1209
|
+
// silently switching auto-follow off (scroll.ts's header; §1.3).
|
|
1210
|
+
if (removedRowsThisPass) {
|
|
1211
|
+
scroll.noteContentShrink(scrollTopBefore);
|
|
1212
|
+
}
|
|
1213
|
+
// Three position invariants, applied after every DOM mutation and in this
|
|
1214
|
+
// order. An armed view restore goes first because it OWNS the position while
|
|
1215
|
+
// it is armed; the read anchor then holds whatever line is on screen; the
|
|
1216
|
+
// bottom pin last, and only if following.
|
|
1217
|
+
//
|
|
1218
|
+
// The anchor is skipped ONLY in the frame the restore actually LANDED, and
|
|
1219
|
+
// that exception is load-bearing rather than tidy: the anchor was captured
|
|
1220
|
+
// BEFORE this frame's mutations, so once the restore has authoritatively moved
|
|
1221
|
+
// the viewport, the anchor's drift measures the restore's own write and
|
|
1222
|
+
// corrects it straight back out — the restore and the anchor cancel and the
|
|
1223
|
+
// reader stays at the top. Skipping it while merely ARMED would be wrong for
|
|
1224
|
+
// the opposite reason: a rebuild spans many frames, and suppressing the anchor
|
|
1225
|
+
// across all of them reintroduces the WebKit read-position slide for every one.
|
|
1226
|
+
const restoreLanded = applyPendingRestore();
|
|
1227
|
+
if (!restoreLanded) {
|
|
1228
|
+
restoreReadAnchor(anchor);
|
|
1229
|
+
}
|
|
785
1230
|
// Single auto-follow invariant, applied after every DOM mutation.
|
|
786
1231
|
stickToBottomIfFollowing();
|
|
1232
|
+
// Absorb every write this frame made — ours and the browser's native scroll
|
|
1233
|
+
// anchoring — into the restore's baseline, so only a move that happens BETWEEN
|
|
1234
|
+
// frames (a real gesture) reads as one.
|
|
1235
|
+
if (pendingRestore !== null) {
|
|
1236
|
+
pendingRestore.lastWrote = scroll.currentScrollTop();
|
|
1237
|
+
}
|
|
1238
|
+
// The post-flush trigger. The reader's position relative to the store's gaps
|
|
1239
|
+
// can change without any scroll event — a tail trim moves the frontier up
|
|
1240
|
+
// under a stationary reader, and a byte-short page leaves a fresh sub-gap
|
|
1241
|
+
// beside them — so the flush is the other place the trigger must run
|
|
1242
|
+
// (docs/paged-scrollback.md §5.4).
|
|
1243
|
+
maybeFetchHistory();
|
|
787
1244
|
}
|
|
788
1245
|
|
|
789
1246
|
function flushRenderInner(): void {
|
|
@@ -792,18 +1249,28 @@ function flushRenderInner(): void {
|
|
|
792
1249
|
// the count-so-far for flushRender's catch to read.
|
|
793
1250
|
flushDrainedThisPass = 0;
|
|
794
1251
|
const ch = store.drainChanges();
|
|
1252
|
+
fullResetThisPass = ch.fullReset;
|
|
1253
|
+
discardedBelowThisPass = discardedBelowPending;
|
|
1254
|
+
discardedBelowPending = -1;
|
|
1255
|
+
removedRowsThisPass = false;
|
|
795
1256
|
|
|
796
1257
|
if (ch.fullReset) {
|
|
1258
|
+
removedRowsThisPass = true;
|
|
797
1259
|
output.replaceChildren();
|
|
798
1260
|
rowEls.clear();
|
|
799
1261
|
renderQueue.clear();
|
|
800
1262
|
trimMarkerEl = null;
|
|
1263
|
+
for (const el of gapMarkerEls.values()) {
|
|
1264
|
+
el.remove();
|
|
1265
|
+
}
|
|
1266
|
+
gapMarkerEls.clear();
|
|
801
1267
|
cursorAbs = -1;
|
|
802
1268
|
} else {
|
|
803
1269
|
for (const abs of ch.evictedLines) {
|
|
804
1270
|
const el = rowEls.get(abs);
|
|
805
1271
|
if (el) {
|
|
806
1272
|
el.remove();
|
|
1273
|
+
removedRowsThisPass = true;
|
|
807
1274
|
}
|
|
808
1275
|
rowEls.delete(abs);
|
|
809
1276
|
renderQueue.delete(abs);
|
|
@@ -843,10 +1310,15 @@ function flushRenderInner(): void {
|
|
|
843
1310
|
// before deep scrollback on a large-history alt-exit.
|
|
844
1311
|
altRendered = false;
|
|
845
1312
|
altPrevRows = [];
|
|
1313
|
+
removedRowsThisPass = true;
|
|
846
1314
|
output.replaceChildren();
|
|
847
1315
|
rowEls.clear();
|
|
848
1316
|
renderQueue.clear();
|
|
849
1317
|
trimMarkerEl = null;
|
|
1318
|
+
for (const el of gapMarkerEls.values()) {
|
|
1319
|
+
el.remove();
|
|
1320
|
+
}
|
|
1321
|
+
gapMarkerEls.clear();
|
|
850
1322
|
queueRowsViewportFirst();
|
|
851
1323
|
}
|
|
852
1324
|
|
|
@@ -889,16 +1361,35 @@ function flushRenderInner(): void {
|
|
|
889
1361
|
}
|
|
890
1362
|
inWindow.sort((a, b) => a - b);
|
|
891
1363
|
belowWindow.sort((a, b) => b - a);
|
|
892
|
-
|
|
1364
|
+
// Drain the two lists sequentially under one budget (no concatenated copy:
|
|
1365
|
+
// during a full-backlog drain the spread re-allocated a backlog-sized array
|
|
1366
|
+
// every frame — pure GC churn on the hot path).
|
|
1367
|
+
const drainRow = (abs: number): boolean => {
|
|
893
1368
|
if (flushDrainedThisPass >= MAX_ROWS_PER_FRAME) {
|
|
894
|
-
|
|
1369
|
+
return false;
|
|
895
1370
|
}
|
|
896
1371
|
upsertRow(abs);
|
|
897
1372
|
renderQueue.delete(abs);
|
|
898
1373
|
flushDrainedThisPass++;
|
|
1374
|
+
return true;
|
|
1375
|
+
};
|
|
1376
|
+
for (const abs of inWindow) {
|
|
1377
|
+
if (!drainRow(abs)) {
|
|
1378
|
+
break;
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
for (const abs of belowWindow) {
|
|
1382
|
+
if (!drainRow(abs)) {
|
|
1383
|
+
break;
|
|
1384
|
+
}
|
|
899
1385
|
}
|
|
900
1386
|
|
|
901
1387
|
updateTrimMarker();
|
|
1388
|
+
// The gap markers are a projection of the store's geometry, so they are
|
|
1389
|
+
// re-derived on every flush rather than maintained incrementally: a page
|
|
1390
|
+
// apply, a browse eviction and a tail trim all move gap edges, and none of
|
|
1391
|
+
// them should have to know a marker exists.
|
|
1392
|
+
updateGapMarkers();
|
|
902
1393
|
|
|
903
1394
|
// More rows pending: keep draining on subsequent frames.
|
|
904
1395
|
if (renderQueue.size > 0) {
|
|
@@ -912,28 +1403,348 @@ function flushRenderInner(): void {
|
|
|
912
1403
|
}
|
|
913
1404
|
}
|
|
914
1405
|
|
|
915
|
-
//
|
|
916
|
-
//
|
|
917
|
-
//
|
|
918
|
-
//
|
|
919
|
-
//
|
|
920
|
-
//
|
|
921
|
-
//
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
1406
|
+
// --- Per-view scroll memory (docs/scroll-position-fidelity.md §3) ---
|
|
1407
|
+
//
|
|
1408
|
+
// A reading position is a LINE, not a pixel offset. A tabbed consumer that saved
|
|
1409
|
+
// `scrollTop` and replayed it on re-entry had that write silently CLAMPED,
|
|
1410
|
+
// because a rebuild has only built the live window plus one frame's budget when
|
|
1411
|
+
// the replay lands (301 of up to 5000 rows, ~5100 of ~85,000 px), and the clamp
|
|
1412
|
+
// was never re-attempted. The saved offset also stops meaning the same line as
|
|
1413
|
+
// soon as the tab's content grows while it is backgrounded, which is every tab
|
|
1414
|
+
// whose session kept working. So the memory is an absolute index plus the row's
|
|
1415
|
+
// on-screen offset, and the restore is RE-ASSERTED until the row it names has
|
|
1416
|
+
// actually been built.
|
|
1417
|
+
//
|
|
1418
|
+
// `screenTop` is deliberately a DIFFERENCE (offsetTop - scrollTop), the same
|
|
1419
|
+
// form ReadAnchor uses: rows report offsets in `.term-output`'s space while
|
|
1420
|
+
// scrollTop belongs to `.term`, so any ABSOLUTE pixel value would need the
|
|
1421
|
+
// rowTopInTermWrap conversion and would be one padding off without it. A
|
|
1422
|
+
// difference cancels the space out.
|
|
1423
|
+
export interface ViewMemory {
|
|
1424
|
+
/** Absolute line index of the content row at the viewport top. */
|
|
1425
|
+
abs: number;
|
|
1426
|
+
/** That row's on-screen position: offsetTop minus the scroll offset. */
|
|
1427
|
+
screenTop: number;
|
|
1428
|
+
/** Whether auto-follow was engaged. */
|
|
1429
|
+
following: boolean;
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
// The armed restore. `gen` is the bind generation it belongs to, so a second
|
|
1433
|
+
// switch mid-drain cannot land the first tab's anchor into the second tab's
|
|
1434
|
+
// store. `lastWrote` is the offset this module last left behind: a position that
|
|
1435
|
+
// does NOT match it was moved by the user, which cancels — the library never
|
|
1436
|
+
// fights a gesture, and it establishes that by knowing its own writes rather
|
|
1437
|
+
// than by listening to a signal (`onScrollPosition`) that also fires for the
|
|
1438
|
+
// browser's clamps, including the one the rebuild itself causes.
|
|
1439
|
+
interface PendingRestore {
|
|
1440
|
+
view: ViewMemory;
|
|
1441
|
+
gen: number;
|
|
1442
|
+
lastWrote: number;
|
|
1443
|
+
deadline: number;
|
|
1444
|
+
}
|
|
1445
|
+
let pendingRestore: PendingRestore | null = null;
|
|
1446
|
+
let bindGen = 0;
|
|
1447
|
+
// Wall-clock of the last inbound frame for the bound store. `renderQueue.size
|
|
1448
|
+
// === 0` alone is NOT "the rebuild finished": it reaches zero BETWEEN a resume
|
|
1449
|
+
// batch's replay chunks (see pendingRowCount's contract), so the settle needs
|
|
1450
|
+
// transport quiet too. Both numbers mirror the tabs feature's already-tuned
|
|
1451
|
+
// catch-up cue, which answers the same question for the same reason.
|
|
1452
|
+
let lastInboundMs = 0;
|
|
1453
|
+
const RESTORE_SETTLE_MS = 250;
|
|
1454
|
+
const RESTORE_MAX_MS = 30000;
|
|
1455
|
+
// A position within this many px of what we last wrote is still ours: a
|
|
1456
|
+
// fractional-layout or subpixel-DPR readback is not a user gesture.
|
|
1457
|
+
const RESTORE_OWN_WRITE_EPSILON_PX = 1;
|
|
1458
|
+
|
|
1459
|
+
/**
|
|
1460
|
+
* Capture the current view as per-view scroll memory a consumer can hand back
|
|
1461
|
+
* to `bind`. Returns null when there is nothing meaningful to remember: no
|
|
1462
|
+
* content rows yet, or the alternate screen is active (an alt grid has no
|
|
1463
|
+
* absolute indices worth restoring, and measuring one would overwrite a tab's
|
|
1464
|
+
* real saved position with an alt-screen row).
|
|
1465
|
+
*
|
|
1466
|
+
* Unlike the private read anchor this measures in BOTH follow states — the
|
|
1467
|
+
* follow half is saved alongside it, and a consumer needs the pair.
|
|
1468
|
+
*/
|
|
1469
|
+
export function captureViewMemory(): ViewMemory | null {
|
|
1470
|
+
if (store.isAlt()) {
|
|
1471
|
+
return null;
|
|
1472
|
+
}
|
|
1473
|
+
const el = rowAtViewportTop();
|
|
1474
|
+
if (el === null) {
|
|
1475
|
+
return null;
|
|
1476
|
+
}
|
|
1477
|
+
const abs = rowAbs(el);
|
|
1478
|
+
if (abs < 0) {
|
|
1479
|
+
return null;
|
|
1480
|
+
}
|
|
1481
|
+
return {
|
|
1482
|
+
abs,
|
|
1483
|
+
screenTop: el.offsetTop - scroll.currentScrollTop(),
|
|
1484
|
+
following: !scroll.isUserScrolledUp(),
|
|
1485
|
+
};
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
/** Drop any armed restore. Called on (re)init and on every bind. */
|
|
1489
|
+
function clearPendingRestore(): void {
|
|
1490
|
+
pendingRestore = null;
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
// applyPendingRestore re-asserts an armed view restore, and is the reason the
|
|
1494
|
+
// restore is not a one-shot: the anchored row may not be BUILT yet. A rebuild
|
|
1495
|
+
// drains at MAX_ROWS_PER_FRAME, so a reader parked deep in history has their row
|
|
1496
|
+
// materialize several frames after the switch, and a single write at frame 1
|
|
1497
|
+
// would be clamped to the partial content and never retried.
|
|
1498
|
+
//
|
|
1499
|
+
// Returns true when it landed (and disarmed).
|
|
1500
|
+
function applyPendingRestore(): boolean {
|
|
1501
|
+
const p = pendingRestore;
|
|
1502
|
+
if (p === null) {
|
|
1503
|
+
return false;
|
|
1504
|
+
}
|
|
1505
|
+
// A newer bind owns the surface, or indices restarted: the saved anchor
|
|
1506
|
+
// describes content that is no longer here.
|
|
1507
|
+
if (p.gen !== bindGen || fullResetThisPass) {
|
|
1508
|
+
clearPendingRestore();
|
|
1509
|
+
return false;
|
|
1510
|
+
}
|
|
1511
|
+
const now = scroll.currentScrollTop();
|
|
1512
|
+
if (Math.abs(now - p.lastWrote) > RESTORE_OWN_WRITE_EPSILON_PX) {
|
|
1513
|
+
clearPendingRestore(); // the user moved: never fight a gesture
|
|
1514
|
+
return false;
|
|
1515
|
+
}
|
|
1516
|
+
const el = rowEls.get(p.view.abs);
|
|
1517
|
+
if (el === undefined) {
|
|
1518
|
+
// Not built yet, or gone for good. Give up only once the surface has
|
|
1519
|
+
// genuinely settled (queue drained AND transport quiet) or the bound
|
|
1520
|
+
// lapses; until then stay armed and try again next frame.
|
|
1521
|
+
const quiet = renderQueue.size === 0 && Date.now() - lastInboundMs > RESTORE_SETTLE_MS;
|
|
1522
|
+
if (quiet || Date.now() > p.deadline) {
|
|
1523
|
+
clearPendingRestore();
|
|
1524
|
+
}
|
|
1525
|
+
return false;
|
|
1526
|
+
}
|
|
1527
|
+
// Same arithmetic as the read anchor's drift correction, for the same
|
|
1528
|
+
// space-cancelling reason, and idempotent: an already-satisfied restore
|
|
1529
|
+
// measures zero and writes nothing.
|
|
1530
|
+
const drift = el.offsetTop - now - p.view.screenTop;
|
|
1531
|
+
scroll.adjustForContentShift(drift);
|
|
1532
|
+
clearPendingRestore();
|
|
1533
|
+
return true;
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
/**
|
|
1537
|
+
* The viewport's absolute index for the paging layer's live questions: which
|
|
1538
|
+
* rows to exempt from eviction, whether a reader is sitting on cache, which gap
|
|
1539
|
+
* an interaction is approaching. All three ask about NOW, so they get the live
|
|
1540
|
+
* measurement.
|
|
1541
|
+
*
|
|
1542
|
+
* `pendingRestoreAbs` is the separate answer for the one caller that asks a
|
|
1543
|
+
* different question — the resume transition, which asks which rows must
|
|
1544
|
+
* SURVIVE a switch (docs/scroll-position-fidelity.md §7.2).
|
|
1545
|
+
*/
|
|
1546
|
+
function viewportAbs(): number {
|
|
1547
|
+
if (!scroll.isUserScrolledUp()) {
|
|
1548
|
+
return store.getWindow().base;
|
|
1549
|
+
}
|
|
1550
|
+
const el = rowAtViewportTop();
|
|
1551
|
+
const abs = el === null ? -1 : rowAbs(el);
|
|
1552
|
+
return abs >= 0 ? abs : store.getWindow().base;
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
/**
|
|
1556
|
+
* The absolute index of an ARMED view restore, or null. Only the resume
|
|
1557
|
+
* transition consults this: during a rebuild the live viewport is a transient —
|
|
1558
|
+
* mid-drain, at a clamped offset — and a reclassify pass that reads it can evict
|
|
1559
|
+
* the very rows the restore is about to bring back. The pending anchor is the
|
|
1560
|
+
* position the user is regaining, which is the one that must survive.
|
|
1561
|
+
*/
|
|
1562
|
+
export function pendingRestoreAbs(): number | null {
|
|
1563
|
+
const p = pendingRestore;
|
|
1564
|
+
if (p === null) {
|
|
1565
|
+
return null;
|
|
1566
|
+
}
|
|
1567
|
+
// Expire on READ as well as in the flush. The flush is the only other place
|
|
1568
|
+
// this state is examined, so an idle surface (queue drained, no inbound frames,
|
|
1569
|
+
// no flush scheduled) would otherwise hold an arm indefinitely and keep
|
|
1570
|
+
// answering with a line the reader has long left.
|
|
1571
|
+
if (Date.now() > p.deadline) {
|
|
1572
|
+
clearPendingRestore();
|
|
1573
|
+
return null;
|
|
1574
|
+
}
|
|
1575
|
+
return p.view.abs;
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
/**
|
|
1579
|
+
* The fetch trigger: decide whether to ask for a page, and for which range.
|
|
1580
|
+
*
|
|
1581
|
+
* Called from the post-flush anchor path and from `scroll.ts`'s position seam.
|
|
1582
|
+
* Both are cheap to over-call — every guard below is a pure read, and pacing
|
|
1583
|
+
* makes a spurious run free — which is deliberate: the alternative (trying to
|
|
1584
|
+
* fire it only when needed) is how a trigger ends up never firing on the idle
|
|
1585
|
+
* session that needs it most.
|
|
1586
|
+
*/
|
|
1587
|
+
export function maybeFetchHistory(): void {
|
|
1588
|
+
// ALT: no paging while the alternate screen is active. The event paths are
|
|
1589
|
+
// scrollback-UI-only, but the pending-demand timer fires from a CLOCK, so
|
|
1590
|
+
// this guard is the load-bearing one — without it a vim session would fetch
|
|
1591
|
+
// pages nobody can see, and each denial would re-arm the timer for the
|
|
1592
|
+
// session's whole duration (docs/paged-scrollback.md §5.5).
|
|
1593
|
+
if (store.isAlt()) {
|
|
1594
|
+
return;
|
|
1595
|
+
}
|
|
1596
|
+
if (requestHistoryFn === null) {
|
|
1597
|
+
return; // paging not wired by this consumer
|
|
1598
|
+
}
|
|
1599
|
+
const budget = historyBudgetFn === null ? PAGE_SIZE : historyBudgetFn();
|
|
1600
|
+
if (!Number.isInteger(budget) || budget < 1) {
|
|
1601
|
+
return;
|
|
1602
|
+
}
|
|
1603
|
+
const abs = viewportAbs();
|
|
1604
|
+
const edges = store.absentEdgesNear(abs, PREFETCH_THRESHOLD);
|
|
1605
|
+
const gap = edges[0];
|
|
1606
|
+
if (gap === undefined) {
|
|
1607
|
+
return;
|
|
1608
|
+
}
|
|
1609
|
+
// APPROACH-ANCHORED: always fetch the end NEAREST the reader, so a wide gap
|
|
1610
|
+
// heals from the side being read. Fetching a fixed end would land pages up to
|
|
1611
|
+
// (gapWidth - budget) lines away from the viewport, leaving the rows under the
|
|
1612
|
+
// reader blank while the far end filled in.
|
|
1613
|
+
const floor = store.pagingFloorIndex();
|
|
1614
|
+
const fromAbs =
|
|
1615
|
+
abs >= gap.hi
|
|
1616
|
+
? Math.max(gap.lo, gap.hi - budget, floor) // approaching from BELOW the gap's top
|
|
1617
|
+
: Math.max(gap.lo, floor); // approaching from above: start at the gap's low edge
|
|
1618
|
+
const maxLines = Math.min(budget, gap.hi - fromAbs);
|
|
1619
|
+
if (maxLines < 1) {
|
|
1620
|
+
return;
|
|
1621
|
+
}
|
|
1622
|
+
requestHistoryFn(fromAbs, maxLines);
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
/**
|
|
1626
|
+
* Re-derive the gap markers from the store's geometry. A projection, not a
|
|
1627
|
+
* mutation log: every call recomputes which gaps exist and reconciles the DOM,
|
|
1628
|
+
* so a gap that healed from either edge moves or loses its marker without any
|
|
1629
|
+
* caller having to know which edge changed.
|
|
1630
|
+
*/
|
|
1631
|
+
function updateGapMarkers(): void {
|
|
1632
|
+
// Through intervals.ts, which declares itself the single source of gap
|
|
1633
|
+
// geometry — and the store's fetch trigger reads it too. This loop used to be
|
|
1634
|
+
// a second copy of `interiorGaps` with the same bounds and the same predicate,
|
|
1635
|
+
// so any correction to gap derivation (a touching-run case, an off-by-one at a
|
|
1636
|
+
// run edge) would have landed on one of them: the renderer marking a hole the
|
|
1637
|
+
// trigger will not fetch, or omitting one over a hole it will.
|
|
1638
|
+
const gaps = new Map<number, number>(); // low index -> high index
|
|
1639
|
+
for (const gap of interiorGaps(store.retainedRanges())) {
|
|
1640
|
+
gaps.set(gap.lo, gap.hi);
|
|
1641
|
+
}
|
|
1642
|
+
// Remove markers whose gap closed.
|
|
1643
|
+
for (const [lo, el] of [...gapMarkerEls]) {
|
|
1644
|
+
if (!gaps.has(lo)) {
|
|
1645
|
+
el.remove();
|
|
1646
|
+
gapMarkerEls.delete(lo);
|
|
930
1647
|
}
|
|
931
|
-
|
|
932
|
-
|
|
1648
|
+
}
|
|
1649
|
+
const floor = store.pagingFloorIndex();
|
|
1650
|
+
for (const [lo, hi] of gaps) {
|
|
1651
|
+
let el = gapMarkerEls.get(lo);
|
|
1652
|
+
if (el === undefined) {
|
|
1653
|
+
el = document.createElement("div");
|
|
1654
|
+
el.className = "term-gap-marker";
|
|
1655
|
+
el.setAttribute("role", "status");
|
|
1656
|
+
gapMarkerEls.set(lo, el);
|
|
1657
|
+
}
|
|
1658
|
+
// Three states, and the IDLE one is the default (§5.4). A gap marker whose
|
|
1659
|
+
// whole reason to exist is "do not read these two regions as contiguous"
|
|
1660
|
+
// cannot fall back to asserting nothing, or the splice it prevents comes
|
|
1661
|
+
// back silently.
|
|
1662
|
+
const condemned = floor >= hi;
|
|
1663
|
+
const label = condemned ? "earlier output trimmed" : "earlier output not loaded";
|
|
1664
|
+
if (el.textContent !== label) {
|
|
1665
|
+
el.textContent = label;
|
|
1666
|
+
el.setAttribute("aria-label", label);
|
|
1667
|
+
}
|
|
1668
|
+
el.dataset["abs"] = String(lo);
|
|
1669
|
+
el.classList.toggle("term-gap-trimmed", condemned);
|
|
1670
|
+
// Position by data-abs order, the same invariant the row inserts keep.
|
|
1671
|
+
const next = firstRowAtOrAfter(hi);
|
|
1672
|
+
if (el.parentElement !== output || el.nextElementSibling !== next) {
|
|
1673
|
+
output.insertBefore(el, next);
|
|
1674
|
+
}
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
// updateTrimMarker shows, hides and LABELS the top-of-store marker as the first
|
|
1679
|
+
// child of output, driven by the store. It carries no data-abs, so
|
|
1680
|
+
// insertRowInOrder (which compares numeric data-abs) never places a row before
|
|
1681
|
+
// it; it stays pinned at the top.
|
|
1682
|
+
//
|
|
1683
|
+
// Three states, because there are three honest things to say about the history
|
|
1684
|
+
// above what is held (docs/paged-scrollback.md §5.4):
|
|
1685
|
+
//
|
|
1686
|
+
// - NOTHING, when the store holds index 0: there is no history above it.
|
|
1687
|
+
// - "earlier output trimmed", the PERMANENT statement. Without paging declared
|
|
1688
|
+
// — including the pre-ack instant — it is the store's own trim bookkeeping,
|
|
1689
|
+
// the pre-paging behavior and the right one when no fetch exists: the client
|
|
1690
|
+
// evicted those rows itself, which is a fact whatever the server can serve.
|
|
1691
|
+
// With paging declared it is EARNED instead: the paging floor has reached the
|
|
1692
|
+
// oldest held index (the server proved nothing at or below it survives — an
|
|
1693
|
+
// empty frontier reply lands exactly here), or the server's own retained edge
|
|
1694
|
+
// is at or above it.
|
|
1695
|
+
// - "earlier output not loaded", the RECOVERABLE statement, with paging
|
|
1696
|
+
// declared and neither of those proofs in hand. This is the state that was
|
|
1697
|
+
// missing: a bounded resume replay routinely leaves a fetchable frontier, and
|
|
1698
|
+
// the old single predicate either said nothing at all (silently presenting a
|
|
1699
|
+
// partial transcript as the beginning of the session) or said "trimmed" about
|
|
1700
|
+
// history the server still holds and the trigger is about to fetch.
|
|
1701
|
+
//
|
|
1702
|
+
// The frontier is deliberately NOT sourced from gap geometry: its lower edge is
|
|
1703
|
+
// a policy question (what is still worth requesting), so an exhausted frontier
|
|
1704
|
+
// still renders a marker here rather than vanishing when the gap closes.
|
|
1705
|
+
function updateTrimMarker(): void {
|
|
1706
|
+
const label = topMarkerLabel();
|
|
1707
|
+
if (label === null) {
|
|
1708
|
+
if (trimMarkerEl !== null && trimMarkerEl.parentElement === output) {
|
|
1709
|
+
trimMarkerEl.remove();
|
|
933
1710
|
}
|
|
934
|
-
|
|
935
|
-
trimMarkerEl.remove();
|
|
1711
|
+
return;
|
|
936
1712
|
}
|
|
1713
|
+
if (trimMarkerEl === null) {
|
|
1714
|
+
trimMarkerEl = document.createElement("div");
|
|
1715
|
+
trimMarkerEl.className = "term-trim-marker";
|
|
1716
|
+
trimMarkerEl.setAttribute("role", "status");
|
|
1717
|
+
}
|
|
1718
|
+
if (trimMarkerEl.textContent !== label) {
|
|
1719
|
+
trimMarkerEl.textContent = label;
|
|
1720
|
+
trimMarkerEl.setAttribute("aria-label", label);
|
|
1721
|
+
}
|
|
1722
|
+
// Mirrors the interior gap marker's class, so one rule styles "gone" in both
|
|
1723
|
+
// places and a reader learns the distinction once.
|
|
1724
|
+
trimMarkerEl.classList.toggle("term-gap-trimmed", label === TRIM_LABEL_GONE);
|
|
1725
|
+
if (trimMarkerEl.parentElement !== output || output.firstChild !== trimMarkerEl) {
|
|
1726
|
+
output.insertBefore(trimMarkerEl, output.firstChild);
|
|
1727
|
+
}
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
/** The permanent label: nothing below the oldest held index can be recovered. */
|
|
1731
|
+
const TRIM_LABEL_GONE = "earlier output trimmed";
|
|
1732
|
+
/** The recoverable label: history above exists and a fetch can bring it back. */
|
|
1733
|
+
const TRIM_LABEL_PENDING = "earlier output not loaded";
|
|
1734
|
+
|
|
1735
|
+
/** Which top-of-store statement is true, or null for no marker (§5.4). */
|
|
1736
|
+
function topMarkerLabel(): string | null {
|
|
1737
|
+
const oldest = store.oldestIndex();
|
|
1738
|
+
if (oldest <= 0) {
|
|
1739
|
+
return null; // holds index 0, or holds nothing: no history above it
|
|
1740
|
+
}
|
|
1741
|
+
if (!store.pagingDeclared()) {
|
|
1742
|
+
return store.hasTrimmedHistory() ? TRIM_LABEL_GONE : null;
|
|
1743
|
+
}
|
|
1744
|
+
const serverOldest = store.serverOldestIndex();
|
|
1745
|
+
const condemned =
|
|
1746
|
+
store.pagingFloorIndex() >= oldest || (serverOldest >= 0 && serverOldest >= oldest);
|
|
1747
|
+
return condemned ? TRIM_LABEL_GONE : TRIM_LABEL_PENDING;
|
|
937
1748
|
}
|
|
938
1749
|
|
|
939
1750
|
// upsertRow builds or updates the DOM row for an absolute index, or removes it
|
|
@@ -945,6 +1756,7 @@ function upsertRow(abs: number): void {
|
|
|
945
1756
|
const stale = rowEls.get(abs);
|
|
946
1757
|
if (stale) {
|
|
947
1758
|
stale.remove();
|
|
1759
|
+
removedRowsThisPass = true;
|
|
948
1760
|
rowEls.delete(abs);
|
|
949
1761
|
}
|
|
950
1762
|
return;
|
|
@@ -991,8 +1803,13 @@ let altRendered = false;
|
|
|
991
1803
|
// The alt row arrays rendered by the last flush, by grid index. Row identity
|
|
992
1804
|
// is the store's change signal (applyScreen swaps exactly the changed rows'
|
|
993
1805
|
// arrays; getAltRows returns the live references), so `prev[y] === rows[y]`
|
|
994
|
-
// means row y's DOM is already current.
|
|
995
|
-
|
|
1806
|
+
// means row y's DOM is already current. A separate MUTABLE container from the
|
|
1807
|
+
// store's own array (which is mutated in place across frames): the full build
|
|
1808
|
+
// snapshots it once, and the reconcile path updates only the entries it
|
|
1809
|
+
// rebuilt — the previous per-frame `rows.slice()` allocated a fresh array on
|
|
1810
|
+
// every alt flush (60fps GC churn under a full-screen TUI) for identical
|
|
1811
|
+
// semantics. Cleared on alt exit and re-init.
|
|
1812
|
+
let altPrevRows: (readonly WireRun[])[] = [];
|
|
996
1813
|
|
|
997
1814
|
function renderAlt(rows: readonly (readonly WireRun[])[]): void {
|
|
998
1815
|
rowEls.clear();
|
|
@@ -1024,8 +1841,9 @@ function renderAlt(rows: readonly (readonly WireRun[])[]): void {
|
|
|
1024
1841
|
const div = output.children[y] as HTMLDivElement;
|
|
1025
1842
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- y < rows.length === children.length
|
|
1026
1843
|
div.replaceChildren(...buildRowSpans(rows[y]!));
|
|
1844
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- same bound as above
|
|
1845
|
+
altPrevRows[y] = rows[y]!;
|
|
1027
1846
|
}
|
|
1028
|
-
altPrevRows = rows.slice();
|
|
1029
1847
|
}
|
|
1030
1848
|
|
|
1031
1849
|
/** Pin the viewport to the bottom iff the user is following. The scroll
|
|
@@ -1120,6 +1938,14 @@ if (typeof document !== "undefined") {
|
|
|
1120
1938
|
* metrics.
|
|
1121
1939
|
*/
|
|
1122
1940
|
export function updateFontMetrics(): void {
|
|
1941
|
+
// Capture BEFORE the metrics change: a row-height change rescales every
|
|
1942
|
+
// offsetTop, so a holding reader's line moves under them with nothing to
|
|
1943
|
+
// correct it — the read anchor only measures drift across a FLUSH's own
|
|
1944
|
+
// mutations, and a pure restyle may not schedule one at all. `abs` (the line
|
|
1945
|
+
// identity, which is what the reader cares about) is exact regardless of when
|
|
1946
|
+
// this runs; `screenTop` can be up to one row stale if the layout changed
|
|
1947
|
+
// before this call, which is a sub-row error rather than a lost position.
|
|
1948
|
+
const before = captureViewMemory();
|
|
1123
1949
|
const cs = window.getComputedStyle(termWrap);
|
|
1124
1950
|
// Refresh the overlay-positioning padding cache from the same computed
|
|
1125
1951
|
// style (one staleness contract for all cached box metrics).
|
|
@@ -1138,6 +1964,31 @@ export function updateFontMetrics(): void {
|
|
|
1138
1964
|
defaultSpacing = cellWidth - measuredW;
|
|
1139
1965
|
output.style.letterSpacing = `${defaultSpacing}px`;
|
|
1140
1966
|
document.documentElement.style.setProperty("--char-w", `${cellWidth}px`);
|
|
1967
|
+
// A holding reader's line is restored through the same machinery a tab switch
|
|
1968
|
+
// uses: arm it and let the next flush re-assert it. Idempotent when the metrics
|
|
1969
|
+
// did not actually change (the drift measures zero), and a no-op for a
|
|
1970
|
+
// following viewport, whose position the bottom pin already owns.
|
|
1971
|
+
// An armed restore SURVIVES this call, and its baseline has to be refreshed or
|
|
1972
|
+
// it does not: the reflow that provoked the call has already moved scrollTop,
|
|
1973
|
+
// so leaving lastWrote stale makes the next flush read that as a gesture and
|
|
1974
|
+
// cancel the very restore this branch is protecting. Declining to arm and
|
|
1975
|
+
// silently killing the existing arm would be the worst of both.
|
|
1976
|
+
if (pendingRestore !== null) {
|
|
1977
|
+
pendingRestore.lastWrote = scroll.currentScrollTop();
|
|
1978
|
+
}
|
|
1979
|
+
// NEVER over an armed restore. A resize settle calls this (through the
|
|
1980
|
+
// consumer's measurable-size check) and a tab switch reconnects, so the two
|
|
1981
|
+
// routinely overlap: replacing a bind's saved anchor with one measured
|
|
1982
|
+
// mid-rebuild would swap the reader's real position for a transient.
|
|
1983
|
+
if (before !== null && !before.following && pendingRestore === null) {
|
|
1984
|
+
pendingRestore = {
|
|
1985
|
+
view: before,
|
|
1986
|
+
gen: bindGen,
|
|
1987
|
+
lastWrote: scroll.currentScrollTop(),
|
|
1988
|
+
deadline: Date.now() + RESTORE_MAX_MS,
|
|
1989
|
+
};
|
|
1990
|
+
scheduleFlush();
|
|
1991
|
+
}
|
|
1141
1992
|
}
|
|
1142
1993
|
|
|
1143
1994
|
const MIN_COLS = 20;
|