@cplieger/web-terminal-engine 3.5.0 → 3.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/jsr.json +1 -1
- package/package.json +3 -3
- package/src/connection.ts +647 -9
- package/src/index.ts +2 -1
- package/src/intervals.ts +81 -0
- package/src/render.ts +899 -48
- package/src/scroll.ts +229 -25
- package/src/store.ts +1443 -68
- package/src/types.ts +28 -0
- package/src/wire-binary.ts +9 -1
package/src/scroll.ts
CHANGED
|
@@ -31,11 +31,62 @@
|
|
|
31
31
|
// exceeded the tolerance the user was yanked back down every few milliseconds
|
|
32
32
|
// (the "scroll up fights me during output" bug). Per the HTML event loop the
|
|
33
33
|
// scroll steps run before that frame's rAF flush, so the first upward tick
|
|
34
|
-
// flips holding before the next pin can fire.
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
34
|
+
// flips holding before the next pin can fire.
|
|
35
|
+
//
|
|
36
|
+
// ENGAGING is direction-based too, and asymmetrically so: only a move DOWNWARD
|
|
37
|
+
// that lands at the bottom re-engages. An upward move never engages, and a
|
|
38
|
+
// scroll event that did not move the position infers nothing. This asymmetry is
|
|
39
|
+
// what makes a content SHRINK safe. A shrink (top-row eviction at the retention
|
|
40
|
+
// cap, ED3 erasing scrollback, a wipe-and-rebuild) lowers scrollHeight, so the
|
|
41
|
+
// browser clamps scrollTop DOWN to the new maximum and delivers that clamp as an
|
|
42
|
+
// UPWARD move that lands at the bottom. The clamp is indistinguishable by
|
|
43
|
+
// position from a user arriving at the tail, but not by direction: a user
|
|
44
|
+
// returning to the tail always moves down, and a clamp always moves up. Before
|
|
45
|
+
// the asymmetry, deriving the state from position alone meant any shrink
|
|
46
|
+
// silently re-engaged auto-follow under a user who had deliberately scrolled up
|
|
47
|
+
// to read, and every subsequent line then pinned them to the bottom. Measured on
|
|
48
|
+
// an inline TUI that erases scrollback on each redraw: scrolled up 20000px, an
|
|
49
|
+
// ED3 collapsed the content, and the viewport was pinned to the tail from there
|
|
50
|
+
// on. The rule now: follow may be engaged by init, by an explicit
|
|
51
|
+
// scrollToBottom/restoreView, or by a downward user scroll reaching the bottom.
|
|
52
|
+
// Nothing else.
|
|
53
|
+
//
|
|
54
|
+
// The two library writes that happen WHILE HOLDING and must not change the
|
|
55
|
+
// state (adjustForContentShift's anchor correction, restoreView's explicit
|
|
56
|
+
// restore) say so directly, by arming a one-event pass-through for the single
|
|
57
|
+
// scroll event their own write produces. That is deliberately not a general
|
|
58
|
+
// "was this event ours?" mechanism: this module does not own every write to the
|
|
59
|
+
// container (a consumer may page the viewport or animate a smooth jump through
|
|
60
|
+
// the DOM), and per CSSOM View an element enqueues at most one scroll event per
|
|
61
|
+
// frame, so a same-frame library write and user gesture are indistinguishable
|
|
62
|
+
// anyway. The arm is set only when the write actually moved the position, so it
|
|
63
|
+
// can never linger and swallow a later real gesture; every write from outside
|
|
64
|
+
// this module is classified by direction like any user scroll, which is correct
|
|
65
|
+
// for all of them (paging up holds, paging down or a smooth jump to the bottom
|
|
66
|
+
// re-engages on landing).
|
|
38
67
|
|
|
68
|
+
// A content SHRINK is ANNOUNCED, not inferred. The classification above works
|
|
69
|
+
// off the clamp's arithmetic signature — an upward move that lands within
|
|
70
|
+
// CLAMP_EPSILON_PX of the bottom — and that signature is lossy: scrollHeight
|
|
71
|
+
// and clientHeight are integer-rounded while scrollTop is fractional, so under
|
|
72
|
+
// browser zoom or a fractional device pixel ratio the residual can exceed the
|
|
73
|
+
// epsilon and a clamp reads as a user scrolling up. The reader is then left
|
|
74
|
+
// "holding at the bottom" — visually at the tail with auto-follow silently off,
|
|
75
|
+
// a state only a keystroke or the jump button clears, and one that survives a
|
|
76
|
+
// tab switch (restoreView carries it deliberately). See
|
|
77
|
+
// docs/scroll-position-fidelity.md §1.3.
|
|
78
|
+
//
|
|
79
|
+
// The layer that REMOVES the rows knows a shrink happened, so it says so:
|
|
80
|
+
// noteContentShrink() arms a one-event pass-through for the clamp its own
|
|
81
|
+
// mutation caused. The epsilon keeps its original and only job, absorbing
|
|
82
|
+
// subpixel residual, and stops being the thing that IDENTIFIES a clamp.
|
|
83
|
+
//
|
|
84
|
+
// The arm carries the same discipline as preserveFollowOnce, for the same
|
|
85
|
+
// reason: it is set only when the position ACTUALLY moved (observed after the
|
|
86
|
+
// mutation, never predicted from a row count), so an event is guaranteed to
|
|
87
|
+
// follow and a lingering arm cannot swallow a later real gesture. It is also
|
|
88
|
+
// cleared unconditionally by the first event that arrives, so it cannot outlive
|
|
89
|
+
// one frame even if that guarantee were ever broken.
|
|
39
90
|
const BOTTOM_TOLERANCE_PX = 24;
|
|
40
91
|
// An upward move only disengages follow when a real gap is left below it.
|
|
41
92
|
// Bigger than 0 to absorb fractional-layout rounding in the shrink-clamp case
|
|
@@ -46,7 +97,17 @@ const CLAMP_EPSILON_PX = 1;
|
|
|
46
97
|
let scrollEl: HTMLElement | null = null;
|
|
47
98
|
let following = true;
|
|
48
99
|
let lastScrollTop = 0;
|
|
100
|
+
// Armed by a library write that must PRESERVE the current follow state across
|
|
101
|
+
// the single scroll event it produces (see the header). Consumed by that one
|
|
102
|
+
// event; never armed when the write did not move the position, because then no
|
|
103
|
+
// event fires and a lingering arm would swallow the next real gesture.
|
|
104
|
+
let preserveFollowOnce = false;
|
|
105
|
+
// Armed by noteContentShrink when a caller's own row removal moved the
|
|
106
|
+
// position: the next event is that clamp, not a gesture. Consumed (and in all
|
|
107
|
+
// cases cleared) by the first event to arrive.
|
|
108
|
+
let shrinkArmed = false;
|
|
49
109
|
let onFollowChange: ((scrolledUp: boolean) => void) | null = null;
|
|
110
|
+
let onPosition: (() => void) | null = null;
|
|
50
111
|
let scrollHandler: (() => void) | null = null;
|
|
51
112
|
|
|
52
113
|
function distanceFromBottom(): number {
|
|
@@ -72,15 +133,18 @@ function setFollowing(next: boolean): void {
|
|
|
72
133
|
|
|
73
134
|
/**
|
|
74
135
|
* Initialize the scroll controller on the scroll container. The optional
|
|
75
|
-
*
|
|
76
|
-
* the user has scrolled up / disengaged auto-follow)
|
|
136
|
+
* callbacks fire whenever the follow state toggles (its argument is true when
|
|
137
|
+
* the user has scrolled up / disengaged auto-follow), and — separately — on
|
|
138
|
+
* every scroll event that reflects a real position change.
|
|
77
139
|
*
|
|
78
140
|
* @param opts.scrollEl Element whose scroll position is observed and owned.
|
|
79
141
|
* @param opts.onUserScrollChange Optional callback fired on follow/hold toggle.
|
|
142
|
+
* @param opts.onScrollPosition Optional callback fired on every real scroll move.
|
|
80
143
|
*/
|
|
81
144
|
export function init(opts: {
|
|
82
145
|
scrollEl: HTMLElement;
|
|
83
146
|
onUserScrollChange?: (scrolledUp: boolean) => void;
|
|
147
|
+
onScrollPosition?: () => void;
|
|
84
148
|
}): void {
|
|
85
149
|
// Detach any prior listener (re-init in tests / re-mount).
|
|
86
150
|
if (scrollEl && scrollHandler) {
|
|
@@ -88,29 +152,135 @@ export function init(opts: {
|
|
|
88
152
|
}
|
|
89
153
|
scrollEl = opts.scrollEl;
|
|
90
154
|
onFollowChange = opts.onUserScrollChange ?? null;
|
|
155
|
+
onPosition = opts.onScrollPosition ?? null;
|
|
91
156
|
following = true;
|
|
92
157
|
lastScrollTop = scrollEl.scrollTop;
|
|
158
|
+
preserveFollowOnce = false;
|
|
159
|
+
shrinkArmed = false;
|
|
93
160
|
scrollHandler = () => {
|
|
94
161
|
if (!scrollEl) {
|
|
95
162
|
return;
|
|
96
163
|
}
|
|
97
164
|
const top = scrollEl.scrollTop;
|
|
98
|
-
const
|
|
165
|
+
const prev = lastScrollTop;
|
|
99
166
|
lastScrollTop = top;
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
167
|
+
// Read-and-clear: whatever this event turns out to be, the announcement
|
|
168
|
+
// does not survive it. An arm that could outlive one event is the bug
|
|
169
|
+
// preserveFollowOnce's "only when it moved" rule exists to prevent, and a
|
|
170
|
+
// coalesced event that nets out downward must not leave one behind.
|
|
171
|
+
const wasShrink = shrinkArmed;
|
|
172
|
+
shrinkArmed = false;
|
|
173
|
+
if (preserveFollowOnce) {
|
|
174
|
+
// This event is the echo of a library write that carries its own follow
|
|
175
|
+
// intent; the state is already correct.
|
|
176
|
+
preserveFollowOnce = false;
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
// The POSITION seam, fired after the early-return above and before the
|
|
180
|
+
// follow decision below. Both halves of that placement are load-bearing
|
|
181
|
+
// (docs/paged-scrollback.md §5.4): after, because the swallowed event is
|
|
182
|
+
// the echo of the library's OWN write — a paged-in prepend goes through
|
|
183
|
+
// adjustForContentShift, and firing the seam for it would turn every
|
|
184
|
+
// prepend into a fresh fetch trigger, a self-feeding loop; and separate
|
|
185
|
+
// from onFollowChange, because that one fires only on a follow/hold TOGGLE,
|
|
186
|
+
// so an idle session where the reader scrolls within history would never
|
|
187
|
+
// notify at all — and idle browsing is exactly when paging must work.
|
|
188
|
+
//
|
|
189
|
+
// Deliberately a BARE notification: no index, no transport. scroll.ts stays
|
|
190
|
+
// index-free and transport-free; the renderer owns the mapping from scroll
|
|
191
|
+
// position to absolute index and decides whether to fetch.
|
|
192
|
+
//
|
|
193
|
+
// NOT "the user scrolled". This sits before the direction branch below, so
|
|
194
|
+
// it also fires for the browser's own clamps — an eviction, an ED3 wipe, a
|
|
195
|
+
// consumer restyling the surface — i.e. every position change that was not
|
|
196
|
+
// the library's own arming write. That is harmless because the fetch
|
|
197
|
+
// trigger re-evaluates its full guard set on each call and asks for nothing
|
|
198
|
+
// when the geometry says nothing is missing, but a reader who mistakes this
|
|
199
|
+
// for a gesture signal will wire the wrong thing to it.
|
|
200
|
+
if (onPosition) {
|
|
201
|
+
onPosition();
|
|
202
|
+
}
|
|
203
|
+
if (top < prev) {
|
|
204
|
+
// Upward: either the user pulling away from the live tail, or the browser
|
|
205
|
+
// clamping after a content shrink. Neither may ENGAGE follow (see the
|
|
206
|
+
// header). Only the former leaves a real gap below, and only that
|
|
207
|
+
// disengages.
|
|
208
|
+
//
|
|
209
|
+
// An ANNOUNCED shrink is the clamp, told to us by the layer that removed
|
|
210
|
+
// the rows, so the state is preserved without consulting the epsilon at
|
|
211
|
+
// all. The epsilon still backs the un-announced case (a shrink from
|
|
212
|
+
// outside this library's mutation paths, e.g. a consumer restyling the
|
|
213
|
+
// surface).
|
|
214
|
+
if (wasShrink) {
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
if (distanceFromBottom() > CLAMP_EPSILON_PX) {
|
|
218
|
+
setFollowing(false);
|
|
219
|
+
}
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
if (top > prev) {
|
|
223
|
+
// Downward: position decides. Reaching the bottom re-engages follow;
|
|
224
|
+
// stopping short of it holds.
|
|
108
225
|
setFollowing(atBottom());
|
|
109
226
|
}
|
|
227
|
+
// Unmoved: a scroll event that changed nothing implies no intent, and the
|
|
228
|
+
// position it reports may be one a shrink clamp already put us at.
|
|
110
229
|
};
|
|
111
230
|
scrollEl.addEventListener("scroll", scrollHandler, { passive: true });
|
|
112
231
|
}
|
|
113
232
|
|
|
233
|
+
/**
|
|
234
|
+
* Write the container's scroll offset on the library's own behalf, preserving
|
|
235
|
+
* the current follow state across the resulting scroll event. `lastScrollTop` is
|
|
236
|
+
* synced to the POST-clamp value so the next event's direction is computed from
|
|
237
|
+
* where the container actually landed, and the one-event pass-through is armed
|
|
238
|
+
* only when the position really moved (no move means no event to consume).
|
|
239
|
+
*/
|
|
240
|
+
function writePreservingFollow(next: number): void {
|
|
241
|
+
if (!scrollEl) {
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
const before = scrollEl.scrollTop;
|
|
245
|
+
scrollEl.scrollTop = next;
|
|
246
|
+
const after = scrollEl.scrollTop;
|
|
247
|
+
lastScrollTop = after;
|
|
248
|
+
if (after !== before) {
|
|
249
|
+
preserveFollowOnce = true;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Announce that the caller's own mutation just REMOVED content, so the scroll
|
|
255
|
+
* event it produced is the browser's clamp rather than a user gesture, and the
|
|
256
|
+
* follow state must survive it.
|
|
257
|
+
*
|
|
258
|
+
* Call it AFTER the mutation, passing the offset read BEFORE it. The arm is set
|
|
259
|
+
* only when the position actually moved, which is what makes it safe: a clamp
|
|
260
|
+
* that moved the position guarantees an event to consume the arm, and a
|
|
261
|
+
* mutation that did not move the position arms nothing. Predicting the movement
|
|
262
|
+
* from a row count instead would arm on every removal — including the many that
|
|
263
|
+
* cannot clamp (rows removed below the viewport, a removal smaller than the
|
|
264
|
+
* remaining bottom gap, a wipe whose scrollHeight is held up by an
|
|
265
|
+
* absolutely-positioned overlay) — and a lingering arm swallows the next real
|
|
266
|
+
* gesture.
|
|
267
|
+
*
|
|
268
|
+
* A native-scroll-anchoring adjustment (Chrome/Firefox lowering scrollTop as
|
|
269
|
+
* rows are removed above the viewport) also moves the position and also arms
|
|
270
|
+
* this; that is correct rather than incidental, since it is likewise the
|
|
271
|
+
* library's own mutation moving the viewport and not the user.
|
|
272
|
+
*
|
|
273
|
+
* @param scrollTopBefore The container's offset read before the mutation.
|
|
274
|
+
*/
|
|
275
|
+
export function noteContentShrink(scrollTopBefore: number): void {
|
|
276
|
+
if (!scrollEl) {
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
if (scrollEl.scrollTop < scrollTopBefore) {
|
|
280
|
+
shrinkArmed = true;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
114
284
|
/**
|
|
115
285
|
* Pin the viewport to the bottom iff the user is following. Called by the
|
|
116
286
|
* renderer after each flush. A no-op when holding (scrolled up) or already at
|
|
@@ -168,25 +338,59 @@ export function currentScrollTop(): number {
|
|
|
168
338
|
* agent session at the retention cap that reads as "the view scrolls itself
|
|
169
339
|
* while I am trying to read".
|
|
170
340
|
*
|
|
171
|
-
* `following` is deliberately NOT
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
*
|
|
341
|
+
* `following` is deliberately NOT changed: this is a library correction, not a
|
|
342
|
+
* user gesture, so it must leave the reader holding exactly as they were. The
|
|
343
|
+
* write goes through writePreservingFollow, which syncs the direction baseline
|
|
344
|
+
* and lets the resulting scroll event pass through without re-deriving the
|
|
345
|
+
* state. Deriving it instead would let a correction that happens to land the
|
|
346
|
+
* reader at the bottom silently re-engage auto-follow.
|
|
176
347
|
*/
|
|
177
348
|
export function adjustForContentShift(deltaPx: number): void {
|
|
178
349
|
if (!scrollEl || following || deltaPx === 0) {
|
|
179
350
|
return;
|
|
180
351
|
}
|
|
181
|
-
scrollEl.scrollTop
|
|
352
|
+
writePreservingFollow(scrollEl.scrollTop + deltaPx);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Restore a saved view: both the scroll offset and the follow state, together
|
|
357
|
+
* and explicitly. For a consumer keeping per-view scroll memory (a tabbed shell
|
|
358
|
+
* re-entering a tab), this is the correct call. `restoreScrollTop` writes only
|
|
359
|
+
* the position and lets the state re-derive, which cannot express a view that
|
|
360
|
+
* was holding AT the bottom — a state that is reachable, because a content
|
|
361
|
+
* shrink under a scrolled-up reader clamps them to the bottom without engaging
|
|
362
|
+
* follow.
|
|
363
|
+
*
|
|
364
|
+
* A non-finite offset is ignored rather than assigned (the DOM would coerce NaN
|
|
365
|
+
* to 0 and silently jump to the top of history); the follow state is still
|
|
366
|
+
* applied, since the caller's intent for it is unambiguous.
|
|
367
|
+
*/
|
|
368
|
+
export function restoreView(view: { top: number; following: boolean }): void {
|
|
369
|
+
if (!scrollEl) {
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
if (Number.isFinite(view.top)) {
|
|
373
|
+
writePreservingFollow(view.top);
|
|
374
|
+
}
|
|
375
|
+
setFollowing(view.following);
|
|
182
376
|
}
|
|
183
377
|
|
|
184
378
|
/**
|
|
185
379
|
* Restore a previously saved scroll offset (per-view scroll memory: a tabbed
|
|
186
|
-
* shell re-entering a tab whose user had scrolled up to read).
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
380
|
+
* shell re-entering a tab whose user had scrolled up to read). Position only:
|
|
381
|
+
* the follow/hold state then re-derives from the resulting scroll event exactly
|
|
382
|
+
* as it does for a user scroll, so a restored read position holds and a
|
|
383
|
+
* restored at-bottom position re-engages following ONLY if the restore moved
|
|
384
|
+
* downward to reach it. Prefer restoreView when the saved view carries a follow
|
|
385
|
+
* state, which is the only way to express "holding at the bottom".
|
|
386
|
+
*
|
|
387
|
+
* @deprecated A pixel offset cannot identify a reading position: replayed into
|
|
388
|
+
* a surface whose rows are still being built it is silently clamped, and
|
|
389
|
+
* replayed into one whose content grew it points at a different line. Use
|
|
390
|
+
* `render.captureViewMemory()` + `render.bind(store, { view })`, which restore
|
|
391
|
+
* a LINE (see docs/scroll-position-fidelity.md §3). Kept for one release for
|
|
392
|
+
* any external consumer holding pixel-space view memory; removed in the next
|
|
393
|
+
* major.
|
|
190
394
|
*/
|
|
191
395
|
export function restoreScrollTop(top: number): void {
|
|
192
396
|
if (!scrollEl) {
|