@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/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. The nothing-below guard keeps
35
- // one non-user case following: when content SHRINKS (top-row eviction, a
36
- // clear), the browser clamps scrollTop DOWN to the new maximum — an upward
37
- // move that lands exactly at the bottom, which must not break auto-follow.
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
- * callback fires whenever the follow state toggles (its argument is true when
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 movedUp = top < lastScrollTop;
165
+ const prev = lastScrollTop;
99
166
  lastScrollTop = top;
100
- // Upward movement with content still below = the user pulling away from
101
- // the live tail: hold immediately, however small the move (see the header
102
- // comment for why the tolerance alone lost the streaming race). An upward
103
- // move that lands AT the bottom is the browser clamping after a content
104
- // shrink, not the user; everything else derives from position as before.
105
- if (movedUp && distanceFromBottom() > CLAMP_EPSILON_PX) {
106
- setFollowing(false);
107
- } else {
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 touched, and neither is `lastScrollTop`: the
172
- * state stays derived purely from the resulting scroll event, exactly as it is
173
- * for the bottom pin. Both directions recompute to holding (an upward shift
174
- * leaves a gap below; a downward one is not at the bottom), so there is no churn
175
- * and no follow flip.
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 += deltaPx;
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). The offset is
187
- * applied verbatim; the follow/hold state then re-derives from the resulting
188
- * scroll event exactly as it does for a user scroll, so a restored read
189
- * position holds and a restored at-bottom position re-engages following.
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) {