@cplieger/web-terminal-engine 5.0.8 → 5.1.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 +13 -5
- package/jsr.json +1 -1
- package/package.json +9 -7
- package/src/connection.ts +154 -12
- package/src/mouse.ts +309 -58
- package/src/render.ts +51 -10
- package/src/types.ts +31 -0
- package/src/wire-binary.ts +12 -1
package/README.md
CHANGED
|
@@ -32,9 +32,17 @@ const out = document.getElementById("term-output") as HTMLElement;
|
|
|
32
32
|
render.init({ output: out, termWrap: wrap }); // optional: maxLines (retained-line cap, default 5000)
|
|
33
33
|
scroll.init({ scrollEl: wrap });
|
|
34
34
|
mouse.init({
|
|
35
|
-
|
|
36
|
-
|
|
35
|
+
// Best-effort input: return whether the report reached a live socket, so a
|
|
36
|
+
// refused one is forgotten instead of retried. With the connection module this
|
|
37
|
+
// is `connection.sendEphemeral`.
|
|
38
|
+
sendReport: (data) => {
|
|
39
|
+
ws.send(data);
|
|
40
|
+
return true;
|
|
41
|
+
},
|
|
42
|
+
cellSize: render.cellSize,
|
|
37
43
|
termElement: () => wrap,
|
|
44
|
+
gridElement: () => out, // the rows' own box: no padding, no scrollbar gutter
|
|
45
|
+
gridSize: render.gridSize, // the RENDERED grid, not the size this client would like
|
|
38
46
|
});
|
|
39
47
|
|
|
40
48
|
ws.binaryType = "arraybuffer";
|
|
@@ -77,15 +85,15 @@ document.addEventListener("keydown", (ev) => {
|
|
|
77
85
|
|
|
78
86
|
## API
|
|
79
87
|
|
|
80
|
-
- **`render`** — DOM renderer driven by `ScreenMessage` / `ScrollMessage` frames. `init` (accepts `maxLines`, the retained-line cap — memory-constrained consumers pass a smaller budget; history above the cap is evicted from the top in batches, and the live screen is never evicted, so a cap at or below the terminal height keeps the full screen with no scrollback), `handleScreen`, `handleScroll`, `updateFontMetrics`, `computeSize`, `getCursorPx`, `setPredictedCursor`, `resetScreen`, `resetScrollback`, `getHighestIndex`, `noteResumeBounds`, `updateReverseVideo`.
|
|
88
|
+
- **`render`** — DOM renderer driven by `ScreenMessage` / `ScrollMessage` frames. `init` (accepts `maxLines`, the retained-line cap — memory-constrained consumers pass a smaller budget; history above the cap is evicted from the top in batches, and the live screen is never evicted, so a cap at or below the terminal height keeps the full screen with no scrollback), `handleScreen`, `handleScroll`, `updateFontMetrics`, `computeSize`, `cellSize`, `gridSize`, `getCursorPx`, `setPredictedCursor`, `resetScreen`, `resetScrollback`, `getHighestIndex`, `noteResumeBounds`, `updateReverseVideo`.
|
|
81
89
|
- **`keyboard`** — Translates `KeyboardEvent` to terminal byte sequences. `mapKeyboardEvent`, `bracketTextForPaste`, `prepareTextForTerminal`, `ctrlByteFor`, plus the shared logical-key encodings (`plainCursorKeySeq`, `plainEscapeSeq`) the toolbar module reuses. Honors `applicationCursor`, `applicationKeypad`, `bracketedPaste`.
|
|
82
90
|
- **`toolbar`** — On-screen mobile toolbar widget (moved out of `keyboard` in v3). `bindMobileToolbar({toolbar, send, ids?})` wires `pointerdown` handlers for an on-screen Ctrl/arrows/Tab/Enter/Esc toolbar (with sticky-Ctrl semantics and kitty/DECCKM-aware arrows byte-identical to the physical-key path), returning a `MobileToolbarController` exposing `applyStickyCtrl`, `setCtrlArmed`, `isCtrlArmed`, and `dispose`.
|
|
83
|
-
- **`mouse`** — SGR 1006 mouse
|
|
91
|
+
- **`mouse`** — SGR 1006 mouse encoder. `init`, `encodeSGR`, `resyncGesture`, `disarmGesture`, `MouseInputHandler`. Auto-gates on `mouseMode > 0`. `MouseInputHandler` carries two optional members, both defaulting to today's behaviour: `gridElement` names the element whose box IS the grid (the scroll container's own box includes its padding and scrollbar gutter, so it is not that box), and `gridSize` supplies the dimensions of the grid ON SCREEN — `render.gridSize`, not the size this client measured — so every report is clamped into it and rows are measured up from the bottom edge (the screen window is the tail of the content). Its required `sendReport(data)` returns whether the report reached a live socket; wire it to `connection.sendEphemeral`. Four consequences of mouse input being best-effort: a release is emitted only for a press that was DELIVERED (a release whose press never arrived is its own desync, because the application never learned the button went down); a drag reports a held button only while the application believes that button is down, so motion for an undelivered gesture is dropped under mode 1002 and reports as no-button under 1003; `resyncGesture()`, called once the resumeAck has declared the server's capabilities (not from `onOpen`, where the ephemeral channel is not yet known, so the release would take the reliable fallback), emits one release for any button the last reading says is now up, so a drag is not left pressed across a reconnect; and motion is coalesced to one report per animation frame, latest wins, while press, release and wheel are never coalesced (a wheel report is a notch count against a screen, so coalescing loses scroll distance). `resyncGesture` shares one gate with the handlers, and each of them enforces it: a gesture stranded by the application turning tracking off is FORGOTTEN by whichever one next observes tracking off, because a release nothing asked for reaches the PTY as keystrokes. tmux answers the same way at attach, clearing `mouse_drag_flag` as it disables the modes. `disarmGesture()` forgets a held gesture without reporting anything — call it when pointing one terminal's socket at another session, since the record belongs to the session that saw the press. Focus reporting is NOT here: it is transport state, and it lives in `connection.setClientFocus`.
|
|
84
92
|
- **`scroll`** — Auto-follow tracker for the scroll container. `init`, `stickToBottom`, `scrollToBottom`, `isUserScrolledUp`, `currentScrollTop`, `restoreView`, `adjustForContentShift`, plus two renderer seams: `noteContentShrink(scrollTopBefore)` says a row removal rather than a gesture caused the scroll event about to arrive, and `reconcileScrollRange()` moves the offset back inside the container's range when the container did not reconcile the shrink itself. The second exists because clamping an offset the content shrank out from under is an implementation behaviour and not a specified one: Blink and Gecko reconcile during layout, WebKit does not, and without the correction an iOS viewport is left parked past the end of the content after an application clears the screen, showing background with the content above it until the reader scrolls.
|
|
85
93
|
- **`modes`** — DEC private mode state (synced from server's `ModesMessage`). `setModes`, `isBracketedPaste`, `isApplicationCursor`, `getMouseMode`, `isMouseSGR`, `isFocusReporting`, `isApplicationKeypad`, `isReverseVideo`.
|
|
86
94
|
- **`decodeWireBinary(buf)`**: Top-level decoder for the binary WebSocket frames. Returns a `ServerMessage` or `null` for invalid/truncated frames.
|
|
87
95
|
- **Wire compatibility metadata**: `WIRE_PROTOCOL_VERSION`, `MIN_SUPPORTED_SERVER_WIRE_VERSION`, `WIRE_INCOMPATIBLE_CLOSE_CODE`, and `WIRE_COMPATIBILITY` publish this client release's directional contract. The same values ship as a language-neutral JSON artifact for non-TypeScript consumers — see [Wire compatibility manifest](#wire-compatibility-manifest).
|
|
88
|
-
- **`connection`**: Client → server WebSocket lifecycle: owns the socket, exponential-backoff reconnect, and the resume/inputAck reliability layer (outbox + server-restart detection). `init(callbacks)`, `connect`, `sendBinary(bytes)`, `sendResize`, `reconnectNow`. The callbacks expose `onMessage(ServerMessage)`, `onOpen`/`onClose`/`onConnecting`/`onOutboxFull`/`onServerRestart`, `onWireVersionMismatch`, and the definitive `onWireIncompatible`; a `computeSize()` provider; and an optional `wsPath` (defaults to `"/ws"`). An explicit below-floor server revision or close code 4002 stops automatic reconnects until `disconnect()` or a page reload. Version-silent and future-revision servers remain tolerated. The module decodes frames internally and applies `modes.setModes`, so a consumer only needs to dispatch screen/scroll to `render`. Prefer this over wiring `WebSocket` + `decodeWireBinary` by hand unless you need full control.
|
|
96
|
+
- **`connection`**: Client → server WebSocket lifecycle: owns the socket, exponential-backoff reconnect, and the resume/inputAck reliability layer (outbox + server-restart detection). `init(callbacks)`, `connect`, `sendBinary(bytes)`, `sendEphemeral(text)`, `setClientFocus(focused)`, `sendResize`, `reconnectNow`. `sendEphemeral` carries BEST-EFFORT input (mouse reports): it sends only when a socket is live right now, returns whether it did, and never touches the outbox or the byte counters, so a report is forgotten rather than replayed against a screen the resume has since repainted. Against a server that declares neither capability it falls back to `sendBinary`, because an unrecognized control is dropped post-latch and total silence is a worse failure than a stale report — on that path the report rides the reliable outbox and can be retransmitted after a reconnect. `setClientFocus` reports the terminal widget's focus: it sends the `focus` control when the server declared it derives the DEC 1004 answer (from attachment state, every attached client's report, and its own `terminal.WithKeepUnfocused`), and otherwise writes `CSI I` / `CSI O` itself while the application has 1004 enabled — including on the enable edge, so an application that turns focus reporting on while the tab is already focused is told so immediately. The callbacks expose `onMessage(ServerMessage)`, `onOpen`/`onClose`/`onConnecting`/`onOutboxFull`/`onServerRestart`, `onWireVersionMismatch`, and the definitive `onWireIncompatible`; a `computeSize()` provider; and an optional `wsPath` (defaults to `"/ws"`). An explicit below-floor server revision or close code 4002 stops automatic reconnects until `disconnect()` or a page reload. Version-silent and future-revision servers remain tolerated. The module decodes frames internally and applies `modes.setModes`, so a consumer only needs to dispatch screen/scroll to `render`. Prefer this over wiring `WebSocket` + `decodeWireBinary` by hand unless you need full control.
|
|
89
97
|
- **`controlFrame(msg)` / `wsURL(proto, host, path?)`** — Low-level helpers for the client → server protocol (0x00-prefixed JSON control frames, WebSocket URL building). Used internally by `connection`; exported for advanced consumers.
|
|
90
98
|
|
|
91
99
|
Wire types (`WireRun`, `ScreenMessage`, `ScrollMessage`, `ModesMessage`, `TitleMessage`, `ResumeAckMessage`, `ServerMessage`, `ControlMessage`) are re-exported from the package root and match the Go server's wire format byte-for-byte.
|
package/jsr.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cplieger/web-terminal-engine",
|
|
3
|
-
"version": "5.0
|
|
3
|
+
"version": "5.1.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"typecheck": "tsc -p tsconfig.json",
|
|
@@ -11,22 +11,24 @@
|
|
|
11
11
|
"devDependencies": {
|
|
12
12
|
"@cplieger/web-terminal-engine-v3": "npm:@cplieger/web-terminal-engine@2.8.0",
|
|
13
13
|
"@eslint/js": "10.0.1",
|
|
14
|
-
"@playwright/test": "1.
|
|
14
|
+
"@playwright/test": "1.63.0",
|
|
15
15
|
"@stryker-mutator/core": "10.0.0",
|
|
16
16
|
"@stryker-mutator/typescript-checker": "10.0.0",
|
|
17
17
|
"@stryker-mutator/vitest-runner": "10.0.0",
|
|
18
18
|
"@types/pngjs": "6.0.5",
|
|
19
19
|
"@typescript/native": "npm:typescript@7.0.2",
|
|
20
|
-
"@vitest/
|
|
20
|
+
"@vitest/browser": "5.0.0",
|
|
21
|
+
"@vitest/browser-playwright": "5.0.0",
|
|
22
|
+
"@vitest/coverage-v8": "5.0.0",
|
|
21
23
|
"esbuild": "0.28.2",
|
|
22
|
-
"eslint": "10.
|
|
24
|
+
"eslint": "10.10.0",
|
|
23
25
|
"fast-check": "4.9.0",
|
|
24
|
-
"
|
|
26
|
+
"playwright": "1.63.0",
|
|
25
27
|
"pngjs": "7.0.0",
|
|
26
28
|
"prettier": "3.9.6",
|
|
27
29
|
"typescript": "npm:@typescript/typescript6@6.0.2",
|
|
28
|
-
"typescript-eslint": "8.
|
|
29
|
-
"vitest": "
|
|
30
|
+
"typescript-eslint": "8.70.0",
|
|
31
|
+
"vitest": "5.0.0"
|
|
30
32
|
},
|
|
31
33
|
"overrides": {
|
|
32
34
|
"qs": "^6.15.2"
|
package/src/connection.ts
CHANGED
|
@@ -43,6 +43,7 @@ import * as modes from "./modes.js";
|
|
|
43
43
|
// end of the gap fills.
|
|
44
44
|
import { PAGE_SIZE } from "./store.js";
|
|
45
45
|
import * as render from "./render.js";
|
|
46
|
+
import * as mouse from "./mouse.js";
|
|
46
47
|
import type { ControlMessage, ScrollMessage, ServerMessage } from "./types.js";
|
|
47
48
|
import { INITIAL_DELAY_MS, nextBackoffDelay } from "./reconnect.js";
|
|
48
49
|
|
|
@@ -304,6 +305,48 @@ function newHistoryState(): HistoryState {
|
|
|
304
305
|
|
|
305
306
|
let history: HistoryState = newHistoryState();
|
|
306
307
|
|
|
308
|
+
/**
|
|
309
|
+
* The capabilities the server DECLARED on this socket's resume ack. Per-socket
|
|
310
|
+
* for the same reason as HistoryState: a carried-over capability would send a
|
|
311
|
+
* control to a server that never declared it, and post-latch an unrecognized
|
|
312
|
+
* text control is tolerated and DROPPED — so the message would vanish silently.
|
|
313
|
+
*/
|
|
314
|
+
interface ServerCaps {
|
|
315
|
+
/** ackFlags bit3: the server serves the `ephemeralInput` control. */
|
|
316
|
+
ephemeralInput: boolean;
|
|
317
|
+
/** ackFlags bit2: the server derives the DEC 1004 answer from `focus`. */
|
|
318
|
+
serverFocus: boolean;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
let caps: ServerCaps = { ephemeralInput: false, serverFocus: false };
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Whether the consumer's terminal widget is focused. Per PAGE, not per socket:
|
|
325
|
+
* it is the widget's own state and must survive a reconnect, because the new
|
|
326
|
+
* socket has to be told the CURRENT value.
|
|
327
|
+
*/
|
|
328
|
+
let clientFocused = false;
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* The focus value THIS socket has been told, or null when it has been told
|
|
332
|
+
* nothing. Per socket, so a reconnect re-asserts focus instead of suppressing it
|
|
333
|
+
* as a repeat.
|
|
334
|
+
*/
|
|
335
|
+
let focusReported: boolean | null = null;
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* The 1004 state last seen in a modes frame on this socket, for the fallback
|
|
339
|
+
* path's enable-edge detection. Per socket: a fresh socket is re-announced its
|
|
340
|
+
* modes, and the edge has to be detected against nothing rather than against the
|
|
341
|
+
* previous socket's last value.
|
|
342
|
+
*/
|
|
343
|
+
let lastFocusReportingSeen = false;
|
|
344
|
+
|
|
345
|
+
/** WebSocket.OPEN. Spelled out because a test's fake constructor has no statics. */
|
|
346
|
+
const WS_OPEN = 1;
|
|
347
|
+
|
|
348
|
+
const ephemeralEncoder = new TextEncoder();
|
|
349
|
+
|
|
307
350
|
/** Cancel both timers and release single-flight. */
|
|
308
351
|
function clearHistoryTimers(): void {
|
|
309
352
|
if (history.dataTimer !== null) {
|
|
@@ -317,14 +360,19 @@ function clearHistoryTimers(): void {
|
|
|
317
360
|
}
|
|
318
361
|
|
|
319
362
|
/**
|
|
320
|
-
* Reset
|
|
321
|
-
* the adaptive budget
|
|
322
|
-
*
|
|
323
|
-
*
|
|
363
|
+
* Reset everything a new socket must not inherit: the fetch state (capability,
|
|
364
|
+
* single-flight, pacing and the adaptive budget), the declared server
|
|
365
|
+
* capabilities, and the focus reporting latches. All of it goes together,
|
|
366
|
+
* atomically. Splitting them is how a client ends up paging against a server
|
|
367
|
+
* that never declared it, bursting into a depleted server bucket, or holding a
|
|
368
|
+
* focus report back from the new socket as a repeat of what the old one was told.
|
|
324
369
|
*/
|
|
325
|
-
function
|
|
370
|
+
function resetForNewSocket(): void {
|
|
326
371
|
clearHistoryTimers();
|
|
327
372
|
history = newHistoryState();
|
|
373
|
+
caps = { ephemeralInput: false, serverFocus: false };
|
|
374
|
+
focusReported = null;
|
|
375
|
+
lastFocusReportingSeen = false;
|
|
328
376
|
}
|
|
329
377
|
|
|
330
378
|
/**
|
|
@@ -642,6 +690,74 @@ function textControl(msg: ControlMessage): string {
|
|
|
642
690
|
return JSON.stringify(msg);
|
|
643
691
|
}
|
|
644
692
|
|
|
693
|
+
/**
|
|
694
|
+
* sendEphemeral delivers BEST-EFFORT input. Where the server declared
|
|
695
|
+
* `ephemeralInput` it sends only on a live socket, never queues, never
|
|
696
|
+
* retransmits, and never touches `outbox`, `outboxBytes`, `bytesSent`,
|
|
697
|
+
* `bytesAcked` or `onOutboxFull`; where it did NOT, the report falls back to
|
|
698
|
+
* reliable input, so it can be retransmitted after a resume and describe a
|
|
699
|
+
* screen since repainted. Returns whether it was sent; on false the caller's
|
|
700
|
+
* contract is to FORGET the event, because a mouse report carries no sequence
|
|
701
|
+
* number. Refusing on a closing socket is what xterm.js's AttachAddon and ttyd
|
|
702
|
+
* both do.
|
|
703
|
+
*/
|
|
704
|
+
export function sendEphemeral(text: string): boolean {
|
|
705
|
+
if (connState.status !== "connected" || connState.sock.readyState !== WS_OPEN) {
|
|
706
|
+
return false;
|
|
707
|
+
}
|
|
708
|
+
if (!connState.upgraded || !caps.ephemeralInput) {
|
|
709
|
+
// Fall back to RELIABLE input rather than dropping the report. An
|
|
710
|
+
// unrecognized text control is tolerated and dropped post-latch, so the
|
|
711
|
+
// report would vanish entirely, and total silence is a worse failure than a
|
|
712
|
+
// stale report: a third-party or mid-upgrade server keeps a working mouse.
|
|
713
|
+
// What is DEGRADED on this path: the report rides the reliable outbox, so a
|
|
714
|
+
// click made before a disconnect is retransmitted after the resume has
|
|
715
|
+
// repainted the screen.
|
|
716
|
+
return sendBinary(ephemeralEncoder.encode(text));
|
|
717
|
+
}
|
|
718
|
+
connState.sock.send(textControl({ type: "ephemeralInput", data: text }));
|
|
719
|
+
return true;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/**
|
|
723
|
+
* setClientFocus reports whether the consumer's terminal widget is focused.
|
|
724
|
+
*
|
|
725
|
+
* Focus is STATE, so it is reconciled rather than replayed: the latest value
|
|
726
|
+
* wins, a repeat of the value already reported sends nothing, a report made
|
|
727
|
+
* while no socket is live is dropped rather than queued, and a reconnect
|
|
728
|
+
* re-asserts the current one. Where the server declared `serverFocus` this sends
|
|
729
|
+
* the `focus` control and the server derives the DEC 1004 answer for the whole
|
|
730
|
+
* session; otherwise the client writes the legacy CSI I / CSI O itself, and only
|
|
731
|
+
* while the application has 1004 enabled.
|
|
732
|
+
*/
|
|
733
|
+
export function setClientFocus(focused: boolean): void {
|
|
734
|
+
clientFocused = focused;
|
|
735
|
+
if (connState.status !== "connected") {
|
|
736
|
+
// A focus report is never QUEUED. The fallback path below writes through the
|
|
737
|
+
// RELIABLE outbox, so a report made while the socket is down would be
|
|
738
|
+
// retransmitted on reconnect and re-assert a focus the widget may since have
|
|
739
|
+
// lost, which is the replay this whole derivation exists to remove. Nothing
|
|
740
|
+
// is lost by dropping it: both the resumeAck and the 1004 enable edge
|
|
741
|
+
// re-report the current value on the new socket.
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
if (focused === focusReported) {
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
focusReported = focused;
|
|
748
|
+
if (caps.serverFocus && connState.upgraded) {
|
|
749
|
+
sendControl({ type: "focus", focused });
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
if (!modes.isFocusReporting()) {
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
// The legacy path, and it rides the RELIABLE outbox: a focus report can be
|
|
756
|
+
// retransmitted after a blip, re-asserting a state that may no longer be true.
|
|
757
|
+
// That is one of the things the server-owned path fixes.
|
|
758
|
+
sendBinary(ephemeralEncoder.encode(focused ? "\x1b[I" : "\x1b[O"));
|
|
759
|
+
}
|
|
760
|
+
|
|
645
761
|
function sendControl(msg: ControlMessage): void {
|
|
646
762
|
if (connState.status !== "connected") {
|
|
647
763
|
return;
|
|
@@ -700,10 +816,6 @@ export function sendResize(): void {
|
|
|
700
816
|
sendControl({ type: "resize", cols, rows });
|
|
701
817
|
}
|
|
702
818
|
|
|
703
|
-
// resetLedger drops a session's reliable-input accounting. Called when the
|
|
704
|
-
// server's boot epoch changes (restart) or when the server no longer recognizes
|
|
705
|
-
// the session -- both invalidate the local bytesSent/bytesAcked/outbox state.
|
|
706
|
-
// The three entry points below differ only in whether the UI hears about it.
|
|
707
819
|
// resetLedger drops a session's reliable-input accounting. Called whenever the
|
|
708
820
|
// server no longer holds the ledger these counters were keyed to (a restart, an
|
|
709
821
|
// idle-GC'd resume key, a session the server does not recognize): its
|
|
@@ -885,7 +997,7 @@ function teardown(): void {
|
|
|
885
997
|
}
|
|
886
998
|
}
|
|
887
999
|
stopHeartbeat();
|
|
888
|
-
|
|
1000
|
+
resetForNewSocket();
|
|
889
1001
|
cb?.clearSolicited?.();
|
|
890
1002
|
cancelScheduledReconnect();
|
|
891
1003
|
connState = { status: "disconnected" };
|
|
@@ -1341,7 +1453,7 @@ export function connect(): void {
|
|
|
1341
1453
|
// exact values, so the replay-jump prediction must be computed from them
|
|
1342
1454
|
// — never from store state, which a frame arriving between send and ack
|
|
1343
1455
|
// could have moved, masking a real jump (docs/paged-scrollback.md §4.5).
|
|
1344
|
-
|
|
1456
|
+
resetForNewSocket();
|
|
1345
1457
|
const sentHaveThrough = cb?.getHaveThrough?.() ?? -1;
|
|
1346
1458
|
// ALWAYS a number. A consumer may ask for fewer lines than the protocol
|
|
1347
1459
|
// ceiling, but it cannot opt OUT of the bound: the server clamps to the
|
|
@@ -1555,6 +1667,18 @@ export function connect(): void {
|
|
|
1555
1667
|
// nothing ever sent, and the store keeps its compatibility tail cap.
|
|
1556
1668
|
history.paging = msg.historyPaging === true;
|
|
1557
1669
|
history.acked = true;
|
|
1670
|
+
// The other two capabilities off the same tail, and the focus re-report
|
|
1671
|
+
// for this socket. Placed here — with the capability handling, BEFORE the
|
|
1672
|
+
// ledger-lost / session-forgotten early returns — for the same reason
|
|
1673
|
+
// onResumeTransition is: a ledger loss is not a capability event, and the
|
|
1674
|
+
// long-absence attach that loses its ledger still needs its focus state
|
|
1675
|
+
// asserted on the new socket.
|
|
1676
|
+
caps = {
|
|
1677
|
+
ephemeralInput: msg.ephemeralInput === true,
|
|
1678
|
+
serverFocus: msg.serverFocus === true,
|
|
1679
|
+
};
|
|
1680
|
+
focusReported = null;
|
|
1681
|
+
setClientFocus(clientFocused);
|
|
1558
1682
|
// The store's ONE ack transition, dispatched BEFORE the early returns
|
|
1559
1683
|
// below. A ledger loss is not a capability event, and the long-absence
|
|
1560
1684
|
// attach that loses its ledger is exactly the one carrying a replay jump
|
|
@@ -1608,6 +1732,12 @@ export function connect(): void {
|
|
|
1608
1732
|
connState.status === "connected" && connState.sock === sock && connState.upgraded,
|
|
1609
1733
|
st,
|
|
1610
1734
|
);
|
|
1735
|
+
// The transport answers for itself here, as it does for focus: the resume is
|
|
1736
|
+
// complete, `upgraded` and `caps` are both set, so this is the first instant
|
|
1737
|
+
// a best-effort report can leave. A gesture still recorded from before the
|
|
1738
|
+
// gap is cancelled now, or the application holds that button for the rest of
|
|
1739
|
+
// the session. Inert until a consumer has called `mouse.init`.
|
|
1740
|
+
mouse.resyncGesture();
|
|
1611
1741
|
return;
|
|
1612
1742
|
}
|
|
1613
1743
|
if (msg.type === "ackOnly") {
|
|
@@ -1636,6 +1766,18 @@ export function connect(): void {
|
|
|
1636
1766
|
};
|
|
1637
1767
|
st.modes = snap;
|
|
1638
1768
|
modes.applySnapshot(snap);
|
|
1769
|
+
if (!caps.serverFocus) {
|
|
1770
|
+
// The 1004 ENABLE edge on the fallback path. An application that enables
|
|
1771
|
+
// focus reporting while the tab is already focused otherwise gets nothing
|
|
1772
|
+
// until the user clicks away; xterm.js's _reportFocus reports the current
|
|
1773
|
+
// state at that instant, and this client did not.
|
|
1774
|
+
const rising = msg.focusReporting && !lastFocusReportingSeen;
|
|
1775
|
+
lastFocusReportingSeen = msg.focusReporting;
|
|
1776
|
+
if (rising) {
|
|
1777
|
+
focusReported = null;
|
|
1778
|
+
setClientFocus(clientFocused);
|
|
1779
|
+
}
|
|
1780
|
+
}
|
|
1639
1781
|
if (typeof msg.inputAck === "number") {
|
|
1640
1782
|
applyAck(st, msg.inputAck);
|
|
1641
1783
|
}
|
|
@@ -1719,7 +1861,7 @@ export function connect(): void {
|
|
|
1719
1861
|
// The fetch state belonged to THIS socket: kill its timers so neither
|
|
1720
1862
|
// fires against a dead socket, and release the solicited window so the
|
|
1721
1863
|
// store stops admitting lines below its stale-re-send watermark.
|
|
1722
|
-
|
|
1864
|
+
resetForNewSocket();
|
|
1723
1865
|
cb?.clearSolicited?.();
|
|
1724
1866
|
if (ev.code === WIRE_INCOMPATIBLE_CLOSE_CODE) {
|
|
1725
1867
|
const reason =
|
package/src/mouse.ts
CHANGED
|
@@ -7,14 +7,18 @@
|
|
|
7
7
|
// 0=left, 1=middle, 2=right, 64=wheel-up, 65=wheel-down
|
|
8
8
|
// +4=shift, +8=alt, +16=ctrl, +32=motion
|
|
9
9
|
//
|
|
10
|
-
//
|
|
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`.
|
|
11
15
|
//
|
|
12
16
|
// Out-of-scope (consumed but not implemented):
|
|
13
17
|
// - X10 mouse mode (mode 9): not supported.
|
|
14
18
|
// - urxvt encoding (mode 1015): not supported.
|
|
15
19
|
// - DEFAULT encoding (raw bytes): not supported; only SGR 1006.
|
|
16
20
|
|
|
17
|
-
import { getMouseMode, isMouseSGR, isMousePixels
|
|
21
|
+
import { getMouseMode, isMouseSGR, isMousePixels } from "./modes.js";
|
|
18
22
|
|
|
19
23
|
const ESC = "\x1b";
|
|
20
24
|
|
|
@@ -75,12 +79,32 @@ function domButtonToXterm(button: number): number {
|
|
|
75
79
|
* and access the terminal DOM element to attach listeners to.
|
|
76
80
|
*/
|
|
77
81
|
export interface MouseInputHandler {
|
|
78
|
-
/**
|
|
79
|
-
|
|
82
|
+
/**
|
|
83
|
+
* Sends one encoded report as best-effort input. Returns whether it reached a
|
|
84
|
+
* live socket; `false` means it was NOT sent and must not be retried (see
|
|
85
|
+
* `connection.sendEphemeral`).
|
|
86
|
+
*/
|
|
87
|
+
sendReport: (data: string) => boolean;
|
|
80
88
|
/** Returns the current cell pixel size for hit-testing pointer coordinates. */
|
|
81
89
|
cellSize: () => { width: number; height: number };
|
|
82
90
|
/** Returns the terminal DOM element to attach mouse listeners to. */
|
|
83
91
|
termElement: () => HTMLElement;
|
|
92
|
+
/**
|
|
93
|
+
* Returns the element whose box IS the grid: its top-left is cell (1,1)'s
|
|
94
|
+
* origin and its bottom edge is the last row's bottom. Defaults to
|
|
95
|
+
* `termElement()`, which is only the same box when that element carries no
|
|
96
|
+
* padding and no reserved scrollbar gutter.
|
|
97
|
+
*/
|
|
98
|
+
gridElement?: () => HTMLElement;
|
|
99
|
+
/**
|
|
100
|
+
* Returns the dimensions of the grid currently on screen (the engine
|
|
101
|
+
* renderer's `gridSize`). Supplying it clamps every report into the grid and
|
|
102
|
+
* anchors the row arithmetic to the grid's bottom edge; omitting it keeps the
|
|
103
|
+
* top-anchored, unclamped hit test. Rows must be the RENDERED height, not the
|
|
104
|
+
* height this client would like: the hit test anchors on it, so a disagreement
|
|
105
|
+
* shifts every reported row rather than loosening a bound.
|
|
106
|
+
*/
|
|
107
|
+
gridSize?: () => { cols: number; rows: number };
|
|
84
108
|
}
|
|
85
109
|
|
|
86
110
|
let handler: MouseInputHandler | null = null;
|
|
@@ -101,6 +125,81 @@ let lastMotion = "";
|
|
|
101
125
|
// registrations on the SAME element, but a re-init on a NEW element would
|
|
102
126
|
// otherwise leak the old one's listeners).
|
|
103
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
|
+
// DOM MouseEvent.button index -> its bit in MouseEvent.buttons. Middle and right
|
|
142
|
+
// are SWAPPED between the two (button 1 = middle = bit 4; button 2 = right =
|
|
143
|
+
// bit 2), so `1 << button` is wrong.
|
|
144
|
+
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
|
+
|
|
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());
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Forget the held-gesture record and the button reading it is judged against.
|
|
162
|
+
function clearGesture(): void {
|
|
163
|
+
pressed.clear();
|
|
164
|
+
lastButtons = 0;
|
|
165
|
+
}
|
|
166
|
+
|
|
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
|
+
}
|
|
181
|
+
|
|
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;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function flushMotion(): void {
|
|
195
|
+
motionFrame = null;
|
|
196
|
+
const seq = pendingMotion;
|
|
197
|
+
pendingMotion = null;
|
|
198
|
+
if (seq === null || !handler) {
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
handler.sendReport(seq);
|
|
202
|
+
}
|
|
104
203
|
|
|
105
204
|
function detach(): void {
|
|
106
205
|
if (!attachedEl) {
|
|
@@ -110,14 +209,12 @@ function detach(): void {
|
|
|
110
209
|
attachedEl.removeEventListener("mouseup", onMouseUp);
|
|
111
210
|
attachedEl.removeEventListener("mousemove", onMouseMove);
|
|
112
211
|
attachedEl.removeEventListener("wheel", onWheel);
|
|
113
|
-
attachedEl.removeEventListener("focusin", onFocusIn);
|
|
114
|
-
attachedEl.removeEventListener("focusout", onFocusOut);
|
|
115
212
|
attachedEl = null;
|
|
116
213
|
}
|
|
117
214
|
|
|
118
215
|
/**
|
|
119
|
-
* Initialize the mouse module by attaching pointer/wheel
|
|
120
|
-
*
|
|
216
|
+
* Initialize the mouse module by attaching pointer/wheel listeners to the
|
|
217
|
+
* terminal element. Listeners gate on the active mouse mode + SGR 1006
|
|
121
218
|
* encoding; they are no-ops when the server hasn't enabled mouse tracking.
|
|
122
219
|
*
|
|
123
220
|
* Returns an idempotent disposer that detaches the listeners and resets the
|
|
@@ -130,14 +227,16 @@ export function init(h: MouseInputHandler): () => void {
|
|
|
130
227
|
handler = h;
|
|
131
228
|
shiftBypass = false;
|
|
132
229
|
lastMotion = "";
|
|
230
|
+
warnedDegenerateCell = false;
|
|
231
|
+
warnedDegenerateGrid = false;
|
|
232
|
+
clearGesture();
|
|
233
|
+
cancelPendingMotion();
|
|
133
234
|
const el = h.termElement();
|
|
134
235
|
attachedEl = el;
|
|
135
236
|
el.addEventListener("mousedown", onMouseDown);
|
|
136
237
|
el.addEventListener("mouseup", onMouseUp);
|
|
137
238
|
el.addEventListener("mousemove", onMouseMove);
|
|
138
239
|
el.addEventListener("wheel", onWheel, { passive: false });
|
|
139
|
-
el.addEventListener("focusin", onFocusIn);
|
|
140
|
-
el.addEventListener("focusout", onFocusOut);
|
|
141
240
|
return function dispose(): void {
|
|
142
241
|
if (attachedEl !== el) {
|
|
143
242
|
return; // superseded by a later init — nothing of ours left to detach
|
|
@@ -146,43 +245,169 @@ export function init(h: MouseInputHandler): () => void {
|
|
|
146
245
|
handler = null;
|
|
147
246
|
shiftBypass = false;
|
|
148
247
|
lastMotion = "";
|
|
248
|
+
clearGesture();
|
|
249
|
+
cancelPendingMotion();
|
|
149
250
|
};
|
|
150
251
|
}
|
|
151
252
|
|
|
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;
|
|
274
|
+
}
|
|
275
|
+
cancelPendingMotion();
|
|
276
|
+
if (trackingOff()) {
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
for (const [button, pos] of [...pressed]) {
|
|
280
|
+
if ((lastButtons & (BUTTONS_BIT[button] ?? 0)) !== 0) {
|
|
281
|
+
continue; // still held
|
|
282
|
+
}
|
|
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
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
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
|
+
|
|
152
306
|
// pixelToCell returns the coordinate pair to report for a mouse event. Normally
|
|
153
307
|
// that's the 1-based cell column/row; under DEC 1016 (SGR-pixels) it's instead
|
|
154
308
|
// the 1-based pixel offset within the terminal element (same SGR grammar, so
|
|
155
309
|
// encodeSGR is reused unchanged). The {col,row} field names carry whichever the
|
|
156
310
|
// active mode reports.
|
|
157
311
|
function pixelToCell(e: MouseEvent): { col: number; row: number } | null {
|
|
158
|
-
|
|
312
|
+
const h = handler;
|
|
313
|
+
if (!h) {
|
|
159
314
|
return null;
|
|
160
315
|
}
|
|
161
|
-
const el =
|
|
316
|
+
const el = (h.gridElement ?? h.termElement)();
|
|
162
317
|
const rect = el.getBoundingClientRect();
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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) {
|
|
167
324
|
return null;
|
|
168
325
|
}
|
|
169
|
-
|
|
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) {
|
|
335
|
+
return null;
|
|
336
|
+
}
|
|
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
|
+
};
|
|
170
347
|
}
|
|
171
|
-
const { width, height } =
|
|
348
|
+
const { width, height } = h.cellSize();
|
|
172
349
|
if (width <= 0 || height <= 0) {
|
|
350
|
+
warnUnmeasuredCell(width, height);
|
|
173
351
|
return null;
|
|
174
352
|
}
|
|
175
|
-
const
|
|
176
|
-
|
|
177
|
-
|
|
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) {
|
|
357
|
+
return null;
|
|
358
|
+
}
|
|
359
|
+
return { col: col0 + 1, row: row0 + 1 };
|
|
360
|
+
}
|
|
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
|
+
|
|
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) {
|
|
178
383
|
return null;
|
|
179
384
|
}
|
|
180
|
-
return {
|
|
385
|
+
return { width: grid.cols * width, height: grid.rows * height };
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function warnUnmeasuredCell(width: number, height: number): void {
|
|
389
|
+
if (warnedDegenerateCell) {
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
warnedDegenerateCell = true;
|
|
393
|
+
console.warn(
|
|
394
|
+
`vterm: mouse reports suppressed, cellSize() answered ${String(width)}x${String(height)}`,
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function warnUnmeasuredGrid(cols: number, rows: number): void {
|
|
399
|
+
if (warnedDegenerateGrid) {
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
warnedDegenerateGrid = true;
|
|
403
|
+
console.warn(
|
|
404
|
+
`vterm: mouse reports suppressed, gridSize() answered ${String(cols)}x${String(rows)}`,
|
|
405
|
+
);
|
|
181
406
|
}
|
|
182
407
|
|
|
183
408
|
function onMouseDown(e: MouseEvent): void {
|
|
184
|
-
|
|
185
|
-
if (
|
|
409
|
+
lastButtons = e.buttons;
|
|
410
|
+
if (trackingOff() || !handler) {
|
|
186
411
|
return;
|
|
187
412
|
}
|
|
188
413
|
if (e.shiftKey) {
|
|
@@ -196,37 +421,66 @@ function onMouseDown(e: MouseEvent): void {
|
|
|
196
421
|
if (!pos) {
|
|
197
422
|
return;
|
|
198
423
|
}
|
|
424
|
+
cancelPendingMotion();
|
|
199
425
|
const code = buttonCode(domButtonToXterm(e.button), false, e.shiftKey, e.altKey, e.ctrlKey);
|
|
200
|
-
handler.
|
|
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);
|
|
430
|
+
}
|
|
201
431
|
e.preventDefault();
|
|
202
432
|
}
|
|
203
433
|
|
|
204
434
|
function onMouseUp(e: MouseEvent): void {
|
|
205
|
-
|
|
206
|
-
if (
|
|
207
|
-
return;
|
|
208
|
-
}
|
|
209
|
-
if (shiftBypass) {
|
|
210
|
-
// Release of a Shift-initiated (native-selection) gesture: report nothing.
|
|
211
|
-
shiftBypass = false;
|
|
435
|
+
lastButtons = e.buttons;
|
|
436
|
+
if (trackingOff() || !handler) {
|
|
212
437
|
return;
|
|
213
438
|
}
|
|
439
|
+
const bypassed = shiftBypass;
|
|
440
|
+
shiftBypass = false;
|
|
441
|
+
cancelPendingMotion();
|
|
214
442
|
const pos = pixelToCell(e);
|
|
215
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
|
|
454
|
+
}
|
|
455
|
+
}
|
|
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.
|
|
216
460
|
return;
|
|
217
461
|
}
|
|
218
|
-
const btn = domButtonToXterm(e.button);
|
|
219
|
-
const code = buttonCode(btn, false, e.shiftKey, e.altKey, e.ctrlKey);
|
|
220
|
-
handler.send(encodeSGR(code, pos.col, pos.row, true));
|
|
221
462
|
lastMotion = ""; // gesture over: next motion always reports
|
|
222
463
|
e.preventDefault();
|
|
223
464
|
}
|
|
224
465
|
|
|
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;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
return null;
|
|
476
|
+
}
|
|
477
|
+
|
|
225
478
|
function onMouseMove(e: MouseEvent): void {
|
|
226
|
-
|
|
227
|
-
if (
|
|
479
|
+
lastButtons = e.buttons;
|
|
480
|
+
if (trackingOff() || !handler) {
|
|
228
481
|
return;
|
|
229
482
|
}
|
|
483
|
+
const mode = getMouseMode();
|
|
230
484
|
if (shiftBypass && e.buttons) {
|
|
231
485
|
return; // a Shift-initiated drag is a native selection; don't report it
|
|
232
486
|
}
|
|
@@ -236,7 +490,11 @@ function onMouseMove(e: MouseEvent): void {
|
|
|
236
490
|
if (mode === 1000) {
|
|
237
491
|
return;
|
|
238
492
|
}
|
|
239
|
-
|
|
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) {
|
|
240
498
|
return;
|
|
241
499
|
}
|
|
242
500
|
const pos = pixelToCell(e);
|
|
@@ -245,25 +503,28 @@ function onMouseMove(e: MouseEvent): void {
|
|
|
245
503
|
}
|
|
246
504
|
// No button held (bare motion in mode 1003) reports xterm's "no button"
|
|
247
505
|
// code 3, not 0 — code 0 would be read as a left-button drag during hover.
|
|
248
|
-
const
|
|
249
|
-
const code = buttonCode(btn, true, e.shiftKey, e.altKey, e.ctrlKey);
|
|
506
|
+
const code = buttonCode(held ?? 3, true, e.shiftKey, e.altKey, e.ctrlKey);
|
|
250
507
|
const seq = encodeSGR(code, pos.col, pos.row, false);
|
|
251
508
|
if (seq === lastMotion) {
|
|
252
509
|
return; // same cell, same buttons/modifiers: suppress the duplicate report
|
|
253
510
|
}
|
|
254
511
|
lastMotion = seq;
|
|
255
|
-
|
|
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);
|
|
256
517
|
}
|
|
257
518
|
|
|
258
519
|
function onWheel(e: WheelEvent): void {
|
|
259
|
-
|
|
260
|
-
if (mode === 0 || !(isMouseSGR() || isMousePixels()) || !handler) {
|
|
520
|
+
if (trackingOff() || !handler) {
|
|
261
521
|
return;
|
|
262
522
|
}
|
|
263
523
|
const pos = pixelToCell(e);
|
|
264
524
|
if (!pos) {
|
|
265
525
|
return;
|
|
266
526
|
}
|
|
527
|
+
cancelPendingMotion();
|
|
267
528
|
// Wheel up = button 64, wheel down = button 65. A gesture with NO vertical
|
|
268
529
|
// component — a horizontal trackpad swipe, or shift-wheel where the browser
|
|
269
530
|
// routes the delta to deltaX — has no wheel button to report: xterm.js's
|
|
@@ -273,24 +534,14 @@ function onWheel(e: WheelEvent): void {
|
|
|
273
534
|
// wheel, and letting a sideways swipe through would scroll the page instead.
|
|
274
535
|
// Reporting 65 here (the pre-fix behavior, `deltaY < 0 ? 64 : 65` with no
|
|
275
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.
|
|
276
541
|
if (e.deltaY !== 0) {
|
|
277
542
|
const btn = e.deltaY < 0 ? 64 : 65;
|
|
278
543
|
const code = buttonCode(btn, false, e.shiftKey, e.altKey, e.ctrlKey);
|
|
279
|
-
handler.
|
|
544
|
+
handler.sendReport(encodeSGR(code, pos.col, pos.row, false));
|
|
280
545
|
}
|
|
281
546
|
e.preventDefault();
|
|
282
547
|
}
|
|
283
|
-
|
|
284
|
-
function onFocusIn(): void {
|
|
285
|
-
if (!isFocusReporting() || !handler) {
|
|
286
|
-
return;
|
|
287
|
-
}
|
|
288
|
-
handler.send(`${ESC}[I`);
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
function onFocusOut(): void {
|
|
292
|
-
if (!isFocusReporting() || !handler) {
|
|
293
|
-
return;
|
|
294
|
-
}
|
|
295
|
-
handler.send(`${ESC}[O`);
|
|
296
|
-
}
|
package/src/render.ts
CHANGED
|
@@ -1355,9 +1355,7 @@ function flushRender(): void {
|
|
|
1355
1355
|
const anchor = captureReadAnchor();
|
|
1356
1356
|
try {
|
|
1357
1357
|
flushRenderInner();
|
|
1358
|
-
//
|
|
1359
|
-
// later transient error its full retry budget again. Any backlog reschedule
|
|
1360
|
-
// was already issued from flushRenderInner's own end-of-body.
|
|
1358
|
+
// Clean pass: give a later transient error its full retry budget again.
|
|
1361
1359
|
renderNoProgressStreak = 0;
|
|
1362
1360
|
} catch (err) {
|
|
1363
1361
|
console.error("vterm: render error", err);
|
|
@@ -1525,7 +1523,6 @@ function flushRenderInner(): void {
|
|
|
1525
1523
|
}
|
|
1526
1524
|
}
|
|
1527
1525
|
|
|
1528
|
-
// Refresh cursor state from the window.
|
|
1529
1526
|
const win = store.getWindow();
|
|
1530
1527
|
const newCursorAbs = win.base + win.cursorRow;
|
|
1531
1528
|
cursorAbs = newCursorAbs;
|
|
@@ -1538,11 +1535,9 @@ function flushRenderInner(): void {
|
|
|
1538
1535
|
// resulting mode is unchanged.
|
|
1539
1536
|
syncCursorBlink();
|
|
1540
1537
|
|
|
1541
|
-
//
|
|
1542
|
-
//
|
|
1543
|
-
//
|
|
1544
|
-
// branch below rebuilds EVERYTHING via queueRowsViewportFirst, so any line
|
|
1545
|
-
// dirtied during the alt session is repainted at exit.
|
|
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.
|
|
1546
1541
|
if (store.isAlt()) {
|
|
1547
1542
|
const altRows = store.getAltRows();
|
|
1548
1543
|
renderAlt(altRows);
|
|
@@ -1570,7 +1565,6 @@ function flushRenderInner(): void {
|
|
|
1570
1565
|
queueRowsViewportFirst();
|
|
1571
1566
|
}
|
|
1572
1567
|
|
|
1573
|
-
// Queue this frame's changed rows for building.
|
|
1574
1568
|
for (const abs of ch.dirtyLines) {
|
|
1575
1569
|
renderQueue.add(abs);
|
|
1576
1570
|
}
|
|
@@ -2222,6 +2216,9 @@ export function updateFontMetrics(): void {
|
|
|
2222
2216
|
const measuredW = measureCellWidth();
|
|
2223
2217
|
cellWidth = Math.round(measuredW);
|
|
2224
2218
|
cellHeight = parseFloat(cs.lineHeight) || 17;
|
|
2219
|
+
// The column count is derived from the cell, so the cache gridSize reads is
|
|
2220
|
+
// stale as of this call.
|
|
2221
|
+
measuredCols = 0;
|
|
2225
2222
|
defaultSpacing = cellWidth - measuredW;
|
|
2226
2223
|
output.style.letterSpacing = `${defaultSpacing}px`;
|
|
2227
2224
|
document.documentElement.style.setProperty("--char-w", `${cellWidth}px`);
|
|
@@ -2255,6 +2252,13 @@ export function updateFontMetrics(): void {
|
|
|
2255
2252
|
const MIN_COLS = 20;
|
|
2256
2253
|
const MIN_ROWS = 5;
|
|
2257
2254
|
|
|
2255
|
+
// The column count of the most recent measurement, which is the count the
|
|
2256
|
+
// resize announce carried to the server. gridSize() reads it instead of
|
|
2257
|
+
// measuring, because it is called on every raw mousemove while an application
|
|
2258
|
+
// tracks motion and computeSize() costs a computed-style read plus two layout
|
|
2259
|
+
// reads. 0 until the first measurement.
|
|
2260
|
+
let measuredCols = 0;
|
|
2261
|
+
|
|
2258
2262
|
/**
|
|
2259
2263
|
* Compute the integer (cols, rows) the terminal element can fit at current
|
|
2260
2264
|
* font metrics, clamped to a minimum of 20×5. Used to decide what dimensions
|
|
@@ -2268,9 +2272,36 @@ export function computeSize(): { cols: number; rows: number } {
|
|
|
2268
2272
|
const contentH = termWrap.clientHeight - padY;
|
|
2269
2273
|
const cols = Math.max(MIN_COLS, Math.floor(contentW / cellWidth));
|
|
2270
2274
|
const rows = Math.max(MIN_ROWS, Math.floor(contentH / cellHeight));
|
|
2275
|
+
measuredCols = cols;
|
|
2271
2276
|
return { cols, rows };
|
|
2272
2277
|
}
|
|
2273
2278
|
|
|
2279
|
+
/**
|
|
2280
|
+
* The dimensions of the grid currently ON SCREEN — what
|
|
2281
|
+
* `mouse.MouseInputHandler.gridSize` wants, so a pointer resolves against the
|
|
2282
|
+
* rows the reader is actually looking at.
|
|
2283
|
+
*
|
|
2284
|
+
* Deliberately not `computeSize()`, whose rows are the size this client WOULD
|
|
2285
|
+
* like: the server's screen is last-writer-wins across every attached client
|
|
2286
|
+
* and relaxes to the smallest on disconnect, so a second attached device — or
|
|
2287
|
+
* any local resize before the next screen frame lands — leaves the measured
|
|
2288
|
+
* height describing a grid nothing is painting. The row hit test anchors on this
|
|
2289
|
+
* number rather than merely clamping to it, so a disagreement of N shifts every
|
|
2290
|
+
* reported row by N. The alt grid keeps its own height, and a TUI runs there.
|
|
2291
|
+
*
|
|
2292
|
+
* Columns stay the measured count: the store has none to answer with (a row's
|
|
2293
|
+
* trailing blank cells are trimmed off the wire) and the axis only bounds a
|
|
2294
|
+
* report, where over-reporting costs nothing.
|
|
2295
|
+
*
|
|
2296
|
+
* Rows are 0 until the first screen frame lands, which the mouse module treats
|
|
2297
|
+
* as "no grid yet" and refuses to report against — the honest answer, since
|
|
2298
|
+
* nothing knows the screen's height before the server describes it.
|
|
2299
|
+
*/
|
|
2300
|
+
export function gridSize(): { cols: number; rows: number } {
|
|
2301
|
+
const rows = store.isAlt() ? store.getAltRows().length : store.getWindow().height;
|
|
2302
|
+
return { cols: measuredCols > 0 ? measuredCols : computeSize().cols, rows };
|
|
2303
|
+
}
|
|
2304
|
+
|
|
2274
2305
|
// rowTopInTermWrap returns a row element's top in termWrap's coordinate space
|
|
2275
2306
|
// (the space every absolutely-positioned overlay — caret, predicted cursor,
|
|
2276
2307
|
// composition view — resolves against). A bare `offsetTop` is NOT that: it is
|
|
@@ -2355,6 +2386,16 @@ export function getCursorPx(): { left: number; top: number; cellH: number } {
|
|
|
2355
2386
|
};
|
|
2356
2387
|
}
|
|
2357
2388
|
|
|
2389
|
+
/**
|
|
2390
|
+
* The measured pixel size of one cell — what `mouse.MouseInputHandler.cellSize`
|
|
2391
|
+
* wants. Only meaningful once `updateFontMetrics` has measured this terminal's
|
|
2392
|
+
* font; before that it answers the module's power-on fallback, not the font in
|
|
2393
|
+
* use.
|
|
2394
|
+
*/
|
|
2395
|
+
export function cellSize(): { width: number; height: number } {
|
|
2396
|
+
return { width: cellWidth, height: cellHeight };
|
|
2397
|
+
}
|
|
2398
|
+
|
|
2358
2399
|
// --- Caret overlay ---
|
|
2359
2400
|
//
|
|
2360
2401
|
// The real cursor is a single absolutely-positioned element appended to
|
package/src/types.ts
CHANGED
|
@@ -119,6 +119,23 @@ export interface ResumeAckMessage {
|
|
|
119
119
|
* old server.
|
|
120
120
|
*/
|
|
121
121
|
historyPaging?: boolean;
|
|
122
|
+
/**
|
|
123
|
+
* The server DECLARES that it owns DEC 1004 focus for this session: it derives
|
|
124
|
+
* the answer from attachment state, every attached client's reported focus and
|
|
125
|
+
* its own keep-unfocused declaration, and honors the `focus` control. Report
|
|
126
|
+
* the terminal widget's focus with `connection.setClientFocus` and write no
|
|
127
|
+
* focus bytes. Absent (or false) means the server does not, and the client
|
|
128
|
+
* writes CSI I / CSI O itself while the application has 1004 enabled.
|
|
129
|
+
*/
|
|
130
|
+
serverFocus?: boolean;
|
|
131
|
+
/**
|
|
132
|
+
* The server DECLARES that it serves the `ephemeralInput` control: a
|
|
133
|
+
* best-effort input channel it does not count against the resume ledger, for
|
|
134
|
+
* input whose value expires (mouse reports). Absent (or false) means the
|
|
135
|
+
* server does not, and `connection.sendEphemeral` falls back to reliable
|
|
136
|
+
* input rather than dropping the report.
|
|
137
|
+
*/
|
|
138
|
+
ephemeralInput?: boolean;
|
|
122
139
|
/**
|
|
123
140
|
* The server's wire-protocol revision (third length-gated tail, absent on
|
|
124
141
|
* older servers). A value OUTSIDE the client's supported range means one
|
|
@@ -258,6 +275,20 @@ export type ControlMessage =
|
|
|
258
275
|
*/
|
|
259
276
|
| { type: "history"; fromAbs: number; maxLines: number }
|
|
260
277
|
| { type: "ping" }
|
|
278
|
+
/**
|
|
279
|
+
* The terminal widget's focus, reported so the server can derive the DEC 1004
|
|
280
|
+
* answer for the whole session (it sees every attached client, a socket dying
|
|
281
|
+
* without a focus-out, and its own keep-unfocused declaration; the browser sees
|
|
282
|
+
* none of that). Sent only to a server that declared `serverFocus`.
|
|
283
|
+
*/
|
|
284
|
+
| { type: "focus"; focused: boolean }
|
|
285
|
+
/**
|
|
286
|
+
* Best-effort input the server does not count against the resume ledger:
|
|
287
|
+
* mouse reports, whose value expires because they describe a screen that may
|
|
288
|
+
* since have been repainted. Carried as a JSON string, so an ESC is \u001b.
|
|
289
|
+
* Sent only to a server that declared `ephemeralInput`.
|
|
290
|
+
*/
|
|
291
|
+
| { type: "ephemeralInput"; data: string }
|
|
261
292
|
/**
|
|
262
293
|
* v4 typed-framing transition (see WIRE_PROTOCOL_VERSION in
|
|
263
294
|
* wire-compatibility.ts): the first
|
package/src/wire-binary.ts
CHANGED
|
@@ -144,7 +144,8 @@ function decodeWireBinaryInner(buf: ArrayBuffer): ServerMessage | null {
|
|
|
144
144
|
// >= 17 bytes: + serverEpoch (restart detection)
|
|
145
145
|
// >= 33 bytes: + committed + oldestIndex (resume gap detection)
|
|
146
146
|
// >= 35 bytes: + serverWireVersion + ackFlags (bit0 = ledgerLost,
|
|
147
|
-
// bit1 = historyPaging
|
|
147
|
+
// bit1 = historyPaging, bit2 = serverFocus,
|
|
148
|
+
// bit3 = ephemeralInput). Unknown bits are IGNORED, which
|
|
148
149
|
// is what lets the server add capabilities here without a
|
|
149
150
|
// protocol bump.
|
|
150
151
|
let serverEpoch: number | undefined;
|
|
@@ -153,6 +154,8 @@ function decodeWireBinaryInner(buf: ArrayBuffer): ServerMessage | null {
|
|
|
153
154
|
let serverWireVersion: number | undefined;
|
|
154
155
|
let ledgerLost: boolean | undefined;
|
|
155
156
|
let historyPaging: boolean | undefined;
|
|
157
|
+
let serverFocus: boolean | undefined;
|
|
158
|
+
let ephemeralInput: boolean | undefined;
|
|
156
159
|
if (buf.byteLength >= 17) {
|
|
157
160
|
serverEpoch = c.u64();
|
|
158
161
|
}
|
|
@@ -165,6 +168,8 @@ function decodeWireBinaryInner(buf: ArrayBuffer): ServerMessage | null {
|
|
|
165
168
|
const ackFlags = c.u8();
|
|
166
169
|
ledgerLost = (ackFlags & 1) !== 0;
|
|
167
170
|
historyPaging = (ackFlags & 2) !== 0;
|
|
171
|
+
serverFocus = (ackFlags & 4) !== 0;
|
|
172
|
+
ephemeralInput = (ackFlags & 8) !== 0;
|
|
168
173
|
}
|
|
169
174
|
const msg: ResumeAckMessage = { type: "resumeAck", received: inputAck };
|
|
170
175
|
if (serverEpoch !== undefined) {
|
|
@@ -185,6 +190,12 @@ function decodeWireBinaryInner(buf: ArrayBuffer): ServerMessage | null {
|
|
|
185
190
|
if (historyPaging !== undefined) {
|
|
186
191
|
msg.historyPaging = historyPaging;
|
|
187
192
|
}
|
|
193
|
+
if (serverFocus !== undefined) {
|
|
194
|
+
msg.serverFocus = serverFocus;
|
|
195
|
+
}
|
|
196
|
+
if (ephemeralInput !== undefined) {
|
|
197
|
+
msg.ephemeralInput = ephemeralInput;
|
|
198
|
+
}
|
|
188
199
|
return msg;
|
|
189
200
|
}
|
|
190
201
|
if (msgType === MSG_ACK_ONLY) {
|