@cplieger/web-terminal-engine 5.0.4 → 5.0.5

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/jsr.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/web-terminal-engine",
3
- "version": "5.0.4",
3
+ "version": "5.0.5",
4
4
  "license": "MPL-2.0",
5
5
  "exports": {
6
6
  ".": "./src/index.ts"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/web-terminal-engine",
3
- "version": "5.0.4",
3
+ "version": "5.0.5",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "typecheck": "tsc -p tsconfig.json",
package/src/keyboard.ts CHANGED
@@ -57,17 +57,6 @@ function modifiersDigit(ev: KeyboardEvent): number {
57
57
  /** The cursor-key trailing letters that honor DECCKM (arrows + Home/End). */
58
58
  type CursorKeyLetter = "A" | "B" | "C" | "D" | "H" | "F";
59
59
 
60
- function isCursorKeyLetter(letter: string): letter is CursorKeyLetter {
61
- return (
62
- letter === "A" ||
63
- letter === "B" ||
64
- letter === "C" ||
65
- letter === "D" ||
66
- letter === "H" ||
67
- letter === "F"
68
- );
69
- }
70
-
71
60
  /**
72
61
  * plainCursorKeySeq is THE encoding of an unmodified logical cursor-key press
73
62
  * (arrows, Home, End) — the one home for the mode decision, shared by the
@@ -114,21 +103,31 @@ function kittyActiveIn(modes: KeyboardModes): boolean {
114
103
  return (modes.getKeyboardFlags() & KITTY_DISAMBIGUATE) !== 0;
115
104
  }
116
105
 
117
- // Letter is the xterm trailing letter (ABCDEFGHPQRS for arrows / Home /
118
- // End / F1-F4 etc.). Without modifiers we send the bare CSI form; with
119
- // modifiers we send CSI 1;{mod}{letter}. xterm.js Keyboard.ts pattern.
106
+ // Letter is the xterm trailing letter for the keys that honor DECCKM: the four
107
+ // arrows plus Home (H) and End (F). Without modifiers we send the bare CSI form;
108
+ // with modifiers we send CSI 1;{mod}{letter}. xterm.js Keyboard.ts pattern.
109
+ // (F1-F4's SS3/CSI pair is encoded inline at its own call site, not here.)
110
+ //
111
+ // The parameter type is `CursorKeyLetter` rather than `string`, so "every
112
+ // letter reaching here honors DECCKM" is a compiler guarantee instead of a
113
+ // runtime net: all three call sites pass A/B/C/D from ARROW_LETTER, or the H/F
114
+ // literals.
120
115
  //
121
116
  // Application cursor mode (DECCKM, CSI ?1) is plumbed via modes.ts.
122
117
  // When the application has set DECCKM, the modifier-less form switches
123
118
  // from CSI to SS3 (ESC O letter) via plainCursorKeySeq; modifier-bearing
124
119
  // forms stay on CSI because they have no SS3 equivalent.
125
- function csiLetter(letter: string, ev: KeyboardEvent, modes: KeyboardModes): string {
120
+ function csiLetter(letter: CursorKeyLetter, ev: KeyboardEvent, modes: KeyboardModes): string {
126
121
  const m = modifiersDigit(ev);
127
122
  if (m === 1) {
128
- if (isCursorKeyLetter(letter)) {
129
- return plainCursorKeySeq(letter, kittyActiveIn(modes), modes.isApplicationCursor());
130
- }
131
- return `${ESC}[${letter}`;
123
+ // `kittyActiveIn(modes)` is always FALSE as reached from today's only
124
+ // caller: mapKeyboardEvent returns through encodeKittyDisambiguate before
125
+ // any cursor key gets here. It is passed anyway rather than hard-coded,
126
+ // because plainCursorKeySeq is the one home for the mode decision and a
127
+ // literal `false` would make this function structurally unable to express
128
+ // the kitty form — a later change to that early return would then
129
+ // mis-encode silently instead of failing to compile or reading wrong.
130
+ return plainCursorKeySeq(letter, kittyActiveIn(modes), modes.isApplicationCursor());
132
131
  }
133
132
  return `${ESC}[1;${m}${letter}`;
134
133
  }
@@ -169,7 +168,7 @@ const FN_TILDE: Record<string, number | undefined> = {
169
168
  F20: 34,
170
169
  };
171
170
 
172
- const ARROW_LETTER: Record<string, string | undefined> = {
171
+ const ARROW_LETTER: Record<string, CursorKeyLetter | undefined> = {
173
172
  ArrowUp: "A",
174
173
  ArrowDown: "B",
175
174
  ArrowRight: "C",
package/src/mouse.ts CHANGED
@@ -264,10 +264,20 @@ function onWheel(e: WheelEvent): void {
264
264
  if (!pos) {
265
265
  return;
266
266
  }
267
- // Wheel up = button 64, wheel down = button 65
268
- const btn = e.deltaY < 0 ? 64 : 65;
269
- const code = buttonCode(btn, false, e.shiftKey, e.altKey, e.ctrlKey);
270
- handler.send(encodeSGR(code, pos.col, pos.row, false));
267
+ // Wheel up = button 64, wheel down = button 65. A gesture with NO vertical
268
+ // component — a horizontal trackpad swipe, or shift-wheel where the browser
269
+ // routes the delta to deltaX — has no wheel button to report: xterm.js's
270
+ // reference encoder returns before encoding when the vertical delta is zero,
271
+ // while its wheel handler still calls preventDefault unconditionally. So
272
+ // parity is "report nothing, still swallow the gesture": a terminal owns the
273
+ // wheel, and letting a sideways swipe through would scroll the page instead.
274
+ // Reporting 65 here (the pre-fix behavior, `deltaY < 0 ? 64 : 65` with no
275
+ // zero case) scrolled the TUI DOWN on a sideways gesture.
276
+ if (e.deltaY !== 0) {
277
+ const btn = e.deltaY < 0 ? 64 : 65;
278
+ const code = buttonCode(btn, false, e.shiftKey, e.altKey, e.ctrlKey);
279
+ handler.send(encodeSGR(code, pos.col, pos.row, false));
280
+ }
271
281
  e.preventDefault();
272
282
  }
273
283
 
package/src/render.ts CHANGED
@@ -2309,19 +2309,48 @@ function rowTopInTermWrap(el: HTMLElement): number {
2309
2309
  return top;
2310
2310
  }
2311
2311
 
2312
+ // rowTopFor resolves the top of the row holding absolute line `abs`, in
2313
+ // termWrap's coordinate space. Three positioners need that answer — the caret
2314
+ // overlay, the predicted-cursor overlay, and `getCursorPx` for the consumer's
2315
+ // IME view and hidden textarea — and they must not disagree, because they
2316
+ // describe the same row. They did: `getCursorPx` fell back to a bare `padT`
2317
+ // while the two that PAINT fell back to grid arithmetic, and `rowEls` is
2318
+ // populated only by the main-screen row builder (`renderAlt` clears it), so for
2319
+ // an ENTIRE alt-screen session the caret painted on the right row while the
2320
+ // consumer was told the top of the terminal every frame.
2321
+ //
2322
+ // Three cases, in order:
2323
+ // - the map holds a built row: its own DOM offset, walked into termWrap space.
2324
+ // - no row for a real index (an alt-screen grid row, or a main row this flush
2325
+ // has not built yet): uniform-grid arithmetic from the window base.
2326
+ // - `abs < 0`: not a row at all. That is the state
2327
+ // `collapseContentSpaceOverlays` leaves behind, where the cursor genuinely
2328
+ // has no row and the content origin IS the accurate answer; the arithmetic
2329
+ // would put the overlay a cell above it.
2330
+ function rowTopFor(abs: number): number {
2331
+ const el = rowEls.get(abs);
2332
+ if (el) {
2333
+ return rowTopInTermWrap(el);
2334
+ }
2335
+ const { padT } = termPadding();
2336
+ if (abs < 0) {
2337
+ return padT;
2338
+ }
2339
+ return padT + (abs - store.getWindow().base) * cellHeight;
2340
+ }
2341
+
2312
2342
  /**
2313
2343
  * Returns the cursor's pixel position relative to the scroll container
2314
2344
  * (termWrap) — the coordinate space of the absolutely-positioned overlays
2315
2345
  * (predicted-cursor, IME composition view) — plus the current cell height.
2316
- * Uses the cursor row's actual DOM offset.
2346
+ * Uses the cursor row's actual DOM offset, or the grid position of a row that
2347
+ * has none (see rowTopFor).
2317
2348
  */
2318
2349
  export function getCursorPx(): { left: number; top: number; cellH: number } {
2319
- const { padL, padT } = termPadding();
2320
- const el = rowEls.get(cursorAbs);
2321
- const top = el ? rowTopInTermWrap(el) : padT;
2350
+ const { padL } = termPadding();
2322
2351
  return {
2323
2352
  left: Math.round(padL + cursorCol * cellWidth),
2324
- top: Math.round(top),
2353
+ top: Math.round(rowTopFor(cursorAbs)),
2325
2354
  cellH: cellHeight,
2326
2355
  };
2327
2356
  }
@@ -2381,15 +2410,13 @@ function positionCursorOverlay(runs: readonly WireRun[] | undefined): void {
2381
2410
  el.className = "term-cursor-overlay";
2382
2411
  return;
2383
2412
  }
2384
- const win = store.getWindow();
2385
- const { padL, padT } = termPadding();
2386
- const rowEl = rowEls.get(cursorAbs);
2387
- // Alt-screen rows are not registered in rowEls; their grid is uniform, so
2388
- // the row offset derives from the window-relative row index. Main-screen
2389
- // rows exist by the time this runs (the flush force-builds the cursor row).
2390
- // rowTopInTermWrap (not bare offsetTop) so the top is in termWrap space
2391
- // regardless of which ancestor is the rows' offsetParent.
2392
- const top = rowEl ? rowTopInTermWrap(rowEl) : padT + (cursorAbs - win.base) * cellHeight;
2413
+ const { padL } = termPadding();
2414
+ // Alt-screen rows are not registered in rowEls; their grid is uniform, so the
2415
+ // row offset derives from the window-relative row index. Main-screen rows
2416
+ // exist by the time this runs (the flush force-builds the cursor row).
2417
+ // rowTopFor is shared with getCursorPx so the consumer's IME view cannot be
2418
+ // told a different row than the one the caret paints on.
2419
+ const top = rowTopFor(cursorAbs);
2393
2420
  const ch = glyphAt(runs, cursorCol);
2394
2421
  // A wide glyph (CJK, emoji) owns two cells; the overlay covers both, like
2395
2422
  // the old inline cursor span did via its continuation-spacer adjustment.
@@ -2433,9 +2460,14 @@ export function setPredictedCursor(row: number, col: number, active: boolean): v
2433
2460
  el.classList.remove("visible");
2434
2461
  return;
2435
2462
  }
2436
- const { padL, padT } = termPadding();
2437
- const rowEl = rowEls.get(predAbs);
2438
- const top = rowEl ? rowTopInTermWrap(rowEl) : padT + row * cellHeight;
2463
+ const { padL } = termPadding();
2464
+ // Same resolver as the caret and getCursorPx. A row the flush has not built
2465
+ // yet answers from grid arithmetic, which for `predAbs >= 0` is exactly the
2466
+ // `padT + row * cellHeight` this used to compute inline. A prediction BELOW
2467
+ // the first line of the session (`predAbs < 0`) now lands on the content
2468
+ // origin instead of a negative top: that row does not exist, and an overlay
2469
+ // at a negative offset paints over the terminal's top padding.
2470
+ const top = rowTopFor(predAbs);
2439
2471
  el.style.left = `${Math.round(padL + col * cellWidth)}px`;
2440
2472
  el.style.top = `${Math.round(top)}px`;
2441
2473
  el.style.width = `${cellWidth}px`;
package/src/store.ts CHANGED
@@ -1262,10 +1262,12 @@ export class LineStore {
1262
1262
  const tailKeys = [...this.lines.keys()]
1263
1263
  .filter((k) => !this.browse.has(k))
1264
1264
  .sort((a, b) => a - b);
1265
+ // `excess >= 1` here, so no guard: `tailCount` is `lines.size -
1266
+ // browse.size` while `tailKeys.length` is `lines.size` minus the browse
1267
+ // keys that are ACTUALLY in `lines`, so `tailKeys.length >= tailCount`
1268
+ // unconditionally — and reaching this line means the gate above found
1269
+ // `tailCount > keep`.
1265
1270
  const excess = tailKeys.length - keep;
1266
- if (excess <= 0) {
1267
- return false;
1268
- }
1269
1271
  const winFloor = this.win.height > 0 ? this.win.base : Number.POSITIVE_INFINITY;
1270
1272
  return this.classifyAsBrowse(tailKeys.slice(0, excess).filter((k) => k < winFloor)) > 0;
1271
1273
  }
@@ -1445,7 +1447,6 @@ export class LineStore {
1445
1447
  this.everEvictedThrough = -1;
1446
1448
  this.erasedThrough = -1;
1447
1449
  this.unconfirmedFrom = Number.POSITIVE_INFINITY;
1448
- this.paging = false;
1449
1450
  this.serverOldest = -1;
1450
1451
  this.win = emptyWindow();
1451
1452
  this.alt = false;
@@ -1933,11 +1934,15 @@ export class LineStore {
1933
1934
  // keeps the classification consistent if one ever does.)
1934
1935
  this.everEvictedThrough = Math.max(this.everEvictedThrough, victim);
1935
1936
  }
1936
- if (this.lines.size === 0) {
1937
- this.oldest = -1;
1938
- this.highest = -1;
1939
- return;
1940
- }
1937
+ // No empty-store arm: the walk cannot evict the last line. The loop
1938
+ // guard's `cursor < this.highest` conjunct stops it once the cursor
1939
+ // reaches the newest key, and with a protected window the sibling
1940
+ // `cursor > winBottom` cannot hold there either. That is the ONLY thing
1941
+ // holding when the target is 0, which `new LineStore(0)` produces
1942
+ // (`evictionBatch(0)` is 1, so `target = 0 - 1 + 1`; the bare
1943
+ // constructor accepts 0, unlike `render.init` and `fromSnapshot`, which
1944
+ // both require a positive integer) — a reason to leave that conjunct
1945
+ // alone.
1941
1946
  // Advance to the lowest remaining key above the victim. Contiguous
1942
1947
  // history (the common case: live scroll-off, resume replay, a
1943
1948
  // cat-bigfile burst) leaves victim + 1 present -- an O(1) probe. Only a