@cplieger/web-terminal-engine 5.3.0 → 6.0.1
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/NOTICE +3 -0
- package/README.md +121 -61
- package/THIRD_PARTY_NOTICES.md +6 -0
- package/jsr.json +3 -1
- package/package.json +4 -2
- package/src/connection.ts +1205 -1676
- package/src/index.ts +20 -100
- package/src/keyboard.ts +56 -133
- package/src/modes.ts +92 -166
- package/src/mouse.ts +380 -413
- package/src/render.ts +1894 -2355
- package/src/scroll.ts +225 -476
- package/src/store.ts +1 -1
- package/src/terminal.ts +135 -0
- package/src/toolbar.ts +29 -43
package/src/render.ts
CHANGED
|
@@ -1,32 +1,21 @@
|
|
|
1
|
-
// Render layer: store-backed, absolute-index DOM rows.
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
// removes the live/history reconciliation that caused the duplicate-rows and
|
|
10
|
-
// view-jumping bugs.
|
|
11
|
-
//
|
|
12
|
-
// Decode frames feed the store (handleScreen/handleScroll); a single
|
|
13
|
-
// requestAnimationFrame flush drains the store's change set and applies it:
|
|
14
|
-
// evicted indices drop their row, dirty indices build/update their row in
|
|
15
|
-
// place. The window block always has `height` rows, so scrollHeight only grows
|
|
16
|
-
// when real history commits — never oscillating mid-redraw.
|
|
1
|
+
// Render layer: store-backed, absolute-index DOM rows. Every terminal line is a
|
|
2
|
+
// `div.term-row` with a `data-abs` attribute equal to its absolute index, in one
|
|
3
|
+
// natively-scrolled container in absolute order; the live window is simply the
|
|
4
|
+
// last `height` indices, so there is no live/history reconciliation to get
|
|
5
|
+
// wrong. Frames feed the store; one requestAnimationFrame flush drains its
|
|
6
|
+
// change set (evicted indices drop their row, dirty ones build in place). The
|
|
7
|
+
// window block always has `height` rows, so scrollHeight grows only when real
|
|
8
|
+
// history commits.
|
|
17
9
|
|
|
18
10
|
import type { ScreenMessage, ScrollMessage, WireRun } from "./types.js";
|
|
19
|
-
import { LineStore, PAGE_SIZE, PREFETCH_THRESHOLD } from "./store.js";
|
|
20
|
-
import
|
|
21
|
-
import {
|
|
11
|
+
import { COMPATIBILITY_TAIL_CAP, LineStore, PAGE_SIZE, PREFETCH_THRESHOLD } from "./store.js";
|
|
12
|
+
import type { ScrollController } from "./scroll.js";
|
|
13
|
+
import type { ModeState } from "./modes.js";
|
|
22
14
|
import { interiorGaps } from "./intervals.js";
|
|
23
15
|
|
|
24
16
|
// --- Width cache (two-tier, xterm.js style) ---
|
|
25
17
|
const WIDTH_FLAT_SIZE = 256;
|
|
26
18
|
const WIDTH_FLAT_UNSET = -9999;
|
|
27
|
-
const widthFlat = new Float32Array(WIDTH_FLAT_SIZE).fill(WIDTH_FLAT_UNSET);
|
|
28
|
-
const widthMap = new Map<string, number>();
|
|
29
|
-
|
|
30
19
|
const VARIANT_REGULAR = 0;
|
|
31
20
|
const VARIANT_BOLD = 1;
|
|
32
21
|
const VARIANT_ITALIC = 2;
|
|
@@ -36,2505 +25,2055 @@ const VARIANT_ITALIC = 2;
|
|
|
36
25
|
// tab close (only a font change clears it). Cap it: a clear on overflow costs
|
|
37
26
|
// an occasional re-measure and changes no rendered output.
|
|
38
27
|
const WIDTH_MAP_MAX = 20_000;
|
|
39
|
-
const variantCtx: (CanvasRenderingContext2D | null)[] = [null, null, null, null];
|
|
40
|
-
let fontString = "";
|
|
41
|
-
|
|
42
|
-
function variantContext(variant: number): CanvasRenderingContext2D {
|
|
43
|
-
let ctx = variantCtx[variant];
|
|
44
|
-
if (ctx) {
|
|
45
|
-
return ctx;
|
|
46
|
-
}
|
|
47
|
-
const canvas = document.createElement("canvas");
|
|
48
|
-
canvas.width = 1;
|
|
49
|
-
canvas.height = 1;
|
|
50
|
-
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- 2d context always available on fresh canvas
|
|
51
|
-
ctx = canvas.getContext("2d")!;
|
|
52
|
-
let f = "";
|
|
53
|
-
if (variant & VARIANT_ITALIC) {
|
|
54
|
-
f += "italic ";
|
|
55
|
-
}
|
|
56
|
-
if (variant & VARIANT_BOLD) {
|
|
57
|
-
f += "bold ";
|
|
58
|
-
}
|
|
59
|
-
f += fontString;
|
|
60
|
-
ctx.font = f;
|
|
61
|
-
variantCtx[variant] = ctx;
|
|
62
|
-
return ctx;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function resetVariantContexts(): void {
|
|
66
|
-
for (let i = 0; i < variantCtx.length; i++) {
|
|
67
|
-
variantCtx[i] = null;
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
function measureChar(ch: string, bold: boolean, italic: boolean): number {
|
|
72
|
-
if (!bold && !italic && ch.length === 1) {
|
|
73
|
-
const cp = ch.charCodeAt(0);
|
|
74
|
-
if (cp < WIDTH_FLAT_SIZE) {
|
|
75
|
-
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounds checked above
|
|
76
|
-
const cached = widthFlat[cp]!;
|
|
77
|
-
if (cached !== WIDTH_FLAT_UNSET) {
|
|
78
|
-
return cached;
|
|
79
|
-
}
|
|
80
|
-
const w = variantContext(VARIANT_REGULAR).measureText(ch).width;
|
|
81
|
-
widthFlat[cp] = w;
|
|
82
|
-
return w;
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
const key = (bold ? "B" : "") + (italic ? "I" : "") + ch;
|
|
86
|
-
const cached = widthMap.get(key);
|
|
87
|
-
if (cached !== undefined) {
|
|
88
|
-
return cached;
|
|
89
|
-
}
|
|
90
|
-
let variant = 0;
|
|
91
|
-
if (bold) {
|
|
92
|
-
variant |= VARIANT_BOLD;
|
|
93
|
-
}
|
|
94
|
-
if (italic) {
|
|
95
|
-
variant |= VARIANT_ITALIC;
|
|
96
|
-
}
|
|
97
|
-
const w = variantContext(variant).measureText(ch).width;
|
|
98
|
-
if (widthMap.size >= WIDTH_MAP_MAX) {
|
|
99
|
-
widthMap.clear();
|
|
100
|
-
}
|
|
101
|
-
widthMap.set(key, w);
|
|
102
|
-
return w;
|
|
103
|
-
}
|
|
104
28
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
span.style.visibility = "hidden";
|
|
110
|
-
span.style.position = "absolute";
|
|
111
|
-
span.style.whiteSpace = "pre";
|
|
112
|
-
span.textContent = "MMMMMMMMMM";
|
|
113
|
-
termWrap.appendChild(span);
|
|
114
|
-
const width = span.getBoundingClientRect().width / 10;
|
|
115
|
-
termWrap.removeChild(span);
|
|
116
|
-
return width;
|
|
117
|
-
}
|
|
29
|
+
const MAX_ROWS_PER_FRAME = 300;
|
|
30
|
+
const MAX_RENDER_NO_PROGRESS_RETRIES = 3;
|
|
31
|
+
const MIN_COLS = 20;
|
|
32
|
+
const MIN_ROWS = 5;
|
|
118
33
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
34
|
+
const RESTORE_SETTLE_MS = 250;
|
|
35
|
+
const RESTORE_MAX_MS = 30000;
|
|
36
|
+
// A position within this many px of what we last wrote is still ours: a
|
|
37
|
+
// fractional-layout or subpixel-DPR readback is not a user gesture.
|
|
38
|
+
const RESTORE_OWN_WRITE_EPSILON_PX = 1;
|
|
122
39
|
|
|
123
|
-
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
|
|
40
|
+
const CURSOR_BLINK_MS = 530;
|
|
41
|
+
// While the application hides the cursor (DECTCM — e.g. a full-screen TUI
|
|
42
|
+
// that paints its own cursor cell, like an agent front-end), the fast toggle
|
|
43
|
+
// would only restyle a display:none overlay, so the interval downshifts to
|
|
44
|
+
// this slow re-check: still polling for the cursor's return (a self-healing
|
|
45
|
+
// backstop even if a transition frame were somehow missed — the flush hook is
|
|
46
|
+
// the primary, instant restart), at a fraction of the 530ms blink's wakeups.
|
|
47
|
+
// For agent consumers the hidden-cursor state is the session's steady state.
|
|
48
|
+
const CURSOR_RECHECK_MS = 4000;
|
|
49
|
+
type BlinkMode = "off" | "idle" | "fast";
|
|
131
50
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
51
|
+
/** The permanent label: nothing below the oldest held index can be recovered. */
|
|
52
|
+
const TRIM_LABEL_GONE = "earlier output trimmed";
|
|
53
|
+
/** The recoverable label: history above exists and a fetch can bring it back. */
|
|
54
|
+
const TRIM_LABEL_PENDING = "earlier output not loaded";
|
|
136
55
|
|
|
137
56
|
/**
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
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).
|
|
57
|
+
* A saved reading position in the only terms that survive the content growing
|
|
58
|
+
* underneath it. Opaque to the consumer: read one out of the renderer and hand
|
|
59
|
+
* the same value back to restore it. A memory is only meaningful to the store
|
|
60
|
+
* it was taken from, since `abs` names a line in THAT index space, and a
|
|
61
|
+
* restore is re-asserted until the named row has been built.
|
|
149
62
|
*/
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
// A session restore (kiro-cli's /chat), a post-resize transcript reprint, or a
|
|
158
|
-
// `cat bigfile` dumps thousands of lines in one wire frame; building them all
|
|
159
|
-
// in a single rAF janks or, on a constrained device, hangs the tab. The store
|
|
160
|
-
// still ingests the whole burst at once (it is cheap, pure data); the renderer
|
|
161
|
-
// drains this queue at most MAX_ROWS_PER_FRAME per frame and reschedules until
|
|
162
|
-
// it is empty, so each frame stays short. The drain is VIEWPORT-FIRST each
|
|
163
|
-
// frame (see flushRenderInner): live-window rows build before any scrollback
|
|
164
|
-
// backlog, and scrollback drains newest->oldest so the backlog fills upward,
|
|
165
|
-
// above the bottom-pinned viewport — offscreen — instead of visibly streaming
|
|
166
|
-
// through it. The cursor row is always built regardless of the budget so the
|
|
167
|
-
// caret never lags.
|
|
168
|
-
const renderQueue = new Set<number>();
|
|
169
|
-
const MAX_ROWS_PER_FRAME = 300;
|
|
170
|
-
|
|
171
|
-
// Cursor state, refreshed from the store window on each flush. The caret is
|
|
172
|
-
// painted by a dedicated overlay element (see positionCursorOverlay), NOT by
|
|
173
|
-
// restyling the span at the cursor cell: rows are pure content, so cursor
|
|
174
|
-
// motion never rewrites row DOM — which preserves any native selection in the
|
|
175
|
-
// old/new cursor rows and deletes the per-keystroke row rebuild entirely
|
|
176
|
-
// (judgement finding: changed-row replaceChildren collapsed a row-local
|
|
177
|
-
// selection).
|
|
178
|
-
let cursorAbs = -1; // absolute index of the row the cursor is on
|
|
179
|
-
let cursorCol = 0;
|
|
180
|
-
let cursorHidden = false;
|
|
181
|
-
let cursorStyleVal = 0; // 0-6: DECSCUSR
|
|
182
|
-
|
|
183
|
-
function cursorClassName(): string {
|
|
184
|
-
// DECSCUSR: 0/1=blinking block, 2=steady block, 3=blinking underline,
|
|
185
|
-
// 4=steady underline, 5=blinking bar, 6=steady bar
|
|
186
|
-
if (cursorStyleVal === 3 || cursorStyleVal === 4) {
|
|
187
|
-
return "term-cursor-underline";
|
|
188
|
-
}
|
|
189
|
-
if (cursorStyleVal === 5 || cursorStyleVal === 6) {
|
|
190
|
-
return "term-cursor-bar";
|
|
191
|
-
}
|
|
192
|
-
return "term-cursor";
|
|
193
|
-
}
|
|
194
|
-
let cellWidth = 8;
|
|
195
|
-
let cellHeight = 17;
|
|
196
|
-
let defaultSpacing = 0;
|
|
197
|
-
let onCursorMove: (() => void) | null = null;
|
|
198
|
-
let pendingFrame: number | undefined;
|
|
199
|
-
|
|
200
|
-
// termWrap's padding, cached: the overlay positioners (caret, predicted
|
|
201
|
-
// cursor, getCursorPx for the IME view) need it EVERY flush, and a live
|
|
202
|
-
// getComputedStyle after the flush's DOM writes forces a style recalc —
|
|
203
|
-
// measured at real cost in the render bench (the caret positioner alone was
|
|
204
|
-
// ~11% of flush CPU under full-screen churn). Lazily read once per attach and
|
|
205
|
-
// refreshed by updateFontMetrics (the same staleness contract as
|
|
206
|
-
// cellWidth/cellHeight: a consumer that restyles the terminal calls
|
|
207
|
-
// updateFontMetrics, which re-reads both).
|
|
208
|
-
let padLeft = 0;
|
|
209
|
-
let padTop = 0;
|
|
210
|
-
let padValid = false;
|
|
211
|
-
|
|
212
|
-
function termPadding(): { padL: number; padT: number } {
|
|
213
|
-
if (!padValid) {
|
|
214
|
-
const cs = window.getComputedStyle(termWrap);
|
|
215
|
-
padLeft = parseFloat(cs.paddingLeft);
|
|
216
|
-
padTop = parseFloat(cs.paddingTop);
|
|
217
|
-
padValid = true;
|
|
218
|
-
}
|
|
219
|
-
return { padL: padLeft, padT: padTop };
|
|
63
|
+
export interface ViewMemory {
|
|
64
|
+
/** Absolute line index of the content row at the viewport top. */
|
|
65
|
+
abs: number;
|
|
66
|
+
/** That row's on-screen position: offsetTop minus the scroll offset. */
|
|
67
|
+
screenTop: number;
|
|
68
|
+
/** Whether auto-follow was engaged. */
|
|
69
|
+
following: boolean;
|
|
220
70
|
}
|
|
221
71
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
// retry a transient throw (a font/measureText race), but a row that throws
|
|
226
|
-
// deterministically would otherwise turn catch -> rAF -> throw into a ~60fps
|
|
227
|
-
// busy loop (CPU/battery burn + per-frame console spam) that never stops, even
|
|
228
|
-
// on an idle session. `flushDrainedThisPass` records forward progress (queued
|
|
229
|
-
// rows actually built this pass; reset at each flushRenderInner entry, and
|
|
230
|
-
// visible to the catch because a mid-drain throw leaves it at the count so
|
|
231
|
-
// far). `renderNoProgressStreak` counts consecutive passes that threw with zero
|
|
232
|
-
// progress; once it passes the cap the catch stops rescheduling and lets the
|
|
233
|
-
// next inbound frame retry (the pre-l-f28 behavior for a stuck row).
|
|
234
|
-
let flushDrainedThisPass = 0;
|
|
235
|
-
let renderNoProgressStreak = 0;
|
|
236
|
-
const MAX_RENDER_NO_PROGRESS_RETRIES = 3;
|
|
237
|
-
// The stall canary's latch (see the reschedule rule near flushRender). Reset by
|
|
238
|
-
// init, and by any flush that ends in a healthy state, so it suppresses a repeat
|
|
239
|
-
// of ONE stall rather than every stall for the life of the process.
|
|
240
|
-
let unnamedStallReported = false;
|
|
241
|
-
// Whether the CURRENT flush performed a full reset (server restart / epoch
|
|
242
|
-
// change). Read by restoreReadAnchor after the flush: a re-anchor across a
|
|
243
|
-
// reset would match unrelated content from the new index space (R2 review).
|
|
244
|
-
let fullResetThisPass = false;
|
|
245
|
-
// The ED3 base observed since the last flush, or -1. Set by handleScreen from
|
|
246
|
-
// the frame itself; consumed (and reset) by the pass that applies it. Renderer-
|
|
247
|
-
// local on purpose: the renderer is the caller of every path that discards a
|
|
248
|
-
// REGION rather than trimming the cap, so nothing in the store's change set has
|
|
249
|
-
// to grow a field for it (docs/scroll-position-fidelity.md §5).
|
|
250
|
-
let discardedBelowPending = -1;
|
|
251
|
-
let discardedBelowThisPass = -1;
|
|
252
|
-
// Whether THIS pass removed rows from the DOM. Only a pass that did can have
|
|
253
|
-
// caused a clamp, and announcing one that did not is unsound on Safari, which
|
|
254
|
-
// updates scrollTop PAST the maximum during an overscroll bounce: the settle back
|
|
255
|
-
// from a rubber-band is a downward move in value with no content change at all,
|
|
256
|
-
// and arming for it would hand the user's own gesture a pass-through.
|
|
257
|
-
let removedRowsThisPass = false;
|
|
258
|
-
|
|
259
|
-
/**
|
|
260
|
-
* Initialize the renderer by attaching it to a pair of DOM elements: the
|
|
261
|
-
* scrollable terminal wrapper and the inner output container that receives
|
|
262
|
-
* row elements. Must be called once before any handleScreen/handleScroll call.
|
|
263
|
-
*
|
|
264
|
-
* @param opts.output Inner element that holds row children.
|
|
265
|
-
* @param opts.termWrap Outer scroll container.
|
|
266
|
-
* @param opts.onCursorMove Optional callback invoked when the cursor moves.
|
|
267
|
-
* @param opts.maxLines Optional retained-line cap for the implicit store
|
|
268
|
-
* (default 5000) — a HISTORY budget floored at the
|
|
269
|
-
* live screen (the store never evicts the current
|
|
270
|
-
* window, so a cap at or below the terminal height
|
|
271
|
-
* keeps the full screen with no scrollback). Governs
|
|
272
|
-
* how many absolute-index lines — and therefore DOM
|
|
273
|
-
* rows — the client keeps; a memory-constrained
|
|
274
|
-
* consumer (a phone) passes a smaller budget. A
|
|
275
|
-
* consumer that manages stores itself (a tabbed
|
|
276
|
-
* shell) constructs each `LineStore` with its own cap
|
|
277
|
-
* and `bind()`s it AFTER init; init always installs a
|
|
278
|
-
* fresh implicit store, so call it before any bind.
|
|
279
|
-
* For the batched eviction to help, choose a cap
|
|
280
|
-
* comfortably above the terminal height (near or
|
|
281
|
-
* below it, eviction degrades to per-line — the
|
|
282
|
-
* screen stays correct, but the churn returns).
|
|
283
|
-
* Non-positive or non-integer values warn and are
|
|
284
|
-
* ignored (the default applies).
|
|
285
|
-
*/
|
|
286
|
-
export function init(opts: {
|
|
72
|
+
/** What `createRenderer` binds to, reads and calls back. */
|
|
73
|
+
export interface RendererOptions {
|
|
74
|
+
/** Inner element that receives the row children. */
|
|
287
75
|
output: HTMLElement;
|
|
76
|
+
/** Outer scroll container; the overlays, `--char-w` and the blink class live on it. */
|
|
288
77
|
termWrap: HTMLElement;
|
|
78
|
+
scroll: ScrollController;
|
|
79
|
+
modes: Pick<ModeState, "isReverseVideo">;
|
|
80
|
+
/** Invoked when the cursor moves. */
|
|
289
81
|
onCursorMove?: () => void;
|
|
82
|
+
/**
|
|
83
|
+
* Retained-line cap for the implicit store (default 5000): a HISTORY budget
|
|
84
|
+
* floored at the live screen, which is never evicted. A non-positive or
|
|
85
|
+
* non-integer value warns and the default applies.
|
|
86
|
+
*/
|
|
290
87
|
maxLines?: number;
|
|
291
|
-
/**
|
|
292
|
-
*
|
|
293
|
-
*
|
|
88
|
+
/**
|
|
89
|
+
* Ask the transport for a page of history (`connection.requestHistory`).
|
|
90
|
+
* Absent means paging is not available and the controller stays dormant.
|
|
91
|
+
*/
|
|
294
92
|
requestHistory?: (fromAbs: number, maxLines: number) => boolean;
|
|
295
|
-
/**
|
|
296
|
-
*
|
|
297
|
-
*
|
|
298
|
-
|
|
93
|
+
/**
|
|
94
|
+
* The transport's current adaptive request budget (`connection.historyBudget`),
|
|
95
|
+
* read at fire time for both the request's length and its anchor.
|
|
96
|
+
*/
|
|
299
97
|
historyBudget?: () => number;
|
|
300
|
-
}): void {
|
|
301
|
-
output = opts.output;
|
|
302
|
-
termWrap = opts.termWrap;
|
|
303
|
-
onCursorMove = opts.onCursorMove ?? null;
|
|
304
|
-
requestHistoryFn = opts.requestHistory ?? null;
|
|
305
|
-
historyBudgetFn = opts.historyBudget ?? null;
|
|
306
|
-
// New attach target: the cached padding belongs to the previous termWrap.
|
|
307
|
-
padValid = false;
|
|
308
|
-
// Fresh attach: ALWAYS install a fresh implicit store, so no cap — a
|
|
309
|
-
// consumer-configured one, or a bound per-tab store's — leaks from a
|
|
310
|
-
// previous attachment into this one (init is the attachment boundary;
|
|
311
|
-
// vitest's non-isolated module reuse relies on the same reset).
|
|
312
|
-
const validCap =
|
|
313
|
-
opts.maxLines !== undefined && Number.isInteger(opts.maxLines) && opts.maxLines > 0;
|
|
314
|
-
if (opts.maxLines !== undefined && !validCap) {
|
|
315
|
-
console.warn(`vterm: ignoring invalid maxLines ${String(opts.maxLines)}`);
|
|
316
|
-
}
|
|
317
|
-
// An absent OR invalid cap is forwarded as absent, which is how the store
|
|
318
|
-
// spells "engine's choice"; a value here would be indistinguishable from a
|
|
319
|
-
// consumer decision and would pin the tail at the compatibility cap forever.
|
|
320
|
-
store = new LineStore(validCap ? opts.maxLines : undefined);
|
|
321
|
-
rowEls.clear();
|
|
322
|
-
renderQueue.clear();
|
|
323
|
-
trimMarkerEl = null;
|
|
324
|
-
for (const el of gapMarkerEls.values()) {
|
|
325
|
-
el.remove();
|
|
326
|
-
}
|
|
327
|
-
gapMarkerEls.clear();
|
|
328
|
-
// Drop the predicted-cursor + caret overlays (re-created lazily against the
|
|
329
|
-
// new termWrap) so re-init starts clean.
|
|
330
|
-
if (predCursorEl) {
|
|
331
|
-
predCursorEl.remove();
|
|
332
|
-
predCursorEl = null;
|
|
333
|
-
}
|
|
334
|
-
if (cursorEl) {
|
|
335
|
-
cursorEl.remove();
|
|
336
|
-
cursorEl = null;
|
|
337
|
-
}
|
|
338
|
-
output.replaceChildren();
|
|
339
|
-
cursorAbs = -1;
|
|
340
|
-
if (pendingFrame !== undefined) {
|
|
341
|
-
cancelAnimationFrame(pendingFrame);
|
|
342
|
-
pendingFrame = undefined;
|
|
343
|
-
}
|
|
344
|
-
flushDrainedThisPass = 0;
|
|
345
|
-
renderNoProgressStreak = 0;
|
|
346
|
-
// The stall canary latches so it cannot spam, and init is the attachment
|
|
347
|
-
// boundary, so the latch resets here with every other piece of pass state. A
|
|
348
|
-
// latch that survived would make the canary a once-per-PROCESS report, silent
|
|
349
|
-
// for every later attachment and for every genuinely new stall.
|
|
350
|
-
unnamedStallReported = false;
|
|
351
|
-
// Attachment boundary: a view restore armed against the PREVIOUS surface must
|
|
352
|
-
// not survive it, or its timer outlives teardown and writes scrollTop on a
|
|
353
|
-
// detached element. Same discipline scroll.init applies to its own arms.
|
|
354
|
-
bindGen++;
|
|
355
|
-
clearPendingRestore();
|
|
356
|
-
discardedBelowPending = -1;
|
|
357
|
-
discardedBelowThisPass = -1;
|
|
358
|
-
lastInboundMs = 0;
|
|
359
|
-
syncCursorBlink();
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
/**
|
|
363
|
-
* Reset internal screen state so the next frame performs a full repaint.
|
|
364
|
-
* With the store model this is a full reset (used on server restart): the
|
|
365
|
-
* store clears and the next flush wipes and rebuilds the DOM.
|
|
366
|
-
*/
|
|
367
|
-
export function resetScreen(): void {
|
|
368
|
-
store.reset();
|
|
369
|
-
scheduleFlush();
|
|
370
98
|
}
|
|
371
99
|
|
|
372
100
|
/**
|
|
373
|
-
*
|
|
374
|
-
*
|
|
101
|
+
* The DOM renderer: it owns a `LineStore` of absolute-index lines and reflects
|
|
102
|
+
* it to one `div.term-row` per line inside `termWrap`, so there is no
|
|
103
|
+
* live-zone/scrollback split to reconcile. Usable from construction with
|
|
104
|
+
* fallback cell metrics; `updateFontMetrics` measures the real font.
|
|
375
105
|
*/
|
|
376
|
-
export
|
|
377
|
-
|
|
378
|
-
|
|
106
|
+
export interface Renderer {
|
|
107
|
+
/** Reset internal screen state so the next frame performs a full repaint. */
|
|
108
|
+
resetScreen(): void;
|
|
109
|
+
/** Clear all rows (history + window); equivalent to `resetScreen`. */
|
|
110
|
+
resetScrollback(): void;
|
|
111
|
+
/**
|
|
112
|
+
* Bind the renderer to a different store and rebuild the surface from it.
|
|
113
|
+
* `opts.view` is the per-view memory `captureViewMemory()` returned when the
|
|
114
|
+
* consumer last left this store; passing `opts` at all means the caller owns
|
|
115
|
+
* the view, and a null view is "no memory", whose follow state is the tail.
|
|
116
|
+
*/
|
|
117
|
+
bind(next: LineStore, opts?: { view?: ViewMemory | null }): void;
|
|
118
|
+
/** The store the renderer is currently bound to; a fresh detached store once disposed. */
|
|
119
|
+
boundStore(): LineStore;
|
|
120
|
+
/** Wipe the DOM and rebuild it from the current store, viewport-first and budgeted. */
|
|
121
|
+
rebuild(): void;
|
|
122
|
+
/**
|
|
123
|
+
* Highest absolute line index the client holds, or -1 if empty. NOT the resume
|
|
124
|
+
* `haveThrough`; that is `getReplayBoundary`.
|
|
125
|
+
*/
|
|
126
|
+
getHighestIndex(): number;
|
|
127
|
+
/**
|
|
128
|
+
* The resume `haveThrough`: the highest index this client will not ask the
|
|
129
|
+
* server to re-send. Differs from `getHighestIndex` by the provisional rows
|
|
130
|
+
* the application drew and the server has not committed.
|
|
131
|
+
*/
|
|
132
|
+
getReplayBoundary(): number;
|
|
133
|
+
/** Record the server's retained-history bounds from a resumeAck. */
|
|
134
|
+
noteResumeBounds(committed: number, oldest: number): void;
|
|
135
|
+
/**
|
|
136
|
+
* Apply a correlated history page; when the reply proved history is gone,
|
|
137
|
+
* raise the paging floor so nothing below it is requested again.
|
|
138
|
+
*/
|
|
139
|
+
handleHistoryReply(msg: ScrollMessage, raiseFloorTo: number | null): void;
|
|
140
|
+
/** Run the store's single resume-ack transition, supplying the viewport the store cannot see. */
|
|
141
|
+
applyResumeTransition(ack: {
|
|
142
|
+
epochChanged: boolean;
|
|
143
|
+
committed: number | null;
|
|
144
|
+
serverOldest: number | null;
|
|
145
|
+
paging: boolean;
|
|
146
|
+
sentHaveThrough: number;
|
|
147
|
+
sentReplayMax: number | null;
|
|
148
|
+
}): void;
|
|
149
|
+
/** Record the window of a history request going out. */
|
|
150
|
+
noteSolicited(fromAbs: number, end: number): void;
|
|
151
|
+
/** Release the in-flight window (reply applied, timed out, socket gone). */
|
|
152
|
+
clearSolicited(): void;
|
|
153
|
+
/** Drop the browse cache; the consumer owns the TTL and the visibility state. */
|
|
154
|
+
dropBrowseCache(pageVisible: boolean): void;
|
|
155
|
+
/** When the browse cache was last created or refreshed, for a consumer's TTL. */
|
|
156
|
+
lastBrowseActivityMs(): number;
|
|
157
|
+
/** Lines currently held as disposable browse cache. */
|
|
158
|
+
browseCacheSize(): number;
|
|
159
|
+
/** The resume replay bound to send: residency minus the window about to arrive anyway. */
|
|
160
|
+
replayMaxForResume(): number;
|
|
161
|
+
/**
|
|
162
|
+
* Rows queued for a DOM (re)build but not yet built. A render-side measure
|
|
163
|
+
* that reaches zero between replay chunks, so not a restore-complete signal.
|
|
164
|
+
*/
|
|
165
|
+
pendingRowCount(): number;
|
|
166
|
+
/** Apply a `ScreenMessage` to the store and schedule a flush. */
|
|
167
|
+
handleScreen(msg: ScreenMessage): void;
|
|
168
|
+
/** Apply a `ScrollMessage` to the store and schedule a flush. */
|
|
169
|
+
handleScroll(msg: ScrollMessage): void;
|
|
170
|
+
/**
|
|
171
|
+
* The renderer's half of a scroll-position event: re-evaluate the paging
|
|
172
|
+
* trigger, then resume a drain that stopped with rows still queued.
|
|
173
|
+
*/
|
|
174
|
+
handleScrollPosition(): void;
|
|
175
|
+
/**
|
|
176
|
+
* Capture the current view as per-view scroll memory a consumer can hand back
|
|
177
|
+
* to `bind`; null when there are no content rows or the alternate screen is active.
|
|
178
|
+
*/
|
|
179
|
+
captureViewMemory(): ViewMemory | null;
|
|
180
|
+
/** The absolute index of an ARMED view restore, or null. */
|
|
181
|
+
pendingRestoreAbs(): number | null;
|
|
182
|
+
/** The fetch trigger: decide whether to ask for a page, and for which range. Cheap to over-call. */
|
|
183
|
+
maybeFetchHistory(): void;
|
|
184
|
+
/**
|
|
185
|
+
* Re-measure the cell width/height from the rendered DOM. Call after any font
|
|
186
|
+
* or zoom change so subsequent `computeSize()` and `getCursorPx()` use fresh
|
|
187
|
+
* metrics.
|
|
188
|
+
*/
|
|
189
|
+
updateFontMetrics(): void;
|
|
190
|
+
/** The integer (cols, rows) the terminal element can fit, clamped to 20x5; `{0,0}` once disposed. */
|
|
191
|
+
computeSize(): { cols: number; rows: number };
|
|
192
|
+
/**
|
|
193
|
+
* The grid currently ON SCREEN (rows from the last frame, 0 before the first),
|
|
194
|
+
* for `MouseInputHandler.gridSize`; `{0,0}` once disposed.
|
|
195
|
+
*/
|
|
196
|
+
gridSize(): { cols: number; rows: number };
|
|
197
|
+
/** The cursor's pixel position in `termWrap`'s coordinate space plus the cell height. */
|
|
198
|
+
getCursorPx(): { left: number; top: number; cellH: number };
|
|
199
|
+
/** The measured pixel size of one cell; the 8x17 fallback before `updateFontMetrics`. */
|
|
200
|
+
cellSize(): { width: number; height: number };
|
|
201
|
+
/** Show or hide the predicted-cursor overlay at window-relative (row, col). */
|
|
202
|
+
setPredictedCursor(row: number, col: number, active: boolean): void;
|
|
203
|
+
/** Apply or remove the DECSCNM reverse-video class on `termWrap`. */
|
|
204
|
+
updateReverseVideo(): void;
|
|
205
|
+
/**
|
|
206
|
+
* Release every timer, listener, element and callback the renderer holds and
|
|
207
|
+
* clear its DOM; every method stays callable and answers its empty value.
|
|
208
|
+
*/
|
|
209
|
+
dispose(): void;
|
|
379
210
|
}
|
|
380
211
|
|
|
381
212
|
/**
|
|
382
|
-
*
|
|
383
|
-
*
|
|
384
|
-
* active tab's cached LineStore (design sections 5, 6, 8). The DOM is wiped and
|
|
385
|
-
* repainted viewport-first from the new store; this is local, so the last-known
|
|
386
|
-
* screen paints without a network round-trip.
|
|
387
|
-
*
|
|
388
|
-
* `opts.view` is the per-view scroll memory `captureViewMemory()` returned when
|
|
389
|
-
* the consumer last left this store, and passing it makes the swap ATOMIC. Both
|
|
390
|
-
* halves matter and they land at different times on purpose:
|
|
391
|
-
*
|
|
392
|
-
* - the FOLLOW half is adopted SYNCHRONOUSLY, before the wipe, so the very
|
|
393
|
-
* first flush's bottom pin is gated on the INCOMING view's state. The
|
|
394
|
-
* follow flag is global (one per kernel), so without this the first frame
|
|
395
|
-
* after a switch is gated on the state of the tab the user just LEFT —
|
|
396
|
-
* binding a following tab while holding in another rendered the cached
|
|
397
|
-
* screen above the viewport and left a black gap until a touch re-engaged
|
|
398
|
-
* follow. It is applied through restoreView at the CURRENT offset, which
|
|
399
|
-
* sets the flag without moving the position.
|
|
400
|
-
* - the POSITION half is armed and re-asserted across the rebuild's frames
|
|
401
|
-
* (see applyPendingRestore), because the row it names is usually not built
|
|
402
|
-
* yet. A view left FOLLOWING arms nothing: the per-flush bottom pin already
|
|
403
|
-
* is the correct answer, which keeps the common path free of any restore.
|
|
213
|
+
* Creates a renderer over `opts.output` inside `opts.termWrap`. Throws, holding
|
|
214
|
+
* nothing, when a listener or timer cannot be acquired.
|
|
404
215
|
*/
|
|
405
|
-
export function
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
// stale-global-flag bug this seam exists to close, just narrowed to the tabs
|
|
413
|
-
// that have nothing saved. A caller that passes no `opts` keeps the pre-3.7
|
|
414
|
-
// behavior and its follow state is left alone.
|
|
415
|
-
if (opts !== undefined) {
|
|
416
|
-
scroll.restoreView({
|
|
417
|
-
top: scroll.currentScrollTop(),
|
|
418
|
-
following: view === null ? true : view.following,
|
|
419
|
-
});
|
|
216
|
+
export function createRenderer(opts: RendererOptions): Renderer {
|
|
217
|
+
// Invariant: `scroll` and `modes` are read by member call at each call site, never as captured methods.
|
|
218
|
+
const { scroll, modes } = opts;
|
|
219
|
+
const validCap =
|
|
220
|
+
opts.maxLines !== undefined && Number.isInteger(opts.maxLines) && opts.maxLines > 0;
|
|
221
|
+
if (opts.maxLines !== undefined && !validCap) {
|
|
222
|
+
console.warn(`vterm: ignoring invalid maxLines ${String(opts.maxLines)}`);
|
|
420
223
|
}
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
224
|
+
let disposed = false;
|
|
225
|
+
const widthFlat = new Float32Array(WIDTH_FLAT_SIZE).fill(WIDTH_FLAT_UNSET);
|
|
226
|
+
const widthMap = new Map<string, number>();
|
|
227
|
+
const variantCtx: (CanvasRenderingContext2D | null)[] = [null, null, null, null];
|
|
228
|
+
let fontString = "";
|
|
229
|
+
|
|
230
|
+
function variantContext(variant: number): CanvasRenderingContext2D {
|
|
231
|
+
let ctx = variantCtx[variant];
|
|
232
|
+
if (ctx) {
|
|
233
|
+
return ctx;
|
|
234
|
+
}
|
|
235
|
+
const canvas = document.createElement("canvas");
|
|
236
|
+
canvas.width = 1;
|
|
237
|
+
canvas.height = 1;
|
|
238
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- 2d context always available on fresh canvas
|
|
239
|
+
ctx = canvas.getContext("2d")!;
|
|
240
|
+
let f = "";
|
|
241
|
+
if (variant & VARIANT_ITALIC) {
|
|
242
|
+
f += "italic ";
|
|
243
|
+
}
|
|
244
|
+
if (variant & VARIANT_BOLD) {
|
|
245
|
+
f += "bold ";
|
|
246
|
+
}
|
|
247
|
+
f += fontString;
|
|
248
|
+
ctx.font = f;
|
|
249
|
+
variantCtx[variant] = ctx;
|
|
250
|
+
return ctx;
|
|
448
251
|
}
|
|
449
|
-
}
|
|
450
252
|
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
return store;
|
|
457
|
-
}
|
|
253
|
+
function resetVariantContexts(): void {
|
|
254
|
+
for (let i = 0; i < variantCtx.length; i++) {
|
|
255
|
+
variantCtx[i] = null;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
458
258
|
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
if (
|
|
476
|
-
|
|
477
|
-
}
|
|
478
|
-
|
|
259
|
+
function measureChar(ch: string, bold: boolean, italic: boolean): number {
|
|
260
|
+
if (!bold && !italic && ch.length === 1) {
|
|
261
|
+
const cp = ch.charCodeAt(0);
|
|
262
|
+
if (cp < WIDTH_FLAT_SIZE) {
|
|
263
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounds checked above
|
|
264
|
+
const cached = widthFlat[cp]!;
|
|
265
|
+
if (cached !== WIDTH_FLAT_UNSET) {
|
|
266
|
+
return cached;
|
|
267
|
+
}
|
|
268
|
+
const w = variantContext(VARIANT_REGULAR).measureText(ch).width;
|
|
269
|
+
widthFlat[cp] = w;
|
|
270
|
+
return w;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
const key = (bold ? "B" : "") + (italic ? "I" : "") + ch;
|
|
274
|
+
const cached = widthMap.get(key);
|
|
275
|
+
if (cached !== undefined) {
|
|
276
|
+
return cached;
|
|
277
|
+
}
|
|
278
|
+
let variant = 0;
|
|
279
|
+
if (bold) {
|
|
280
|
+
variant |= VARIANT_BOLD;
|
|
281
|
+
}
|
|
282
|
+
if (italic) {
|
|
283
|
+
variant |= VARIANT_ITALIC;
|
|
479
284
|
}
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
285
|
+
const w = variantContext(variant).measureText(ch).width;
|
|
286
|
+
if (widthMap.size >= WIDTH_MAP_MAX) {
|
|
287
|
+
widthMap.clear();
|
|
288
|
+
}
|
|
289
|
+
widthMap.set(key, w);
|
|
290
|
+
return w;
|
|
483
291
|
}
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
292
|
+
|
|
293
|
+
// Measured against termWrap so the web font applied by CSS is what is measured.
|
|
294
|
+
function measureCellWidth(el: HTMLElement): number {
|
|
295
|
+
const span = document.createElement("span");
|
|
296
|
+
span.style.visibility = "hidden";
|
|
297
|
+
span.style.position = "absolute";
|
|
298
|
+
span.style.whiteSpace = "pre";
|
|
299
|
+
span.textContent = "MMMMMMMMMM";
|
|
300
|
+
el.appendChild(span);
|
|
301
|
+
const width = span.getBoundingClientRect().width / 10;
|
|
302
|
+
el.removeChild(span);
|
|
303
|
+
return width;
|
|
487
304
|
}
|
|
488
|
-
}
|
|
489
305
|
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
* from newest to oldest so rows adjacent to the viewport fill in before deep
|
|
494
|
-
* history; the existing per-frame budget spreads a large backlog across frames
|
|
495
|
-
* so the switch never janks. Used by bind(); also safe to call directly to
|
|
496
|
-
* force a full repaint of the current store.
|
|
497
|
-
*
|
|
498
|
-
* The wipe covers content space in full: the rows AND the four overlays anchored
|
|
499
|
-
* into it (see collapseContentSpaceOverlays). Leaving the overlays behind left
|
|
500
|
-
* the container a measured 81081px of scroll range over zero rows of content,
|
|
501
|
-
* which is a black pane the bottom pin refuses to correct.
|
|
502
|
-
*/
|
|
503
|
-
export function rebuild(): void {
|
|
504
|
-
// The wipe is the largest content shrink this module performs, and it happens
|
|
505
|
-
// OUTSIDE a flush (bind calls it synchronously), so the flush's own
|
|
506
|
-
// announcement cannot cover it. Unannounced, the clamp it causes falls back to
|
|
507
|
-
// the epsilon — exactly the inference §4 exists to stop relying on.
|
|
508
|
-
const scrollTopBeforeWipe = scroll.currentScrollTop();
|
|
509
|
-
output.replaceChildren();
|
|
510
|
-
rowEls.clear();
|
|
511
|
-
renderQueue.clear();
|
|
512
|
-
trimMarkerEl = null;
|
|
513
|
-
for (const el of gapMarkerEls.values()) {
|
|
514
|
-
el.remove();
|
|
515
|
-
}
|
|
516
|
-
gapMarkerEls.clear();
|
|
517
|
-
cursorAbs = -1;
|
|
518
|
-
altRendered = false;
|
|
519
|
-
altPrevRows = [];
|
|
520
|
-
// Fresh surface: a stale give-up streak from a prior store must not deny the
|
|
521
|
-
// rebuilt surface its full transient-retry budget.
|
|
522
|
-
renderNoProgressStreak = 0;
|
|
523
|
-
// The rows are not the only thing in content space. Collapse the overlays
|
|
524
|
-
// with them, BEFORE the offset below is read.
|
|
525
|
-
collapseContentSpaceOverlays();
|
|
526
|
-
// Alt screen paints from the ephemeral grid in the flush, so it needs no
|
|
527
|
-
// absolute-index queueing here.
|
|
528
|
-
if (!store.isAlt()) {
|
|
529
|
-
queueRowsViewportFirst();
|
|
530
|
-
}
|
|
531
|
-
scroll.noteContentShrink(scrollTopBeforeWipe);
|
|
532
|
-
scheduleFlush();
|
|
533
|
-
}
|
|
306
|
+
// --- State --- (the nullable fields are null once disposed)
|
|
307
|
+
let output: HTMLElement | null = opts.output;
|
|
308
|
+
let termWrap: HTMLElement | null = opts.termWrap;
|
|
534
309
|
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
function
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
310
|
+
// An absent OR invalid cap is forwarded as absent, which is how the store
|
|
311
|
+
// spells "engine's choice"; a value here would be indistinguishable from a
|
|
312
|
+
// consumer decision and would pin the tail at the compatibility cap forever.
|
|
313
|
+
let store: LineStore | null = new LineStore(validCap ? opts.maxLines : undefined);
|
|
314
|
+
// abs index -> its row element, kept in ascending data-abs order in `output`.
|
|
315
|
+
const rowEls = new Map<number, HTMLDivElement>();
|
|
316
|
+
|
|
317
|
+
// The "earlier output trimmed" marker, a data-abs-less first child of output.
|
|
318
|
+
let trimMarkerEl: HTMLDivElement | null = null;
|
|
319
|
+
|
|
320
|
+
// Per-gap "earlier output not loaded" markers, keyed by the gap's LOW index.
|
|
321
|
+
// Each carries that index as `data-abs` so `insertRowInOrder` and the
|
|
322
|
+
// read-anchor binary searches keep their monotonic-`data-abs` invariant; an
|
|
323
|
+
// INTERIOR marker cannot be data-abs-less the way the top trim marker is. A
|
|
324
|
+
// marker is a PROJECTION of the store's gap geometry, re-derived whenever
|
|
325
|
+
// either edge moves and removed when the gap closes (docs/paged-scrollback.md §5.4).
|
|
326
|
+
const gapMarkerEls = new Map<number, HTMLDivElement>();
|
|
327
|
+
|
|
328
|
+
let requestHistoryFn: ((fromAbs: number, maxLines: number) => boolean) | null =
|
|
329
|
+
opts.requestHistory ?? null;
|
|
330
|
+
let historyBudgetFn: (() => number) | null = opts.historyBudget ?? null;
|
|
331
|
+
|
|
332
|
+
// Rows awaiting a DOM (re)build, drained at most MAX_ROWS_PER_FRAME per frame:
|
|
333
|
+
// a session restore or `cat bigfile` dumps thousands of lines in one frame,
|
|
334
|
+
// and building them in one rAF hangs a constrained device. The drain is
|
|
335
|
+
// VIEWPORT-FIRST (live-window rows, then scrollback newest->oldest so the
|
|
336
|
+
// backlog fills upward, offscreen above the pinned viewport), and the cursor
|
|
337
|
+
// row is always built so the caret never lags.
|
|
338
|
+
const renderQueue = new Set<number>();
|
|
339
|
+
|
|
340
|
+
// The caret is a dedicated overlay (positionCursorOverlay), NOT a restyled
|
|
341
|
+
// span: rows are pure content, so cursor motion never rewrites row DOM and a
|
|
342
|
+
// native selection in the old or new cursor row survives.
|
|
343
|
+
let cursorAbs = -1; // absolute index of the row the cursor is on
|
|
344
|
+
let cursorCol = 0;
|
|
345
|
+
let cursorHidden = false;
|
|
346
|
+
let cursorStyleVal = 0; // 0-6: DECSCUSR
|
|
347
|
+
|
|
348
|
+
function cursorClassName(): string {
|
|
349
|
+
// DECSCUSR: 0/1=blinking block, 2=steady block, 3=blinking underline,
|
|
350
|
+
// 4=steady underline, 5=blinking bar, 6=steady bar
|
|
351
|
+
if (cursorStyleVal === 3 || cursorStyleVal === 4) {
|
|
352
|
+
return "term-cursor-underline";
|
|
353
|
+
}
|
|
354
|
+
if (cursorStyleVal === 5 || cursorStyleVal === 6) {
|
|
355
|
+
return "term-cursor-bar";
|
|
356
|
+
}
|
|
357
|
+
return "term-cursor";
|
|
358
|
+
}
|
|
359
|
+
let cellWidth = 8;
|
|
360
|
+
let cellHeight = 17;
|
|
361
|
+
let defaultSpacing = 0;
|
|
362
|
+
let onCursorMove: (() => void) | null = opts.onCursorMove ?? null;
|
|
363
|
+
let pendingFrame: number | undefined;
|
|
364
|
+
|
|
365
|
+
// termWrap's padding, cached: the overlay positioners need it EVERY flush,
|
|
366
|
+
// and a live getComputedStyle after the flush's DOM writes forces a style
|
|
367
|
+
// recalc (the caret positioner alone measured ~11% of flush CPU). Refreshed
|
|
368
|
+
// by updateFontMetrics, the same staleness contract as cellWidth/cellHeight.
|
|
369
|
+
let padLeft = 0;
|
|
370
|
+
let padTop = 0;
|
|
371
|
+
let padValid = false;
|
|
372
|
+
|
|
373
|
+
function termPadding(): { padL: number; padT: number } {
|
|
374
|
+
if (!padValid && termWrap) {
|
|
375
|
+
const cs = window.getComputedStyle(termWrap);
|
|
376
|
+
padLeft = parseFloat(cs.paddingLeft);
|
|
377
|
+
padTop = parseFloat(cs.paddingTop);
|
|
378
|
+
padValid = true;
|
|
379
|
+
}
|
|
380
|
+
return { padL: padLeft, padT: padTop };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Bounded error-path reschedule. A row whose build throws stays queued, and
|
|
384
|
+
// flushRender's catch reschedules to retry a transient throw (a measureText
|
|
385
|
+
// race); a deterministic throw would turn that into a ~60fps busy loop.
|
|
386
|
+
// `flushDrainedThisPass` records rows built this pass (visible to the catch
|
|
387
|
+
// mid-drain); once `renderNoProgressStreak` passes the cap the catch stops
|
|
388
|
+
// rescheduling and lets the next inbound frame retry.
|
|
389
|
+
let flushDrainedThisPass = 0;
|
|
390
|
+
let renderNoProgressStreak = 0;
|
|
391
|
+
// The stall canary's latch, reset by any healthy flush so it suppresses a
|
|
392
|
+
// repeat of ONE stall rather than every stall for the life of the instance.
|
|
393
|
+
let unnamedStallReported = false;
|
|
394
|
+
// Whether the CURRENT flush performed a full reset. Read by restoreReadAnchor:
|
|
395
|
+
// a re-anchor across a reset would match unrelated content from the new index space.
|
|
396
|
+
let fullResetThisPass = false;
|
|
397
|
+
// The ED3 base observed since the last flush, or -1; consumed by the pass that
|
|
398
|
+
// applies it. Renderer-local because the renderer calls every path that
|
|
399
|
+
// discards a REGION rather than trimming the cap (docs/scroll-position-fidelity.md §5).
|
|
400
|
+
let discardedBelowPending = -1;
|
|
401
|
+
let discardedBelowThisPass = -1;
|
|
402
|
+
// Whether THIS pass removed rows. Announcing a shrink for a pass that did not
|
|
403
|
+
// is unsound on Safari, which moves scrollTop PAST the maximum during an
|
|
404
|
+
// overscroll bounce: the settle back is a downward move with no content
|
|
405
|
+
// change, and arming for it would hand the user's own gesture a pass-through.
|
|
406
|
+
let removedRowsThisPass = false;
|
|
407
|
+
|
|
408
|
+
function resetScreen(): void {
|
|
409
|
+
if (!store) {
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
store.reset();
|
|
413
|
+
scheduleFlush();
|
|
414
|
+
}
|
|
588
415
|
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
return store.highestIndex();
|
|
597
|
-
}
|
|
416
|
+
function resetScrollback(): void {
|
|
417
|
+
if (!store) {
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
store.reset();
|
|
421
|
+
scheduleFlush();
|
|
422
|
+
}
|
|
598
423
|
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
424
|
+
// The FOLLOW half of the view is adopted SYNCHRONOUSLY, before the wipe, so
|
|
425
|
+
// the first flush's bottom pin is gated on the INCOMING view rather than the
|
|
426
|
+
// tab just left. The POSITION half is armed and re-asserted across the
|
|
427
|
+
// rebuild's frames (applyPendingRestore) because the row it names is usually
|
|
428
|
+
// not built yet; a FOLLOWING view arms nothing, the per-flush pin answers.
|
|
429
|
+
function bind(next: LineStore, bindOpts?: { view?: ViewMemory | null }): void {
|
|
430
|
+
if (!store) {
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
const view = bindOpts?.view ?? null;
|
|
434
|
+
// A NULL view under `bindOpts` is "no memory", whose follow state is the
|
|
435
|
+
// tail; otherwise such a tab inherits the OUTGOING tab's follow flag.
|
|
436
|
+
if (bindOpts !== undefined) {
|
|
437
|
+
scroll.restoreView({
|
|
438
|
+
top: scroll.currentScrollTop(),
|
|
439
|
+
following: view === null ? true : view.following,
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
// The transport's own `clearSolicited` lands on whichever store is bound
|
|
443
|
+
// WHEN IT FIRES, and the order of `bind` against the consumer's teardown is
|
|
444
|
+
// the consumer's choice. Closed here, or the outgoing store's window strands
|
|
445
|
+
// open with no socket or timer to close it, and any later frame in that
|
|
446
|
+
// range can resurrect an evicted row and be classified as browse cache.
|
|
447
|
+
store.clearSolicited();
|
|
448
|
+
store = next;
|
|
449
|
+
// Cancel, then arm, one slot: a second switch mid-drain must not land the
|
|
450
|
+
// first tab's anchor into this store.
|
|
451
|
+
bindGen++;
|
|
452
|
+
clearPendingRestore();
|
|
453
|
+
rebuild();
|
|
454
|
+
if (view !== null && !view.following) {
|
|
455
|
+
pendingRestore = {
|
|
456
|
+
view,
|
|
457
|
+
gen: bindGen,
|
|
458
|
+
lastWrote: scroll.currentScrollTop(),
|
|
459
|
+
deadline: Date.now() + RESTORE_MAX_MS,
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
}
|
|
612
463
|
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
* store uses these to tell a genuine history trim (the server evicted lines
|
|
617
|
-
* the client was missing) from a still-loading state, which drives the
|
|
618
|
-
* "earlier output trimmed" marker. Resync guard 8.2.2.
|
|
619
|
-
*/
|
|
620
|
-
export function noteResumeBounds(committed: number, oldest: number): void {
|
|
621
|
-
store.noteResumeBounds(committed, oldest);
|
|
622
|
-
scheduleFlush();
|
|
623
|
-
}
|
|
464
|
+
function boundStore(): LineStore {
|
|
465
|
+
return store ?? new LineStore();
|
|
466
|
+
}
|
|
624
467
|
|
|
625
|
-
//
|
|
468
|
+
// Queue every retained line viewport-first: the live window ascending, then
|
|
469
|
+
// scrollback newest->oldest. Iterates the retained key set, NOT the integer
|
|
470
|
+
// range [oldest, highest], so a sparse store (a frame whose base jumped far
|
|
471
|
+
// from a retained index) never freezes the drain.
|
|
472
|
+
function queueRowsViewportFirst(): void {
|
|
473
|
+
if (!store) {
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
const winBase = store.getWindow().base;
|
|
477
|
+
const inWindow: number[] = [];
|
|
478
|
+
const belowWindow: number[] = [];
|
|
479
|
+
store.forEachLine((abs) => {
|
|
480
|
+
if (abs >= winBase) {
|
|
481
|
+
inWindow.push(abs);
|
|
482
|
+
} else {
|
|
483
|
+
belowWindow.push(abs);
|
|
484
|
+
}
|
|
485
|
+
});
|
|
486
|
+
for (const abs of inWindow) {
|
|
487
|
+
renderQueue.add(abs);
|
|
488
|
+
}
|
|
489
|
+
for (let i = belowWindow.length - 1; i >= 0; i--) {
|
|
490
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- index in range
|
|
491
|
+
renderQueue.add(belowWindow[i]!);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
626
494
|
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
495
|
+
function rebuild(): void {
|
|
496
|
+
// The wipe is the largest content shrink this module performs and happens
|
|
497
|
+
// OUTSIDE a flush (bind calls it synchronously), so it announces its own
|
|
498
|
+
// clamp; unannounced, the clamp falls back to the epsilon inference.
|
|
499
|
+
if (!store || !output) {
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
const scrollTopBeforeWipe = scroll.currentScrollTop();
|
|
503
|
+
output.replaceChildren();
|
|
504
|
+
rowEls.clear();
|
|
505
|
+
renderQueue.clear();
|
|
506
|
+
trimMarkerEl = null;
|
|
507
|
+
for (const el of gapMarkerEls.values()) {
|
|
508
|
+
el.remove();
|
|
509
|
+
}
|
|
510
|
+
gapMarkerEls.clear();
|
|
511
|
+
cursorAbs = -1;
|
|
512
|
+
altRendered = false;
|
|
513
|
+
altPrevRows = [];
|
|
514
|
+
// A stale give-up streak must not deny the rebuilt surface its retry budget.
|
|
515
|
+
renderNoProgressStreak = 0;
|
|
516
|
+
// The overlays share content space with the rows; collapse them BEFORE the
|
|
517
|
+
// offset below is read.
|
|
518
|
+
collapseContentSpaceOverlays();
|
|
519
|
+
// Alt screen paints from the ephemeral grid in the flush.
|
|
520
|
+
if (!store.isAlt()) {
|
|
521
|
+
queueRowsViewportFirst();
|
|
522
|
+
}
|
|
523
|
+
scroll.noteContentShrink(scrollTopBeforeWipe);
|
|
524
|
+
scheduleFlush();
|
|
639
525
|
}
|
|
640
|
-
store.applyHistoryScroll(msg, viewportAbs());
|
|
641
|
-
scheduleFlush();
|
|
642
|
-
}
|
|
643
526
|
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
// a transient that belongs to neither the outgoing nor the incoming view, and
|
|
660
|
-
// the store's reclassify pass uses it to decide which rows survive: reading
|
|
661
|
-
// the transient can evict the very rows an armed restore is about to bring
|
|
662
|
-
// back. An armed restore names the position the user is REGAINING, so it is
|
|
663
|
-
// the answer to "which rows must survive" (docs/scroll-position-fidelity.md
|
|
664
|
-
// §7.2, §7.3). Every other viewportAbs consumer asks about NOW and keeps the
|
|
665
|
-
// live value.
|
|
666
|
-
const pending = pendingRestoreAbs();
|
|
667
|
-
// The store no longer infers whether the reader is following; this layer knows.
|
|
668
|
-
// `isUserScrolledUp()` is the one authoritative source, and an ARMED RESTORE
|
|
669
|
-
// overrides it outright: mid-rebuild the browser has clamped the offset, so the
|
|
670
|
-
// live flag describes the transient rather than the view the reader is about to
|
|
671
|
-
// regain — and a restore is armed only for a position in history.
|
|
672
|
-
const pendingRestoreArmed = pending !== null;
|
|
673
|
-
store.applyResumeAck({
|
|
674
|
-
...ack,
|
|
675
|
-
viewportAbs: pending ?? viewportAbs(),
|
|
676
|
-
following: !pendingRestoreArmed && !scroll.isUserScrolledUp(),
|
|
677
|
-
});
|
|
678
|
-
scheduleFlush();
|
|
679
|
-
}
|
|
527
|
+
// Four elements INSIDE the scroll container carry a `top` in content
|
|
528
|
+
// coordinates (the caret, the predicted cursor, the IME view, the consumer's
|
|
529
|
+
// hidden textarea) and hold its scrollable overflow after the rows are gone:
|
|
530
|
+
// measured, an 81081px range over zero rows, a black pane with `stickToBottom`
|
|
531
|
+
// reading "already at the bottom". Collapsing them here also makes the wipe's
|
|
532
|
+
// clamp SYNCHRONOUS, which `noteContentShrink` (arms only on an observed move)
|
|
533
|
+
// and `bind` (records `pendingRestore.lastWrote` from the same offset) need.
|
|
534
|
+
function collapseContentSpaceOverlays(): void {
|
|
535
|
+
// `cursorAbs` is -1 by now, the state positionCursorOverlay hides for.
|
|
536
|
+
positionCursorOverlay(undefined);
|
|
537
|
+
predCursorEl?.classList.remove("visible");
|
|
538
|
+
// The IME view and the textarea are the CONSUMER's, reachable through the
|
|
539
|
+
// cursor seam; `getCursorPx` reports the content origin while `rowEls` is empty.
|
|
540
|
+
onCursorMove?.();
|
|
541
|
+
}
|
|
680
542
|
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
}
|
|
543
|
+
function getHighestIndex(): number {
|
|
544
|
+
return store ? store.highestIndex() : -1;
|
|
545
|
+
}
|
|
685
546
|
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
547
|
+
// Claiming provisional rows on the wire is what leaves a stale copy of the last
|
|
548
|
+
// screen parked in scrollback after a reattach. See LineStore.replayBoundary.
|
|
549
|
+
function getReplayBoundary(): number {
|
|
550
|
+
return store ? store.replayBoundary() : -1;
|
|
551
|
+
}
|
|
690
552
|
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
scheduleFlush();
|
|
699
|
-
}
|
|
553
|
+
function noteResumeBounds(committed: number, oldest: number): void {
|
|
554
|
+
if (!store) {
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
store.noteResumeBounds(committed, oldest);
|
|
558
|
+
scheduleFlush();
|
|
559
|
+
}
|
|
700
560
|
|
|
701
|
-
|
|
702
|
-
export function lastBrowseActivityMs(): number {
|
|
703
|
-
return store.lastBrowseActivityMs();
|
|
704
|
-
}
|
|
561
|
+
// --- demand-paged scrollback: the consumer's seams (docs/paged-scrollback.md) ---
|
|
705
562
|
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
563
|
+
// The viewport index is supplied here rather than by the caller because the
|
|
564
|
+
// renderer is the only layer that knows it, and the store's eviction needs it
|
|
565
|
+
// to decide which end of the cache is safe to drop.
|
|
566
|
+
function handleHistoryReply(msg: ScrollMessage, raiseFloorTo: number | null): void {
|
|
567
|
+
if (!store) {
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
if (raiseFloorTo !== null) {
|
|
571
|
+
store.raisePagingFloor(raiseFloorTo);
|
|
572
|
+
}
|
|
573
|
+
store.applyHistoryScroll(msg, viewportAbs());
|
|
574
|
+
scheduleFlush();
|
|
575
|
+
}
|
|
710
576
|
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
}
|
|
577
|
+
function applyResumeTransition(ack: {
|
|
578
|
+
epochChanged: boolean;
|
|
579
|
+
committed: number | null;
|
|
580
|
+
serverOldest: number | null;
|
|
581
|
+
paging: boolean;
|
|
582
|
+
sentHaveThrough: number;
|
|
583
|
+
sentReplayMax: number | null;
|
|
584
|
+
}): void {
|
|
585
|
+
if (!store) {
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
// A tab switch reconnects, so this transition routinely runs mid-rebuild at
|
|
589
|
+
// a clamped offset over rows still being built. The store's reclassify pass
|
|
590
|
+
// uses the viewport to decide which rows survive, and the live transient
|
|
591
|
+
// would evict the rows an armed restore is about to bring back; the restore
|
|
592
|
+
// names the position the user is REGAINING, so it wins, and it also
|
|
593
|
+
// overrides the live follow flag (docs/scroll-position-fidelity.md §7.2).
|
|
594
|
+
const pending = pendingRestoreAbs();
|
|
595
|
+
const pendingRestoreArmed = pending !== null;
|
|
596
|
+
store.applyResumeAck({
|
|
597
|
+
...ack,
|
|
598
|
+
viewportAbs: pending ?? viewportAbs(),
|
|
599
|
+
following: !pendingRestoreArmed && !scroll.isUserScrolledUp(),
|
|
600
|
+
});
|
|
601
|
+
scheduleFlush();
|
|
602
|
+
}
|
|
719
603
|
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
* resume replay, a wipe-and-rebuild after a store swap, or any burst larger
|
|
724
|
-
* than one frame's build budget.
|
|
725
|
-
*
|
|
726
|
-
* Exposed so a consumer can surface a "still catching up" affordance during a
|
|
727
|
-
* large restore. It is a RENDER-side measure, not a transport one: it reaches
|
|
728
|
-
* zero between the server's replay chunks, so on its own it is not a
|
|
729
|
-
* restore-complete signal. Pair it with the resumeAck bounds (the store's
|
|
730
|
-
* highestIndex reaching the reported `committed`) to tell "this frame's backlog
|
|
731
|
-
* is drained" from "the restore has finished arriving".
|
|
732
|
-
*/
|
|
733
|
-
export function pendingRowCount(): number {
|
|
734
|
-
return renderQueue.size;
|
|
735
|
-
}
|
|
604
|
+
function noteSolicited(fromAbs: number, end: number): void {
|
|
605
|
+
store?.noteSolicited(fromAbs, end);
|
|
606
|
+
}
|
|
736
607
|
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
if (c === undefined || c < 0) {
|
|
740
|
-
return null;
|
|
608
|
+
function clearSolicited(): void {
|
|
609
|
+
store?.clearSolicited();
|
|
741
610
|
}
|
|
742
|
-
return "#" + c.toString(16).padStart(6, "0");
|
|
743
|
-
}
|
|
744
611
|
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
function linkifySpans(
|
|
749
|
-
spans: (HTMLSpanElement | HTMLAnchorElement)[],
|
|
750
|
-
): (HTMLSpanElement | HTMLAnchorElement)[] {
|
|
751
|
-
const out: (HTMLSpanElement | HTMLAnchorElement)[] = [];
|
|
752
|
-
for (const span of spans) {
|
|
753
|
-
// Pass anchors through untouched. A span may already be an <a> from an
|
|
754
|
-
// OSC 8 hyperlink emitted by the application (see buildRowSpans). The
|
|
755
|
-
// app-provided href is authoritative and takes precedence over heuristic
|
|
756
|
-
// autolinking — re-scanning it with URL_RE would rebuild the link from
|
|
757
|
-
// the *visible* text, which for a URL that wraps across rows is only a
|
|
758
|
-
// fragment. That truncates the href and defeats the terminal's
|
|
759
|
-
// clickable-across-line-wraps behavior. Skip; only autolink plain text.
|
|
760
|
-
if (span.tagName === "A") {
|
|
761
|
-
out.push(span);
|
|
762
|
-
continue;
|
|
763
|
-
}
|
|
764
|
-
const text = span.textContent;
|
|
765
|
-
URL_RE.lastIndex = 0;
|
|
766
|
-
let match: RegExpExecArray | null;
|
|
767
|
-
let last = 0;
|
|
768
|
-
let found = false;
|
|
769
|
-
while ((match = URL_RE.exec(text)) !== null) {
|
|
770
|
-
found = true;
|
|
771
|
-
if (match.index > last) {
|
|
772
|
-
const pre = span.cloneNode(false) as HTMLSpanElement;
|
|
773
|
-
pre.textContent = text.slice(last, match.index);
|
|
774
|
-
out.push(pre);
|
|
775
|
-
}
|
|
776
|
-
const a = document.createElement("a");
|
|
777
|
-
a.href = match[0];
|
|
778
|
-
a.target = "_blank";
|
|
779
|
-
a.rel = "noopener noreferrer";
|
|
780
|
-
// Auto-detected bare URLs are tightly scoped to the matched URL text
|
|
781
|
-
// (never a padded/bordered region), so `.term-autolink` keeps a persistent
|
|
782
|
-
// underline for discoverability. OSC 8 hyperlinks (buildRowSpans below) get
|
|
783
|
-
// only `.term-link`, which the UI underlines on hover — an app can attach a
|
|
784
|
-
// single OSC 8 link to a whole region (e.g. a URL wrapping inside a table
|
|
785
|
-
// cell, where the link stays open across the cell padding/borders), and a
|
|
786
|
-
// persistent underline would then bleed across the cell/row.
|
|
787
|
-
a.className = "term-link term-autolink";
|
|
788
|
-
a.textContent = match[0];
|
|
789
|
-
// Copy inline styles from the source span property-by-property, never
|
|
790
|
-
// via cssText: a cssText assignment is a string-PARSED style write, the
|
|
791
|
-
// one kind a style-src CSP reasons about, and this was the renderer's
|
|
792
|
-
// only such write. With it gone, every style write in the render path
|
|
793
|
-
// is an individually-assigned property (unambiguously ungoverned by
|
|
794
|
-
// style-src), which keeps the consumers' CSP-hardening story clean.
|
|
795
|
-
for (let i = 0; i < span.style.length; i++) {
|
|
796
|
-
const prop = span.style.item(i);
|
|
797
|
-
a.style.setProperty(
|
|
798
|
-
prop,
|
|
799
|
-
span.style.getPropertyValue(prop),
|
|
800
|
-
span.style.getPropertyPriority(prop),
|
|
801
|
-
);
|
|
802
|
-
}
|
|
803
|
-
out.push(a);
|
|
804
|
-
last = match.index + match[0].length;
|
|
612
|
+
function dropBrowseCache(pageVisible: boolean): void {
|
|
613
|
+
if (!store) {
|
|
614
|
+
return;
|
|
805
615
|
}
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
616
|
+
store.dropBrowseCache(viewportAbs(), pageVisible);
|
|
617
|
+
scheduleFlush();
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
function lastBrowseActivityMs(): number {
|
|
621
|
+
return store ? store.lastBrowseActivityMs() : 0;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function browseCacheSize(): number {
|
|
625
|
+
return store ? store.browseCacheSize() : 0;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// Residency minus the window about to be sent anyway, so an attach does not
|
|
629
|
+
// download rows the cap would immediately trim.
|
|
630
|
+
function replayMaxForResume(): number {
|
|
631
|
+
if (!store) {
|
|
632
|
+
return COMPATIBILITY_TAIL_CAP;
|
|
812
633
|
}
|
|
634
|
+
return Math.max(1, store.tailCap() - store.getWindow().height);
|
|
813
635
|
}
|
|
814
|
-
return out;
|
|
815
|
-
}
|
|
816
636
|
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
//
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
for (const s of spans) {
|
|
826
|
-
for (const ch of s.textContent) {
|
|
827
|
-
if (/\s/.test(ch)) {
|
|
828
|
-
continue;
|
|
829
|
-
}
|
|
830
|
-
const cp = ch.codePointAt(0) ?? 0;
|
|
831
|
-
if (cp >= 0x2500 && cp <= 0x259f) {
|
|
832
|
-
continue;
|
|
833
|
-
}
|
|
834
|
-
return true;
|
|
637
|
+
function pendingRowCount(): number {
|
|
638
|
+
return renderQueue.size;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// --- Color helpers ---
|
|
642
|
+
function colorHex(c: number | undefined): string | null {
|
|
643
|
+
if (c === undefined || c < 0) {
|
|
644
|
+
return null;
|
|
835
645
|
}
|
|
646
|
+
return "#" + c.toString(16).padStart(6, "0");
|
|
836
647
|
}
|
|
837
|
-
return false;
|
|
838
|
-
}
|
|
839
648
|
|
|
840
|
-
// ---
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
const
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
const isStrike = (attrs & 16) !== 0;
|
|
854
|
-
const isDim = (attrs & 32) !== 0;
|
|
855
|
-
const isHidden = (attrs & 64) !== 0;
|
|
856
|
-
const isBlink = (attrs & 128) !== 0;
|
|
857
|
-
const isOverline = (attrs & 256) !== 0;
|
|
858
|
-
const isDoubleUnderline = (attrs & 512) !== 0;
|
|
859
|
-
|
|
860
|
-
// Server swaps FG/BG for inverse in wire.go, but when both are
|
|
861
|
-
// default (-1) the swap is a no-op. Detect inverse + defaults and
|
|
862
|
-
// apply theme-inverted colors so the inverted space is visible.
|
|
863
|
-
let fg = colorHex(run.f);
|
|
864
|
-
let bg = colorHex(run.b);
|
|
865
|
-
if (isInverse && fg === null && bg === null) {
|
|
866
|
-
fg = "var(--bg)";
|
|
867
|
-
bg = "var(--text)";
|
|
868
|
-
}
|
|
869
|
-
const ucColor = colorHex(run.uc);
|
|
870
|
-
|
|
871
|
-
const applyStyle = (span: HTMLSpanElement, spacing: number): void => {
|
|
872
|
-
if (isHidden) {
|
|
873
|
-
span.style.visibility = "hidden";
|
|
874
|
-
}
|
|
875
|
-
if (fg !== null) {
|
|
876
|
-
span.style.color = fg;
|
|
877
|
-
}
|
|
878
|
-
if (bg !== null) {
|
|
879
|
-
span.style.background = bg;
|
|
880
|
-
}
|
|
881
|
-
if (isBold) {
|
|
882
|
-
span.style.fontWeight = "bold";
|
|
883
|
-
}
|
|
884
|
-
if (isItalic) {
|
|
885
|
-
span.style.fontStyle = "italic";
|
|
886
|
-
}
|
|
887
|
-
if (isDim) {
|
|
888
|
-
span.style.opacity = ".5";
|
|
889
|
-
}
|
|
890
|
-
// Build text-decoration combining all line types.
|
|
891
|
-
const decoLines: string[] = [];
|
|
892
|
-
if (isDoubleUnderline) {
|
|
893
|
-
decoLines.push("underline");
|
|
894
|
-
} else if (isUnderline) {
|
|
895
|
-
decoLines.push("underline");
|
|
896
|
-
}
|
|
897
|
-
if (isOverline) {
|
|
898
|
-
decoLines.push("overline");
|
|
899
|
-
}
|
|
900
|
-
if (isStrike) {
|
|
901
|
-
decoLines.push("line-through");
|
|
649
|
+
// --- URL detection (xterm.js addon-web-links pattern) ---
|
|
650
|
+
const URL_RE = /(https?|HTTPS?):\/\/[^\s"'!*(){}|\\^<>`]*[^\s"':,.!?{}|\\^~[\]`()<>]/g;
|
|
651
|
+
|
|
652
|
+
function linkifySpans(
|
|
653
|
+
spans: (HTMLSpanElement | HTMLAnchorElement)[],
|
|
654
|
+
): (HTMLSpanElement | HTMLAnchorElement)[] {
|
|
655
|
+
const out: (HTMLSpanElement | HTMLAnchorElement)[] = [];
|
|
656
|
+
for (const span of spans) {
|
|
657
|
+
// An OSC 8 anchor's href is authoritative: re-scanning its VISIBLE text
|
|
658
|
+
// would rebuild a URL that wraps across rows from a fragment.
|
|
659
|
+
if (span.tagName === "A") {
|
|
660
|
+
out.push(span);
|
|
661
|
+
continue;
|
|
902
662
|
}
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
663
|
+
const text = span.textContent;
|
|
664
|
+
URL_RE.lastIndex = 0;
|
|
665
|
+
let match: RegExpExecArray | null;
|
|
666
|
+
let last = 0;
|
|
667
|
+
let found = false;
|
|
668
|
+
while ((match = URL_RE.exec(text)) !== null) {
|
|
669
|
+
found = true;
|
|
670
|
+
if (match.index > last) {
|
|
671
|
+
const pre = span.cloneNode(false) as HTMLSpanElement;
|
|
672
|
+
pre.textContent = text.slice(last, match.index);
|
|
673
|
+
out.push(pre);
|
|
907
674
|
}
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
675
|
+
const a = document.createElement("a");
|
|
676
|
+
a.href = match[0];
|
|
677
|
+
a.target = "_blank";
|
|
678
|
+
a.rel = "noopener noreferrer";
|
|
679
|
+
// `.term-autolink` keeps a persistent underline because the match is
|
|
680
|
+
// scoped to the URL text; an OSC 8 link can span a padded region (a URL
|
|
681
|
+
// wrapping inside a table cell), so it gets only the hover underline.
|
|
682
|
+
a.className = "term-link term-autolink";
|
|
683
|
+
a.textContent = match[0];
|
|
684
|
+
// Property-by-property, never cssText: a cssText assignment is a
|
|
685
|
+
// string-PARSED style write, the one kind a style-src CSP governs.
|
|
686
|
+
for (let i = 0; i < span.style.length; i++) {
|
|
687
|
+
const prop = span.style.item(i);
|
|
688
|
+
a.style.setProperty(
|
|
689
|
+
prop,
|
|
690
|
+
span.style.getPropertyValue(prop),
|
|
691
|
+
span.style.getPropertyPriority(prop),
|
|
692
|
+
);
|
|
693
|
+
}
|
|
694
|
+
out.push(a);
|
|
695
|
+
last = match.index + match[0].length;
|
|
912
696
|
}
|
|
913
|
-
if (
|
|
914
|
-
span
|
|
697
|
+
if (!found) {
|
|
698
|
+
out.push(span);
|
|
699
|
+
} else if (last < text.length) {
|
|
700
|
+
const post = span.cloneNode(false) as HTMLSpanElement;
|
|
701
|
+
post.textContent = text.slice(last);
|
|
702
|
+
out.push(post);
|
|
915
703
|
}
|
|
916
|
-
|
|
917
|
-
|
|
704
|
+
}
|
|
705
|
+
return out;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
// A hyperlink run is "link text" only if it has a glyph that is not whitespace
|
|
709
|
+
// and not box-drawing or block-element (U+2500–U+259F): an app may keep an
|
|
710
|
+
// OSC 8 link open across table borders and padding while a URL wraps, and
|
|
711
|
+
// anchoring those cells would bleed the underline across the cell and row.
|
|
712
|
+
function runHasLinkText(spans: (HTMLSpanElement | HTMLAnchorElement)[]): boolean {
|
|
713
|
+
for (const s of spans) {
|
|
714
|
+
for (const ch of s.textContent) {
|
|
715
|
+
if (/\s/.test(ch)) {
|
|
716
|
+
continue;
|
|
717
|
+
}
|
|
718
|
+
const cp = ch.codePointAt(0) ?? 0;
|
|
719
|
+
if (cp >= 0x2500 && cp <= 0x259f) {
|
|
720
|
+
continue;
|
|
721
|
+
}
|
|
722
|
+
return true;
|
|
918
723
|
}
|
|
919
|
-
}
|
|
724
|
+
}
|
|
725
|
+
return false;
|
|
726
|
+
}
|
|
920
727
|
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
const
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
}
|
|
927
|
-
const span = document.createElement("span");
|
|
928
|
-
span.textContent = buffer;
|
|
929
|
-
applyStyle(span, prevSpacing ?? 0);
|
|
930
|
-
out.push(span);
|
|
931
|
-
buffer = "";
|
|
932
|
-
};
|
|
933
|
-
for (const ch of run.t) {
|
|
934
|
-
if (ch === "\uFFFF") {
|
|
935
|
-
// Wide-char continuation placeholder: mark previous span as double-width.
|
|
936
|
-
// Flush any buffered text first so the wide char is in its own span.
|
|
937
|
-
flush();
|
|
938
|
-
if (out.length > 0) {
|
|
939
|
-
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- length checked above
|
|
940
|
-
const prev = out[out.length - 1]!;
|
|
941
|
-
|
|
942
|
-
const prevText = prev.textContent;
|
|
943
|
-
if (prevText.length > 0) {
|
|
944
|
-
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion, @typescript-eslint/no-misused-spread -- terminal text is ASCII/CJK, safe to spread; .at(-1) guaranteed by length check
|
|
945
|
-
const lastChar = [...prevText].at(-1)!;
|
|
946
|
-
const w = measureChar(lastChar, isBold, isItalic);
|
|
947
|
-
prev.style.letterSpacing = `${cellWidth * 2 - w}px`;
|
|
948
|
-
}
|
|
949
|
-
}
|
|
950
|
-
// The spacer occupies the wide char's second cell. (Column arithmetic
|
|
951
|
-
// for the caret lives in glyphAt, which mirrors this advance rule —
|
|
952
|
-
// the engine reports cursor_col in true cell coordinates, a wide
|
|
953
|
-
// glyph moving curX by 2.)
|
|
728
|
+
// --- Build row DOM ---
|
|
729
|
+
function buildRowSpans(runs: readonly WireRun[]): (HTMLSpanElement | HTMLAnchorElement)[] {
|
|
730
|
+
const out: (HTMLSpanElement | HTMLAnchorElement)[] = [];
|
|
731
|
+
for (const run of runs) {
|
|
732
|
+
if (!run.t) {
|
|
954
733
|
continue;
|
|
955
734
|
}
|
|
956
|
-
const
|
|
957
|
-
const
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
735
|
+
const runStartIdx = out.length;
|
|
736
|
+
const attrs = run.a ?? 0;
|
|
737
|
+
const isBold = (attrs & 1) !== 0;
|
|
738
|
+
const isItalic = (attrs & 2) !== 0;
|
|
739
|
+
const isUnderline = (attrs & 4) !== 0;
|
|
740
|
+
const isInverse = (attrs & 8) !== 0;
|
|
741
|
+
const isStrike = (attrs & 16) !== 0;
|
|
742
|
+
const isDim = (attrs & 32) !== 0;
|
|
743
|
+
const isHidden = (attrs & 64) !== 0;
|
|
744
|
+
const isBlink = (attrs & 128) !== 0;
|
|
745
|
+
const isOverline = (attrs & 256) !== 0;
|
|
746
|
+
const isDoubleUnderline = (attrs & 512) !== 0;
|
|
747
|
+
|
|
748
|
+
// Server swaps FG/BG for inverse in wire.go, but when both are
|
|
749
|
+
// default (-1) the swap is a no-op. Detect inverse + defaults and
|
|
750
|
+
// apply theme-inverted colors so the inverted space is visible.
|
|
751
|
+
let fg = colorHex(run.f);
|
|
752
|
+
let bg = colorHex(run.b);
|
|
753
|
+
if (isInverse && fg === null && bg === null) {
|
|
754
|
+
fg = "var(--bg)";
|
|
755
|
+
bg = "var(--text)";
|
|
963
756
|
}
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
757
|
+
const ucColor = colorHex(run.uc);
|
|
758
|
+
|
|
759
|
+
const applyStyle = (span: HTMLSpanElement, spacing: number): void => {
|
|
760
|
+
if (isHidden) {
|
|
761
|
+
span.style.visibility = "hidden";
|
|
762
|
+
}
|
|
763
|
+
if (fg !== null) {
|
|
764
|
+
span.style.color = fg;
|
|
765
|
+
}
|
|
766
|
+
if (bg !== null) {
|
|
767
|
+
span.style.background = bg;
|
|
768
|
+
}
|
|
769
|
+
if (isBold) {
|
|
770
|
+
span.style.fontWeight = "bold";
|
|
771
|
+
}
|
|
772
|
+
if (isItalic) {
|
|
773
|
+
span.style.fontStyle = "italic";
|
|
774
|
+
}
|
|
775
|
+
if (isDim) {
|
|
776
|
+
span.style.opacity = ".5";
|
|
777
|
+
}
|
|
778
|
+
// Build text-decoration combining all line types.
|
|
779
|
+
const decoLines: string[] = [];
|
|
780
|
+
if (isDoubleUnderline) {
|
|
781
|
+
decoLines.push("underline");
|
|
782
|
+
} else if (isUnderline) {
|
|
783
|
+
decoLines.push("underline");
|
|
784
|
+
}
|
|
785
|
+
if (isOverline) {
|
|
786
|
+
decoLines.push("overline");
|
|
787
|
+
}
|
|
788
|
+
if (isStrike) {
|
|
789
|
+
decoLines.push("line-through");
|
|
790
|
+
}
|
|
791
|
+
if (decoLines.length > 0) {
|
|
792
|
+
let deco = decoLines.join(" ");
|
|
793
|
+
if (isDoubleUnderline) {
|
|
794
|
+
deco += " double";
|
|
795
|
+
}
|
|
796
|
+
span.style.textDecoration = deco;
|
|
797
|
+
}
|
|
798
|
+
if (ucColor !== null) {
|
|
799
|
+
span.style.textDecorationColor = ucColor;
|
|
800
|
+
}
|
|
801
|
+
if (spacing !== defaultSpacing) {
|
|
802
|
+
span.style.letterSpacing = `${spacing}px`;
|
|
803
|
+
}
|
|
804
|
+
if (isBlink) {
|
|
805
|
+
span.classList.add("term-blink");
|
|
806
|
+
}
|
|
807
|
+
};
|
|
808
|
+
|
|
809
|
+
let prevSpacing: number | null = null;
|
|
810
|
+
let buffer = "";
|
|
811
|
+
const flush = (): void => {
|
|
812
|
+
if (buffer.length === 0) {
|
|
813
|
+
return;
|
|
814
|
+
}
|
|
815
|
+
const span = document.createElement("span");
|
|
816
|
+
span.textContent = buffer;
|
|
817
|
+
applyStyle(span, prevSpacing ?? 0);
|
|
818
|
+
out.push(span);
|
|
819
|
+
buffer = "";
|
|
820
|
+
};
|
|
821
|
+
for (const ch of run.t) {
|
|
822
|
+
if (ch === "\uFFFF") {
|
|
823
|
+
// Wide-char continuation placeholder: mark previous span as double-width.
|
|
824
|
+
// Flush any buffered text first so the wide char is in its own span.
|
|
825
|
+
flush();
|
|
826
|
+
if (out.length > 0) {
|
|
827
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- length checked above
|
|
828
|
+
const prev = out[out.length - 1]!;
|
|
829
|
+
|
|
830
|
+
const prevText = prev.textContent;
|
|
831
|
+
if (prevText.length > 0) {
|
|
832
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion, @typescript-eslint/no-misused-spread -- terminal text is ASCII/CJK, safe to spread; .at(-1) guaranteed by length check
|
|
833
|
+
const lastChar = [...prevText].at(-1)!;
|
|
834
|
+
const w = measureChar(lastChar, isBold, isItalic);
|
|
835
|
+
prev.style.letterSpacing = `${cellWidth * 2 - w}px`;
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
// The spacer occupies the wide char's second cell. (Column arithmetic
|
|
839
|
+
// for the caret lives in glyphAt, which mirrors this advance rule —
|
|
840
|
+
// the engine reports cursor_col in true cell coordinates, a wide
|
|
841
|
+
// glyph moving curX by 2.)
|
|
842
|
+
continue;
|
|
843
|
+
}
|
|
844
|
+
const w = measureChar(ch, isBold, isItalic);
|
|
845
|
+
const spacing = cellWidth - w;
|
|
846
|
+
if (prevSpacing === null) {
|
|
847
|
+
prevSpacing = spacing;
|
|
848
|
+
} else if (spacing !== prevSpacing) {
|
|
849
|
+
flush();
|
|
850
|
+
prevSpacing = spacing;
|
|
851
|
+
}
|
|
852
|
+
buffer += ch;
|
|
853
|
+
}
|
|
854
|
+
flush();
|
|
855
|
+
// Decorative cells inside an open OSC 8 link stay plain spans (see
|
|
856
|
+
// runHasLinkText); every text run of a wrapped URL still carries the full href.
|
|
857
|
+
const href = run.u && /^https?:\/\//i.test(run.u) ? run.u : null;
|
|
858
|
+
const runSpans = out.splice(runStartIdx);
|
|
859
|
+
if (href && runHasLinkText(runSpans)) {
|
|
860
|
+
const a = document.createElement("a");
|
|
861
|
+
a.href = href;
|
|
862
|
+
a.target = "_blank";
|
|
863
|
+
a.rel = "noopener noreferrer";
|
|
864
|
+
// Attr bit 1024 (vt.AttrAutolink) is a server-detected bare URL, styled
|
|
865
|
+
// like the client's own autolinks; an OSC 8 link keeps the hover-only base.
|
|
866
|
+
a.className = (attrs & 1024) !== 0 ? "term-link term-autolink" : "term-link";
|
|
867
|
+
for (const s of runSpans) {
|
|
868
|
+
a.appendChild(s);
|
|
869
|
+
}
|
|
870
|
+
out.push(a);
|
|
871
|
+
} else {
|
|
872
|
+
for (const s of runSpans) {
|
|
873
|
+
out.push(s);
|
|
874
|
+
}
|
|
997
875
|
}
|
|
998
876
|
}
|
|
877
|
+
if (out.length === 0) {
|
|
878
|
+
const span = document.createElement("span");
|
|
879
|
+
span.textContent = "\u00a0";
|
|
880
|
+
out.push(span);
|
|
881
|
+
}
|
|
882
|
+
return linkifySpans(out);
|
|
999
883
|
}
|
|
1000
|
-
if (out.length === 0) {
|
|
1001
|
-
const span = document.createElement("span");
|
|
1002
|
-
span.textContent = "\u00a0";
|
|
1003
|
-
out.push(span);
|
|
1004
|
-
}
|
|
1005
|
-
return linkifySpans(out);
|
|
1006
|
-
}
|
|
1007
|
-
|
|
1008
|
-
// --- Frame handling: feed the store, then flush to DOM ---
|
|
1009
|
-
|
|
1010
|
-
/**
|
|
1011
|
-
* Apply a `ScreenMessage`: update the store's window + changed rows, then
|
|
1012
|
-
* schedule a render flush. The store handles merging changed rows by absolute
|
|
1013
|
-
* index, so no client-side frame coalescing is needed.
|
|
1014
|
-
*/
|
|
1015
|
-
export function handleScreen(msg: ScreenMessage): void {
|
|
1016
|
-
lastInboundMs = Date.now();
|
|
1017
|
-
if (msg.scrollbackCleared) {
|
|
1018
|
-
// ED3: the application discarded its saved lines, so everything the store
|
|
1019
|
-
// holds below this base is about to go. Recorded HERE, at the one place that
|
|
1020
|
-
// sees the frame before the store consumes it, so restoreReadAnchor can tell
|
|
1021
|
-
// a region DISCARD from an ordinary cap trim — the two need opposite
|
|
1022
|
-
// recoveries and only the frame knows which happened
|
|
1023
|
-
// (docs/scroll-position-fidelity.md §5). Max, because several frames can
|
|
1024
|
-
// land between flushes.
|
|
1025
|
-
discardedBelowPending = Math.max(discardedBelowPending, msg.base);
|
|
1026
|
-
}
|
|
1027
|
-
store.applyScreen(msg);
|
|
1028
|
-
scheduleFlush();
|
|
1029
|
-
}
|
|
1030
884
|
|
|
1031
|
-
|
|
1032
|
-
* Apply a `ScrollMessage`: commit history lines into the store by absolute
|
|
1033
|
-
* index, then schedule a render flush.
|
|
1034
|
-
*/
|
|
1035
|
-
export function handleScroll(msg: ScrollMessage): void {
|
|
1036
|
-
lastInboundMs = Date.now();
|
|
1037
|
-
store.applyScroll(msg);
|
|
1038
|
-
scheduleFlush();
|
|
1039
|
-
}
|
|
885
|
+
// --- Frame handling: feed the store, then flush to DOM ---
|
|
1040
886
|
|
|
1041
|
-
function
|
|
1042
|
-
|
|
1043
|
-
|
|
887
|
+
function handleScreen(msg: ScreenMessage): void {
|
|
888
|
+
if (!store) {
|
|
889
|
+
return;
|
|
890
|
+
}
|
|
891
|
+
lastInboundMs = Date.now();
|
|
892
|
+
if (msg.scrollbackCleared) {
|
|
893
|
+
// ED3. Recorded at the one place that sees the frame before the store
|
|
894
|
+
// consumes it, so restoreReadAnchor can tell a region DISCARD from a cap
|
|
895
|
+
// trim; the two need opposite recoveries (docs/scroll-position-fidelity.md §5).
|
|
896
|
+
// Max, because several frames can land between flushes.
|
|
897
|
+
discardedBelowPending = Math.max(discardedBelowPending, msg.base);
|
|
898
|
+
}
|
|
899
|
+
store.applyScreen(msg);
|
|
900
|
+
scheduleFlush();
|
|
1044
901
|
}
|
|
1045
|
-
pendingFrame = requestAnimationFrame(flushRender);
|
|
1046
|
-
}
|
|
1047
902
|
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
* `scroll.init({ onScrollPosition })` and nothing else: it is the ONE hook the
|
|
1051
|
-
* renderer needs from a position change, so a consumer cannot half-wire it.
|
|
1052
|
-
*
|
|
1053
|
-
* Two jobs, in this order. Re-evaluate the demand-paging trigger, because the
|
|
1054
|
-
* reader may have approached a gap. Then finish a drain that stopped with rows
|
|
1055
|
-
* still queued, which is reachable on an idle session: the bounded error path in
|
|
1056
|
-
* `flushRender`'s catch deliberately stops rescheduling, and no inbound frame
|
|
1057
|
-
* arrives to restart it.
|
|
1058
|
-
*
|
|
1059
|
-
* It replaced a pair of exported calls (`maybeFetchHistory` plus a public
|
|
1060
|
-
* `resumeDrain`) because that pair was a silent-omission trap of exactly the
|
|
1061
|
-
* shape the paged-scrollback docs warn about: a consumer upgrading the engine and
|
|
1062
|
-
* keeping its old one-line wiring got paging and no drain recovery, with no type
|
|
1063
|
-
* error and no runtime complaint. `maybeFetchHistory` stays exported for the
|
|
1064
|
-
* transport's own retry path, which is a different event.
|
|
1065
|
-
*/
|
|
1066
|
-
export function handleScrollPosition(): void {
|
|
1067
|
-
maybeFetchHistory();
|
|
1068
|
-
resumeDrain();
|
|
1069
|
-
}
|
|
1070
|
-
|
|
1071
|
-
/**
|
|
1072
|
-
* Resume a drain that still has rows queued. Private: reached only through
|
|
1073
|
-
* `handleScrollPosition`, so there is one wiring obligation rather than two.
|
|
1074
|
-
*
|
|
1075
|
-
* Three refusals, and each one is load-bearing rather than defensive:
|
|
1076
|
-
*
|
|
1077
|
-
* **An empty queue.** `flushRender` runs `applyPendingRestore`,
|
|
1078
|
-
* `restoreReadAnchor` and `stickToBottomIfFollowing` unconditionally; only the
|
|
1079
|
-
* drain is queue-gated. So a flush scheduled from a scroll handler would run the
|
|
1080
|
-
* position invariants at moments the renderer never previously flushed at, and
|
|
1081
|
-
* because `atBottom` tolerates `BOTTOM_TOLERANCE_PX` while `stickToBottom` pins
|
|
1082
|
-
* on any non-zero gap, a downward scroll landing just short of the tail on an
|
|
1083
|
-
* IDLE session would snap to the bottom one frame later. It would also expire an
|
|
1084
|
-
* armed view restore early through `RESTORE_OWN_WRITE_EPSILON_PX`.
|
|
1085
|
-
*
|
|
1086
|
-
* **Alt screen.** Alt is a NAMED suspension of the drain (see the reschedule rule
|
|
1087
|
-
* below), and a resumption path that ignores it is not a resumption path. The
|
|
1088
|
-
* drain would survive anyway, because `flushRenderInner`'s alt branch returns
|
|
1089
|
-
* before the drain, but the flush around it would still call `renderAlt` (which
|
|
1090
|
-
* replaces the whole output subtree, destroying an in-alt selection) and run all
|
|
1091
|
-
* three position invariants. Leaning on the body of a function to make a
|
|
1092
|
-
* scheduling decision safe is how the next edit to that body becomes a bug. Alt
|
|
1093
|
-
* exit re-queues everything through `queueRowsViewportFirst`, so refusing costs
|
|
1094
|
-
* nothing.
|
|
1095
|
-
*
|
|
1096
|
-
* **A give-up already retried.** The catch's give-up exists so a deterministically
|
|
1097
|
-
* throwing row cannot become a 60fps loop. Retrying it from a scroll event would
|
|
1098
|
-
* move that loop outside the module rather than remove it: a flick is one scroll
|
|
1099
|
-
* event per frame, and `scroll.ts`'s position callback fires for the browser's own
|
|
1100
|
-
* clamps too, so a stuck row would log two errors per frame for the length of a
|
|
1101
|
-
* gesture. One retry per give-up is enough to serve the case this function exists
|
|
1102
|
-
* for (the reader scrolls, the surface completes) without reopening the loop the
|
|
1103
|
-
* cap closed. The retry is re-earned by anything that clears the streak: real
|
|
1104
|
-
* forward progress, an empty queue, a rebuild, or a re-attach. NOT by the give-up
|
|
1105
|
-
* log — a retry that throws gives up again, and re-arming there would restore the
|
|
1106
|
-
* per-gesture loop one indirection further out.
|
|
1107
|
-
*
|
|
1108
|
-
* Residual, accepted: `scroll.ts` fires its position seam before its own
|
|
1109
|
-
* announced-shrink early return, so a programmatic clamp (a cap eviction) can spend
|
|
1110
|
-
* the retry with no user action. The exposure is small because any successful flush
|
|
1111
|
-
* re-arms it, and the alternative (a time bound) adds a clock to a path whose whole
|
|
1112
|
-
* purpose is to be cheap.
|
|
1113
|
-
*/
|
|
1114
|
-
function resumeDrain(): void {
|
|
1115
|
-
if (renderQueue.size === 0 || store.isAlt()) {
|
|
1116
|
-
return;
|
|
1117
|
-
}
|
|
1118
|
-
// A frame is already coming, so there is nothing to resume and nothing to spend.
|
|
1119
|
-
// Without this the retry is burned in a state where it buys NOTHING: the catch
|
|
1120
|
-
// reaches `streak === MAX` from its own progress-less branch and schedules a frame
|
|
1121
|
-
// on the way out, so for one frame the streak is at the cap with a flush pending.
|
|
1122
|
-
// A scroll landing there would set the streak past the cap while `scheduleFlush`
|
|
1123
|
-
// no-ops on the occupied slot, and the real give-up one frame later would then
|
|
1124
|
-
// find its one retry already gone. The spend must be gated on the give-up having
|
|
1125
|
-
// HAPPENED, and an empty frame slot is what distinguishes that.
|
|
1126
|
-
if (pendingFrame !== undefined) {
|
|
1127
|
-
return;
|
|
1128
|
-
}
|
|
1129
|
-
if (renderNoProgressStreak >= MAX_RENDER_NO_PROGRESS_RETRIES) {
|
|
1130
|
-
if (renderNoProgressStreak > MAX_RENDER_NO_PROGRESS_RETRIES) {
|
|
903
|
+
function handleScroll(msg: ScrollMessage): void {
|
|
904
|
+
if (!store) {
|
|
1131
905
|
return;
|
|
1132
906
|
}
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
// give-up plus one scroll disabled the resume for the rest of the attachment,
|
|
1138
|
-
// invisibly, because that state presents to the canary as a plain give-up. With
|
|
1139
|
-
// the count carrying it there is nothing to keep in sync: every existing
|
|
1140
|
-
// `renderNoProgressStreak = 0` re-arms the retry by construction.
|
|
1141
|
-
renderNoProgressStreak = MAX_RENDER_NO_PROGRESS_RETRIES + 1;
|
|
1142
|
-
}
|
|
1143
|
-
scheduleFlush();
|
|
1144
|
-
}
|
|
1145
|
-
|
|
1146
|
-
// --- Read-position anchoring (manual scroll anchoring) ---
|
|
1147
|
-
//
|
|
1148
|
-
// A flush can change the content height ABOVE the reading position: rows evicted
|
|
1149
|
-
// from the top of history once the retention cap is reached, and the trim marker
|
|
1150
|
-
// appearing. Every such change moves whatever the user is reading, unless the
|
|
1151
|
-
// viewport is shifted by the same amount.
|
|
1152
|
-
//
|
|
1153
|
-
// Chrome and Firefox do that natively (scroll anchoring, `overflow-anchor`).
|
|
1154
|
-
// WebKit does not implement it at all, so on Safari — including the iPad, where
|
|
1155
|
-
// this UI mostly runs — a user scrolled up to read had their position dragged
|
|
1156
|
-
// one line per evicted row for as long as output kept arriving.
|
|
1157
|
-
//
|
|
1158
|
-
// The anchor is the first row at or below the viewport top, found by binary
|
|
1159
|
-
// search over the container's children: they are in document order, so their
|
|
1160
|
-
// offsetTop is monotonic. ~13 reads for a full 5000-row buffer, and only while
|
|
1161
|
-
// the user is actually scrolled up — a following viewport takes this path not at
|
|
1162
|
-
// all, because the bottom pin owns its position.
|
|
1163
|
-
interface ReadAnchor {
|
|
1164
|
-
el: HTMLElement;
|
|
1165
|
-
/** The anchor row's absolute index, so an eviction that removes the element
|
|
1166
|
-
* itself (batched cap eviction frees up to a whole batch at once) can
|
|
1167
|
-
* re-resolve to the nearest surviving row instead of giving up. */
|
|
1168
|
-
abs: number;
|
|
1169
|
-
/** Where the row sat ON SCREEN: offsetTop minus the scroll offset. Measuring
|
|
1170
|
-
* the screen position rather than the container offset is what makes the
|
|
1171
|
-
* correction idempotent — see restoreReadAnchor. */
|
|
1172
|
-
screenTop: number;
|
|
1173
|
-
}
|
|
1174
|
-
|
|
1175
|
-
// rowAtViewportTop returns the first CONTENT row at or below the viewport top —
|
|
1176
|
-
// the ONE row-selection primitive every "where is the reader" question resolves
|
|
1177
|
-
// through: the read anchor, the paging trigger's viewportAbs, and the per-view
|
|
1178
|
-
// memory a tabbed consumer saves. One definition because two would drift, and
|
|
1179
|
-
// they would drift exactly during a rebuild, which is when the answer matters
|
|
1180
|
-
// most (docs/scroll-position-fidelity.md §7.2).
|
|
1181
|
-
//
|
|
1182
|
-
// Children are in document order with monotonic offsetTop, so this is a binary
|
|
1183
|
-
// search: ~13 reads for a full 5000-row buffer. Non-content children are NEVER a
|
|
1184
|
-
// reading position and are skipped: a marker is an annotation ABOUT a hole, so
|
|
1185
|
-
// holding one in place would pin the annotation rather than the text the reader
|
|
1186
|
-
// is looking at.
|
|
1187
|
-
//
|
|
1188
|
-
// "Content row" is decided by IDENTITY in rowEls, not by the presence of a
|
|
1189
|
-
// data-abs attribute. A gap marker carries one — updateGapMarkers sets it to the
|
|
1190
|
-
// gap's LOW index so insertRowInOrder keeps the container monotonic — so an
|
|
1191
|
-
// attribute test skips only the trim marker and happily returns a gap marker as
|
|
1192
|
-
// a reading position. Its index is the first ABSENT line, which rowEls can never
|
|
1193
|
-
// resolve, so a restore anchored there could never land and a viewportAbs built
|
|
1194
|
-
// on it would name a line the store does not hold.
|
|
1195
|
-
function isContentRow(el: HTMLElement): boolean {
|
|
1196
|
-
const abs = rowAbs(el);
|
|
1197
|
-
return abs >= 0 && rowEls.get(abs) === el;
|
|
1198
|
-
}
|
|
907
|
+
lastInboundMs = Date.now();
|
|
908
|
+
store.applyScroll(msg);
|
|
909
|
+
scheduleFlush();
|
|
910
|
+
}
|
|
1199
911
|
|
|
1200
|
-
function
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
912
|
+
function scheduleFlush(): void {
|
|
913
|
+
if (pendingFrame !== undefined) {
|
|
914
|
+
return;
|
|
915
|
+
}
|
|
916
|
+
pendingFrame = requestAnimationFrame(flushRender);
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
// One hook rather than two, because a consumer wiring `maybeFetchHistory` and
|
|
920
|
+
// not a public `resumeDrain` would get paging and no drain recovery with no
|
|
921
|
+
// type error. The resume is reachable on an idle session: the bounded error
|
|
922
|
+
// path stops rescheduling, and no inbound frame arrives to restart it.
|
|
923
|
+
function handleScrollPosition(): void {
|
|
924
|
+
maybeFetchHistory();
|
|
925
|
+
resumeDrain();
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
// Three refusals, each load-bearing. An EMPTY QUEUE: the flush runs the
|
|
929
|
+
// position invariants unconditionally, so a flush from a scroll handler would
|
|
930
|
+
// snap a downward scroll landing just short of the tail to the bottom one
|
|
931
|
+
// frame later. ALT SCREEN: a named suspension of the drain; alt exit re-queues
|
|
932
|
+
// everything. A GIVE-UP ALREADY RETRIED: a flick is one scroll event per
|
|
933
|
+
// frame, so retrying a deterministically throwing row per event would move
|
|
934
|
+
// the 60fps loop outside the module; one retry per give-up, re-earned by
|
|
935
|
+
// whatever clears the streak, never by the give-up log itself.
|
|
936
|
+
function resumeDrain(): void {
|
|
937
|
+
if (!store || renderQueue.size === 0 || store.isAlt()) {
|
|
938
|
+
return;
|
|
939
|
+
}
|
|
940
|
+
// The catch reaches `streak === MAX` from its progress-less branch and
|
|
941
|
+
// schedules a frame on the way out, so for one frame the streak sits at the
|
|
942
|
+
// cap with a flush pending; a scroll landing there would burn the retry
|
|
943
|
+
// while `scheduleFlush` no-ops. An empty frame slot is what proves the
|
|
944
|
+
// give-up HAPPENED.
|
|
945
|
+
if (pendingFrame !== undefined) {
|
|
946
|
+
return;
|
|
947
|
+
}
|
|
948
|
+
if (renderNoProgressStreak >= MAX_RENDER_NO_PROGRESS_RETRIES) {
|
|
949
|
+
if (renderNoProgressStreak > MAX_RENDER_NO_PROGRESS_RETRIES) {
|
|
950
|
+
return;
|
|
951
|
+
}
|
|
952
|
+
// The retry is carried in the streak itself rather than a second flag,
|
|
953
|
+
// so every `renderNoProgressStreak = 0` re-arms it by construction; a flag
|
|
954
|
+
// had to be cleared at every such site and was missed at the common ones.
|
|
955
|
+
renderNoProgressStreak = MAX_RENDER_NO_PROGRESS_RETRIES + 1;
|
|
1217
956
|
}
|
|
957
|
+
scheduleFlush();
|
|
1218
958
|
}
|
|
1219
|
-
|
|
1220
|
-
//
|
|
1221
|
-
//
|
|
1222
|
-
//
|
|
1223
|
-
//
|
|
1224
|
-
|
|
1225
|
-
|
|
959
|
+
|
|
960
|
+
// --- Read-position anchoring (manual scroll anchoring) ---
|
|
961
|
+
// A flush can change the content height ABOVE the reading position (top-of-
|
|
962
|
+
// history eviction at the cap, the trim marker appearing). WebKit has no
|
|
963
|
+
// `overflow-anchor`, so on Safari a scrolled-up reader was dragged one line per
|
|
964
|
+
// evicted row. The anchor is the first content row at or below the viewport
|
|
965
|
+
// top; a following viewport never takes this path, the bottom pin owns it.
|
|
966
|
+
interface ReadAnchor {
|
|
967
|
+
el: HTMLElement;
|
|
968
|
+
/** The anchor row's index, so an eviction of the element itself re-resolves to the nearest survivor. */
|
|
969
|
+
abs: number;
|
|
970
|
+
/** Where the row sat ON SCREEN (offsetTop minus scroll offset), which makes the correction idempotent. */
|
|
971
|
+
screenTop: number;
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
// "Content row" is decided by IDENTITY in rowEls, not by a data-abs attribute:
|
|
975
|
+
// a gap marker carries its gap's LOW index (so insertRowInOrder stays
|
|
976
|
+
// monotonic), and an attribute test would return it as a reading position
|
|
977
|
+
// naming a line the store does not hold.
|
|
978
|
+
function isContentRow(el: HTMLElement): boolean {
|
|
979
|
+
const abs = rowAbs(el);
|
|
980
|
+
return abs >= 0 && rowEls.get(abs) === el;
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
// The ONE row-selection primitive every "where is the reader" question
|
|
984
|
+
// resolves through (the read anchor, the paging trigger, the per-view memory):
|
|
985
|
+
// two definitions would drift exactly during a rebuild, when the answer
|
|
986
|
+
// matters most (docs/scroll-position-fidelity.md §7.2). Binary search, since
|
|
987
|
+
// the children are in document order with monotonic offsetTop.
|
|
988
|
+
function rowAtViewportTop(): HTMLElement | null {
|
|
989
|
+
if (!output) {
|
|
990
|
+
return null;
|
|
991
|
+
}
|
|
992
|
+
const kids = output.children;
|
|
993
|
+
if (kids.length === 0) {
|
|
994
|
+
return null;
|
|
995
|
+
}
|
|
996
|
+
const offset = scroll.currentScrollTop();
|
|
997
|
+
let lo = 0;
|
|
998
|
+
let hi = kids.length - 1;
|
|
999
|
+
let found: HTMLElement | null = null;
|
|
1000
|
+
while (lo <= hi) {
|
|
1001
|
+
const mid = (lo + hi) >> 1;
|
|
1002
|
+
const el = kids[mid] as HTMLElement;
|
|
1003
|
+
if (el.offsetTop >= offset) {
|
|
1004
|
+
found = el;
|
|
1005
|
+
hi = mid - 1;
|
|
1006
|
+
} else {
|
|
1007
|
+
lo = mid + 1;
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
// Walk past markers. The bound is O(gaps + 1), not 1: two gap markers CAN
|
|
1011
|
+
// be adjacent siblings once predictReplayJump retires the window.
|
|
1012
|
+
while (found !== null && !isContentRow(found)) {
|
|
1013
|
+
found = found.nextElementSibling as HTMLElement | null;
|
|
1014
|
+
}
|
|
1015
|
+
return found;
|
|
1226
1016
|
}
|
|
1227
|
-
return found;
|
|
1228
|
-
}
|
|
1229
1017
|
|
|
1230
|
-
function captureReadAnchor(): ReadAnchor | null {
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
// tail-drag (docs/scroll-position-fidelity.md §1.2).
|
|
1243
|
-
return null;
|
|
1244
|
-
}
|
|
1245
|
-
return { el, abs: rowAbs(el), screenTop: el.offsetTop - scroll.currentScrollTop() };
|
|
1246
|
-
}
|
|
1018
|
+
function captureReadAnchor(): ReadAnchor | null {
|
|
1019
|
+
if (!scroll.isUserScrolledUp()) {
|
|
1020
|
+
return null; // following: stickToBottom owns the position
|
|
1021
|
+
}
|
|
1022
|
+
const el = rowAtViewportTop();
|
|
1023
|
+
if (el === null) {
|
|
1024
|
+
// Stand down rather than fall back to the tail: a tail proxy is what
|
|
1025
|
+
// turned a large content shrink into a tail-drag (docs/scroll-position-fidelity.md §1.2).
|
|
1026
|
+
return null;
|
|
1027
|
+
}
|
|
1028
|
+
return { el, abs: rowAbs(el), screenTop: el.offsetTop - scroll.currentScrollTop() };
|
|
1029
|
+
}
|
|
1247
1030
|
|
|
1248
|
-
//
|
|
1249
|
-
//
|
|
1250
|
-
//
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
} else {
|
|
1272
|
-
lo = mid + 1;
|
|
1031
|
+
// The nearest surviving CONTENT row at or below a lost reading position, by
|
|
1032
|
+
// binary search over the ascending-data-abs children; markers are skipped by
|
|
1033
|
+
// identity (isContentRow).
|
|
1034
|
+
function firstRowAtOrAfter(abs: number): HTMLElement | null {
|
|
1035
|
+
if (!output) {
|
|
1036
|
+
return null;
|
|
1037
|
+
}
|
|
1038
|
+
const kids = output.children;
|
|
1039
|
+
let lo = 0;
|
|
1040
|
+
let hi = kids.length - 1;
|
|
1041
|
+
let found: HTMLElement | null = null;
|
|
1042
|
+
while (lo <= hi) {
|
|
1043
|
+
const mid = (lo + hi) >> 1;
|
|
1044
|
+
const el = kids[mid] as HTMLElement;
|
|
1045
|
+
if (rowAbs(el) >= abs) {
|
|
1046
|
+
found = el;
|
|
1047
|
+
hi = mid - 1;
|
|
1048
|
+
} else {
|
|
1049
|
+
lo = mid + 1;
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
while (found !== null && !isContentRow(found)) {
|
|
1053
|
+
found = found.nextElementSibling as HTMLElement | null;
|
|
1273
1054
|
}
|
|
1055
|
+
return found;
|
|
1274
1056
|
}
|
|
1275
|
-
|
|
1276
|
-
|
|
1057
|
+
|
|
1058
|
+
function restoreReadAnchor(anchor: ReadAnchor | null): void {
|
|
1059
|
+
if (anchor === null) {
|
|
1060
|
+
return; // following: stickToBottom owns the position
|
|
1061
|
+
}
|
|
1062
|
+
let el: HTMLElement | null = anchor.el;
|
|
1063
|
+
if (el.parentElement !== output) {
|
|
1064
|
+
if (fullResetThisPass) {
|
|
1065
|
+
// Indices restarted from 0, so a child with a matching data-abs is
|
|
1066
|
+
// UNRELATED content; the fresh session's own follow state owns the viewport.
|
|
1067
|
+
return;
|
|
1068
|
+
}
|
|
1069
|
+
// Batched cap eviction frees up to a whole batch in one pass, so a reader
|
|
1070
|
+
// parked near the buffer top loses the anchored ELEMENT while unread rows
|
|
1071
|
+
// survive below it; hold the nearest survivor at the anchor's screen position.
|
|
1072
|
+
el = firstRowAtOrAfter(anchor.abs);
|
|
1073
|
+
if (el === null) {
|
|
1074
|
+
return; // nothing survives (reset/clear): nothing to hold
|
|
1075
|
+
}
|
|
1076
|
+
// A region DISCARD (ED3), not a cap trim: nothing surviving is guaranteed
|
|
1077
|
+
// ADJACENT to what the reader saw. An inline TUI that reprints on resize
|
|
1078
|
+
// brings the same text back at new indices, and holding the survivor at
|
|
1079
|
+
// the old screen position is the "random jump on resize" symptom
|
|
1080
|
+
// (docs/scroll-position-fidelity.md §1.2, §5). The test is the ANCHOR's
|
|
1081
|
+
// index only: requiring the SURVIVOR above the base too never held, since
|
|
1082
|
+
// the reprint re-delivers lines below the base in the same frame.
|
|
1083
|
+
if (discardedBelowThisPass >= 0 && anchor.abs < discardedBelowThisPass) {
|
|
1084
|
+
return;
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
// Correct by how far the row DRIFTED ON SCREEN, not by the content delta:
|
|
1088
|
+
// Chrome and Firefox anchor natively, so their offsetTop change comes with
|
|
1089
|
+
// a matching scrollTop change and this measures zero; correcting the content
|
|
1090
|
+
// delta there would double-compensate. On Safari the drift is the whole delta.
|
|
1091
|
+
const drift = el.offsetTop - scroll.currentScrollTop() - anchor.screenTop;
|
|
1092
|
+
scroll.adjustForContentShift(drift);
|
|
1277
1093
|
}
|
|
1278
|
-
return found;
|
|
1279
|
-
}
|
|
1280
1094
|
|
|
1281
|
-
function
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
//
|
|
1291
|
-
|
|
1292
|
-
|
|
1095
|
+
function flushRender(): void {
|
|
1096
|
+
pendingFrame = undefined;
|
|
1097
|
+
// Read BEFORE any mutation, twice over: the anchor needs the pre-mutation
|
|
1098
|
+
// screen position, and noteContentShrink needs the pre-mutation offset to tell
|
|
1099
|
+
// whether this pass's row removals actually moved the viewport.
|
|
1100
|
+
const scrollTopBefore = scroll.currentScrollTop();
|
|
1101
|
+
const anchor = captureReadAnchor();
|
|
1102
|
+
try {
|
|
1103
|
+
flushRenderInner();
|
|
1104
|
+
// Clean pass: give a later transient error its full retry budget again.
|
|
1105
|
+
renderNoProgressStreak = 0;
|
|
1106
|
+
} catch (err) {
|
|
1107
|
+
console.error("vterm: render error", err);
|
|
1108
|
+
// The throw skipped flushRenderInner's own reschedule, so finish the drain
|
|
1109
|
+
// here, BOUNDED: reschedule while the pass made progress or the
|
|
1110
|
+
// no-progress streak is under the cap, then stop and let the next inbound
|
|
1111
|
+
// frame retry.
|
|
1112
|
+
if (renderQueue.size === 0) {
|
|
1113
|
+
renderNoProgressStreak = 0;
|
|
1114
|
+
} else if (flushDrainedThisPass > 0) {
|
|
1115
|
+
renderNoProgressStreak = 0;
|
|
1116
|
+
scheduleFlush();
|
|
1117
|
+
} else if (renderNoProgressStreak < MAX_RENDER_NO_PROGRESS_RETRIES) {
|
|
1118
|
+
renderNoProgressStreak++;
|
|
1119
|
+
scheduleFlush();
|
|
1120
|
+
} else {
|
|
1121
|
+
console.error("vterm: giving up render retry after repeated no-progress errors");
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
// Announce the shrink BEFORE any write of our own, or the comparison
|
|
1125
|
+
// measures our write instead of the browser's clamp; then reconcile an
|
|
1126
|
+
// out-of-range offset before the invariants below measure the geometry.
|
|
1127
|
+
if (removedRowsThisPass) {
|
|
1128
|
+
scroll.noteContentShrink(scrollTopBefore);
|
|
1129
|
+
scroll.reconcileScrollRange();
|
|
1130
|
+
}
|
|
1131
|
+
// Three position invariants in this order: an armed restore OWNS the
|
|
1132
|
+
// position, the read anchor holds the line on screen, the bottom pin last.
|
|
1133
|
+
// The anchor is skipped ONLY in the frame the restore LANDED: captured before
|
|
1134
|
+
// this frame's mutations, its drift would measure the restore's own write and
|
|
1135
|
+
// correct it straight back out. Skipping it while merely ARMED would
|
|
1136
|
+
// reintroduce the WebKit slide for every frame of a multi-frame rebuild.
|
|
1137
|
+
const restoreLanded = applyPendingRestore();
|
|
1138
|
+
if (!restoreLanded) {
|
|
1139
|
+
restoreReadAnchor(anchor);
|
|
1140
|
+
}
|
|
1141
|
+
// Single auto-follow invariant, applied after every DOM mutation.
|
|
1142
|
+
stickToBottomIfFollowing();
|
|
1143
|
+
// Absorb every write this frame made — ours and the browser's native scroll
|
|
1144
|
+
// anchoring — into the restore's baseline, so only a move that happens BETWEEN
|
|
1145
|
+
// frames (a real gesture) reads as one.
|
|
1146
|
+
if (pendingRestore !== null) {
|
|
1147
|
+
pendingRestore.lastWrote = scroll.currentScrollTop();
|
|
1148
|
+
}
|
|
1149
|
+
// The post-flush trigger. The reader's position relative to the store's gaps
|
|
1150
|
+
// can change without any scroll event — a tail trim moves the frontier up
|
|
1151
|
+
// under a stationary reader, and a byte-short page leaves a fresh sub-gap
|
|
1152
|
+
// beside them — so the flush is the other place the trigger must run
|
|
1153
|
+
// (docs/paged-scrollback.md §5.4).
|
|
1154
|
+
maybeFetchHistory();
|
|
1155
|
+
reportUnnamedDrainStall();
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
// After a flush, owed rows are in one of four states: nothing owed, a frame
|
|
1159
|
+
// scheduled, suspended on the alt screen (renderAlt replaces the whole output
|
|
1160
|
+
// subtree; alt exit re-queues everything), or stopped and logged by the bounded
|
|
1161
|
+
// error path. Anything else is a stall nobody owns: `scheduleFlush` is a single
|
|
1162
|
+
// slot, so a non-empty queue with an empty slot drops every later request
|
|
1163
|
+
// silently. A future suspension point must be named here.
|
|
1164
|
+
function reportUnnamedDrainStall(): void {
|
|
1165
|
+
if (!store || renderQueue.size === 0 || pendingFrame !== undefined || store.isAlt()) {
|
|
1166
|
+
unnamedStallReported = false;
|
|
1293
1167
|
return;
|
|
1294
1168
|
}
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
// pass, so a reader parked within a batch of the buffer top loses the
|
|
1298
|
-
// anchored ELEMENT while rows they had not read yet survive below it.
|
|
1299
|
-
// Re-resolve to the first surviving row at or after the anchored index
|
|
1300
|
-
// and hold THAT at the anchor's screen position — the view resumes at
|
|
1301
|
-
// the nearest surviving content instead of silently jumping past up to
|
|
1302
|
-
// a batch of unread lines (WebKit has no native anchoring to catch it).
|
|
1303
|
-
el = firstRowAtOrAfter(anchor.abs);
|
|
1304
|
-
if (el === null) {
|
|
1305
|
-
return; // nothing survives (reset/clear): nothing to hold
|
|
1306
|
-
}
|
|
1307
|
-
// A region DISCARD, not a cap trim: the application erased its saved lines
|
|
1308
|
-
// (ED3), so nothing surviving is guaranteed ADJACENT to what the reader was
|
|
1309
|
-
// looking at. On an inline TUI that reprints on resize the same text comes
|
|
1310
|
-
// straight back at new indices, so the nearest surviving row is unrelated
|
|
1311
|
-
// content — and holding it at the reader's screen position is the "random jump
|
|
1312
|
-
// on resize" symptom. A cap trim is the opposite case and keeps the re-resolve
|
|
1313
|
-
// above: it removes the oldest contiguous run, so the survivor really is the
|
|
1314
|
-
// reader's neighbour (docs/scroll-position-fidelity.md §1.2, §5).
|
|
1315
|
-
//
|
|
1316
|
-
// The test is the ANCHOR's index. An earlier version also required the
|
|
1317
|
-
// SURVIVOR to sit at or above the discard base, which never held for the shape
|
|
1318
|
-
// this exists for: the reprint re-delivers lines below that base in the same
|
|
1319
|
-
// frame, so the survivor looked adjacent and the correction fired anyway.
|
|
1320
|
-
//
|
|
1321
|
-
// The remaining `anchor.abs <` clause is a GUARD, not a live path, and the
|
|
1322
|
-
// reachability argument is worth recording because it is not obvious. For a
|
|
1323
|
-
// single well-formed frame it cannot fire: `msg.base` is both the discard bound
|
|
1324
|
-
// and the new window base, so an anchor at or above it sits inside the live
|
|
1325
|
-
// window, which cap eviction protects and `truncateBelowWindow` only trims
|
|
1326
|
-
// above. Across several frames in one batch (ED3 at a low base, the window
|
|
1327
|
-
// settling high) the anchor's ELEMENT normally survives instead — the store
|
|
1328
|
-
// coalesces an evict-then-reapply of the same index, so `upsertRow` reuses the
|
|
1329
|
-
// element and this whole branch is skipped, leaving the ordinary drift
|
|
1330
|
-
// correction to handle the history the discard removed above the reader, which
|
|
1331
|
-
// is right. Measured: that path applies a legitimate -1700px correction for
|
|
1332
|
-
// 100 discarded rows, and it is NOT this branch.
|
|
1333
|
-
if (discardedBelowThisPass >= 0 && anchor.abs < discardedBelowThisPass) {
|
|
1169
|
+
if (renderNoProgressStreak >= MAX_RENDER_NO_PROGRESS_RETRIES) {
|
|
1170
|
+
// Named and already logged by the catch; not healthy, so the latch stays.
|
|
1334
1171
|
return;
|
|
1335
1172
|
}
|
|
1173
|
+
if (unnamedStallReported) {
|
|
1174
|
+
return;
|
|
1175
|
+
}
|
|
1176
|
+
unnamedStallReported = true;
|
|
1177
|
+
console.warn(
|
|
1178
|
+
`vterm: ${String(renderQueue.size)} rows queued with no scheduled frame and no named suspension`,
|
|
1179
|
+
);
|
|
1336
1180
|
}
|
|
1337
|
-
// Correct by how far the row DRIFTED ON SCREEN, not by how much the content
|
|
1338
|
-
// above it changed. The two differ exactly when the browser already did this
|
|
1339
|
-
// itself: Chrome and Firefox have native scroll anchoring, so their offsetTop
|
|
1340
|
-
// change comes with a matching scrollTop change and the screen position is
|
|
1341
|
-
// already right — this then measures zero drift and does nothing. Correcting
|
|
1342
|
-
// the content delta instead would double-compensate there and throw the view
|
|
1343
|
-
// the other way. On Safari, which has no native anchoring, the drift is the
|
|
1344
|
-
// whole content delta and this is the only thing that fixes it.
|
|
1345
|
-
const drift = el.offsetTop - scroll.currentScrollTop() - anchor.screenTop;
|
|
1346
|
-
scroll.adjustForContentShift(drift);
|
|
1347
|
-
}
|
|
1348
1181
|
|
|
1349
|
-
function
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
// retry -- the pre-l-f28 behavior for a permanently stuck row.
|
|
1375
|
-
if (renderQueue.size === 0) {
|
|
1376
|
-
renderNoProgressStreak = 0;
|
|
1377
|
-
} else if (flushDrainedThisPass > 0) {
|
|
1378
|
-
renderNoProgressStreak = 0;
|
|
1379
|
-
scheduleFlush();
|
|
1380
|
-
} else if (renderNoProgressStreak < MAX_RENDER_NO_PROGRESS_RETRIES) {
|
|
1381
|
-
renderNoProgressStreak++;
|
|
1382
|
-
scheduleFlush();
|
|
1182
|
+
function flushRenderInner(): void {
|
|
1183
|
+
flushDrainedThisPass = 0;
|
|
1184
|
+
if (!store || !output) {
|
|
1185
|
+
return;
|
|
1186
|
+
}
|
|
1187
|
+
const ch = store.drainChanges();
|
|
1188
|
+
fullResetThisPass = ch.fullReset;
|
|
1189
|
+
discardedBelowThisPass = discardedBelowPending;
|
|
1190
|
+
discardedBelowPending = -1;
|
|
1191
|
+
removedRowsThisPass = false;
|
|
1192
|
+
|
|
1193
|
+
if (ch.fullReset) {
|
|
1194
|
+
removedRowsThisPass = true;
|
|
1195
|
+
output.replaceChildren();
|
|
1196
|
+
rowEls.clear();
|
|
1197
|
+
renderQueue.clear();
|
|
1198
|
+
trimMarkerEl = null;
|
|
1199
|
+
for (const el of gapMarkerEls.values()) {
|
|
1200
|
+
el.remove();
|
|
1201
|
+
}
|
|
1202
|
+
gapMarkerEls.clear();
|
|
1203
|
+
cursorAbs = -1;
|
|
1204
|
+
// Collapse the overlays HERE rather than in the flush tail, so the shrink
|
|
1205
|
+
// this pass announces measures the real height and not a phantom one.
|
|
1206
|
+
collapseContentSpaceOverlays();
|
|
1383
1207
|
} else {
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
// And correct the offset when the container left it past the end of the
|
|
1395
|
-
// shrunken content instead of reconciling it (scroll.ts's header: the third
|
|
1396
|
-
// arithmetic state). Before the three invariants below, so they measure a
|
|
1397
|
-
// geometry the container agrees with rather than one it is still holding an
|
|
1398
|
-
// impossible offset into.
|
|
1399
|
-
scroll.reconcileScrollRange();
|
|
1400
|
-
}
|
|
1401
|
-
// Three position invariants, applied after every DOM mutation and in this
|
|
1402
|
-
// order. An armed view restore goes first because it OWNS the position while
|
|
1403
|
-
// it is armed; the read anchor then holds whatever line is on screen; the
|
|
1404
|
-
// bottom pin last, and only if following.
|
|
1405
|
-
//
|
|
1406
|
-
// The anchor is skipped ONLY in the frame the restore actually LANDED, and
|
|
1407
|
-
// that exception is load-bearing rather than tidy: the anchor was captured
|
|
1408
|
-
// BEFORE this frame's mutations, so once the restore has authoritatively moved
|
|
1409
|
-
// the viewport, the anchor's drift measures the restore's own write and
|
|
1410
|
-
// corrects it straight back out — the restore and the anchor cancel and the
|
|
1411
|
-
// reader stays at the top. Skipping it while merely ARMED would be wrong for
|
|
1412
|
-
// the opposite reason: a rebuild spans many frames, and suppressing the anchor
|
|
1413
|
-
// across all of them reintroduces the WebKit read-position slide for every one.
|
|
1414
|
-
const restoreLanded = applyPendingRestore();
|
|
1415
|
-
if (!restoreLanded) {
|
|
1416
|
-
restoreReadAnchor(anchor);
|
|
1417
|
-
}
|
|
1418
|
-
// Single auto-follow invariant, applied after every DOM mutation.
|
|
1419
|
-
stickToBottomIfFollowing();
|
|
1420
|
-
// Absorb every write this frame made — ours and the browser's native scroll
|
|
1421
|
-
// anchoring — into the restore's baseline, so only a move that happens BETWEEN
|
|
1422
|
-
// frames (a real gesture) reads as one.
|
|
1423
|
-
if (pendingRestore !== null) {
|
|
1424
|
-
pendingRestore.lastWrote = scroll.currentScrollTop();
|
|
1425
|
-
}
|
|
1426
|
-
// The post-flush trigger. The reader's position relative to the store's gaps
|
|
1427
|
-
// can change without any scroll event — a tail trim moves the frontier up
|
|
1428
|
-
// under a stationary reader, and a byte-short page leaves a fresh sub-gap
|
|
1429
|
-
// beside them — so the flush is the other place the trigger must run
|
|
1430
|
-
// (docs/paged-scrollback.md §5.4).
|
|
1431
|
-
maybeFetchHistory();
|
|
1432
|
-
reportUnnamedDrainStall();
|
|
1433
|
-
}
|
|
1208
|
+
for (const abs of ch.evictedLines) {
|
|
1209
|
+
const el = rowEls.get(abs);
|
|
1210
|
+
if (el) {
|
|
1211
|
+
el.remove();
|
|
1212
|
+
removedRowsThisPass = true;
|
|
1213
|
+
}
|
|
1214
|
+
rowEls.delete(abs);
|
|
1215
|
+
renderQueue.delete(abs);
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1434
1218
|
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
// 1. nothing owed - the queue is empty.
|
|
1444
|
-
// 2. scheduled - a frame will drain it.
|
|
1445
|
-
// 3. suspended, named edge - alt screen. `renderAlt` replaces the whole output
|
|
1446
|
-
// subtree, so continuing the drain would inject
|
|
1447
|
-
// main-buffer rows into the alt grid; the alt-exit
|
|
1448
|
-
// branch re-queues everything via
|
|
1449
|
-
// queueRowsViewportFirst, so nothing is lost. The
|
|
1450
|
-
// resumption edge is alt exit. A future third
|
|
1451
|
-
// suspension point must declare its edge here.
|
|
1452
|
-
// 4. stopped and reported - the bounded error path gave up after
|
|
1453
|
-
// MAX_RENDER_NO_PROGRESS_RETRIES and logged. It is a
|
|
1454
|
-
// guard on the process, so it refuses rather than
|
|
1455
|
-
// looping; the next inbound frame retries, or one
|
|
1456
|
-
// scroll does (resumeDrain, once per give-up).
|
|
1457
|
-
//
|
|
1458
|
-
// Anything else is a stall nobody owns. Reported once PER EPISODE: the latch
|
|
1459
|
-
// clears as soon as a flush ends in a healthy state, so a repeat of one stall
|
|
1460
|
-
// stays quiet while a genuinely new stall later is still reported. Log spam would
|
|
1461
|
-
// be its own defect, and so would a canary that goes deaf after the first report.
|
|
1462
|
-
function reportUnnamedDrainStall(): void {
|
|
1463
|
-
if (renderQueue.size === 0 || pendingFrame !== undefined || store.isAlt()) {
|
|
1464
|
-
// A healthy end: the episode (if any) is over, so re-arm the canary.
|
|
1465
|
-
unnamedStallReported = false;
|
|
1466
|
-
return;
|
|
1467
|
-
}
|
|
1468
|
-
if (renderNoProgressStreak >= MAX_RENDER_NO_PROGRESS_RETRIES) {
|
|
1469
|
-
// Named and already logged by the catch. Not a healthy state, so the latch is
|
|
1470
|
-
// left as it is rather than re-armed.
|
|
1471
|
-
return;
|
|
1472
|
-
}
|
|
1473
|
-
if (unnamedStallReported) {
|
|
1474
|
-
return;
|
|
1475
|
-
}
|
|
1476
|
-
unnamedStallReported = true;
|
|
1477
|
-
console.warn(
|
|
1478
|
-
`vterm: ${String(renderQueue.size)} rows queued with no scheduled frame and no named suspension`,
|
|
1479
|
-
);
|
|
1480
|
-
}
|
|
1219
|
+
const win = store.getWindow();
|
|
1220
|
+
const newCursorAbs = win.base + win.cursorRow;
|
|
1221
|
+
cursorAbs = newCursorAbs;
|
|
1222
|
+
cursorCol = win.cursorCol;
|
|
1223
|
+
cursorHidden = win.cursorHidden;
|
|
1224
|
+
cursorStyleVal = win.cursorStyle;
|
|
1225
|
+
blinkEnabled = win.cursorBlink;
|
|
1226
|
+
syncCursorBlink();
|
|
1481
1227
|
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
if (ch.fullReset) {
|
|
1494
|
-
removedRowsThisPass = true;
|
|
1495
|
-
output.replaceChildren();
|
|
1496
|
-
rowEls.clear();
|
|
1497
|
-
renderQueue.clear();
|
|
1498
|
-
trimMarkerEl = null;
|
|
1499
|
-
for (const el of gapMarkerEls.values()) {
|
|
1500
|
-
el.remove();
|
|
1228
|
+
// Skipping the dirtyLines queueing is safe: alt exit rebuilds from the
|
|
1229
|
+
// store, repainting any line dirtied during the alt session.
|
|
1230
|
+
if (store.isAlt()) {
|
|
1231
|
+
const altRows = store.getAltRows();
|
|
1232
|
+
renderAlt(altRows);
|
|
1233
|
+
positionCursorOverlay(altRows[win.cursorRow]);
|
|
1234
|
+
if (onCursorMove) {
|
|
1235
|
+
onCursorMove();
|
|
1236
|
+
}
|
|
1237
|
+
return;
|
|
1501
1238
|
}
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
// is exactly what does not run on the three paths listed there.
|
|
1513
|
-
collapseContentSpaceOverlays();
|
|
1514
|
-
} else {
|
|
1515
|
-
for (const abs of ch.evictedLines) {
|
|
1516
|
-
const el = rowEls.get(abs);
|
|
1517
|
-
if (el) {
|
|
1239
|
+
if (altRendered) {
|
|
1240
|
+
// Just exited alt: drop the ephemeral rows and rebuild from the store.
|
|
1241
|
+
altRendered = false;
|
|
1242
|
+
altPrevRows = [];
|
|
1243
|
+
removedRowsThisPass = true;
|
|
1244
|
+
output.replaceChildren();
|
|
1245
|
+
rowEls.clear();
|
|
1246
|
+
renderQueue.clear();
|
|
1247
|
+
trimMarkerEl = null;
|
|
1248
|
+
for (const el of gapMarkerEls.values()) {
|
|
1518
1249
|
el.remove();
|
|
1519
|
-
removedRowsThisPass = true;
|
|
1520
1250
|
}
|
|
1521
|
-
|
|
1522
|
-
|
|
1251
|
+
gapMarkerEls.clear();
|
|
1252
|
+
queueRowsViewportFirst();
|
|
1523
1253
|
}
|
|
1524
|
-
}
|
|
1525
1254
|
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
cursorAbs = newCursorAbs;
|
|
1529
|
-
cursorCol = win.cursorCol;
|
|
1530
|
-
cursorHidden = win.cursorHidden;
|
|
1531
|
-
cursorStyleVal = win.cursorStyle;
|
|
1532
|
-
blinkEnabled = win.cursorBlink;
|
|
1533
|
-
// Reconcile the blink interval with the fresh cursor state (blink mode and
|
|
1534
|
-
// DECTCM visibility may both have changed this frame); no-op when the
|
|
1535
|
-
// resulting mode is unchanged.
|
|
1536
|
-
syncCursorBlink();
|
|
1537
|
-
|
|
1538
|
-
// Skips the dirtyLines queueing below — safe: main-buffer rows never
|
|
1539
|
-
// repaint while alt is active, and alt-exit rebuilds via
|
|
1540
|
-
// queueRowsViewportFirst, repainting any line dirtied during the alt session.
|
|
1541
|
-
if (store.isAlt()) {
|
|
1542
|
-
const altRows = store.getAltRows();
|
|
1543
|
-
renderAlt(altRows);
|
|
1544
|
-
positionCursorOverlay(altRows[win.cursorRow]);
|
|
1545
|
-
if (onCursorMove) {
|
|
1546
|
-
onCursorMove();
|
|
1547
|
-
}
|
|
1548
|
-
return;
|
|
1549
|
-
}
|
|
1550
|
-
if (altRendered) {
|
|
1551
|
-
// Just exited alt: drop the ephemeral rows and rebuild from the store,
|
|
1552
|
-
// viewport-first (shared with rebuild()) so the visible viewport fills
|
|
1553
|
-
// before deep scrollback on a large-history alt-exit.
|
|
1554
|
-
altRendered = false;
|
|
1555
|
-
altPrevRows = [];
|
|
1556
|
-
removedRowsThisPass = true;
|
|
1557
|
-
output.replaceChildren();
|
|
1558
|
-
rowEls.clear();
|
|
1559
|
-
renderQueue.clear();
|
|
1560
|
-
trimMarkerEl = null;
|
|
1561
|
-
for (const el of gapMarkerEls.values()) {
|
|
1562
|
-
el.remove();
|
|
1255
|
+
for (const abs of ch.dirtyLines) {
|
|
1256
|
+
renderQueue.add(abs);
|
|
1563
1257
|
}
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
// The cursor's row is built regardless of the budget — the caret overlay
|
|
1573
|
-
// positions off the row element's offsetTop, so a huge backlog must never
|
|
1574
|
-
// leave it floating over a not-yet-built row. Content-driven only: cursor
|
|
1575
|
-
// MOTION no longer touches row DOM (the overlay carries the caret), so a
|
|
1576
|
-
// selection anywhere — including on the cursor row — survives typing.
|
|
1577
|
-
if (renderQueue.has(newCursorAbs) || !rowEls.has(newCursorAbs)) {
|
|
1578
|
-
upsertRow(newCursorAbs);
|
|
1579
|
-
renderQueue.delete(newCursorAbs);
|
|
1580
|
-
}
|
|
1581
|
-
|
|
1582
|
-
// Drain up to MAX_ROWS_PER_FRAME queued rows this frame, viewport-first;
|
|
1583
|
-
// the rest carry over to the next frame (scheduled below) so one big burst
|
|
1584
|
-
// never blocks paint. Live-window rows build first (ascending), then the
|
|
1585
|
-
// scrollback backlog newest->oldest, so the backlog fills upward above the
|
|
1586
|
-
// bottom-pinned viewport — offscreen. A Set's insertion order did the
|
|
1587
|
-
// opposite under load: a multi-thousand-row backlog (a resume replay,
|
|
1588
|
-
// kiro-cli's post-resize transcript reprint, `cat bigfile`) queued ahead of
|
|
1589
|
-
// freshly-dirtied window rows, so the visible screen either churned through
|
|
1590
|
-
// history or froze (only the force-built cursor row moving) for seconds on
|
|
1591
|
-
// a slow device while the backlog drained. flushDrainedThisPass doubles as
|
|
1592
|
-
// the per-frame budget counter and the forward-progress signal the
|
|
1593
|
-
// error-path reschedule reads (it was reset to 0 at entry and nothing
|
|
1594
|
-
// between there and here touches it).
|
|
1595
|
-
const inWindow: number[] = [];
|
|
1596
|
-
const belowWindow: number[] = [];
|
|
1597
|
-
for (const abs of renderQueue) {
|
|
1598
|
-
if (abs >= win.base) {
|
|
1599
|
-
inWindow.push(abs);
|
|
1600
|
-
} else {
|
|
1601
|
-
belowWindow.push(abs);
|
|
1258
|
+
|
|
1259
|
+
// The cursor's row is built regardless of the budget: the caret overlay
|
|
1260
|
+
// positions off the row element's offsetTop, so a backlog must never leave
|
|
1261
|
+
// it floating over a not-yet-built row.
|
|
1262
|
+
if (renderQueue.has(newCursorAbs) || !rowEls.has(newCursorAbs)) {
|
|
1263
|
+
upsertRow(newCursorAbs);
|
|
1264
|
+
renderQueue.delete(newCursorAbs);
|
|
1602
1265
|
}
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1266
|
+
|
|
1267
|
+
// Viewport-first under one budget: a Set's insertion order queued a
|
|
1268
|
+
// multi-thousand-row backlog ahead of freshly dirtied window rows, so the
|
|
1269
|
+
// visible screen churned through history or froze for seconds on a slow
|
|
1270
|
+
// device. flushDrainedThisPass doubles as the budget counter and the
|
|
1271
|
+
// forward-progress signal the error-path reschedule reads.
|
|
1272
|
+
const inWindow: number[] = [];
|
|
1273
|
+
const belowWindow: number[] = [];
|
|
1274
|
+
for (const abs of renderQueue) {
|
|
1275
|
+
if (abs >= win.base) {
|
|
1276
|
+
inWindow.push(abs);
|
|
1277
|
+
} else {
|
|
1278
|
+
belowWindow.push(abs);
|
|
1279
|
+
}
|
|
1612
1280
|
}
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1281
|
+
inWindow.sort((a, b) => a - b);
|
|
1282
|
+
belowWindow.sort((a, b) => b - a);
|
|
1283
|
+
// No concatenated copy: a spread re-allocated a backlog-sized array per frame.
|
|
1284
|
+
const drainRow = (abs: number): boolean => {
|
|
1285
|
+
if (flushDrainedThisPass >= MAX_ROWS_PER_FRAME) {
|
|
1286
|
+
return false;
|
|
1287
|
+
}
|
|
1288
|
+
upsertRow(abs);
|
|
1289
|
+
renderQueue.delete(abs);
|
|
1290
|
+
flushDrainedThisPass++;
|
|
1291
|
+
return true;
|
|
1292
|
+
};
|
|
1293
|
+
for (const abs of inWindow) {
|
|
1294
|
+
if (!drainRow(abs)) {
|
|
1295
|
+
break;
|
|
1296
|
+
}
|
|
1621
1297
|
}
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1298
|
+
for (const abs of belowWindow) {
|
|
1299
|
+
if (!drainRow(abs)) {
|
|
1300
|
+
break;
|
|
1301
|
+
}
|
|
1626
1302
|
}
|
|
1627
|
-
}
|
|
1628
1303
|
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
// them should have to know a marker exists.
|
|
1634
|
-
updateGapMarkers();
|
|
1304
|
+
updateTrimMarker();
|
|
1305
|
+
// Re-derived every flush rather than maintained incrementally: a page apply,
|
|
1306
|
+
// a browse eviction and a tail trim all move gap edges.
|
|
1307
|
+
updateGapMarkers();
|
|
1635
1308
|
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
}
|
|
1309
|
+
if (renderQueue.size > 0) {
|
|
1310
|
+
scheduleFlush();
|
|
1311
|
+
}
|
|
1640
1312
|
|
|
1641
|
-
|
|
1313
|
+
positionCursorOverlay(store.getLine(cursorAbs));
|
|
1642
1314
|
|
|
1643
|
-
|
|
1644
|
-
|
|
1315
|
+
if (onCursorMove) {
|
|
1316
|
+
onCursorMove();
|
|
1317
|
+
}
|
|
1645
1318
|
}
|
|
1646
|
-
}
|
|
1647
|
-
|
|
1648
|
-
// --- Per-view scroll memory (docs/scroll-position-fidelity.md §3) ---
|
|
1649
|
-
//
|
|
1650
|
-
// A reading position is a LINE, not a pixel offset. A tabbed consumer that saved
|
|
1651
|
-
// `scrollTop` and replayed it on re-entry had that write silently CLAMPED,
|
|
1652
|
-
// because a rebuild has only built the live window plus one frame's budget when
|
|
1653
|
-
// the replay lands (301 of up to 5000 rows, ~5100 of ~85,000 px), and the clamp
|
|
1654
|
-
// was never re-attempted. The saved offset also stops meaning the same line as
|
|
1655
|
-
// soon as the tab's content grows while it is backgrounded, which is every tab
|
|
1656
|
-
// whose session kept working. So the memory is an absolute index plus the row's
|
|
1657
|
-
// on-screen offset, and the restore is RE-ASSERTED until the row it names has
|
|
1658
|
-
// actually been built.
|
|
1659
|
-
//
|
|
1660
|
-
// `screenTop` is deliberately a DIFFERENCE (offsetTop - scrollTop), the same
|
|
1661
|
-
// form ReadAnchor uses: rows report offsets in `.term-output`'s space while
|
|
1662
|
-
// scrollTop belongs to `.term`, so any ABSOLUTE pixel value would need the
|
|
1663
|
-
// rowTopInTermWrap conversion and would be one padding off without it. A
|
|
1664
|
-
// difference cancels the space out.
|
|
1665
|
-
|
|
1666
|
-
/**
|
|
1667
|
-
* A saved reading position, in the only terms that survive the content growing
|
|
1668
|
-
* underneath it: which line the reader had at the top of the viewport, where on
|
|
1669
|
-
* screen that row sat, and whether they were following the tail.
|
|
1670
|
-
*
|
|
1671
|
-
* Effectively opaque to the consumer — read one out of the renderer and hand the
|
|
1672
|
-
* same value back to restore it; the fields are documented because JSR publishes
|
|
1673
|
-
* them, not because a caller should compute one. A memory is only meaningful to
|
|
1674
|
-
* the store it was taken from, since `abs` names a line in THAT index space, and
|
|
1675
|
-
* a restore is re-asserted until the named row has been built rather than
|
|
1676
|
-
* applied once.
|
|
1677
|
-
*/
|
|
1678
|
-
export interface ViewMemory {
|
|
1679
|
-
/** Absolute line index of the content row at the viewport top. */
|
|
1680
|
-
abs: number;
|
|
1681
|
-
/** That row's on-screen position: offsetTop minus the scroll offset. */
|
|
1682
|
-
screenTop: number;
|
|
1683
|
-
/** Whether auto-follow was engaged. */
|
|
1684
|
-
following: boolean;
|
|
1685
|
-
}
|
|
1686
|
-
|
|
1687
|
-
// The armed restore. `gen` is the bind generation it belongs to, so a second
|
|
1688
|
-
// switch mid-drain cannot land the first tab's anchor into the second tab's
|
|
1689
|
-
// store. `lastWrote` is the offset this module last left behind: a position that
|
|
1690
|
-
// does NOT match it was moved by the user, which cancels — the library never
|
|
1691
|
-
// fights a gesture, and it establishes that by knowing its own writes rather
|
|
1692
|
-
// than by listening to a signal (`onScrollPosition`) that also fires for the
|
|
1693
|
-
// browser's clamps, including the one the rebuild itself causes.
|
|
1694
|
-
interface PendingRestore {
|
|
1695
|
-
view: ViewMemory;
|
|
1696
|
-
gen: number;
|
|
1697
|
-
lastWrote: number;
|
|
1698
|
-
deadline: number;
|
|
1699
|
-
}
|
|
1700
|
-
let pendingRestore: PendingRestore | null = null;
|
|
1701
|
-
let bindGen = 0;
|
|
1702
|
-
// Wall-clock of the last inbound frame for the bound store. `renderQueue.size
|
|
1703
|
-
// === 0` alone is NOT "the rebuild finished": it reaches zero BETWEEN a resume
|
|
1704
|
-
// batch's replay chunks (see pendingRowCount's contract), so the settle needs
|
|
1705
|
-
// transport quiet too. Both numbers mirror the tabs feature's already-tuned
|
|
1706
|
-
// catch-up cue, which answers the same question for the same reason.
|
|
1707
|
-
let lastInboundMs = 0;
|
|
1708
|
-
const RESTORE_SETTLE_MS = 250;
|
|
1709
|
-
const RESTORE_MAX_MS = 30000;
|
|
1710
|
-
// A position within this many px of what we last wrote is still ours: a
|
|
1711
|
-
// fractional-layout or subpixel-DPR readback is not a user gesture.
|
|
1712
|
-
const RESTORE_OWN_WRITE_EPSILON_PX = 1;
|
|
1713
1319
|
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1320
|
+
// --- Per-view scroll memory (docs/scroll-position-fidelity.md §3) ---
|
|
1321
|
+
// A reading position is a LINE, not a pixel offset: a replayed `scrollTop` is
|
|
1322
|
+
// silently CLAMPED while a rebuild has built only the live window plus one
|
|
1323
|
+
// frame's budget, and it stops meaning the same line once the content grows in
|
|
1324
|
+
// the background. `screenTop` is a DIFFERENCE (offsetTop - scrollTop) because
|
|
1325
|
+
// rows report offsets in `.term-output`'s space while scrollTop belongs to
|
|
1326
|
+
// `.term`; an absolute value would be one padding off.
|
|
1327
|
+
|
|
1328
|
+
// `gen` is the bind generation, so a second switch mid-drain cannot land the
|
|
1329
|
+
// first tab's anchor into the second tab's store. `lastWrote` is the offset
|
|
1330
|
+
// this module last left behind: a position that does NOT match it was moved by
|
|
1331
|
+
// the user, which cancels. Knowing its own writes is how the library never
|
|
1332
|
+
// fights a gesture; `onScrollPosition` also fires for the browser's clamps.
|
|
1333
|
+
interface PendingRestore {
|
|
1334
|
+
view: ViewMemory;
|
|
1335
|
+
gen: number;
|
|
1336
|
+
lastWrote: number;
|
|
1337
|
+
deadline: number;
|
|
1338
|
+
}
|
|
1339
|
+
let pendingRestore: PendingRestore | null = null;
|
|
1340
|
+
let bindGen = 0;
|
|
1341
|
+
// Wall-clock of the last inbound frame. `renderQueue.size === 0` alone is NOT
|
|
1342
|
+
// "the rebuild finished": it reaches zero BETWEEN a resume batch's replay
|
|
1343
|
+
// chunks, so the settle needs transport quiet too.
|
|
1344
|
+
let lastInboundMs = 0;
|
|
1345
|
+
|
|
1346
|
+
// Null on the alternate screen: an alt grid has no absolute indices worth
|
|
1347
|
+
// restoring, and measuring one would overwrite a tab's real saved position.
|
|
1348
|
+
function captureViewMemory(): ViewMemory | null {
|
|
1349
|
+
if (!store || store.isAlt()) {
|
|
1350
|
+
return null;
|
|
1351
|
+
}
|
|
1352
|
+
const el = rowAtViewportTop();
|
|
1353
|
+
if (el === null) {
|
|
1354
|
+
return null;
|
|
1355
|
+
}
|
|
1356
|
+
const abs = rowAbs(el);
|
|
1357
|
+
if (abs < 0) {
|
|
1358
|
+
return null;
|
|
1359
|
+
}
|
|
1360
|
+
return {
|
|
1361
|
+
abs,
|
|
1362
|
+
screenTop: el.offsetTop - scroll.currentScrollTop(),
|
|
1363
|
+
following: !scroll.isUserScrolledUp(),
|
|
1364
|
+
};
|
|
1735
1365
|
}
|
|
1736
|
-
return {
|
|
1737
|
-
abs,
|
|
1738
|
-
screenTop: el.offsetTop - scroll.currentScrollTop(),
|
|
1739
|
-
following: !scroll.isUserScrolledUp(),
|
|
1740
|
-
};
|
|
1741
|
-
}
|
|
1742
|
-
|
|
1743
|
-
/** Drop any armed restore. Called on (re)init and on every bind. */
|
|
1744
|
-
function clearPendingRestore(): void {
|
|
1745
|
-
pendingRestore = null;
|
|
1746
|
-
}
|
|
1747
1366
|
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
// materialize several frames after the switch, and a single write at frame 1
|
|
1752
|
-
// would be clamped to the partial content and never retried.
|
|
1753
|
-
//
|
|
1754
|
-
// Returns true when it landed (and disarmed).
|
|
1755
|
-
function applyPendingRestore(): boolean {
|
|
1756
|
-
const p = pendingRestore;
|
|
1757
|
-
if (p === null) {
|
|
1758
|
-
return false;
|
|
1367
|
+
/** Drop any armed restore. Called on every bind. */
|
|
1368
|
+
function clearPendingRestore(): void {
|
|
1369
|
+
pendingRestore = null;
|
|
1759
1370
|
}
|
|
1760
|
-
|
|
1761
|
-
//
|
|
1762
|
-
|
|
1371
|
+
|
|
1372
|
+
// Re-asserts an armed view restore, because the anchored row may not be BUILT
|
|
1373
|
+
// yet: a reader parked deep in history has their row materialize several
|
|
1374
|
+
// frames after the switch. Returns true when it landed (and disarmed).
|
|
1375
|
+
function applyPendingRestore(): boolean {
|
|
1376
|
+
const p = pendingRestore;
|
|
1377
|
+
if (p === null) {
|
|
1378
|
+
return false;
|
|
1379
|
+
}
|
|
1380
|
+
// A newer bind owns the surface, or indices restarted: the saved anchor
|
|
1381
|
+
// describes content that is no longer here.
|
|
1382
|
+
if (p.gen !== bindGen || fullResetThisPass) {
|
|
1383
|
+
clearPendingRestore();
|
|
1384
|
+
return false;
|
|
1385
|
+
}
|
|
1386
|
+
const now = scroll.currentScrollTop();
|
|
1387
|
+
if (Math.abs(now - p.lastWrote) > RESTORE_OWN_WRITE_EPSILON_PX) {
|
|
1388
|
+
clearPendingRestore(); // the user moved: never fight a gesture
|
|
1389
|
+
return false;
|
|
1390
|
+
}
|
|
1391
|
+
const el = rowEls.get(p.view.abs);
|
|
1392
|
+
if (el === undefined) {
|
|
1393
|
+
// Not built yet, or gone for good. Give up only once the surface has
|
|
1394
|
+
// genuinely settled (queue drained AND transport quiet) or the bound
|
|
1395
|
+
// lapses; until then stay armed and try again next frame.
|
|
1396
|
+
const quiet = renderQueue.size === 0 && Date.now() - lastInboundMs > RESTORE_SETTLE_MS;
|
|
1397
|
+
if (quiet || Date.now() > p.deadline) {
|
|
1398
|
+
clearPendingRestore();
|
|
1399
|
+
}
|
|
1400
|
+
return false;
|
|
1401
|
+
}
|
|
1402
|
+
// Same arithmetic as the read anchor's drift correction, for the same
|
|
1403
|
+
// space-cancelling reason, and idempotent: an already-satisfied restore
|
|
1404
|
+
// measures zero and writes nothing.
|
|
1405
|
+
const drift = el.offsetTop - now - p.view.screenTop;
|
|
1406
|
+
scroll.adjustForContentShift(drift);
|
|
1763
1407
|
clearPendingRestore();
|
|
1764
|
-
return
|
|
1408
|
+
return true;
|
|
1765
1409
|
}
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1410
|
+
|
|
1411
|
+
// The viewport's absolute index for the paging layer's questions about NOW;
|
|
1412
|
+
// `pendingRestoreAbs` answers the resume transition, which asks which rows
|
|
1413
|
+
// must SURVIVE a switch (docs/scroll-position-fidelity.md §7.2).
|
|
1414
|
+
function viewportAbs(): number {
|
|
1415
|
+
if (!store) {
|
|
1416
|
+
return 0;
|
|
1417
|
+
}
|
|
1418
|
+
if (!scroll.isUserScrolledUp()) {
|
|
1419
|
+
return store.getWindow().base;
|
|
1420
|
+
}
|
|
1421
|
+
const el = rowAtViewportTop();
|
|
1422
|
+
const abs = el === null ? -1 : rowAbs(el);
|
|
1423
|
+
return abs >= 0 ? abs : store.getWindow().base;
|
|
1770
1424
|
}
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1425
|
+
|
|
1426
|
+
function pendingRestoreAbs(): number | null {
|
|
1427
|
+
const p = pendingRestore;
|
|
1428
|
+
if (p === null) {
|
|
1429
|
+
return null;
|
|
1430
|
+
}
|
|
1431
|
+
// Expire on READ as well as in the flush: an idle surface schedules no
|
|
1432
|
+
// flush and would otherwise hold an arm indefinitely.
|
|
1433
|
+
if (Date.now() > p.deadline) {
|
|
1778
1434
|
clearPendingRestore();
|
|
1435
|
+
return null;
|
|
1779
1436
|
}
|
|
1780
|
-
return
|
|
1437
|
+
return p.view.abs;
|
|
1781
1438
|
}
|
|
1782
|
-
// Same arithmetic as the read anchor's drift correction, for the same
|
|
1783
|
-
// space-cancelling reason, and idempotent: an already-satisfied restore
|
|
1784
|
-
// measures zero and writes nothing.
|
|
1785
|
-
const drift = el.offsetTop - now - p.view.screenTop;
|
|
1786
|
-
scroll.adjustForContentShift(drift);
|
|
1787
|
-
clearPendingRestore();
|
|
1788
|
-
return true;
|
|
1789
|
-
}
|
|
1790
1439
|
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1440
|
+
// Every guard below is a pure read and pacing makes a spurious run free, which
|
|
1441
|
+
// is deliberate: firing it only when needed is how a trigger ends up never
|
|
1442
|
+
// firing on the idle session that needs it most.
|
|
1443
|
+
function maybeFetchHistory(): void {
|
|
1444
|
+
// The pending-demand timer fires from a CLOCK, so this alt guard is the
|
|
1445
|
+
// load-bearing one: without it a vim session would fetch pages nobody can
|
|
1446
|
+
// see, and each denial would re-arm the timer (docs/paged-scrollback.md §5.5).
|
|
1447
|
+
if (!store || store.isAlt()) {
|
|
1448
|
+
return;
|
|
1449
|
+
}
|
|
1450
|
+
if (requestHistoryFn === null) {
|
|
1451
|
+
return; // paging not wired by this consumer
|
|
1452
|
+
}
|
|
1453
|
+
const budget = historyBudgetFn === null ? PAGE_SIZE : historyBudgetFn();
|
|
1454
|
+
if (!Number.isInteger(budget) || budget < 1) {
|
|
1455
|
+
return;
|
|
1456
|
+
}
|
|
1457
|
+
const abs = viewportAbs();
|
|
1458
|
+
const edges = store.absentEdgesNear(abs, PREFETCH_THRESHOLD);
|
|
1459
|
+
const gap = edges[0];
|
|
1460
|
+
if (gap === undefined) {
|
|
1461
|
+
return;
|
|
1462
|
+
}
|
|
1463
|
+
// APPROACH-ANCHORED: always fetch the end NEAREST the reader, so a wide gap
|
|
1464
|
+
// heals from the side being read. Fetching a fixed end would land pages up to
|
|
1465
|
+
// (gapWidth - budget) lines away from the viewport, leaving the rows under the
|
|
1466
|
+
// reader blank while the far end filled in.
|
|
1467
|
+
const floor = store.pagingFloorIndex();
|
|
1468
|
+
const fromAbs =
|
|
1469
|
+
abs >= gap.hi
|
|
1470
|
+
? Math.max(gap.lo, gap.hi - budget, floor) // approaching from BELOW the gap's top
|
|
1471
|
+
: Math.max(gap.lo, floor); // approaching from above: start at the gap's low edge
|
|
1472
|
+
const maxLines = Math.min(budget, gap.hi - fromAbs);
|
|
1473
|
+
if (maxLines < 1) {
|
|
1474
|
+
return;
|
|
1475
|
+
}
|
|
1476
|
+
requestHistoryFn(fromAbs, maxLines);
|
|
1804
1477
|
}
|
|
1805
|
-
const el = rowAtViewportTop();
|
|
1806
|
-
const abs = el === null ? -1 : rowAbs(el);
|
|
1807
|
-
return abs >= 0 ? abs : store.getWindow().base;
|
|
1808
|
-
}
|
|
1809
1478
|
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1479
|
+
// Re-derive the gap markers from the store's geometry, through intervals.ts,
|
|
1480
|
+
// the single source of gap geometry the fetch trigger reads too: a second
|
|
1481
|
+
// derivation here would have the renderer marking a hole the trigger will not
|
|
1482
|
+
// fetch, or omitting one over a hole it will.
|
|
1483
|
+
function updateGapMarkers(): void {
|
|
1484
|
+
if (!store || !output) {
|
|
1485
|
+
return;
|
|
1486
|
+
}
|
|
1487
|
+
const gaps = new Map<number, number>(); // low index -> high index
|
|
1488
|
+
for (const gap of interiorGaps(store.retainedRanges())) {
|
|
1489
|
+
gaps.set(gap.lo, gap.hi);
|
|
1490
|
+
}
|
|
1491
|
+
// Remove markers whose gap closed.
|
|
1492
|
+
for (const [lo, el] of [...gapMarkerEls]) {
|
|
1493
|
+
if (!gaps.has(lo)) {
|
|
1494
|
+
el.remove();
|
|
1495
|
+
gapMarkerEls.delete(lo);
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
const floor = store.pagingFloorIndex();
|
|
1499
|
+
for (const [lo, hi] of gaps) {
|
|
1500
|
+
let el = gapMarkerEls.get(lo);
|
|
1501
|
+
if (el === undefined) {
|
|
1502
|
+
el = document.createElement("div");
|
|
1503
|
+
el.className = "term-gap-marker";
|
|
1504
|
+
el.setAttribute("role", "status");
|
|
1505
|
+
gapMarkerEls.set(lo, el);
|
|
1506
|
+
}
|
|
1507
|
+
// A gap marker exists to say "do not read these two regions as
|
|
1508
|
+
// contiguous", so it cannot fall back to asserting nothing.
|
|
1509
|
+
const condemned = floor >= hi;
|
|
1510
|
+
const label = condemned ? "earlier output trimmed" : "earlier output not loaded";
|
|
1511
|
+
if (el.textContent !== label) {
|
|
1512
|
+
el.textContent = label;
|
|
1513
|
+
el.setAttribute("aria-label", label);
|
|
1514
|
+
}
|
|
1515
|
+
el.dataset["abs"] = String(lo);
|
|
1516
|
+
el.classList.toggle("term-gap-trimmed", condemned);
|
|
1517
|
+
// Position by data-abs order, the same invariant the row inserts keep.
|
|
1518
|
+
const next = firstRowAtOrAfter(hi);
|
|
1519
|
+
if (el.parentElement !== output || el.nextElementSibling !== next) {
|
|
1520
|
+
output.insertBefore(el, next);
|
|
1521
|
+
}
|
|
1522
|
+
}
|
|
1829
1523
|
}
|
|
1830
|
-
return p.view.abs;
|
|
1831
|
-
}
|
|
1832
1524
|
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
}
|
|
1864
|
-
// APPROACH-ANCHORED: always fetch the end NEAREST the reader, so a wide gap
|
|
1865
|
-
// heals from the side being read. Fetching a fixed end would land pages up to
|
|
1866
|
-
// (gapWidth - budget) lines away from the viewport, leaving the rows under the
|
|
1867
|
-
// reader blank while the far end filled in.
|
|
1868
|
-
const floor = store.pagingFloorIndex();
|
|
1869
|
-
const fromAbs =
|
|
1870
|
-
abs >= gap.hi
|
|
1871
|
-
? Math.max(gap.lo, gap.hi - budget, floor) // approaching from BELOW the gap's top
|
|
1872
|
-
: Math.max(gap.lo, floor); // approaching from above: start at the gap's low edge
|
|
1873
|
-
const maxLines = Math.min(budget, gap.hi - fromAbs);
|
|
1874
|
-
if (maxLines < 1) {
|
|
1875
|
-
return;
|
|
1876
|
-
}
|
|
1877
|
-
requestHistoryFn(fromAbs, maxLines);
|
|
1878
|
-
}
|
|
1525
|
+
// The top-of-store marker carries no data-abs, so insertRowInOrder never
|
|
1526
|
+
// places a row before it. Its frontier is deliberately NOT sourced from gap
|
|
1527
|
+
// geometry: the lower edge is a policy question (what is still worth
|
|
1528
|
+
// requesting), so an exhausted frontier still renders a marker rather than
|
|
1529
|
+
// vanishing when the gap closes (docs/paged-scrollback.md §5.4).
|
|
1530
|
+
function updateTrimMarker(): void {
|
|
1531
|
+
if (!output) {
|
|
1532
|
+
return;
|
|
1533
|
+
}
|
|
1534
|
+
const label = topMarkerLabel();
|
|
1535
|
+
if (label === null) {
|
|
1536
|
+
if (trimMarkerEl !== null && trimMarkerEl.parentElement === output) {
|
|
1537
|
+
trimMarkerEl.remove();
|
|
1538
|
+
}
|
|
1539
|
+
return;
|
|
1540
|
+
}
|
|
1541
|
+
if (trimMarkerEl === null) {
|
|
1542
|
+
trimMarkerEl = document.createElement("div");
|
|
1543
|
+
trimMarkerEl.className = "term-trim-marker";
|
|
1544
|
+
trimMarkerEl.setAttribute("role", "status");
|
|
1545
|
+
}
|
|
1546
|
+
if (trimMarkerEl.textContent !== label) {
|
|
1547
|
+
trimMarkerEl.textContent = label;
|
|
1548
|
+
trimMarkerEl.setAttribute("aria-label", label);
|
|
1549
|
+
}
|
|
1550
|
+
// The interior gap marker's class, so one rule styles "gone" in both places.
|
|
1551
|
+
trimMarkerEl.classList.toggle("term-gap-trimmed", label === TRIM_LABEL_GONE);
|
|
1552
|
+
if (trimMarkerEl.parentElement !== output || output.firstChild !== trimMarkerEl) {
|
|
1553
|
+
output.insertBefore(trimMarkerEl, output.firstChild);
|
|
1554
|
+
}
|
|
1555
|
+
}
|
|
1879
1556
|
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
for (const gap of interiorGaps(store.retainedRanges())) {
|
|
1895
|
-
gaps.set(gap.lo, gap.hi);
|
|
1896
|
-
}
|
|
1897
|
-
// Remove markers whose gap closed.
|
|
1898
|
-
for (const [lo, el] of [...gapMarkerEls]) {
|
|
1899
|
-
if (!gaps.has(lo)) {
|
|
1900
|
-
el.remove();
|
|
1901
|
-
gapMarkerEls.delete(lo);
|
|
1557
|
+
// Three honest statements about the history above what is held: nothing when
|
|
1558
|
+
// index 0 is held; "trimmed" when the client evicted the rows itself with no
|
|
1559
|
+
// paging, or when paging proved nothing below survives; "not loaded" when
|
|
1560
|
+
// paging is declared and neither proof is in hand (docs/paged-scrollback.md §5.4).
|
|
1561
|
+
function topMarkerLabel(): string | null {
|
|
1562
|
+
if (!store) {
|
|
1563
|
+
return null;
|
|
1564
|
+
}
|
|
1565
|
+
const oldest = store.oldestIndex();
|
|
1566
|
+
if (oldest <= 0) {
|
|
1567
|
+
return null; // holds index 0, or holds nothing: no history above it
|
|
1568
|
+
}
|
|
1569
|
+
if (!store.pagingDeclared()) {
|
|
1570
|
+
return store.hasTrimmedHistory() ? TRIM_LABEL_GONE : null;
|
|
1902
1571
|
}
|
|
1572
|
+
const serverOldest = store.serverOldestIndex();
|
|
1573
|
+
const condemned =
|
|
1574
|
+
store.pagingFloorIndex() >= oldest || (serverOldest >= 0 && serverOldest >= oldest);
|
|
1575
|
+
return condemned ? TRIM_LABEL_GONE : TRIM_LABEL_PENDING;
|
|
1903
1576
|
}
|
|
1904
|
-
|
|
1905
|
-
for
|
|
1906
|
-
|
|
1577
|
+
|
|
1578
|
+
// upsertRow builds or updates the DOM row for an absolute index, or removes it
|
|
1579
|
+
// if the store no longer holds it. New rows are inserted in ascending data-abs
|
|
1580
|
+
// order.
|
|
1581
|
+
function upsertRow(abs: number): void {
|
|
1582
|
+
if (!store) {
|
|
1583
|
+
return;
|
|
1584
|
+
}
|
|
1585
|
+
const runs = store.getLine(abs);
|
|
1586
|
+
if (runs === undefined) {
|
|
1587
|
+
const stale = rowEls.get(abs);
|
|
1588
|
+
if (stale) {
|
|
1589
|
+
stale.remove();
|
|
1590
|
+
removedRowsThisPass = true;
|
|
1591
|
+
rowEls.delete(abs);
|
|
1592
|
+
}
|
|
1593
|
+
return;
|
|
1594
|
+
}
|
|
1595
|
+
const spans = buildRowSpans(runs);
|
|
1596
|
+
let el = rowEls.get(abs);
|
|
1907
1597
|
if (el === undefined) {
|
|
1908
1598
|
el = document.createElement("div");
|
|
1909
|
-
el.className = "term-
|
|
1910
|
-
el.
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
// back silently.
|
|
1917
|
-
const condemned = floor >= hi;
|
|
1918
|
-
const label = condemned ? "earlier output trimmed" : "earlier output not loaded";
|
|
1919
|
-
if (el.textContent !== label) {
|
|
1920
|
-
el.textContent = label;
|
|
1921
|
-
el.setAttribute("aria-label", label);
|
|
1922
|
-
}
|
|
1923
|
-
el.dataset["abs"] = String(lo);
|
|
1924
|
-
el.classList.toggle("term-gap-trimmed", condemned);
|
|
1925
|
-
// Position by data-abs order, the same invariant the row inserts keep.
|
|
1926
|
-
const next = firstRowAtOrAfter(hi);
|
|
1927
|
-
if (el.parentElement !== output || el.nextElementSibling !== next) {
|
|
1928
|
-
output.insertBefore(el, next);
|
|
1599
|
+
el.className = "term-row";
|
|
1600
|
+
el.dataset["abs"] = String(abs);
|
|
1601
|
+
el.replaceChildren(...spans);
|
|
1602
|
+
insertRowInOrder(el, abs);
|
|
1603
|
+
rowEls.set(abs, el);
|
|
1604
|
+
} else {
|
|
1605
|
+
el.replaceChildren(...spans);
|
|
1929
1606
|
}
|
|
1930
1607
|
}
|
|
1931
|
-
}
|
|
1932
1608
|
|
|
1933
|
-
//
|
|
1934
|
-
//
|
|
1935
|
-
//
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
// missing: a bounded resume replay routinely leaves a fetchable frontier, and
|
|
1953
|
-
// the old single predicate either said nothing at all (silently presenting a
|
|
1954
|
-
// partial transcript as the beginning of the session) or said "trimmed" about
|
|
1955
|
-
// history the server still holds and the trigger is about to fetch.
|
|
1956
|
-
//
|
|
1957
|
-
// The frontier is deliberately NOT sourced from gap geometry: its lower edge is
|
|
1958
|
-
// a policy question (what is still worth requesting), so an exhausted frontier
|
|
1959
|
-
// still renders a marker here rather than vanishing when the gap closes.
|
|
1960
|
-
function updateTrimMarker(): void {
|
|
1961
|
-
const label = topMarkerLabel();
|
|
1962
|
-
if (label === null) {
|
|
1963
|
-
if (trimMarkerEl !== null && trimMarkerEl.parentElement === output) {
|
|
1964
|
-
trimMarkerEl.remove();
|
|
1965
|
-
}
|
|
1966
|
-
return;
|
|
1967
|
-
}
|
|
1968
|
-
if (trimMarkerEl === null) {
|
|
1969
|
-
trimMarkerEl = document.createElement("div");
|
|
1970
|
-
trimMarkerEl.className = "term-trim-marker";
|
|
1971
|
-
trimMarkerEl.setAttribute("role", "status");
|
|
1972
|
-
}
|
|
1973
|
-
if (trimMarkerEl.textContent !== label) {
|
|
1974
|
-
trimMarkerEl.textContent = label;
|
|
1975
|
-
trimMarkerEl.setAttribute("aria-label", label);
|
|
1976
|
-
}
|
|
1977
|
-
// Mirrors the interior gap marker's class, so one rule styles "gone" in both
|
|
1978
|
-
// places and a reader learns the distinction once.
|
|
1979
|
-
trimMarkerEl.classList.toggle("term-gap-trimmed", label === TRIM_LABEL_GONE);
|
|
1980
|
-
if (trimMarkerEl.parentElement !== output || output.firstChild !== trimMarkerEl) {
|
|
1981
|
-
output.insertBefore(trimMarkerEl, output.firstChild);
|
|
1609
|
+
// insertRowInOrder places a freshly-created row element among output's
|
|
1610
|
+
// children so they stay in ascending data-abs order. The common case (a new
|
|
1611
|
+
// highest index) is an O(1) append; out-of-order inserts scan for the slot.
|
|
1612
|
+
function insertRowInOrder(el: HTMLDivElement, abs: number): void {
|
|
1613
|
+
if (!output) {
|
|
1614
|
+
return;
|
|
1615
|
+
}
|
|
1616
|
+
const last = output.lastElementChild as HTMLElement | null;
|
|
1617
|
+
if (last === null || rowAbs(last) < abs) {
|
|
1618
|
+
output.appendChild(el);
|
|
1619
|
+
return;
|
|
1620
|
+
}
|
|
1621
|
+
for (const child of output.children) {
|
|
1622
|
+
if (rowAbs(child as HTMLElement) > abs) {
|
|
1623
|
+
output.insertBefore(el, child);
|
|
1624
|
+
return;
|
|
1625
|
+
}
|
|
1626
|
+
}
|
|
1627
|
+
output.appendChild(el);
|
|
1982
1628
|
}
|
|
1983
|
-
}
|
|
1984
1629
|
|
|
1985
|
-
|
|
1986
|
-
const
|
|
1987
|
-
|
|
1988
|
-
const TRIM_LABEL_PENDING = "earlier output not loaded";
|
|
1989
|
-
|
|
1990
|
-
/** Which top-of-store statement is true, or null for no marker (§5.4). */
|
|
1991
|
-
function topMarkerLabel(): string | null {
|
|
1992
|
-
const oldest = store.oldestIndex();
|
|
1993
|
-
if (oldest <= 0) {
|
|
1994
|
-
return null; // holds index 0, or holds nothing: no history above it
|
|
1995
|
-
}
|
|
1996
|
-
if (!store.pagingDeclared()) {
|
|
1997
|
-
return store.hasTrimmedHistory() ? TRIM_LABEL_GONE : null;
|
|
1630
|
+
function rowAbs(el: HTMLElement): number {
|
|
1631
|
+
const v = el.dataset["abs"];
|
|
1632
|
+
return v === undefined ? -1 : Number(v);
|
|
1998
1633
|
}
|
|
1999
|
-
const serverOldest = store.serverOldestIndex();
|
|
2000
|
-
const condemned =
|
|
2001
|
-
store.pagingFloorIndex() >= oldest || (serverOldest >= 0 && serverOldest >= oldest);
|
|
2002
|
-
return condemned ? TRIM_LABEL_GONE : TRIM_LABEL_PENDING;
|
|
2003
|
-
}
|
|
2004
1634
|
|
|
2005
|
-
//
|
|
2006
|
-
|
|
2007
|
-
//
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
removedRowsThisPass = true;
|
|
2015
|
-
rowEls.delete(abs);
|
|
2016
|
-
}
|
|
2017
|
-
return;
|
|
2018
|
-
}
|
|
2019
|
-
const spans = buildRowSpans(runs);
|
|
2020
|
-
let el = rowEls.get(abs);
|
|
2021
|
-
if (el === undefined) {
|
|
2022
|
-
el = document.createElement("div");
|
|
2023
|
-
el.className = "term-row";
|
|
2024
|
-
el.dataset["abs"] = String(abs);
|
|
2025
|
-
el.replaceChildren(...spans);
|
|
2026
|
-
insertRowInOrder(el, abs);
|
|
2027
|
-
rowEls.set(abs, el);
|
|
2028
|
-
} else {
|
|
2029
|
-
el.replaceChildren(...spans);
|
|
2030
|
-
}
|
|
2031
|
-
}
|
|
1635
|
+
// --- Alt screen (ephemeral grid; no history) ---
|
|
1636
|
+
let altRendered = false;
|
|
1637
|
+
// The alt row arrays rendered by the last flush, by grid index. Row identity
|
|
1638
|
+
// is the store's change signal (applyScreen swaps exactly the changed rows'
|
|
1639
|
+
// arrays), so `prev[y] === rows[y]` means row y's DOM is current. A separate
|
|
1640
|
+
// mutable container from the store's array, which is mutated in place: the
|
|
1641
|
+
// full build snapshots it once and the reconcile path updates only the
|
|
1642
|
+
// entries it rebuilt, instead of a fresh `rows.slice()` per alt flush.
|
|
1643
|
+
let altPrevRows: (readonly WireRun[])[] = [];
|
|
2032
1644
|
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
// highest index) is an O(1) append; out-of-order inserts scan for the slot.
|
|
2036
|
-
function insertRowInOrder(el: HTMLDivElement, abs: number): void {
|
|
2037
|
-
const last = output.lastElementChild as HTMLElement | null;
|
|
2038
|
-
if (last === null || rowAbs(last) < abs) {
|
|
2039
|
-
output.appendChild(el);
|
|
2040
|
-
return;
|
|
2041
|
-
}
|
|
2042
|
-
for (const child of output.children) {
|
|
2043
|
-
if (rowAbs(child as HTMLElement) > abs) {
|
|
2044
|
-
output.insertBefore(el, child);
|
|
1645
|
+
function renderAlt(rows: readonly (readonly WireRun[])[]): void {
|
|
1646
|
+
if (!output) {
|
|
2045
1647
|
return;
|
|
2046
1648
|
}
|
|
1649
|
+
rowEls.clear();
|
|
1650
|
+
// Full (re)build: first alt frame, a grid-height change, or a desynced DOM.
|
|
1651
|
+
if (!altRendered || output.children.length !== rows.length) {
|
|
1652
|
+
altRendered = true;
|
|
1653
|
+
const els: HTMLDivElement[] = [];
|
|
1654
|
+
for (const runs of rows) {
|
|
1655
|
+
const div = document.createElement("div");
|
|
1656
|
+
div.className = "term-row";
|
|
1657
|
+
div.replaceChildren(...buildRowSpans(runs));
|
|
1658
|
+
els.push(div);
|
|
1659
|
+
}
|
|
1660
|
+
// The first alt frame removes the entire main-buffer scrollback for one
|
|
1661
|
+
// screen of grid, and the alt branch in flushRenderInner returns before
|
|
1662
|
+
// the shared bookkeeping, so the flag is set here or not at all. Over-
|
|
1663
|
+
// announcing on a resize is harmless: both seams are position-gated.
|
|
1664
|
+
removedRowsThisPass = true;
|
|
1665
|
+
output.replaceChildren(...els);
|
|
1666
|
+
altPrevRows = rows.slice();
|
|
1667
|
+
return;
|
|
1668
|
+
}
|
|
1669
|
+
// Reconcile in place: a TUI that repaints a few lines (vim, a progress bar)
|
|
1670
|
+
// touches only those rows' DOM, measured ~50x less flush CPU.
|
|
1671
|
+
for (let y = 0; y < rows.length; y++) {
|
|
1672
|
+
if (altPrevRows[y] === rows[y]) {
|
|
1673
|
+
continue;
|
|
1674
|
+
}
|
|
1675
|
+
const div = output.children[y] as HTMLDivElement;
|
|
1676
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- y < rows.length === children.length
|
|
1677
|
+
div.replaceChildren(...buildRowSpans(rows[y]!));
|
|
1678
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- same bound as above
|
|
1679
|
+
altPrevRows[y] = rows[y]!;
|
|
1680
|
+
}
|
|
2047
1681
|
}
|
|
2048
|
-
output.appendChild(el);
|
|
2049
|
-
}
|
|
2050
|
-
|
|
2051
|
-
function rowAbs(el: HTMLElement): number {
|
|
2052
|
-
const v = el.dataset["abs"];
|
|
2053
|
-
return v === undefined ? -1 : Number(v);
|
|
2054
|
-
}
|
|
2055
1682
|
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
// The alt row arrays rendered by the last flush, by grid index. Row identity
|
|
2059
|
-
// is the store's change signal (applyScreen swaps exactly the changed rows'
|
|
2060
|
-
// arrays; getAltRows returns the live references), so `prev[y] === rows[y]`
|
|
2061
|
-
// means row y's DOM is already current. A separate MUTABLE container from the
|
|
2062
|
-
// store's own array (which is mutated in place across frames): the full build
|
|
2063
|
-
// snapshots it once, and the reconcile path updates only the entries it
|
|
2064
|
-
// rebuilt — the previous per-frame `rows.slice()` allocated a fresh array on
|
|
2065
|
-
// every alt flush (60fps GC churn under a full-screen TUI) for identical
|
|
2066
|
-
// semantics. Cleared on alt exit and re-init.
|
|
2067
|
-
let altPrevRows: (readonly WireRun[])[] = [];
|
|
2068
|
-
|
|
2069
|
-
function renderAlt(rows: readonly (readonly WireRun[])[]): void {
|
|
2070
|
-
rowEls.clear();
|
|
2071
|
-
// Full (re)build: first alt frame, a grid-height change (resize), or a
|
|
2072
|
-
// desynced DOM (defensive; e.g. a consumer poked the subtree).
|
|
2073
|
-
if (!altRendered || output.children.length !== rows.length) {
|
|
2074
|
-
altRendered = true;
|
|
2075
|
-
const els: HTMLDivElement[] = [];
|
|
2076
|
-
for (const runs of rows) {
|
|
2077
|
-
const div = document.createElement("div");
|
|
2078
|
-
div.className = "term-row";
|
|
2079
|
-
div.replaceChildren(...buildRowSpans(runs));
|
|
2080
|
-
els.push(div);
|
|
2081
|
-
}
|
|
2082
|
-
// This replaceChildren REMOVES rows, and on the first alt frame it removes
|
|
2083
|
-
// the entire main-buffer scrollback in favour of one screen of grid: the
|
|
2084
|
-
// largest shrink in this module after rebuild()'s wipe. The alt branch in
|
|
2085
|
-
// flushRenderInner returns before the shared bookkeeping, so the flag is set
|
|
2086
|
-
// here or not at all, and without it the pass reached neither
|
|
2087
|
-
// noteContentShrink (leaving the clamp to be inferred from its lossy
|
|
2088
|
-
// arithmetic signature, which can silently switch auto-follow off) nor
|
|
2089
|
-
// reconcileScrollRange (leaving the viewport parked past the grid).
|
|
2090
|
-
//
|
|
2091
|
-
// Over-announcing is harmless by mechanism: the resize and desynced-DOM
|
|
2092
|
-
// cases reach this branch too, and both seams are position-gated, so a
|
|
2093
|
-
// rebuild that moves nothing and strands nothing announces nothing.
|
|
2094
|
-
removedRowsThisPass = true;
|
|
2095
|
-
output.replaceChildren(...els);
|
|
2096
|
-
altPrevRows = rows.slice();
|
|
2097
|
-
return;
|
|
2098
|
-
}
|
|
2099
|
-
// Reconcile in place: rebuild only rows whose array identity changed. A
|
|
2100
|
-
// full-screen TUI that repaints everything (htop) rebuilds every row either
|
|
2101
|
-
// way; one that repaints a few lines (vim editing, a progress bar) now
|
|
2102
|
-
// touches only those rows' DOM — measured ~50x less flush CPU in the render
|
|
2103
|
-
// bench's partial-update scenario, and the browser skips layout for
|
|
2104
|
-
// untouched rows.
|
|
2105
|
-
for (let y = 0; y < rows.length; y++) {
|
|
2106
|
-
if (altPrevRows[y] === rows[y]) {
|
|
2107
|
-
continue;
|
|
2108
|
-
}
|
|
2109
|
-
const div = output.children[y] as HTMLDivElement;
|
|
2110
|
-
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- y < rows.length === children.length
|
|
2111
|
-
div.replaceChildren(...buildRowSpans(rows[y]!));
|
|
2112
|
-
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- same bound as above
|
|
2113
|
-
altPrevRows[y] = rows[y]!;
|
|
1683
|
+
function stickToBottomIfFollowing(): void {
|
|
1684
|
+
scroll.stickToBottom();
|
|
2114
1685
|
}
|
|
2115
|
-
}
|
|
2116
1686
|
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
1687
|
+
// --- Cursor blink ---
|
|
1688
|
+
let blinkInterval: ReturnType<typeof setInterval> | null = null;
|
|
1689
|
+
let blinkEnabled = true;
|
|
1690
|
+
// What the running interval (if any) is configured as; syncCursorBlink()
|
|
1691
|
+
// reconfigures only on mode change, so it is safe to call on every flush.
|
|
1692
|
+
let blinkMode: BlinkMode = "off";
|
|
2122
1693
|
|
|
2123
|
-
//
|
|
2124
|
-
|
|
2125
|
-
//
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
// this slow re-check: still polling for the cursor's return (a self-healing
|
|
2129
|
-
// backstop even if a transition frame were somehow missed — the flush hook is
|
|
2130
|
-
// the primary, instant restart), at a fraction of the 530ms blink's wakeups.
|
|
2131
|
-
// For agent consumers the hidden-cursor state is the session's steady state.
|
|
2132
|
-
const CURSOR_RECHECK_MS = 4000;
|
|
2133
|
-
let blinkInterval: ReturnType<typeof setInterval> | null = null;
|
|
2134
|
-
let blinkEnabled = true;
|
|
2135
|
-
// What the running interval (if any) is configured as. syncCursorBlink()
|
|
2136
|
-
// reconfigures only on mode change, so it is safe to call on every flush.
|
|
2137
|
-
type BlinkMode = "off" | "idle" | "fast";
|
|
2138
|
-
let blinkMode: BlinkMode = "off";
|
|
2139
|
-
|
|
2140
|
-
// hiddenDoc reports whether the page is currently background/hidden. The blink
|
|
2141
|
-
// interval is gated on it: rAF-driven flushes already freeze in a hidden tab,
|
|
2142
|
-
// but a plain setInterval keeps firing (throttled), so an idle hidden terminal
|
|
2143
|
-
// would keep toggling a class — pointless wakeups that cost battery on mobile.
|
|
2144
|
-
function hiddenDoc(): boolean {
|
|
2145
|
-
return typeof document !== "undefined" && document.visibilityState === "hidden";
|
|
2146
|
-
}
|
|
2147
|
-
|
|
2148
|
-
// desiredBlinkMode folds the three gates into an interval mode: no timer at
|
|
2149
|
-
// all when blinking is disabled or the page is hidden (both have event-driven
|
|
2150
|
-
// restarts — the next mode frame's flush and visibilitychange), the fast
|
|
2151
|
-
// phase toggle when the cursor is visible, and the slow re-check while the
|
|
2152
|
-
// application hides it.
|
|
2153
|
-
function desiredBlinkMode(): BlinkMode {
|
|
2154
|
-
if (!blinkEnabled || hiddenDoc()) {
|
|
2155
|
-
return "off";
|
|
1694
|
+
// rAF-driven flushes freeze in a hidden tab, but a plain setInterval keeps
|
|
1695
|
+
// firing (throttled), so an idle hidden terminal would keep toggling a class:
|
|
1696
|
+
// pointless wakeups that cost battery on mobile.
|
|
1697
|
+
function hiddenDoc(): boolean {
|
|
1698
|
+
return document.visibilityState === "hidden";
|
|
2156
1699
|
}
|
|
2157
|
-
return cursorHidden ? "idle" : "fast";
|
|
2158
|
-
}
|
|
2159
1700
|
|
|
2160
|
-
//
|
|
2161
|
-
//
|
|
2162
|
-
//
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
if (mode === blinkMode) {
|
|
2169
|
-
return;
|
|
2170
|
-
}
|
|
2171
|
-
blinkMode = mode;
|
|
2172
|
-
if (blinkInterval !== null) {
|
|
2173
|
-
clearInterval(blinkInterval);
|
|
2174
|
-
blinkInterval = null;
|
|
2175
|
-
}
|
|
2176
|
-
termWrap.classList.remove("cursor-blink-off");
|
|
2177
|
-
if (mode === "fast") {
|
|
2178
|
-
blinkInterval = setInterval(() => {
|
|
2179
|
-
termWrap.classList.toggle("cursor-blink-off");
|
|
2180
|
-
}, CURSOR_BLINK_MS);
|
|
2181
|
-
} else if (mode === "idle") {
|
|
2182
|
-
blinkInterval = setInterval(syncCursorBlink, CURSOR_RECHECK_MS);
|
|
1701
|
+
// No timer when blinking is disabled or the page is hidden (both have
|
|
1702
|
+
// event-driven restarts), the fast toggle when the cursor is visible, the slow
|
|
1703
|
+
// re-check while the application hides it.
|
|
1704
|
+
function desiredBlinkMode(): BlinkMode {
|
|
1705
|
+
if (!blinkEnabled || hiddenDoc()) {
|
|
1706
|
+
return "off";
|
|
1707
|
+
}
|
|
1708
|
+
return cursorHidden ? "idle" : "fast";
|
|
2183
1709
|
}
|
|
2184
|
-
}
|
|
2185
1710
|
|
|
2186
|
-
//
|
|
2187
|
-
//
|
|
2188
|
-
//
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
if ((termWrap as HTMLElement | undefined) === undefined) {
|
|
1711
|
+
// Every mode change resets the phase to solid. The blink class lives on
|
|
1712
|
+
// termWrap because the caret overlay is a termWrap child, not inside output,
|
|
1713
|
+
// and the `.cursor-blink-off .term-cursor` descendant selector must reach it.
|
|
1714
|
+
function syncCursorBlink(): void {
|
|
1715
|
+
const el = termWrap;
|
|
1716
|
+
if (!el) {
|
|
2193
1717
|
return;
|
|
2194
1718
|
}
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
// mutations, and a pure restyle may not schedule one at all. `abs` (the line
|
|
2213
|
-
// identity, which is what the reader cares about) is exact regardless of when
|
|
2214
|
-
// this runs; `screenTop` can be up to one row stale if the layout changed
|
|
2215
|
-
// before this call, which is a sub-row error rather than a lost position.
|
|
2216
|
-
const before = captureViewMemory();
|
|
2217
|
-
const cs = window.getComputedStyle(termWrap);
|
|
2218
|
-
// Refresh the overlay-positioning padding cache from the same computed
|
|
2219
|
-
// style (one staleness contract for all cached box metrics).
|
|
2220
|
-
padLeft = parseFloat(cs.paddingLeft);
|
|
2221
|
-
padTop = parseFloat(cs.paddingTop);
|
|
2222
|
-
padValid = true;
|
|
2223
|
-
const fontSize = cs.fontSize;
|
|
2224
|
-
const family = cs.fontFamily;
|
|
2225
|
-
fontString = `${fontSize} ${family}`;
|
|
2226
|
-
widthFlat.fill(WIDTH_FLAT_UNSET);
|
|
2227
|
-
widthMap.clear();
|
|
2228
|
-
resetVariantContexts();
|
|
2229
|
-
const measuredW = measureCellWidth();
|
|
2230
|
-
cellWidth = Math.round(measuredW);
|
|
2231
|
-
cellHeight = parseFloat(cs.lineHeight) || 17;
|
|
2232
|
-
// The column count is derived from the cell, so the cache gridSize reads is
|
|
2233
|
-
// stale as of this call.
|
|
2234
|
-
measuredCols = 0;
|
|
2235
|
-
defaultSpacing = cellWidth - measuredW;
|
|
2236
|
-
output.style.letterSpacing = `${defaultSpacing}px`;
|
|
2237
|
-
document.documentElement.style.setProperty("--char-w", `${cellWidth}px`);
|
|
2238
|
-
// A holding reader's line is restored through the same machinery a tab switch
|
|
2239
|
-
// uses: arm it and let the next flush re-assert it. Idempotent when the metrics
|
|
2240
|
-
// did not actually change (the drift measures zero), and a no-op for a
|
|
2241
|
-
// following viewport, whose position the bottom pin already owns.
|
|
2242
|
-
// An armed restore SURVIVES this call, and its baseline has to be refreshed or
|
|
2243
|
-
// it does not: the reflow that provoked the call has already moved scrollTop,
|
|
2244
|
-
// so leaving lastWrote stale makes the next flush read that as a gesture and
|
|
2245
|
-
// cancel the very restore this branch is protecting. Declining to arm and
|
|
2246
|
-
// silently killing the existing arm would be the worst of both.
|
|
2247
|
-
if (pendingRestore !== null) {
|
|
2248
|
-
pendingRestore.lastWrote = scroll.currentScrollTop();
|
|
2249
|
-
}
|
|
2250
|
-
// NEVER over an armed restore. A resize settle calls this (through the
|
|
2251
|
-
// consumer's measurable-size check) and a tab switch reconnects, so the two
|
|
2252
|
-
// routinely overlap: replacing a bind's saved anchor with one measured
|
|
2253
|
-
// mid-rebuild would swap the reader's real position for a transient.
|
|
2254
|
-
if (before !== null && !before.following && pendingRestore === null) {
|
|
2255
|
-
pendingRestore = {
|
|
2256
|
-
view: before,
|
|
2257
|
-
gen: bindGen,
|
|
2258
|
-
lastWrote: scroll.currentScrollTop(),
|
|
2259
|
-
deadline: Date.now() + RESTORE_MAX_MS,
|
|
2260
|
-
};
|
|
2261
|
-
scheduleFlush();
|
|
1719
|
+
const mode = desiredBlinkMode();
|
|
1720
|
+
if (mode === blinkMode) {
|
|
1721
|
+
return;
|
|
1722
|
+
}
|
|
1723
|
+
blinkMode = mode;
|
|
1724
|
+
if (blinkInterval !== null) {
|
|
1725
|
+
clearInterval(blinkInterval);
|
|
1726
|
+
blinkInterval = null;
|
|
1727
|
+
}
|
|
1728
|
+
el.classList.remove("cursor-blink-off");
|
|
1729
|
+
if (mode === "fast") {
|
|
1730
|
+
blinkInterval = setInterval(() => {
|
|
1731
|
+
el.classList.toggle("cursor-blink-off");
|
|
1732
|
+
}, CURSOR_BLINK_MS);
|
|
1733
|
+
} else if (mode === "idle") {
|
|
1734
|
+
blinkInterval = setInterval(syncCursorBlink, CURSOR_RECHECK_MS);
|
|
1735
|
+
}
|
|
2262
1736
|
}
|
|
2263
|
-
}
|
|
2264
|
-
|
|
2265
|
-
const MIN_COLS = 20;
|
|
2266
|
-
const MIN_ROWS = 5;
|
|
2267
|
-
|
|
2268
|
-
// The column count of the most recent measurement, which is the count the
|
|
2269
|
-
// resize announce carried to the server. gridSize() reads it instead of
|
|
2270
|
-
// measuring, because it is called on every raw mousemove while an application
|
|
2271
|
-
// tracks motion and computeSize() costs a computed-style read plus two layout
|
|
2272
|
-
// reads. 0 until the first measurement.
|
|
2273
|
-
let measuredCols = 0;
|
|
2274
1737
|
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
}
|
|
1738
|
+
// --- Font metrics & sizing ---
|
|
1739
|
+
function updateFontMetrics(): void {
|
|
1740
|
+
// Capture BEFORE the metrics change: a row-height change rescales every
|
|
1741
|
+
// offsetTop, and the read anchor only measures drift across a FLUSH's own
|
|
1742
|
+
// mutations, which a pure restyle may not schedule at all.
|
|
1743
|
+
if (!output || !termWrap) {
|
|
1744
|
+
return;
|
|
1745
|
+
}
|
|
1746
|
+
const before = captureViewMemory();
|
|
1747
|
+
const cs = window.getComputedStyle(termWrap);
|
|
1748
|
+
padLeft = parseFloat(cs.paddingLeft);
|
|
1749
|
+
padTop = parseFloat(cs.paddingTop);
|
|
1750
|
+
padValid = true;
|
|
1751
|
+
const fontSize = cs.fontSize;
|
|
1752
|
+
const family = cs.fontFamily;
|
|
1753
|
+
fontString = `${fontSize} ${family}`;
|
|
1754
|
+
widthFlat.fill(WIDTH_FLAT_UNSET);
|
|
1755
|
+
widthMap.clear();
|
|
1756
|
+
resetVariantContexts();
|
|
1757
|
+
const measuredW = measureCellWidth(termWrap);
|
|
1758
|
+
cellWidth = Math.round(measuredW);
|
|
1759
|
+
cellHeight = parseFloat(cs.lineHeight) || 17;
|
|
1760
|
+
measuredCols = 0;
|
|
1761
|
+
defaultSpacing = cellWidth - measuredW;
|
|
1762
|
+
output.style.letterSpacing = `${defaultSpacing}px`;
|
|
1763
|
+
termWrap.style.setProperty("--char-w", `${cellWidth}px`);
|
|
1764
|
+
// An armed restore SURVIVES this call and its baseline must be refreshed:
|
|
1765
|
+
// the reflow that provoked the call has already moved scrollTop, and a stale
|
|
1766
|
+
// lastWrote makes the next flush read that as a gesture and cancel it.
|
|
1767
|
+
if (pendingRestore !== null) {
|
|
1768
|
+
pendingRestore.lastWrote = scroll.currentScrollTop();
|
|
1769
|
+
}
|
|
1770
|
+
// A holding reader's line is restored through the tab-switch machinery, but
|
|
1771
|
+
// NEVER over an armed restore: a resize settle and a tab switch routinely
|
|
1772
|
+
// overlap, and an anchor measured mid-rebuild is a transient.
|
|
1773
|
+
if (before !== null && !before.following && pendingRestore === null) {
|
|
1774
|
+
pendingRestore = {
|
|
1775
|
+
view: before,
|
|
1776
|
+
gen: bindGen,
|
|
1777
|
+
lastWrote: scroll.currentScrollTop(),
|
|
1778
|
+
deadline: Date.now() + RESTORE_MAX_MS,
|
|
1779
|
+
};
|
|
1780
|
+
scheduleFlush();
|
|
1781
|
+
}
|
|
1782
|
+
}
|
|
2291
1783
|
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
*
|
|
2297
|
-
* Deliberately not `computeSize()`, whose rows are the size this client WOULD
|
|
2298
|
-
* like: the server's screen is last-writer-wins across every attached client
|
|
2299
|
-
* and relaxes to the smallest on disconnect, so a second attached device — or
|
|
2300
|
-
* any local resize before the next screen frame lands — leaves the measured
|
|
2301
|
-
* height describing a grid nothing is painting. The row hit test anchors on this
|
|
2302
|
-
* number rather than merely clamping to it, so a disagreement of N shifts every
|
|
2303
|
-
* reported row by N. The alt grid keeps its own height, and a TUI runs there.
|
|
2304
|
-
*
|
|
2305
|
-
* Columns stay the measured count: the store has none to answer with (a row's
|
|
2306
|
-
* trailing blank cells are trimmed off the wire) and the axis only bounds a
|
|
2307
|
-
* report, where over-reporting costs nothing.
|
|
2308
|
-
*
|
|
2309
|
-
* Rows are 0 until the first screen frame lands, which the mouse module treats
|
|
2310
|
-
* as "no grid yet" and refuses to report against — the honest answer, since
|
|
2311
|
-
* nothing knows the screen's height before the server describes it.
|
|
2312
|
-
*/
|
|
2313
|
-
export function gridSize(): { cols: number; rows: number } {
|
|
2314
|
-
const rows = store.isAlt() ? store.getAltRows().length : store.getWindow().height;
|
|
2315
|
-
return { cols: measuredCols > 0 ? measuredCols : computeSize().cols, rows };
|
|
2316
|
-
}
|
|
1784
|
+
// The column count of the most recent measurement. gridSize() reads it
|
|
1785
|
+
// instead of measuring because it runs on every raw mousemove under motion
|
|
1786
|
+
// tracking, and computeSize() costs a computed-style read plus two layout reads.
|
|
1787
|
+
let measuredCols = 0;
|
|
2317
1788
|
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
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
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
1789
|
+
function computeSize(): { cols: number; rows: number } {
|
|
1790
|
+
if (!termWrap) {
|
|
1791
|
+
return { cols: 0, rows: 0 };
|
|
1792
|
+
}
|
|
1793
|
+
const cs = window.getComputedStyle(termWrap);
|
|
1794
|
+
const padX = parseFloat(cs.paddingLeft) + parseFloat(cs.paddingRight);
|
|
1795
|
+
const padY = parseFloat(cs.paddingTop) + parseFloat(cs.paddingBottom);
|
|
1796
|
+
const contentW = termWrap.clientWidth - padX;
|
|
1797
|
+
const contentH = termWrap.clientHeight - padY;
|
|
1798
|
+
const cols = Math.max(MIN_COLS, Math.floor(contentW / cellWidth));
|
|
1799
|
+
const rows = Math.max(MIN_ROWS, Math.floor(contentH / cellHeight));
|
|
1800
|
+
measuredCols = cols;
|
|
1801
|
+
return { cols, rows };
|
|
1802
|
+
}
|
|
1803
|
+
|
|
1804
|
+
// Rows are the store's, not `computeSize()`'s: the server's screen is
|
|
1805
|
+
// last-writer-wins across attached clients, so the measured height can
|
|
1806
|
+
// describe a grid nothing is painting, and the mouse hit test ANCHORS on this
|
|
1807
|
+
// number. Columns stay measured: the store has none (trailing blanks are
|
|
1808
|
+
// trimmed off the wire) and that axis only bounds a report.
|
|
1809
|
+
function gridSize(): { cols: number; rows: number } {
|
|
1810
|
+
if (!store) {
|
|
1811
|
+
return { cols: 0, rows: 0 };
|
|
1812
|
+
}
|
|
1813
|
+
const rows = store.isAlt() ? store.getAltRows().length : store.getWindow().height;
|
|
1814
|
+
return { cols: measuredCols > 0 ? measuredCols : computeSize().cols, rows };
|
|
1815
|
+
}
|
|
1816
|
+
|
|
1817
|
+
// A row's top in termWrap's coordinate space, which every absolutely
|
|
1818
|
+
// positioned overlay resolves against. A bare `offsetTop` is relative to the
|
|
1819
|
+
// offsetParent, and a stylesheet that positions `.term-output` puts rows in
|
|
1820
|
+
// output-space, one padding short (the caret floated 4px above every glyph).
|
|
1821
|
+
// Walking the offsetParent chain is correct under either stylesheet.
|
|
1822
|
+
function rowTopInTermWrap(el: HTMLElement): number {
|
|
1823
|
+
const wrap = termWrap;
|
|
1824
|
+
if (!wrap) {
|
|
1825
|
+
return 0;
|
|
1826
|
+
}
|
|
1827
|
+
let top = 0;
|
|
1828
|
+
let node: Element | null = el;
|
|
1829
|
+
while (node instanceof HTMLElement && node !== wrap) {
|
|
1830
|
+
top += node.offsetTop;
|
|
1831
|
+
node = node.offsetParent;
|
|
1832
|
+
// An intermediate parent's offsetTop locates its BORDER box while
|
|
1833
|
+
// offsetTop is measured from the padding edge; termWrap's own absolute
|
|
1834
|
+
// children resolve from its padding edge already.
|
|
1835
|
+
if (node instanceof HTMLElement && node !== wrap) {
|
|
1836
|
+
top += node.clientTop;
|
|
1837
|
+
}
|
|
1838
|
+
}
|
|
1839
|
+
// Chain ended off termWrap (display:none, detached): the rect delta is
|
|
1840
|
+
// space-independent, re-based into content space by scrollTop.
|
|
1841
|
+
if (node !== wrap) {
|
|
1842
|
+
return el.getBoundingClientRect().top - wrap.getBoundingClientRect().top + wrap.scrollTop;
|
|
1843
|
+
}
|
|
1844
|
+
return top;
|
|
1845
|
+
}
|
|
1846
|
+
|
|
1847
|
+
// The ONE resolver for the caret, the predicted cursor and `getCursorPx`, so
|
|
1848
|
+
// the consumer's IME view is never told a different row than the caret paints
|
|
1849
|
+
// on (`rowEls` is empty for a whole alt-screen session). A built row answers
|
|
1850
|
+
// from its DOM offset; a real index with no row from grid arithmetic; `abs <
|
|
1851
|
+
// 0` is not a row at all and the content origin IS the accurate answer.
|
|
1852
|
+
function rowTopFor(abs: number): number {
|
|
1853
|
+
const el = rowEls.get(abs);
|
|
1854
|
+
if (el) {
|
|
1855
|
+
return rowTopInTermWrap(el);
|
|
1856
|
+
}
|
|
1857
|
+
const { padT } = termPadding();
|
|
1858
|
+
if (abs < 0 || !store) {
|
|
1859
|
+
return padT;
|
|
1860
|
+
}
|
|
1861
|
+
return padT + (abs - store.getWindow().base) * cellHeight;
|
|
2352
1862
|
}
|
|
2353
|
-
return top;
|
|
2354
|
-
}
|
|
2355
|
-
|
|
2356
|
-
// rowTopFor resolves the top of the row holding absolute line `abs`, in
|
|
2357
|
-
// termWrap's coordinate space. Three positioners need that answer — the caret
|
|
2358
|
-
// overlay, the predicted-cursor overlay, and `getCursorPx` for the consumer's
|
|
2359
|
-
// IME view and hidden textarea — and they must not disagree, because they
|
|
2360
|
-
// describe the same row. They did: `getCursorPx` fell back to a bare `padT`
|
|
2361
|
-
// while the two that PAINT fell back to grid arithmetic, and `rowEls` is
|
|
2362
|
-
// populated only by the main-screen row builder (`renderAlt` clears it), so for
|
|
2363
|
-
// an ENTIRE alt-screen session the caret painted on the right row while the
|
|
2364
|
-
// consumer was told the top of the terminal every frame.
|
|
2365
|
-
//
|
|
2366
|
-
// Three cases, in order:
|
|
2367
|
-
// - the map holds a built row: its own DOM offset, walked into termWrap space.
|
|
2368
|
-
// - no row for a real index (an alt-screen grid row, or a main row this flush
|
|
2369
|
-
// has not built yet): uniform-grid arithmetic from the window base.
|
|
2370
|
-
// - `abs < 0`: not a row at all. That is the state
|
|
2371
|
-
// `collapseContentSpaceOverlays` leaves behind, where the cursor genuinely
|
|
2372
|
-
// has no row and the content origin IS the accurate answer; the arithmetic
|
|
2373
|
-
// would put the overlay a cell above it.
|
|
2374
|
-
function rowTopFor(abs: number): number {
|
|
2375
|
-
const el = rowEls.get(abs);
|
|
2376
|
-
if (el) {
|
|
2377
|
-
return rowTopInTermWrap(el);
|
|
2378
|
-
}
|
|
2379
|
-
const { padT } = termPadding();
|
|
2380
|
-
if (abs < 0) {
|
|
2381
|
-
return padT;
|
|
2382
|
-
}
|
|
2383
|
-
return padT + (abs - store.getWindow().base) * cellHeight;
|
|
2384
|
-
}
|
|
2385
1863
|
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
top: Math.round(rowTopFor(cursorAbs)),
|
|
2398
|
-
cellH: cellHeight,
|
|
2399
|
-
};
|
|
2400
|
-
}
|
|
1864
|
+
function getCursorPx(): { left: number; top: number; cellH: number } {
|
|
1865
|
+
if (disposed) {
|
|
1866
|
+
return { left: 0, top: 0, cellH: 0 };
|
|
1867
|
+
}
|
|
1868
|
+
const { padL } = termPadding();
|
|
1869
|
+
return {
|
|
1870
|
+
left: Math.round(padL + cursorCol * cellWidth),
|
|
1871
|
+
top: Math.round(rowTopFor(cursorAbs)),
|
|
1872
|
+
cellH: cellHeight,
|
|
1873
|
+
};
|
|
1874
|
+
}
|
|
2401
1875
|
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
|
|
2405
|
-
* font; before that it answers the module's power-on fallback, not the font in
|
|
2406
|
-
* use.
|
|
2407
|
-
*/
|
|
2408
|
-
export function cellSize(): { width: number; height: number } {
|
|
2409
|
-
return { width: cellWidth, height: cellHeight };
|
|
2410
|
-
}
|
|
1876
|
+
function cellSize(): { width: number; height: number } {
|
|
1877
|
+
return disposed ? { width: 0, height: 0 } : { width: cellWidth, height: cellHeight };
|
|
1878
|
+
}
|
|
2411
1879
|
|
|
2412
|
-
// --- Caret overlay ---
|
|
2413
|
-
|
|
2414
|
-
// The real cursor is a single absolutely-positioned element appended to
|
|
2415
|
-
// termWrap (the positioned scroll container, same coordinate system as the
|
|
2416
|
-
// predicted-cursor overlay), NOT a restyled span inside the cursor row. Rows
|
|
2417
|
-
// stay pure content: cursor motion repositions this element and never
|
|
2418
|
-
// rewrites row DOM, so a native selection survives typing — including on the
|
|
2419
|
-
// cursor row itself — and the per-keystroke row rebuild is gone.
|
|
2420
|
-
let cursorEl: HTMLElement | null = null;
|
|
2421
|
-
|
|
2422
|
-
function ensureCursorEl(): HTMLElement {
|
|
2423
|
-
if (cursorEl === null) {
|
|
2424
|
-
cursorEl = document.createElement("div");
|
|
2425
|
-
cursorEl.setAttribute("aria-hidden", "true");
|
|
2426
|
-
termWrap.appendChild(cursorEl);
|
|
2427
|
-
}
|
|
2428
|
-
return cursorEl;
|
|
2429
|
-
}
|
|
1880
|
+
// --- Caret overlay: one absolutely positioned child of termWrap ---
|
|
1881
|
+
let cursorEl: HTMLElement | null = null;
|
|
2430
1882
|
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
if (!runs) {
|
|
2439
|
-
return " ";
|
|
1883
|
+
function ensureCursorEl(): HTMLElement {
|
|
1884
|
+
if (cursorEl === null) {
|
|
1885
|
+
cursorEl = document.createElement("div");
|
|
1886
|
+
cursorEl.setAttribute("aria-hidden", "true");
|
|
1887
|
+
termWrap?.appendChild(cursorEl);
|
|
1888
|
+
}
|
|
1889
|
+
return cursorEl;
|
|
2440
1890
|
}
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
|
|
1891
|
+
|
|
1892
|
+
// The character at `col`, advancing columns exactly like buildRowSpans (the
|
|
1893
|
+
// \uFFFF placeholder occupies a wide char's second cell). A miss reads as a
|
|
1894
|
+
// space, so the block cursor paints an inverted blank like a real terminal.
|
|
1895
|
+
function glyphAt(runs: readonly WireRun[] | undefined, col: number): string {
|
|
1896
|
+
if (!runs) {
|
|
1897
|
+
return " ";
|
|
2445
1898
|
}
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
|
|
1899
|
+
let c = 0;
|
|
1900
|
+
for (const run of runs) {
|
|
1901
|
+
if (!run.t) {
|
|
1902
|
+
continue;
|
|
1903
|
+
}
|
|
1904
|
+
for (const ch of run.t) {
|
|
1905
|
+
if (c === col) {
|
|
1906
|
+
return ch === "\uFFFF" ? " " : ch;
|
|
1907
|
+
}
|
|
1908
|
+
c++;
|
|
2449
1909
|
}
|
|
2450
|
-
c++;
|
|
2451
1910
|
}
|
|
1911
|
+
return " ";
|
|
2452
1912
|
}
|
|
2453
|
-
return " ";
|
|
2454
|
-
}
|
|
2455
1913
|
|
|
2456
|
-
//
|
|
2457
|
-
//
|
|
2458
|
-
//
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
|
|
2484
|
-
|
|
2485
|
-
|
|
1914
|
+
// `runs` is the cursor row's content (main: store.getLine; alt: the grid row);
|
|
1915
|
+
// the block style copies the glyph under the cursor so the inverted cell reads
|
|
1916
|
+
// as text. Hidden when the cursor is hidden or the screen is empty.
|
|
1917
|
+
function positionCursorOverlay(runs: readonly WireRun[] | undefined): void {
|
|
1918
|
+
const el = ensureCursorEl();
|
|
1919
|
+
if (cursorHidden || cursorAbs < 0) {
|
|
1920
|
+
el.className = "term-cursor-overlay";
|
|
1921
|
+
return;
|
|
1922
|
+
}
|
|
1923
|
+
const { padL } = termPadding();
|
|
1924
|
+
const top = rowTopFor(cursorAbs);
|
|
1925
|
+
const ch = glyphAt(runs, cursorCol);
|
|
1926
|
+
// A wide glyph (CJK, emoji) owns two cells; the overlay covers both.
|
|
1927
|
+
const wide = measureChar(ch, false, false) > cellWidth * 1.5;
|
|
1928
|
+
el.textContent = ch === " " ? "\u00a0" : ch;
|
|
1929
|
+
el.className = `term-cursor-overlay visible ${cursorClassName()}`;
|
|
1930
|
+
el.style.left = `${Math.round(padL + cursorCol * cellWidth)}px`;
|
|
1931
|
+
el.style.top = `${Math.round(top)}px`;
|
|
1932
|
+
el.style.width = `${wide ? cellWidth * 2 : cellWidth}px`;
|
|
1933
|
+
el.style.height = `${cellHeight}px`;
|
|
1934
|
+
el.style.lineHeight = `${cellHeight}px`;
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1937
|
+
let predCursorEl: HTMLElement | null = null;
|
|
1938
|
+
|
|
1939
|
+
// Renderer-owned, so the engine never depends on a host-provided scaffold
|
|
1940
|
+
// element; styled by the consumer's `.pred-cursor` rule.
|
|
1941
|
+
function createPredCursorEl(): HTMLElement {
|
|
1942
|
+
const el = document.createElement("div");
|
|
1943
|
+
el.className = "pred-cursor";
|
|
1944
|
+
el.setAttribute("aria-hidden", "true");
|
|
1945
|
+
termWrap?.appendChild(el);
|
|
1946
|
+
return el;
|
|
1947
|
+
}
|
|
1948
|
+
|
|
1949
|
+
function setPredictedCursor(row: number, col: number, active: boolean): void {
|
|
1950
|
+
if (!store) {
|
|
1951
|
+
return;
|
|
1952
|
+
}
|
|
1953
|
+
const el = predCursorEl ?? (predCursorEl = createPredCursorEl());
|
|
1954
|
+
const win = store.getWindow();
|
|
1955
|
+
const predAbs = win.base + row;
|
|
1956
|
+
if (!active || (predAbs === cursorAbs && col === cursorCol)) {
|
|
1957
|
+
el.classList.remove("visible");
|
|
1958
|
+
return;
|
|
1959
|
+
}
|
|
1960
|
+
const { padL } = termPadding();
|
|
1961
|
+
// A prediction below the first line of the session (`predAbs < 0`) lands on
|
|
1962
|
+
// the content origin; a negative top would paint over the top padding.
|
|
1963
|
+
const top = rowTopFor(predAbs);
|
|
1964
|
+
el.style.left = `${Math.round(padL + col * cellWidth)}px`;
|
|
1965
|
+
el.style.top = `${Math.round(top)}px`;
|
|
1966
|
+
el.style.width = `${cellWidth}px`;
|
|
1967
|
+
el.style.height = `${cellHeight}px`;
|
|
1968
|
+
el.classList.add("visible");
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1971
|
+
function updateReverseVideo(): void {
|
|
1972
|
+
if (!termWrap) {
|
|
1973
|
+
return;
|
|
1974
|
+
}
|
|
1975
|
+
if (modes.isReverseVideo()) {
|
|
1976
|
+
termWrap.classList.add("term-reverse-video");
|
|
1977
|
+
} else {
|
|
1978
|
+
termWrap.classList.remove("term-reverse-video");
|
|
1979
|
+
}
|
|
1980
|
+
}
|
|
2486
1981
|
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
//
|
|
2490
|
-
|
|
2491
|
-
|
|
2492
|
-
|
|
2493
|
-
// `#pred-cursor` scaffold element.
|
|
2494
|
-
function createPredCursorEl(): HTMLElement {
|
|
2495
|
-
const el = document.createElement("div");
|
|
2496
|
-
el.className = "pred-cursor";
|
|
2497
|
-
el.setAttribute("aria-hidden", "true");
|
|
2498
|
-
termWrap.appendChild(el);
|
|
2499
|
-
return el;
|
|
2500
|
-
}
|
|
1982
|
+
// Pause the blink interval while hidden; resume (cursor solid, phase reset)
|
|
1983
|
+
// when the tab is foregrounded again. blinkEnabled/cursorHidden keep their
|
|
1984
|
+
// server-driven state across a background stint.
|
|
1985
|
+
const onVisibilityChange = (): void => {
|
|
1986
|
+
syncCursorBlink();
|
|
1987
|
+
};
|
|
2501
1988
|
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
|
|
2510
|
-
|
|
2511
|
-
|
|
2512
|
-
|
|
2513
|
-
|
|
2514
|
-
|
|
2515
|
-
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
2525
|
-
|
|
2526
|
-
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
|
|
2530
|
-
|
|
1989
|
+
function dispose(): void {
|
|
1990
|
+
if (disposed) {
|
|
1991
|
+
return;
|
|
1992
|
+
}
|
|
1993
|
+
disposed = true;
|
|
1994
|
+
if (pendingFrame !== undefined) {
|
|
1995
|
+
cancelAnimationFrame(pendingFrame);
|
|
1996
|
+
pendingFrame = undefined;
|
|
1997
|
+
}
|
|
1998
|
+
if (blinkInterval !== null) {
|
|
1999
|
+
clearInterval(blinkInterval);
|
|
2000
|
+
blinkInterval = null;
|
|
2001
|
+
}
|
|
2002
|
+
blinkMode = "off";
|
|
2003
|
+
document.removeEventListener("visibilitychange", onVisibilityChange);
|
|
2004
|
+
resetVariantContexts();
|
|
2005
|
+
widthFlat.fill(WIDTH_FLAT_UNSET);
|
|
2006
|
+
widthMap.clear();
|
|
2007
|
+
rowEls.clear();
|
|
2008
|
+
for (const el of gapMarkerEls.values()) {
|
|
2009
|
+
el.remove();
|
|
2010
|
+
}
|
|
2011
|
+
gapMarkerEls.clear();
|
|
2012
|
+
renderQueue.clear();
|
|
2013
|
+
requestHistoryFn = null;
|
|
2014
|
+
historyBudgetFn = null;
|
|
2015
|
+
onCursorMove = null;
|
|
2016
|
+
pendingRestore = null;
|
|
2017
|
+
store = null;
|
|
2018
|
+
altPrevRows = [];
|
|
2019
|
+
altRendered = false;
|
|
2020
|
+
trimMarkerEl?.remove();
|
|
2021
|
+
trimMarkerEl = null;
|
|
2022
|
+
cursorEl?.remove();
|
|
2023
|
+
cursorEl = null;
|
|
2024
|
+
predCursorEl?.remove();
|
|
2025
|
+
predCursorEl = null;
|
|
2026
|
+
if (output) {
|
|
2027
|
+
output.replaceChildren();
|
|
2028
|
+
output.style.letterSpacing = "";
|
|
2029
|
+
}
|
|
2030
|
+
if (termWrap) {
|
|
2031
|
+
termWrap.classList.remove("cursor-blink-off", "term-reverse-video");
|
|
2032
|
+
termWrap.style.removeProperty("--char-w");
|
|
2033
|
+
}
|
|
2034
|
+
output = null;
|
|
2035
|
+
termWrap = null;
|
|
2036
|
+
}
|
|
2531
2037
|
|
|
2532
|
-
|
|
2533
|
-
|
|
2534
|
-
|
|
2535
|
-
|
|
2536
|
-
|
|
2537
|
-
|
|
2538
|
-
termWrap.classList.remove("term-reverse-video");
|
|
2038
|
+
try {
|
|
2039
|
+
document.addEventListener("visibilitychange", onVisibilityChange);
|
|
2040
|
+
syncCursorBlink();
|
|
2041
|
+
} catch (err) {
|
|
2042
|
+
dispose();
|
|
2043
|
+
throw err;
|
|
2539
2044
|
}
|
|
2045
|
+
|
|
2046
|
+
return {
|
|
2047
|
+
resetScreen,
|
|
2048
|
+
resetScrollback,
|
|
2049
|
+
bind,
|
|
2050
|
+
boundStore,
|
|
2051
|
+
rebuild,
|
|
2052
|
+
getHighestIndex,
|
|
2053
|
+
getReplayBoundary,
|
|
2054
|
+
noteResumeBounds,
|
|
2055
|
+
handleHistoryReply,
|
|
2056
|
+
applyResumeTransition,
|
|
2057
|
+
noteSolicited,
|
|
2058
|
+
clearSolicited,
|
|
2059
|
+
dropBrowseCache,
|
|
2060
|
+
lastBrowseActivityMs,
|
|
2061
|
+
browseCacheSize,
|
|
2062
|
+
replayMaxForResume,
|
|
2063
|
+
pendingRowCount,
|
|
2064
|
+
handleScreen,
|
|
2065
|
+
handleScroll,
|
|
2066
|
+
handleScrollPosition,
|
|
2067
|
+
captureViewMemory,
|
|
2068
|
+
pendingRestoreAbs,
|
|
2069
|
+
maybeFetchHistory,
|
|
2070
|
+
updateFontMetrics,
|
|
2071
|
+
computeSize,
|
|
2072
|
+
gridSize,
|
|
2073
|
+
getCursorPx,
|
|
2074
|
+
cellSize,
|
|
2075
|
+
setPredictedCursor,
|
|
2076
|
+
updateReverseVideo,
|
|
2077
|
+
dispose,
|
|
2078
|
+
};
|
|
2540
2079
|
}
|