@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/src/mouse.ts CHANGED
@@ -1,24 +1,12 @@
1
- // Mouse event encoding for terminal input (SGR 1006 protocol).
2
- //
3
- // Encodes mouse events as SGR sequences: ESC[<code;col;rowM (press/move)
4
- // or ESC[<code;col;rowm (release). Coordinates are 1-based.
5
- //
6
- // Button encoding (matches xterm/xterm.js):
7
- // 0=left, 1=middle, 2=right, 64=wheel-up, 65=wheel-down
8
- // +4=shift, +8=alt, +16=ctrl, +32=motion
9
- //
10
- // Mouse reports are BEST-EFFORT: a report carries no sequence number and
11
- // describes a screen a resume may since have repainted, so `sendReport` may
12
- // refuse one and a refused report is forgotten rather than retried. Focus is not
13
- // mouse input at all — it is transport state the server derives — and lives in
14
- // `connection.setClientFocus`.
15
- //
16
- // Out-of-scope (consumed but not implemented):
17
- // - X10 mouse mode (mode 9): not supported.
18
- // - urxvt encoding (mode 1015): not supported.
19
- // - DEFAULT encoding (raw bytes): not supported; only SGR 1006.
1
+ // Mouse event encoding for terminal input, SGR 1006 only (no X10, urxvt or raw
2
+ // encoding): ESC[<code;col;rowM for press/move, ESC[<code;col;rowm for release,
3
+ // 1-based coordinates, xterm button codes (0/1/2 buttons, 64/65 wheel, +4
4
+ // shift, +8 alt, +16 ctrl, +32 motion). Reports are BEST-EFFORT: one carries no
5
+ // sequence number and describes a screen a resume may since have repainted, so
6
+ // a refused report is forgotten rather than retried. Focus is transport state
7
+ // the server derives and lives in `connection.setClientFocus`.
20
8
 
21
- import { getMouseMode, isMouseSGR, isMousePixels } from "./modes.js";
9
+ import type { ModeState } from "./modes.js";
22
10
 
23
11
  const ESC = "\x1b";
24
12
 
@@ -47,14 +35,9 @@ function buttonCode(
47
35
  }
48
36
 
49
37
  /**
50
- * Encode a mouse event into an SGR 1006 escape sequence ready to send to the
51
- * terminal as input. The returned string starts with ESC[< and ends with `M`
52
- * for press/move or `m` for release.
53
- *
54
- * @param code Button code returned from `buttonCode` (button + modifier bits).
55
- * @param col 1-based column.
56
- * @param row 1-based row.
57
- * @param release `true` for release events (terminator `m`), `false` otherwise.
38
+ * Encode a mouse event into an SGR 1006 escape sequence: `code` is the xterm
39
+ * button code plus modifier bits, `col` and `row` are 1-based, and `release`
40
+ * selects the `m` terminator over `M`.
58
41
  */
59
42
  export function encodeSGR(code: number, col: number, row: number, release: boolean): string {
60
43
  const final = release ? "m" : "M";
@@ -74,7 +57,7 @@ function domButtonToXterm(button: number): number {
74
57
  }
75
58
 
76
59
  /**
77
- * Adapter the consumer wires into `init` so the mouse module can: send encoded
60
+ * Adapter the consumer supplies so the mouse controller can: send encoded
78
61
  * input bytes to the server, query current cell pixel size for hit-testing,
79
62
  * and access the terminal DOM element to attach listeners to.
80
63
  */
@@ -107,441 +90,425 @@ export interface MouseInputHandler {
107
90
  gridSize?: () => { cols: number; rows: number };
108
91
  }
109
92
 
110
- let handler: MouseInputHandler | null = null;
111
- // True while a Shift-initiated press is in flight. Shift+click/drag is the
112
- // xterm convention for "bypass application mouse tracking": the gesture is
113
- // reserved for the browser's native text selection instead of being reported
114
- // to (and preventDefault-ed away from) the TUI. Tracked from press to release
115
- // so the move/up of a bypassed drag stay bypassed even if Shift is lifted
116
- // mid-drag.
117
- let shiftBypass = false;
118
- // Last motion report sent, for same-cell dedup: a drag within one cell fires
119
- // many DOM mousemove events that would all encode to the identical SGR
120
- // sequence and flood the PTY (xterm.js applies the same suppression). Cleared
121
- // on press/release so the first motion of a new gesture always reports.
122
- let lastMotion = "";
123
- // The element the current init() attached to, so a re-init or dispose can
124
- // detach the exact listener set (addEventListener dedups identical
125
- // registrations on the SAME element, but a re-init on a NEW element would
126
- // otherwise leak the old one's listeners).
127
- let attachedEl: HTMLElement | null = null;
128
- // One warning per wiring, not per event: a non-positive cell size makes every
129
- // report vanish, which is indistinguishable from mouse-tracking being off, and
130
- // that silence is why a consumer wiring cellSize to a guess went unnoticed.
131
- let warnedDegenerateCell = false;
132
- // The same silence for the other divisor, and it is worse: an empty grid clamps
133
- // every coordinate to 0, so instead of vanishing the report becomes a confident
134
- // (1,1) for every pointer position.
135
- let warnedDegenerateGrid = false;
136
- // The buttons whose PRESS was actually delivered, with the coordinates that
137
- // press reported. This is what the APPLICATION believes is held, so it also
138
- // decides which reports are truthful: a release is emitted only for a recorded
139
- // button, and a motion report claims a held button only for a recorded one.
140
- const pressed = new Map<number, { col: number; row: number }>();
141
93
  // DOM MouseEvent.button index -> its bit in MouseEvent.buttons. Middle and right
142
94
  // are SWAPPED between the two (button 1 = middle = bit 4; button 2 = right =
143
95
  // bit 2), so `1 << button` is wrong.
144
96
  const BUTTONS_BIT = [1, 4, 2, 8, 16] as const;
145
- // The freshest `buttons` bitmask any mouse event gave this element, which is the
146
- // only reading of present button state the DOM offers outside an event.
147
- let lastButtons = 0;
148
- // The motion report waiting for the next animation frame, and the frame handle
149
- // that will flush it. Motion coalesces to one report per frame, latest wins.
150
- let pendingMotion: string | null = null;
151
- let motionFrame: number | null = null;
152
97
 
153
- // The one predicate every read and every write of `pressed` is gated on: the
154
- // application is asking for SGR-encoded reports right now. A record written
155
- // under tracking outlives the mode that created it, and an SGR release written
156
- // once tracking is off lands in the PTY as ordinary keystrokes.
157
- function trackingActive(): boolean {
158
- return getMouseMode() !== 0 && (isMouseSGR() || isMousePixels());
98
+ /** `MouseInputHandler` plus the mode state the listeners gate on. */
99
+ export interface MouseControllerOptions extends MouseInputHandler {
100
+ modes: Pick<ModeState, "getMouseMode" | "isMouseSGR" | "isMousePixels">;
159
101
  }
160
102
 
161
- // Forget the held-gesture record and the button reading it is judged against.
162
- function clearGesture(): void {
163
- pressed.clear();
164
- lastButtons = 0;
103
+ /** Pointer and wheel forwarding for one terminal element. */
104
+ export interface MouseController {
105
+ /**
106
+ * Cancels a gesture left in flight once the resume has declared the server's
107
+ * capabilities: emits one release for each recorded button the last reading
108
+ * says is now up, so a drag is not left pressed across a reconnect.
109
+ */
110
+ resyncGesture(): void;
111
+ /** Forgets the held gesture without reporting anything, for a consumer pointing this terminal at another session. */
112
+ disarmGesture(): void;
113
+ /** Detaches the four listeners, cancels the pending motion frame and forgets the gesture. Idempotent. */
114
+ dispose(): void;
165
115
  }
166
116
 
167
- // True when the application is not tracking, and the shared gate for every
168
- // handler and for resyncGesture. Forgetting the record here is what closes the
169
- // stranded-gesture sequence: an SGR release written while nothing asked for one
170
- // reaches the PTY as keystrokes, and carrying the record into a later re-enable
171
- // would describe a press that program never saw. tmux answers the same way at
172
- // attach, disabling every mouse mode and clearing mouse_drag_flag with it
173
- // (tty.c, tty_start_tty).
174
- function trackingOff(): boolean {
175
- if (trackingActive()) {
176
- return false;
177
- }
178
- clearGesture();
179
- return true;
180
- }
117
+ /**
118
+ * Creates a mouse controller over `opts.termElement()`. The listeners gate on
119
+ * the active mouse mode and SGR 1006 encoding, so they are inert until the
120
+ * application enables tracking. Throws, holding nothing, when a listener cannot
121
+ * be attached.
122
+ */
123
+ export function createMouseController(opts: MouseControllerOptions): MouseController {
124
+ // Invariant: `modes` and `handler` are read by member call at each call site, never as captured methods.
125
+ const modes = opts.modes;
126
+ let handler: MouseInputHandler | null = opts;
127
+ // True while a Shift-initiated press is in flight. Shift+click/drag is the
128
+ // xterm convention for "bypass application mouse tracking": the gesture is
129
+ // reserved for the browser's native text selection instead of being reported
130
+ // to (and preventDefault-ed away from) the TUI. Tracked from press to release
131
+ // so the move/up of a bypassed drag stay bypassed even if Shift is lifted
132
+ // mid-drag.
133
+ let shiftBypass = false;
134
+ // Last motion report sent, for same-cell dedup: a drag within one cell fires
135
+ // many DOM mousemove events that would all encode to the identical SGR
136
+ // sequence and flood the PTY (xterm.js applies the same suppression). Cleared
137
+ // on press/release so the first motion of a new gesture always reports.
138
+ let lastMotion = "";
139
+ // The element the listeners are attached to, so dispose detaches the exact set.
140
+ let attachedEl: HTMLElement | null = null;
141
+ // One warning per wiring, not per event: a non-positive cell size makes every
142
+ // report vanish, which is indistinguishable from mouse-tracking being off, and
143
+ // that silence is why a consumer wiring cellSize to a guess went unnoticed.
144
+ let warnedDegenerateCell = false;
145
+ // The same silence for the other divisor, and it is worse: an empty grid clamps
146
+ // every coordinate to 0, so instead of vanishing the report becomes a confident
147
+ // (1,1) for every pointer position.
148
+ let warnedDegenerateGrid = false;
149
+ // The buttons whose PRESS was actually delivered, with the coordinates that
150
+ // press reported. This is what the APPLICATION believes is held, so it also
151
+ // decides which reports are truthful: a release is emitted only for a recorded
152
+ // button, and a motion report claims a held button only for a recorded one.
153
+ const pressed = new Map<number, { col: number; row: number }>();
154
+ // The freshest `buttons` bitmask any mouse event gave this element, which is the
155
+ // only reading of present button state the DOM offers outside an event.
156
+ let lastButtons = 0;
157
+ // The motion report waiting for the next animation frame, and the frame handle
158
+ // that will flush it. Motion coalesces to one report per frame, latest wins.
159
+ let pendingMotion: string | null = null;
160
+ let motionFrame: number | null = null;
181
161
 
182
- // Drop the pending motion report and disarm its frame. A press, release or wheel
183
- // CANCELS rather than flushes: the button event carries a newer position, so
184
- // ordering is correct by construction and the superseded motion is exactly what
185
- // coalescing is entitled to drop.
186
- function cancelPendingMotion(): void {
187
- pendingMotion = null;
188
- if (motionFrame !== null) {
189
- cancelAnimationFrame(motionFrame);
190
- motionFrame = null;
162
+ // The one predicate every read and every write of `pressed` is gated on: the
163
+ // application is asking for SGR-encoded reports right now. A record written
164
+ // under tracking outlives the mode that created it, and an SGR release written
165
+ // once tracking is off lands in the PTY as ordinary keystrokes.
166
+ function trackingActive(): boolean {
167
+ return modes.getMouseMode() !== 0 && (modes.isMouseSGR() || modes.isMousePixels());
191
168
  }
192
- }
193
169
 
194
- function flushMotion(): void {
195
- motionFrame = null;
196
- const seq = pendingMotion;
197
- pendingMotion = null;
198
- if (seq === null || !handler) {
199
- return;
170
+ // Forget the held-gesture record and the button reading it is judged against.
171
+ function clearGesture(): void {
172
+ pressed.clear();
173
+ lastButtons = 0;
200
174
  }
201
- handler.sendReport(seq);
202
- }
203
175
 
204
- function detach(): void {
205
- if (!attachedEl) {
206
- return;
176
+ // True when the application is not tracking, and the shared gate for every
177
+ // handler and for resyncGesture. Forgetting the record here is what closes the
178
+ // stranded-gesture sequence: an SGR release written while nothing asked for one
179
+ // reaches the PTY as keystrokes, and carrying the record into a later re-enable
180
+ // would describe a press that program never saw. tmux answers the same way at
181
+ // attach, disabling every mouse mode and clearing mouse_drag_flag with it
182
+ // (tty.c, tty_start_tty).
183
+ function trackingOff(): boolean {
184
+ if (trackingActive()) {
185
+ return false;
186
+ }
187
+ clearGesture();
188
+ return true;
207
189
  }
208
- attachedEl.removeEventListener("mousedown", onMouseDown);
209
- attachedEl.removeEventListener("mouseup", onMouseUp);
210
- attachedEl.removeEventListener("mousemove", onMouseMove);
211
- attachedEl.removeEventListener("wheel", onWheel);
212
- attachedEl = null;
213
- }
214
190
 
215
- /**
216
- * Initialize the mouse module by attaching pointer/wheel listeners to the
217
- * terminal element. Listeners gate on the active mouse mode + SGR 1006
218
- * encoding; they are no-ops when the server hasn't enabled mouse tracking.
219
- *
220
- * Returns an idempotent disposer that detaches the listeners and resets the
221
- * module state, so a consumer can tear the terminal down (or re-init on a
222
- * different element) without leaking listeners. Re-initializing without
223
- * disposing first detaches the previous element automatically.
224
- */
225
- export function init(h: MouseInputHandler): () => void {
226
- detach(); // self-heal a re-init on a different element
227
- handler = h;
228
- shiftBypass = false;
229
- lastMotion = "";
230
- warnedDegenerateCell = false;
231
- warnedDegenerateGrid = false;
232
- clearGesture();
233
- cancelPendingMotion();
234
- const el = h.termElement();
235
- attachedEl = el;
236
- el.addEventListener("mousedown", onMouseDown);
237
- el.addEventListener("mouseup", onMouseUp);
238
- el.addEventListener("mousemove", onMouseMove);
239
- el.addEventListener("wheel", onWheel, { passive: false });
240
- return function dispose(): void {
241
- if (attachedEl !== el) {
242
- return; // superseded by a later init — nothing of ours left to detach
191
+ // Drop the pending motion report and disarm its frame. A press, release or wheel
192
+ // CANCELS rather than flushes: the button event carries a newer position, so
193
+ // ordering is correct by construction and the superseded motion is exactly what
194
+ // coalescing is entitled to drop.
195
+ function cancelPendingMotion(): void {
196
+ pendingMotion = null;
197
+ if (motionFrame !== null) {
198
+ cancelAnimationFrame(motionFrame);
199
+ motionFrame = null;
243
200
  }
244
- detach();
245
- handler = null;
246
- shiftBypass = false;
247
- lastMotion = "";
248
- clearGesture();
249
- cancelPendingMotion();
250
- };
251
- }
201
+ }
252
202
 
253
- /**
254
- * resyncGesture cancels a gesture left in flight, called once the resume has
255
- * declared the server's capabilities (the resumeAck, not the socket's open: the
256
- * ephemeral channel is not yet known at open, so a release sent there would take
257
- * the reliable fallback and become the one replayable mouse report). A button the
258
- * server believes is still held is a stuck drag for the rest of the session. For
259
- * each recorded button the last reading says is now UP it emits one release at
260
- * that press's coordinates; a button still down is left alone, because it
261
- * genuinely is.
262
- *
263
- * This reports PRESENT state, which is what RFB and Guacamole do implicitly with
264
- * their next message, not a replay of the missed release. The synthesis is OUR
265
- * JUDGEMENT — no reference addresses a producer-side resync (tmux resets its own
266
- * gesture model at attach, but tmux is the CONSUMER and can) — chosen over
267
- * emitting nothing and letting the next real click unstick the application. Its
268
- * bound: the reading is the freshest the browser gave THIS element, so a release
269
- * that happened off-element is invisible either way.
270
- */
271
- export function resyncGesture(): void {
272
- if (!handler) {
273
- return;
203
+ function flushMotion(): void {
204
+ motionFrame = null;
205
+ const seq = pendingMotion;
206
+ pendingMotion = null;
207
+ if (seq === null || !handler) {
208
+ return;
209
+ }
210
+ handler.sendReport(seq);
274
211
  }
275
- cancelPendingMotion();
276
- if (trackingOff()) {
277
- return;
212
+
213
+ function detach(): void {
214
+ if (!attachedEl) {
215
+ return;
216
+ }
217
+ attachedEl.removeEventListener("mousedown", onMouseDown);
218
+ attachedEl.removeEventListener("mouseup", onMouseUp);
219
+ attachedEl.removeEventListener("mousemove", onMouseMove);
220
+ attachedEl.removeEventListener("wheel", onWheel);
221
+ attachedEl = null;
278
222
  }
279
- for (const [button, pos] of [...pressed]) {
280
- if ((lastButtons & (BUTTONS_BIT[button] ?? 0)) !== 0) {
281
- continue; // still held
223
+
224
+ // Called on the resumeAck, not the socket's open: the ephemeral channel is
225
+ // not yet known at open, so a release sent there would take the reliable
226
+ // fallback and become the one replayable mouse report. Reports PRESENT state
227
+ // (what RFB and Guacamole do implicitly with their next message), one release
228
+ // per recorded button the last reading says is UP; a release that happened
229
+ // off-element is invisible either way.
230
+ function resyncGesture(): void {
231
+ if (!handler) {
232
+ return;
233
+ }
234
+ cancelPendingMotion();
235
+ if (trackingOff()) {
236
+ return;
282
237
  }
283
- const code = buttonCode(domButtonToXterm(button), false, false, false, false);
284
- if (handler.sendReport(encodeSGR(code, pos.col, pos.row, true))) {
285
- pressed.delete(button); // only a DELIVERED release ends the gesture, as in onMouseUp
238
+ for (const [button, pos] of [...pressed]) {
239
+ if ((lastButtons & (BUTTONS_BIT[button] ?? 0)) !== 0) {
240
+ continue; // still held
241
+ }
242
+ const code = buttonCode(domButtonToXterm(button), false, false, false, false);
243
+ if (handler.sendReport(encodeSGR(code, pos.col, pos.row, true))) {
244
+ pressed.delete(button); // only a DELIVERED release ends the gesture, as in onMouseUp
245
+ }
286
246
  }
287
247
  }
288
- }
289
248
 
290
- /**
291
- * disarmGesture forgets the held gesture without reporting anything, for a
292
- * consumer pointing one terminal's socket at a different session.
293
- *
294
- * The record says what the OUTGOING session's application believes is held, and
295
- * this module is per terminal while sessions multiplex over it, so a release
296
- * surviving the switch would reach a session that never saw the press — the
297
- * desync `pressed` exists to prevent, arriving through resyncGesture. Nothing is
298
- * emitted instead: the same treatment the consumer gives every other latched
299
- * input class on a switch.
300
- */
301
- export function disarmGesture(): void {
302
- cancelPendingMotion();
303
- clearGesture();
304
- }
305
-
306
- // pixelToCell returns the coordinate pair to report for a mouse event. Normally
307
- // that's the 1-based cell column/row; under DEC 1016 (SGR-pixels) it's instead
308
- // the 1-based pixel offset within the terminal element (same SGR grammar, so
309
- // encodeSGR is reused unchanged). The {col,row} field names carry whichever the
310
- // active mode reports.
311
- function pixelToCell(e: MouseEvent): { col: number; row: number } | null {
312
- const h = handler;
313
- if (!h) {
314
- return null;
249
+ // The record says what the OUTGOING session's application believes is held,
250
+ // and sessions multiplex over one controller, so a release surviving the
251
+ // switch would reach a session that never saw the press.
252
+ function disarmGesture(): void {
253
+ cancelPendingMotion();
254
+ clearGesture();
315
255
  }
316
- const el = (h.gridElement ?? h.termElement)();
317
- const rect = el.getBoundingClientRect();
318
- const grid = h.gridSize?.() ?? null;
319
- if (grid !== null && (grid.cols <= 0 || grid.rows <= 0)) {
320
- // rows 0 is the documented pre-first-frame answer (render.gridSize): the
321
- // server has not described the screen yet, so refuse without spending the
322
- // warning latch that exists to make a genuine mis-wiring loud.
323
- if (grid.cols > 0 && grid.rows === 0) {
256
+
257
+ // pixelToCell returns the coordinate pair to report for a mouse event. Normally
258
+ // that's the 1-based cell column/row; under DEC 1016 (SGR-pixels) it's instead
259
+ // the 1-based pixel offset within the terminal element (same SGR grammar, so
260
+ // encodeSGR is reused unchanged). The {col,row} field names carry whichever the
261
+ // active mode reports.
262
+ function pixelToCell(e: MouseEvent): { col: number; row: number } | null {
263
+ const h = handler;
264
+ if (!h) {
324
265
  return null;
325
266
  }
326
- warnUnmeasuredGrid(grid.cols, grid.rows);
327
- return null;
328
- }
329
- if (isMousePixels()) {
330
- const box = grid === null ? null : pixelBox(h, grid);
331
- if (box === null) {
332
- const px = Math.round(e.clientX - rect.left);
333
- const py = Math.round(e.clientY - rect.top);
334
- if (px < 0 || py < 0) {
267
+ const el = (h.gridElement ?? h.termElement)();
268
+ const rect = el.getBoundingClientRect();
269
+ const grid = h.gridSize?.() ?? null;
270
+ if (grid !== null && (grid.cols <= 0 || grid.rows <= 0)) {
271
+ // rows 0 is the documented pre-first-frame answer (render.gridSize): the
272
+ // server has not described the screen yet, so refuse without spending the
273
+ // warning latch that exists to make a genuine mis-wiring loud.
274
+ if (grid.cols > 0 && grid.rows === 0) {
335
275
  return null;
336
276
  }
337
- return { col: px + 1, row: py + 1 }; // 1-based pixel offsets
338
- }
339
- // Same origin as the cell branch below — the grid's BOTTOM edge — so one
340
- // press resolves to the same row under either encoding. Measured from the
341
- // box's top edge instead, a partial top row shifts every pixel report by up
342
- // to a cell.
343
- return {
344
- col: clampToGrid(Math.round(e.clientX - rect.left), box.width) + 1,
345
- row: clampToGrid(Math.round(e.clientY - (rect.bottom - box.height)), box.height) + 1,
346
- };
347
- }
348
- const { width, height } = h.cellSize();
349
- if (width <= 0 || height <= 0) {
350
- warnUnmeasuredCell(width, height);
351
- return null;
352
- }
353
- const col0 = Math.floor((e.clientX - rect.left) / width);
354
- if (grid === null) {
355
- const row0 = Math.floor((e.clientY - rect.top) / height);
356
- if (col0 < 0 || row0 < 0) {
277
+ warnUnmeasuredGrid(grid.cols, grid.rows);
278
+ return null;
279
+ }
280
+ if (modes.isMousePixels()) {
281
+ const box = grid === null ? null : pixelBox(h, grid);
282
+ if (box === null) {
283
+ const px = Math.round(e.clientX - rect.left);
284
+ const py = Math.round(e.clientY - rect.top);
285
+ if (px < 0 || py < 0) {
286
+ return null;
287
+ }
288
+ return { col: px + 1, row: py + 1 }; // 1-based pixel offsets
289
+ }
290
+ // Same origin as the cell branch below — the grid's BOTTOM edge — so one
291
+ // press resolves to the same row under either encoding. Measured from the
292
+ // box's top edge instead, a partial top row shifts every pixel report by up
293
+ // to a cell.
294
+ return {
295
+ col: clampToGrid(Math.round(e.clientX - rect.left), box.width) + 1,
296
+ row: clampToGrid(Math.round(e.clientY - (rect.bottom - box.height)), box.height) + 1,
297
+ };
298
+ }
299
+ const { width, height } = h.cellSize();
300
+ if (width <= 0 || height <= 0) {
301
+ warnUnmeasuredCell(width, height);
357
302
  return null;
358
303
  }
359
- return { col: col0 + 1, row: row0 + 1 };
304
+ const col0 = Math.floor((e.clientX - rect.left) / width);
305
+ if (grid === null) {
306
+ const row0 = Math.floor((e.clientY - rect.top) / height);
307
+ if (col0 < 0 || row0 < 0) {
308
+ return null;
309
+ }
310
+ return { col: col0 + 1, row: row0 + 1 };
311
+ }
312
+ // The screen window is the TAIL of the content, so the grid is bottom-anchored:
313
+ // on the primary screen a bottom-pinned view shows a PARTIAL row at the top and
314
+ // row 0 begins below the box's top edge, which top-anchored arithmetic reports
315
+ // one row out for.
316
+ const row0 = grid.rows - 1 - Math.floor((rect.bottom - e.clientY) / height);
317
+ return { col: clampToGrid(col0, grid.cols) + 1, row: clampToGrid(row0, grid.rows) + 1 };
360
318
  }
361
- // The screen window is the TAIL of the content, so the grid is bottom-anchored:
362
- // on the primary screen a bottom-pinned view shows a PARTIAL row at the top and
363
- // row 0 begins below the box's top edge, which top-anchored arithmetic reports
364
- // one row out for.
365
- const row0 = grid.rows - 1 - Math.floor((rect.bottom - e.clientY) / height);
366
- return { col: clampToGrid(col0, grid.cols) + 1, row: clampToGrid(row0, grid.rows) + 1 };
367
- }
368
-
369
- /** Clamp a 0-based grid coordinate into `0..limit-1`, and never below 0. */
370
- function clampToGrid(value: number, limit: number): number {
371
- return Math.max(0, Math.min(value, limit - 1));
372
- }
373
319
 
374
- // The grid's own box in pixels, or null while the cell is unmeasured — in which
375
- // case a pixel report goes out UNCLAMPED rather than being withheld: the offset
376
- // is still the right offset, only its bound is unknown.
377
- function pixelBox(
378
- h: MouseInputHandler,
379
- grid: { cols: number; rows: number },
380
- ): { width: number; height: number } | null {
381
- const { width, height } = h.cellSize();
382
- if (width <= 0 || height <= 0) {
383
- return null;
320
+ /** Clamp a 0-based grid coordinate into `0..limit-1`, and never below 0. */
321
+ function clampToGrid(value: number, limit: number): number {
322
+ return Math.max(0, Math.min(value, limit - 1));
384
323
  }
385
- return { width: grid.cols * width, height: grid.rows * height };
386
- }
387
324
 
388
- function warnUnmeasuredCell(width: number, height: number): void {
389
- if (warnedDegenerateCell) {
390
- return;
325
+ // The grid's own box in pixels, or null while the cell is unmeasured — in which
326
+ // case a pixel report goes out UNCLAMPED rather than being withheld: the offset
327
+ // is still the right offset, only its bound is unknown.
328
+ function pixelBox(
329
+ h: MouseInputHandler,
330
+ grid: { cols: number; rows: number },
331
+ ): { width: number; height: number } | null {
332
+ const { width, height } = h.cellSize();
333
+ if (width <= 0 || height <= 0) {
334
+ return null;
335
+ }
336
+ return { width: grid.cols * width, height: grid.rows * height };
391
337
  }
392
- warnedDegenerateCell = true;
393
- console.warn(
394
- `vterm: mouse reports suppressed, cellSize() answered ${String(width)}x${String(height)}`,
395
- );
396
- }
397
338
 
398
- function warnUnmeasuredGrid(cols: number, rows: number): void {
399
- if (warnedDegenerateGrid) {
400
- return;
339
+ function warnUnmeasuredCell(width: number, height: number): void {
340
+ if (warnedDegenerateCell) {
341
+ return;
342
+ }
343
+ warnedDegenerateCell = true;
344
+ console.warn(
345
+ `vterm: mouse reports suppressed, cellSize() answered ${String(width)}x${String(height)}`,
346
+ );
401
347
  }
402
- warnedDegenerateGrid = true;
403
- console.warn(
404
- `vterm: mouse reports suppressed, gridSize() answered ${String(cols)}x${String(rows)}`,
405
- );
406
- }
407
348
 
408
- function onMouseDown(e: MouseEvent): void {
409
- lastButtons = e.buttons;
410
- if (trackingOff() || !handler) {
411
- return;
412
- }
413
- if (e.shiftKey) {
414
- // Shift+press: leave the whole gesture to the browser (native selection).
415
- shiftBypass = true;
416
- return;
417
- }
418
- shiftBypass = false;
419
- lastMotion = ""; // new gesture: next motion always reports
420
- const pos = pixelToCell(e);
421
- if (!pos) {
422
- return;
423
- }
424
- cancelPendingMotion();
425
- const code = buttonCode(domButtonToXterm(e.button), false, e.shiftKey, e.altKey, e.ctrlKey);
426
- if (handler.sendReport(encodeSGR(code, pos.col, pos.row, false))) {
427
- // Only a DELIVERED press starts a gesture, so onMouseUp cannot report a
428
- // release for a press the application never saw.
429
- pressed.set(e.button, pos);
349
+ function warnUnmeasuredGrid(cols: number, rows: number): void {
350
+ if (warnedDegenerateGrid) {
351
+ return;
352
+ }
353
+ warnedDegenerateGrid = true;
354
+ console.warn(
355
+ `vterm: mouse reports suppressed, gridSize() answered ${String(cols)}x${String(rows)}`,
356
+ );
430
357
  }
431
- e.preventDefault();
432
- }
433
358
 
434
- function onMouseUp(e: MouseEvent): void {
435
- lastButtons = e.buttons;
436
- if (trackingOff() || !handler) {
437
- return;
438
- }
439
- const bypassed = shiftBypass;
440
- shiftBypass = false;
441
- cancelPendingMotion();
442
- const pos = pixelToCell(e);
443
- if (!pos) {
444
- // The record SURVIVES a release that could not be reported. `pressed` is what
445
- // the application believes is held, and it was told nothing here, so
446
- // resyncGesture must still find the button and cancel it on the next open.
447
- return;
448
- }
449
- if (pressed.has(e.button)) {
450
- const btn = domButtonToXterm(e.button);
451
- const code = buttonCode(btn, false, e.shiftKey, e.altKey, e.ctrlKey);
452
- if (handler.sendReport(encodeSGR(code, pos.col, pos.row, true))) {
453
- pressed.delete(e.button); // only a DELIVERED release ends the gesture
359
+ function onMouseDown(e: MouseEvent): void {
360
+ lastButtons = e.buttons;
361
+ if (trackingOff() || !handler) {
362
+ return;
454
363
  }
364
+ if (e.shiftKey) {
365
+ // Shift+press: leave the whole gesture to the browser (native selection).
366
+ shiftBypass = true;
367
+ return;
368
+ }
369
+ shiftBypass = false;
370
+ lastMotion = ""; // new gesture: next motion always reports
371
+ const pos = pixelToCell(e);
372
+ if (!pos) {
373
+ return;
374
+ }
375
+ cancelPendingMotion();
376
+ const code = buttonCode(domButtonToXterm(e.button), false, e.shiftKey, e.altKey, e.ctrlKey);
377
+ if (handler.sendReport(encodeSGR(code, pos.col, pos.row, false))) {
378
+ // Only a DELIVERED press starts a gesture, so onMouseUp cannot report a
379
+ // release for a press the application never saw.
380
+ pressed.set(e.button, pos);
381
+ }
382
+ e.preventDefault();
455
383
  }
456
- if (bypassed) {
457
- // The Shift-initiated gesture is the browser's (native selection), so its
458
- // own default is left alone; a button recorded BEFORE the bypass began
459
- // belongs to a delivered gesture and is still owed the release above.
460
- return;
461
- }
462
- lastMotion = ""; // gesture over: next motion always reports
463
- e.preventDefault();
464
- }
465
384
 
466
- // The button to report as held: the highest-priority DOM button that is both
467
- // down now and recorded as delivered, or null when the application believes none
468
- // is. Priority matches the DOM's own left/middle/right order.
469
- function heldButton(buttons: number): number | null {
470
- for (const button of [0, 1, 2]) {
471
- if ((buttons & (BUTTONS_BIT[button] ?? 0)) !== 0 && pressed.has(button)) {
472
- return button;
385
+ function onMouseUp(e: MouseEvent): void {
386
+ lastButtons = e.buttons;
387
+ if (trackingOff() || !handler) {
388
+ return;
389
+ }
390
+ const bypassed = shiftBypass;
391
+ shiftBypass = false;
392
+ cancelPendingMotion();
393
+ const pos = pixelToCell(e);
394
+ if (!pos) {
395
+ // The record SURVIVES a release that could not be reported. `pressed` is what
396
+ // the application believes is held, and it was told nothing here, so
397
+ // resyncGesture must still find the button and cancel it on the next open.
398
+ return;
399
+ }
400
+ if (pressed.has(e.button)) {
401
+ const btn = domButtonToXterm(e.button);
402
+ const code = buttonCode(btn, false, e.shiftKey, e.altKey, e.ctrlKey);
403
+ if (handler.sendReport(encodeSGR(code, pos.col, pos.row, true))) {
404
+ pressed.delete(e.button); // only a DELIVERED release ends the gesture
405
+ }
473
406
  }
407
+ if (bypassed) {
408
+ // The Shift-initiated gesture is the browser's (native selection), so its
409
+ // own default is left alone; a button recorded BEFORE the bypass began
410
+ // belongs to a delivered gesture and is still owed the release above.
411
+ return;
412
+ }
413
+ lastMotion = ""; // gesture over: next motion always reports
414
+ e.preventDefault();
474
415
  }
475
- return null;
476
- }
477
416
 
478
- function onMouseMove(e: MouseEvent): void {
479
- lastButtons = e.buttons;
480
- if (trackingOff() || !handler) {
481
- return;
482
- }
483
- const mode = getMouseMode();
484
- if (shiftBypass && e.buttons) {
485
- return; // a Shift-initiated drag is a native selection; don't report it
486
- }
487
- // mode 1000: no motion events
488
- // mode 1002: motion only while button held (drag)
489
- // mode 1003: all motion events
490
- if (mode === 1000) {
491
- return;
492
- }
493
- // A button the application never saw pressed is not held for reporting
494
- // purposes: a drag whose press was refused (or began off the element) would
495
- // otherwise start a drag the pairing rule above then refuses to end.
496
- const held = heldButton(e.buttons);
497
- if (mode === 1002 && held === null) {
498
- return;
499
- }
500
- const pos = pixelToCell(e);
501
- if (!pos) {
502
- return;
417
+ // The button to report as held: the highest-priority DOM button that is both
418
+ // down now and recorded as delivered, or null when the application believes none
419
+ // is. Priority matches the DOM's own left/middle/right order.
420
+ function heldButton(buttons: number): number | null {
421
+ for (const button of [0, 1, 2]) {
422
+ if ((buttons & (BUTTONS_BIT[button] ?? 0)) !== 0 && pressed.has(button)) {
423
+ return button;
424
+ }
425
+ }
426
+ return null;
503
427
  }
504
- // No button held (bare motion in mode 1003) reports xterm's "no button"
505
- // code 3, not 0 — code 0 would be read as a left-button drag during hover.
506
- const code = buttonCode(held ?? 3, true, e.shiftKey, e.altKey, e.ctrlKey);
507
- const seq = encodeSGR(code, pos.col, pos.row, false);
508
- if (seq === lastMotion) {
509
- return; // same cell, same buttons/modifiers: suppress the duplicate report
428
+
429
+ function onMouseMove(e: MouseEvent): void {
430
+ lastButtons = e.buttons;
431
+ if (trackingOff() || !handler) {
432
+ return;
433
+ }
434
+ const mode = modes.getMouseMode();
435
+ if (shiftBypass && e.buttons) {
436
+ return; // a Shift-initiated drag is a native selection; don't report it
437
+ }
438
+ // mode 1000: no motion events
439
+ // mode 1002: motion only while button held (drag)
440
+ // mode 1003: all motion events
441
+ if (mode === 1000) {
442
+ return;
443
+ }
444
+ // A button the application never saw pressed is not held for reporting
445
+ // purposes: a drag whose press was refused (or began off the element) would
446
+ // otherwise start a drag the pairing rule above then refuses to end.
447
+ const held = heldButton(e.buttons);
448
+ if (mode === 1002 && held === null) {
449
+ return;
450
+ }
451
+ const pos = pixelToCell(e);
452
+ if (!pos) {
453
+ return;
454
+ }
455
+ // No button held (bare motion in mode 1003) reports xterm's "no button"
456
+ // code 3, not 0 — code 0 would be read as a left-button drag during hover.
457
+ const code = buttonCode(held ?? 3, true, e.shiftKey, e.altKey, e.ctrlKey);
458
+ const seq = encodeSGR(code, pos.col, pos.row, false);
459
+ if (seq === lastMotion) {
460
+ return; // same cell, same buttons/modifiers: suppress the duplicate report
461
+ }
462
+ lastMotion = seq;
463
+ // Coalesce MOTION to one report per animation frame, latest wins. Under DEC
464
+ // 1016 the cell dedup above does nothing, so pixel mode would otherwise emit
465
+ // one report per DOM mousemove.
466
+ pendingMotion = seq;
467
+ motionFrame ??= requestAnimationFrame(flushMotion);
510
468
  }
511
- lastMotion = seq;
512
- // Coalesce MOTION to one report per animation frame, latest wins. Under DEC
513
- // 1016 the cell dedup above does nothing, so pixel mode would otherwise emit
514
- // one report per DOM mousemove.
515
- pendingMotion = seq;
516
- motionFrame ??= requestAnimationFrame(flushMotion);
517
- }
518
469
 
519
- function onWheel(e: WheelEvent): void {
520
- if (trackingOff() || !handler) {
521
- return;
470
+ function onWheel(e: WheelEvent): void {
471
+ if (trackingOff() || !handler) {
472
+ return;
473
+ }
474
+ const pos = pixelToCell(e);
475
+ if (!pos) {
476
+ return;
477
+ }
478
+ cancelPendingMotion();
479
+ // A gesture with NO vertical component (a horizontal swipe, shift-wheel
480
+ // routed to deltaX) has no wheel button: xterm.js reports nothing and still
481
+ // calls preventDefault, since a terminal owns the wheel and a sideways swipe
482
+ // let through would scroll the page. A wheel report is never coalesced: it
483
+ // is a NOTCH COUNT against a screen, so dropping one loses scroll distance.
484
+ if (e.deltaY !== 0) {
485
+ const btn = e.deltaY < 0 ? 64 : 65;
486
+ const code = buttonCode(btn, false, e.shiftKey, e.altKey, e.ctrlKey);
487
+ handler.sendReport(encodeSGR(code, pos.col, pos.row, false));
488
+ }
489
+ e.preventDefault();
522
490
  }
523
- const pos = pixelToCell(e);
524
- if (!pos) {
525
- return;
491
+
492
+ function dispose(): void {
493
+ detach();
494
+ handler = null;
495
+ shiftBypass = false;
496
+ lastMotion = "";
497
+ clearGesture();
498
+ cancelPendingMotion();
526
499
  }
527
- cancelPendingMotion();
528
- // Wheel up = button 64, wheel down = button 65. A gesture with NO vertical
529
- // component — a horizontal trackpad swipe, or shift-wheel where the browser
530
- // routes the delta to deltaX — has no wheel button to report: xterm.js's
531
- // reference encoder returns before encoding when the vertical delta is zero,
532
- // while its wheel handler still calls preventDefault unconditionally. So
533
- // parity is "report nothing, still swallow the gesture": a terminal owns the
534
- // wheel, and letting a sideways swipe through would scroll the page instead.
535
- // Reporting 65 here (the pre-fix behavior, `deltaY < 0 ? 64 : 65` with no
536
- // zero case) scrolled the TUI DOWN on a sideways gesture.
537
- //
538
- // A wheel report is never coalesced: it is a NOTCH COUNT against a screen, so
539
- // dropping one loses scroll distance — which is why xterm.js expands a single
540
- // WheelEvent into N discrete reports.
541
- if (e.deltaY !== 0) {
542
- const btn = e.deltaY < 0 ? 64 : 65;
543
- const code = buttonCode(btn, false, e.shiftKey, e.altKey, e.ctrlKey);
544
- handler.sendReport(encodeSGR(code, pos.col, pos.row, false));
500
+
501
+ const el = opts.termElement();
502
+ attachedEl = el;
503
+ try {
504
+ el.addEventListener("mousedown", onMouseDown);
505
+ el.addEventListener("mouseup", onMouseUp);
506
+ el.addEventListener("mousemove", onMouseMove);
507
+ el.addEventListener("wheel", onWheel, { passive: false });
508
+ } catch (err) {
509
+ dispose();
510
+ throw err;
545
511
  }
546
- e.preventDefault();
512
+
513
+ return { resyncGesture, disarmGesture, dispose };
547
514
  }