@cplieger/web-terminal-ui 7.0.7 → 7.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 +21 -1
- package/css/02-terminal.css +17 -0
- package/package.json +5 -5
- package/src/kernel/kernel.ts +121 -9
package/README.md
CHANGED
|
@@ -186,7 +186,27 @@ want one, for example Ctrl+L); `reattach()` attaches again to whatever the serve
|
|
|
186
186
|
serves now, for a session that has ended (see "When a session ends"); and
|
|
187
187
|
`destroy()` tears every feature down and releases the kernel.
|
|
188
188
|
|
|
189
|
-
###
|
|
189
|
+
### Mouse and focus
|
|
190
|
+
|
|
191
|
+
A terminal application that turns mouse tracking on receives the clicks, drags
|
|
192
|
+
and wheel gestures a user makes in the browser, as SGR reports. The pointer says
|
|
193
|
+
so while that is happening: at rest the grid carries a text cursor, and while an
|
|
194
|
+
application holds the mouse the grid and any links in it carry an arrow instead,
|
|
195
|
+
because a click is reported to that application rather than selecting text or
|
|
196
|
+
opening the link. Shift-drag still selects and shift-click still opens a link,
|
|
197
|
+
which is the convention xterm set, so nothing becomes unreachable.
|
|
198
|
+
|
|
199
|
+
Mouse input is best-effort, as it is in every terminal: a report says where the
|
|
200
|
+
pointer is at that moment and carries nothing a receiver could use to notice it
|
|
201
|
+
had gone stale, so a report made while the socket is down is dropped rather than
|
|
202
|
+
delivered late against a screen that has since been repainted. Typed input keeps
|
|
203
|
+
its delivery guarantee and is unaffected.
|
|
204
|
+
|
|
205
|
+
Focus reporting is the server's answer rather than this UI's. The kernel tells
|
|
206
|
+
the server whether its own terminal widget holds focus and the server decides
|
|
207
|
+
what the application is told, so several devices attached to one session cannot
|
|
208
|
+
contradict each other. A blur that a press causes and that the same gesture's
|
|
209
|
+
click undoes is not reported, because the terminal did not lose focus.
|
|
190
210
|
|
|
191
211
|
`persistScrollback` takes the storage, not a switch, because storage is a
|
|
192
212
|
decision this library should not make for you. Chrome's page-lifecycle guidance
|
package/css/02-terminal.css
CHANGED
|
@@ -149,6 +149,11 @@
|
|
|
149
149
|
kernel focus handling + contextMenu feature). */
|
|
150
150
|
user-select: text;
|
|
151
151
|
-webkit-touch-callout: default;
|
|
152
|
+
|
|
153
|
+
/* Stated rather than inherited: the UA default over text is already an I-beam,
|
|
154
|
+
but the tracking rule below has to have something deterministic to override
|
|
155
|
+
(and to hand back to). */
|
|
156
|
+
cursor: text;
|
|
152
157
|
}
|
|
153
158
|
|
|
154
159
|
/* IME composition view: floating overlay positioned at the cursor
|
|
@@ -327,6 +332,18 @@
|
|
|
327
332
|
--bg: var(--text-default);
|
|
328
333
|
}
|
|
329
334
|
|
|
335
|
+
/* An application holds the mouse (DEC 1000/1002/1003) and the kernel toggles
|
|
336
|
+
.wt-mouse-app on the scroll container. A plain click is reported to that
|
|
337
|
+
application, so neither the I-beam nor the link's pointer is true any longer:
|
|
338
|
+
both promise something the click no longer does. Deliberately no
|
|
339
|
+
`user-select: none` here, because the engine hands a shifted gesture to the
|
|
340
|
+
browser, so shift+drag selecting and shift+click opening a link are the only
|
|
341
|
+
ways left to reach either and blanketing selection off would remove them. */
|
|
342
|
+
:where(.wt-root) .term.wt-mouse-app .term-output,
|
|
343
|
+
:where(.wt-root) .term.wt-mouse-app .term-link {
|
|
344
|
+
cursor: default;
|
|
345
|
+
}
|
|
346
|
+
|
|
330
347
|
:where(.wt-root) .term-blink {
|
|
331
348
|
animation: wt-blink-anim 1s step-end infinite;
|
|
332
349
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cplieger/web-terminal-ui",
|
|
3
|
-
"version": "7.0
|
|
3
|
+
"version": "7.1.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Reference touch-first browser UI for @cplieger/web-terminal-engine: a composable kernel + feature modules (tabs, activity monitor, mobile toolbar, context menu, IME, predictive echo, viewport handling) and CSS. One createTerminal() call.",
|
|
6
6
|
"license": "MPL-2.0",
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
"verify": "bash scripts/verify.sh"
|
|
41
41
|
},
|
|
42
42
|
"peerDependencies": {
|
|
43
|
-
"@cplieger/web-terminal-engine": "^5.0
|
|
43
|
+
"@cplieger/web-terminal-engine": "^5.1.0"
|
|
44
44
|
},
|
|
45
45
|
"overrides": {
|
|
46
46
|
"qs": "^6.15.2"
|
|
@@ -50,15 +50,15 @@
|
|
|
50
50
|
"@stryker-mutator/core": "10.0.0",
|
|
51
51
|
"@stryker-mutator/typescript-checker": "10.0.0",
|
|
52
52
|
"@stryker-mutator/vitest-runner": "10.0.0",
|
|
53
|
-
"@types/node": "26.
|
|
53
|
+
"@types/node": "26.5.0",
|
|
54
54
|
"@typescript/native": "npm:typescript@7.0.2",
|
|
55
55
|
"@vitest/browser": "5.0.0",
|
|
56
56
|
"@vitest/browser-playwright": "5.0.0",
|
|
57
57
|
"@vitest/coverage-v8": "5.0.0",
|
|
58
58
|
"eslint": "10.10.0",
|
|
59
59
|
"fast-check": "4.9.0",
|
|
60
|
-
"html-validate": "11.
|
|
61
|
-
"knip": "6.
|
|
60
|
+
"html-validate": "11.15.0",
|
|
61
|
+
"knip": "6.35.1",
|
|
62
62
|
"playwright": "1.63.0",
|
|
63
63
|
"prettier": "3.9.6",
|
|
64
64
|
"stylelint": "17.15.0",
|
package/src/kernel/kernel.ts
CHANGED
|
@@ -13,12 +13,11 @@
|
|
|
13
13
|
// still single-instance, so createTerminal is called once per page (tabs
|
|
14
14
|
// multiplex sessions over the one kernel, section 22.5).
|
|
15
15
|
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
// suppression is a tracked follow-up.
|
|
16
|
+
// The kernel owns the terminal widget's DOM, so it owns the widget's focus: it
|
|
17
|
+
// REPORTS focus and blur to the transport (`connection.setClientFocus`) and
|
|
18
|
+
// never writes DEC 1004 bytes itself. The server derives the answer for the
|
|
19
|
+
// session from every attached client's report plus its own hold, which is the
|
|
20
|
+
// only place the question can be answered once a second device attaches.
|
|
22
21
|
|
|
23
22
|
import {
|
|
24
23
|
render,
|
|
@@ -26,6 +25,7 @@ import {
|
|
|
26
25
|
connection,
|
|
27
26
|
keyboard,
|
|
28
27
|
modes,
|
|
28
|
+
mouse,
|
|
29
29
|
LineStore,
|
|
30
30
|
} from "@cplieger/web-terminal-engine";
|
|
31
31
|
import * as composition from "../composition.js";
|
|
@@ -400,9 +400,9 @@ function buildTerminal(
|
|
|
400
400
|
// output never does (the scroll controller's follow/hold job is unchanged),
|
|
401
401
|
// so scrolling up to read while output streams is preserved. A no-op on the
|
|
402
402
|
// alt screen (no scrollback) and when already following. NOTE: every caller
|
|
403
|
-
// of sendBytes
|
|
404
|
-
//
|
|
405
|
-
//
|
|
403
|
+
// of sendBytes is genuine user input (typed keys, paste, mobile toolbar);
|
|
404
|
+
// mouse reports deliberately bypass this funnel and go out as ephemeral
|
|
405
|
+
// input, or the view would snap on every motion report.
|
|
406
406
|
scroll.scrollToBottom();
|
|
407
407
|
for (const obs of inputObservers) {
|
|
408
408
|
obs(out);
|
|
@@ -575,6 +575,100 @@ function buildTerminal(
|
|
|
575
575
|
});
|
|
576
576
|
render.updateFontMetrics();
|
|
577
577
|
|
|
578
|
+
// A mouse report is BEST-EFFORT and never enters the reliable outbox: it
|
|
579
|
+
// carries no sequence number, so one retransmitted after a resume describes a
|
|
580
|
+
// screen since repainted, and `sendEphemeral` refuses rather than queues. Not
|
|
581
|
+
// sendBytes either: that funnel snaps the view to the bottom on every accepted
|
|
582
|
+
// byte, so a motion report would jump the viewport. The listeners belong on the
|
|
583
|
+
// scroll container (a click in its padding or over the reserved scrollbar
|
|
584
|
+
// gutter never reaches a row, and the wheel has to be cancellable there); the
|
|
585
|
+
// coordinate frame is the row container, whose box IS the grid.
|
|
586
|
+
const disposeMouse = mouse.init({
|
|
587
|
+
sendReport: (data) => connection.sendEphemeral(data),
|
|
588
|
+
cellSize: render.cellSize,
|
|
589
|
+
termElement: () => termWrap,
|
|
590
|
+
gridElement: () => outputEl,
|
|
591
|
+
// The RENDERED grid, not computeSize(): the row hit test anchors on its
|
|
592
|
+
// height, and this client's own measurement disagrees with the negotiated
|
|
593
|
+
// screen whenever a second device is attached or a local resize has not been
|
|
594
|
+
// answered yet, which would shift every reported row.
|
|
595
|
+
gridSize: render.gridSize,
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
// A press over the display-only output takes focus off the hidden textarea and
|
|
599
|
+
// the click handler below puts it back, so that blur/focus pair is an artifact
|
|
600
|
+
// of this input model, not a focus change the application should be told about.
|
|
601
|
+
// xterm.js never emits one because its textarea keeps focus through a click on
|
|
602
|
+
// the terminal. So a blur raised inside a press is withheld and the gesture's
|
|
603
|
+
// REAL end state is reported once it resolves: nothing when focus came back,
|
|
604
|
+
// one report when it did not (a live selection, a link and a bare touch tap all
|
|
605
|
+
// decline to restore it). Measured before this: 3600 focus reports in 90s of
|
|
606
|
+
// clicking, which reaches an application as FocusLost/FocusGained per click.
|
|
607
|
+
let pressHoldsFocus = false;
|
|
608
|
+
let focusResolve: ReturnType<typeof setTimeout> | null = null;
|
|
609
|
+
|
|
610
|
+
function reportFocusNow(): void {
|
|
611
|
+
connection.setClientFocus(
|
|
612
|
+
document.activeElement !== null && termWrap.contains(document.activeElement),
|
|
613
|
+
);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// Scheduled rather than read at pointerup: the focus restore happens in the
|
|
617
|
+
// `click` handler, which the browser dispatches after pointerup within the same
|
|
618
|
+
// task, so a task boundary is the first point where the end state is settled.
|
|
619
|
+
function endPressFocusGesture(): void {
|
|
620
|
+
if (focusResolve !== null) {
|
|
621
|
+
clearTimeout(focusResolve);
|
|
622
|
+
}
|
|
623
|
+
focusResolve = setTimeout(() => {
|
|
624
|
+
focusResolve = null;
|
|
625
|
+
pressHoldsFocus = false;
|
|
626
|
+
reportFocusNow();
|
|
627
|
+
}, 0);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// The keyboard target is the hidden textarea INSIDE termWrap, so the widget's
|
|
631
|
+
// own focus events bubble to here. What goes out is the widget's STATE, not
|
|
632
|
+
// DEC 1004 bytes: the server derives the answer from this report plus every
|
|
633
|
+
// other attached client's plus its own hold.
|
|
634
|
+
termWrap.addEventListener(
|
|
635
|
+
"focusin",
|
|
636
|
+
() => {
|
|
637
|
+
connection.setClientFocus(true);
|
|
638
|
+
},
|
|
639
|
+
{ signal },
|
|
640
|
+
);
|
|
641
|
+
termWrap.addEventListener(
|
|
642
|
+
"focusout",
|
|
643
|
+
(ev) => {
|
|
644
|
+
if (ev.relatedTarget instanceof Node && termWrap.contains(ev.relatedTarget)) {
|
|
645
|
+
return; // focus moving WITHIN the terminal is not a blur
|
|
646
|
+
}
|
|
647
|
+
if (pressHoldsFocus) {
|
|
648
|
+
return; // this gesture's own doing; endPressFocusGesture reports the truth
|
|
649
|
+
}
|
|
650
|
+
connection.setClientFocus(false);
|
|
651
|
+
},
|
|
652
|
+
{ signal },
|
|
653
|
+
);
|
|
654
|
+
// Seeded once, so a terminal mounted with its textarea already focused does not
|
|
655
|
+
// report blurred until the first real focusin.
|
|
656
|
+
reportFocusNow();
|
|
657
|
+
|
|
658
|
+
// The kernel is the toggler, and the class goes on the scroll container rather
|
|
659
|
+
// than on the rows: `.term-output` is replaced wholesale by the renderer, and
|
|
660
|
+
// `.term` is the element that survives every rebuild. What the shape MEANS is
|
|
661
|
+
// at the CSS rule it drives (css/02-terminal.css).
|
|
662
|
+
function updateMousePointer(): void {
|
|
663
|
+
termWrap.classList.toggle("wt-mouse-app", modes.getMouseMode() !== 0);
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// Derived once here as well as from the two triggers below: the modes module is
|
|
667
|
+
// a singleton that outlives a terminal, so a re-created terminal on a page
|
|
668
|
+
// whose mirror already holds a tracking mode would otherwise show the resting
|
|
669
|
+
// pointer until the next frame.
|
|
670
|
+
updateMousePointer();
|
|
671
|
+
|
|
578
672
|
composition.init({
|
|
579
673
|
textarea: input,
|
|
580
674
|
compositionView: compositionViewEl,
|
|
@@ -698,6 +792,7 @@ function buildTerminal(
|
|
|
698
792
|
bus.emit("wire:title", { session: activeSession?.id ?? "", title: msg.title });
|
|
699
793
|
} else if (msg.type === "modes") {
|
|
700
794
|
render.updateReverseVideo();
|
|
795
|
+
updateMousePointer();
|
|
701
796
|
bus.emit("wire:modes", msg);
|
|
702
797
|
} else if (msg.type === "clipboard") {
|
|
703
798
|
// Inbound OSC 52. With no clipboard feature subscribed this is a no-op
|
|
@@ -969,9 +1064,13 @@ function buildTerminal(
|
|
|
969
1064
|
pointerDownX = e.clientX;
|
|
970
1065
|
pointerDownY = e.clientY;
|
|
971
1066
|
pointerDownTime = e.timeStamp;
|
|
1067
|
+
pressHoldsFocus = true;
|
|
972
1068
|
},
|
|
973
1069
|
{ passive: true, signal },
|
|
974
1070
|
);
|
|
1071
|
+
for (const end of ["pointerup", "pointercancel", "lostpointercapture"] as const) {
|
|
1072
|
+
termWrap.addEventListener(end, endPressFocusGesture, { passive: true, signal });
|
|
1073
|
+
}
|
|
975
1074
|
termWrap.addEventListener(
|
|
976
1075
|
"pointerup",
|
|
977
1076
|
(e) => {
|
|
@@ -1476,6 +1575,11 @@ function buildTerminal(
|
|
|
1476
1575
|
// detach here and the onSwitch attach below.
|
|
1477
1576
|
composition.cancelComposition();
|
|
1478
1577
|
resetToPlaceholder(input);
|
|
1578
|
+
// A held mouse gesture is latched input too, and the mouse module is per
|
|
1579
|
+
// TERMINAL while sessions multiplex over it: a record kept across the switch
|
|
1580
|
+
// would let the incoming session's first open synthesize a release for a
|
|
1581
|
+
// press its application never saw.
|
|
1582
|
+
mouse.disarmGesture();
|
|
1479
1583
|
for (const { instance } of instances) {
|
|
1480
1584
|
instance.onDetach?.();
|
|
1481
1585
|
}
|
|
@@ -1483,6 +1587,9 @@ function buildTerminal(
|
|
|
1483
1587
|
// Reconnect the terminal WS to this session using its per-tab resume
|
|
1484
1588
|
// state; the renderer was already pointed at its store by the owner.
|
|
1485
1589
|
connection.setSession(session.id);
|
|
1590
|
+
// setSession restores the incoming session's mode mirror synchronously and
|
|
1591
|
+
// delivers no modes frame, so nothing else re-derives the pointer here.
|
|
1592
|
+
updateMousePointer();
|
|
1486
1593
|
// The owned first connect has happened (session id is now on the WS URL);
|
|
1487
1594
|
// wake-reconnect handlers may fire from here on.
|
|
1488
1595
|
connectionInitiated = true;
|
|
@@ -1671,12 +1778,17 @@ function buildTerminal(
|
|
|
1671
1778
|
// Reset the composition singleton: a failure during an IME cycle must not
|
|
1672
1779
|
// leave module state or a deferred send alive for a later terminal mount.
|
|
1673
1780
|
composition.teardown();
|
|
1781
|
+
disposeMouse();
|
|
1674
1782
|
connection.disconnect();
|
|
1675
1783
|
connState.destroy();
|
|
1676
1784
|
if (toastTimer !== null) {
|
|
1677
1785
|
clearTimeout(toastTimer);
|
|
1678
1786
|
toastTimer = null;
|
|
1679
1787
|
}
|
|
1788
|
+
if (focusResolve !== null) {
|
|
1789
|
+
clearTimeout(focusResolve);
|
|
1790
|
+
focusResolve = null;
|
|
1791
|
+
}
|
|
1680
1792
|
for (const off of subscriptions) {
|
|
1681
1793
|
off();
|
|
1682
1794
|
}
|