@cplieger/web-terminal-engine 5.0.9 → 5.1.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/README.md +13 -5
- package/jsr.json +1 -1
- package/package.json +9 -9
- package/src/connection.ts +127 -8
- package/src/modes.ts +0 -1
- package/src/mouse.ts +309 -58
- package/src/render.ts +47 -0
- package/src/types.ts +33 -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 drops the report. The client writes no focus bytes in either case, because one client cannot answer for a session two devices may be attached to and a client-written `CSI I` would override a keep-unfocused policy it cannot see; the next resumeAck re-asserts the current value once the capabilities are known. 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.
|
|
3
|
+
"version": "5.1.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"typecheck": "tsc -p tsconfig.json",
|
|
@@ -11,24 +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/browser": "
|
|
21
|
-
"@vitest/browser-playwright": "
|
|
22
|
-
"@vitest/coverage-v8": "
|
|
20
|
+
"@vitest/browser": "5.0.0",
|
|
21
|
+
"@vitest/browser-playwright": "5.0.0",
|
|
22
|
+
"@vitest/coverage-v8": "5.0.0",
|
|
23
23
|
"esbuild": "0.28.2",
|
|
24
|
-
"eslint": "10.
|
|
24
|
+
"eslint": "10.10.0",
|
|
25
25
|
"fast-check": "4.9.0",
|
|
26
|
-
"playwright": "1.
|
|
26
|
+
"playwright": "1.63.0",
|
|
27
27
|
"pngjs": "7.0.0",
|
|
28
28
|
"prettier": "3.9.6",
|
|
29
29
|
"typescript": "npm:@typescript/typescript6@6.0.2",
|
|
30
|
-
"typescript-eslint": "8.
|
|
31
|
-
"vitest": "
|
|
30
|
+
"typescript-eslint": "8.70.0",
|
|
31
|
+
"vitest": "5.0.0"
|
|
32
32
|
},
|
|
33
33
|
"overrides": {
|
|
34
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,40 @@ 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
|
+
/** WebSocket.OPEN. Spelled out because a test's fake constructor has no statics. */
|
|
338
|
+
const WS_OPEN = 1;
|
|
339
|
+
|
|
340
|
+
const ephemeralEncoder = new TextEncoder();
|
|
341
|
+
|
|
307
342
|
/** Cancel both timers and release single-flight. */
|
|
308
343
|
function clearHistoryTimers(): void {
|
|
309
344
|
if (history.dataTimer !== null) {
|
|
@@ -317,14 +352,18 @@ function clearHistoryTimers(): void {
|
|
|
317
352
|
}
|
|
318
353
|
|
|
319
354
|
/**
|
|
320
|
-
* Reset
|
|
321
|
-
* the adaptive budget
|
|
322
|
-
*
|
|
323
|
-
*
|
|
355
|
+
* Reset everything a new socket must not inherit: the fetch state (capability,
|
|
356
|
+
* single-flight, pacing and the adaptive budget), the declared server
|
|
357
|
+
* capabilities, and the focus latch. All of it goes together, atomically.
|
|
358
|
+
* Splitting them is how a client ends up paging against a server that never
|
|
359
|
+
* declared it, bursting into a depleted server bucket, or holding a focus report
|
|
360
|
+
* back from the new socket as a repeat of what the old one was told.
|
|
324
361
|
*/
|
|
325
|
-
function
|
|
362
|
+
function resetForNewSocket(): void {
|
|
326
363
|
clearHistoryTimers();
|
|
327
364
|
history = newHistoryState();
|
|
365
|
+
caps = { ephemeralInput: false, serverFocus: false };
|
|
366
|
+
focusReported = null;
|
|
328
367
|
}
|
|
329
368
|
|
|
330
369
|
/**
|
|
@@ -642,6 +681,68 @@ function textControl(msg: ControlMessage): string {
|
|
|
642
681
|
return JSON.stringify(msg);
|
|
643
682
|
}
|
|
644
683
|
|
|
684
|
+
/**
|
|
685
|
+
* sendEphemeral delivers BEST-EFFORT input. Where the server declared
|
|
686
|
+
* `ephemeralInput` it sends only on a live socket, never queues, never
|
|
687
|
+
* retransmits, and never touches `outbox`, `outboxBytes`, `bytesSent`,
|
|
688
|
+
* `bytesAcked` or `onOutboxFull`; where it did NOT, the report falls back to
|
|
689
|
+
* reliable input, so it can be retransmitted after a resume and describe a
|
|
690
|
+
* screen since repainted. Returns whether it was sent; on false the caller's
|
|
691
|
+
* contract is to FORGET the event, because a mouse report carries no sequence
|
|
692
|
+
* number. Refusing on a closing socket is what xterm.js's AttachAddon and ttyd
|
|
693
|
+
* both do.
|
|
694
|
+
*/
|
|
695
|
+
export function sendEphemeral(text: string): boolean {
|
|
696
|
+
if (connState.status !== "connected" || connState.sock.readyState !== WS_OPEN) {
|
|
697
|
+
return false;
|
|
698
|
+
}
|
|
699
|
+
if (!connState.upgraded || !caps.ephemeralInput) {
|
|
700
|
+
// Fall back to RELIABLE input rather than dropping the report. An
|
|
701
|
+
// unrecognized text control is tolerated and dropped post-latch, so the
|
|
702
|
+
// report would vanish entirely, and total silence is a worse failure than a
|
|
703
|
+
// stale report: a third-party or mid-upgrade server keeps a working mouse.
|
|
704
|
+
// What is DEGRADED on this path: the report rides the reliable outbox, so a
|
|
705
|
+
// click made before a disconnect is retransmitted after the resume has
|
|
706
|
+
// repainted the screen.
|
|
707
|
+
return sendBinary(ephemeralEncoder.encode(text));
|
|
708
|
+
}
|
|
709
|
+
connState.sock.send(textControl({ type: "ephemeralInput", data: text }));
|
|
710
|
+
return true;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/**
|
|
714
|
+
* setClientFocus reports whether the consumer's terminal widget is focused.
|
|
715
|
+
*
|
|
716
|
+
* Focus is STATE, so it is reconciled rather than replayed: the latest value
|
|
717
|
+
* wins, a repeat of the value already reported sends nothing, a report made
|
|
718
|
+
* while no socket is live is dropped rather than queued, and a reconnect
|
|
719
|
+
* re-asserts the current one. Where the server declared `serverFocus` this sends
|
|
720
|
+
* the `focus` control and the server derives the DEC 1004 answer for the whole
|
|
721
|
+
* session; where it did not, the report is dropped.
|
|
722
|
+
*/
|
|
723
|
+
export function setClientFocus(focused: boolean): void {
|
|
724
|
+
clientFocused = focused;
|
|
725
|
+
if (connState.status !== "connected") {
|
|
726
|
+
// Focus is STATE, so a report with no socket to carry it is DROPPED rather
|
|
727
|
+
// than held: the widget's value may change again before the next socket
|
|
728
|
+
// exists, and a held report would assert a focus it may since have lost.
|
|
729
|
+
// Nothing is lost by dropping it — the resumeAck re-reports the current
|
|
730
|
+
// value on the new socket.
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
if (!caps.serverFocus || !connState.upgraded) {
|
|
734
|
+
// A server that does not derive the answer gets NO report, because the client
|
|
735
|
+
// never writes the DEC 1004 bytes itself. Nothing is lost by dropping it: the
|
|
736
|
+
// resumeAck re-reports the current value once the capabilities are known.
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
if (focused === focusReported) {
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
focusReported = focused;
|
|
743
|
+
sendControl({ type: "focus", focused });
|
|
744
|
+
}
|
|
745
|
+
|
|
645
746
|
function sendControl(msg: ControlMessage): void {
|
|
646
747
|
if (connState.status !== "connected") {
|
|
647
748
|
return;
|
|
@@ -881,7 +982,7 @@ function teardown(): void {
|
|
|
881
982
|
}
|
|
882
983
|
}
|
|
883
984
|
stopHeartbeat();
|
|
884
|
-
|
|
985
|
+
resetForNewSocket();
|
|
885
986
|
cb?.clearSolicited?.();
|
|
886
987
|
cancelScheduledReconnect();
|
|
887
988
|
connState = { status: "disconnected" };
|
|
@@ -1337,7 +1438,7 @@ export function connect(): void {
|
|
|
1337
1438
|
// exact values, so the replay-jump prediction must be computed from them
|
|
1338
1439
|
// — never from store state, which a frame arriving between send and ack
|
|
1339
1440
|
// could have moved, masking a real jump (docs/paged-scrollback.md §4.5).
|
|
1340
|
-
|
|
1441
|
+
resetForNewSocket();
|
|
1341
1442
|
const sentHaveThrough = cb?.getHaveThrough?.() ?? -1;
|
|
1342
1443
|
// ALWAYS a number. A consumer may ask for fewer lines than the protocol
|
|
1343
1444
|
// ceiling, but it cannot opt OUT of the bound: the server clamps to the
|
|
@@ -1551,6 +1652,18 @@ export function connect(): void {
|
|
|
1551
1652
|
// nothing ever sent, and the store keeps its compatibility tail cap.
|
|
1552
1653
|
history.paging = msg.historyPaging === true;
|
|
1553
1654
|
history.acked = true;
|
|
1655
|
+
// The other two capabilities off the same tail, and the focus re-report
|
|
1656
|
+
// for this socket. Placed here — with the capability handling, BEFORE the
|
|
1657
|
+
// ledger-lost / session-forgotten early returns — for the same reason
|
|
1658
|
+
// onResumeTransition is: a ledger loss is not a capability event, and the
|
|
1659
|
+
// long-absence attach that loses its ledger still needs its focus state
|
|
1660
|
+
// asserted on the new socket.
|
|
1661
|
+
caps = {
|
|
1662
|
+
ephemeralInput: msg.ephemeralInput === true,
|
|
1663
|
+
serverFocus: msg.serverFocus === true,
|
|
1664
|
+
};
|
|
1665
|
+
focusReported = null;
|
|
1666
|
+
setClientFocus(clientFocused);
|
|
1554
1667
|
// The store's ONE ack transition, dispatched BEFORE the early returns
|
|
1555
1668
|
// below. A ledger loss is not a capability event, and the long-absence
|
|
1556
1669
|
// attach that loses its ledger is exactly the one carrying a replay jump
|
|
@@ -1604,6 +1717,12 @@ export function connect(): void {
|
|
|
1604
1717
|
connState.status === "connected" && connState.sock === sock && connState.upgraded,
|
|
1605
1718
|
st,
|
|
1606
1719
|
);
|
|
1720
|
+
// The transport answers for itself here, as it does for focus: the resume is
|
|
1721
|
+
// complete, `upgraded` and `caps` are both set, so this is the first instant
|
|
1722
|
+
// a best-effort report can leave. A gesture still recorded from before the
|
|
1723
|
+
// gap is cancelled now, or the application holds that button for the rest of
|
|
1724
|
+
// the session. Inert until a consumer has called `mouse.init`.
|
|
1725
|
+
mouse.resyncGesture();
|
|
1607
1726
|
return;
|
|
1608
1727
|
}
|
|
1609
1728
|
if (msg.type === "ackOnly") {
|
|
@@ -1715,7 +1834,7 @@ export function connect(): void {
|
|
|
1715
1834
|
// The fetch state belonged to THIS socket: kill its timers so neither
|
|
1716
1835
|
// fires against a dead socket, and release the solicited window so the
|
|
1717
1836
|
// store stops admitting lines below its stale-re-send watermark.
|
|
1718
|
-
|
|
1837
|
+
resetForNewSocket();
|
|
1719
1838
|
cb?.clearSolicited?.();
|
|
1720
1839
|
if (ev.code === WIRE_INCOMPATIBLE_CLOSE_CODE) {
|
|
1721
1840
|
const reason =
|
package/src/modes.ts
CHANGED
|
@@ -11,7 +11,6 @@
|
|
|
11
11
|
// - paste handling: wraps in \e[200~..\e[201~ only when
|
|
12
12
|
// bracketedPaste is on; otherwise sends raw text.
|
|
13
13
|
// - mouse.ts: encodes mouse events only when mouseMode > 0.
|
|
14
|
-
// - focus reporting: sends ESC[I / ESC[O when focusReporting is on.
|
|
15
14
|
//
|
|
16
15
|
// Defaults: bracketedPaste starts true because most modern shells
|
|
17
16
|
// (bash 4.4+, zsh, fish) enable it immediately on startup via
|
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
|
@@ -2216,6 +2216,9 @@ export function updateFontMetrics(): void {
|
|
|
2216
2216
|
const measuredW = measureCellWidth();
|
|
2217
2217
|
cellWidth = Math.round(measuredW);
|
|
2218
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;
|
|
2219
2222
|
defaultSpacing = cellWidth - measuredW;
|
|
2220
2223
|
output.style.letterSpacing = `${defaultSpacing}px`;
|
|
2221
2224
|
document.documentElement.style.setProperty("--char-w", `${cellWidth}px`);
|
|
@@ -2249,6 +2252,13 @@ export function updateFontMetrics(): void {
|
|
|
2249
2252
|
const MIN_COLS = 20;
|
|
2250
2253
|
const MIN_ROWS = 5;
|
|
2251
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
|
+
|
|
2252
2262
|
/**
|
|
2253
2263
|
* Compute the integer (cols, rows) the terminal element can fit at current
|
|
2254
2264
|
* font metrics, clamped to a minimum of 20×5. Used to decide what dimensions
|
|
@@ -2262,9 +2272,36 @@ export function computeSize(): { cols: number; rows: number } {
|
|
|
2262
2272
|
const contentH = termWrap.clientHeight - padY;
|
|
2263
2273
|
const cols = Math.max(MIN_COLS, Math.floor(contentW / cellWidth));
|
|
2264
2274
|
const rows = Math.max(MIN_ROWS, Math.floor(contentH / cellHeight));
|
|
2275
|
+
measuredCols = cols;
|
|
2265
2276
|
return { cols, rows };
|
|
2266
2277
|
}
|
|
2267
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
|
+
|
|
2268
2305
|
// rowTopInTermWrap returns a row element's top in termWrap's coordinate space
|
|
2269
2306
|
// (the space every absolutely-positioned overlay — caret, predicted cursor,
|
|
2270
2307
|
// composition view — resolves against). A bare `offsetTop` is NOT that: it is
|
|
@@ -2349,6 +2386,16 @@ export function getCursorPx(): { left: number; top: number; cellH: number } {
|
|
|
2349
2386
|
};
|
|
2350
2387
|
}
|
|
2351
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
|
+
|
|
2352
2399
|
// --- Caret overlay ---
|
|
2353
2400
|
//
|
|
2354
2401
|
// The real cursor is a single absolutely-positioned element appended to
|
package/src/types.ts
CHANGED
|
@@ -119,6 +119,25 @@ 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. Absent
|
|
126
|
+
* (or false) means the server does not, and the report is DROPPED — the client
|
|
127
|
+
* writes no focus bytes in either case, because one client cannot answer for a
|
|
128
|
+
* session two devices may be attached to and a client-written CSI I would
|
|
129
|
+
* override a keep-unfocused policy it cannot see. Report the terminal widget's
|
|
130
|
+
* focus with `connection.setClientFocus` regardless of this bit.
|
|
131
|
+
*/
|
|
132
|
+
serverFocus?: boolean;
|
|
133
|
+
/**
|
|
134
|
+
* The server DECLARES that it serves the `ephemeralInput` control: a
|
|
135
|
+
* best-effort input channel it does not count against the resume ledger, for
|
|
136
|
+
* input whose value expires (mouse reports). Absent (or false) means the
|
|
137
|
+
* server does not, and `connection.sendEphemeral` falls back to reliable
|
|
138
|
+
* input rather than dropping the report.
|
|
139
|
+
*/
|
|
140
|
+
ephemeralInput?: boolean;
|
|
122
141
|
/**
|
|
123
142
|
* The server's wire-protocol revision (third length-gated tail, absent on
|
|
124
143
|
* older servers). A value OUTSIDE the client's supported range means one
|
|
@@ -258,6 +277,20 @@ export type ControlMessage =
|
|
|
258
277
|
*/
|
|
259
278
|
| { type: "history"; fromAbs: number; maxLines: number }
|
|
260
279
|
| { type: "ping" }
|
|
280
|
+
/**
|
|
281
|
+
* The terminal widget's focus, reported so the server can derive the DEC 1004
|
|
282
|
+
* answer for the whole session (it sees every attached client, a socket dying
|
|
283
|
+
* without a focus-out, and its own keep-unfocused declaration; the browser sees
|
|
284
|
+
* none of that). Sent only to a server that declared `serverFocus`.
|
|
285
|
+
*/
|
|
286
|
+
| { type: "focus"; focused: boolean }
|
|
287
|
+
/**
|
|
288
|
+
* Best-effort input the server does not count against the resume ledger:
|
|
289
|
+
* mouse reports, whose value expires because they describe a screen that may
|
|
290
|
+
* since have been repainted. Carried as a JSON string, so an ESC is \u001b.
|
|
291
|
+
* Sent only to a server that declared `ephemeralInput`.
|
|
292
|
+
*/
|
|
293
|
+
| { type: "ephemeralInput"; data: string }
|
|
261
294
|
/**
|
|
262
295
|
* v4 typed-framing transition (see WIRE_PROTOCOL_VERSION in
|
|
263
296
|
* 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) {
|