@cplieger/web-terminal-ui 8.2.1 → 8.3.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 +65 -612
- package/css/01-scope.css +3 -9
- package/package.json +6 -6
- package/src/features/connection-banner.ts +2 -2
- package/src/features/tabs/index.ts +12 -48
- package/src/features/tabs/model.ts +2 -3
- package/src/index.ts +1 -0
- package/src/kernel/keyboard-presence.ts +112 -0
- package/src/kernel/loading-status.ts +4 -4
- package/src/kernel/pane.ts +92 -30
- package/src/kernel/shell.ts +45 -2
- package/src/kernel/types.ts +9 -0
- package/src/viewport.ts +18 -7
package/css/01-scope.css
CHANGED
|
@@ -15,15 +15,9 @@
|
|
|
15
15
|
.wt-root.wt-viewport {
|
|
16
16
|
position: fixed;
|
|
17
17
|
inset: 0;
|
|
18
|
-
/* Clip like the container mode below, so the two
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
position:fixed descendants escape a clip regardless. It exists so the
|
|
22
|
-
asymmetry cannot be mistaken for a deliberate difference, and so a future
|
|
23
|
-
transform on .term (see docs/tab-switch-repaint.md) is bounded by a
|
|
24
|
-
declaration rather than by the viewport happening to coincide with the box.
|
|
25
|
-
overflow:clip specifically, not hidden: no scroll container, no block
|
|
26
|
-
formatting context, nothing programmatically scrollable. */
|
|
18
|
+
/* Clip like the container mode below, so the two modes agree. Inert while
|
|
19
|
+
this box is the viewport, but it bounds a future transform on .term by a
|
|
20
|
+
declaration. clip, not hidden: no scroll container, no BFC. */
|
|
27
21
|
overflow: clip;
|
|
28
22
|
}
|
|
29
23
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cplieger/web-terminal-ui",
|
|
3
|
-
"version": "8.
|
|
3
|
+
"version": "8.3.1",
|
|
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",
|
|
@@ -52,20 +52,20 @@
|
|
|
52
52
|
"@stryker-mutator/vitest-runner": "10.0.0",
|
|
53
53
|
"@types/node": "26.6.3",
|
|
54
54
|
"@typescript/native": "npm:typescript@7.0.2",
|
|
55
|
-
"@vitest/browser": "5.0.
|
|
56
|
-
"@vitest/browser-playwright": "5.0.
|
|
57
|
-
"@vitest/coverage-v8": "5.0.
|
|
55
|
+
"@vitest/browser": "5.0.3",
|
|
56
|
+
"@vitest/browser-playwright": "5.0.3",
|
|
57
|
+
"@vitest/coverage-v8": "5.0.3",
|
|
58
58
|
"eslint": "10.11.0",
|
|
59
59
|
"fast-check": "4.10.2",
|
|
60
60
|
"html-validate": "11.16.1",
|
|
61
|
-
"knip": "6.
|
|
61
|
+
"knip": "6.39.0",
|
|
62
62
|
"playwright": "1.63.0",
|
|
63
63
|
"prettier": "3.9.9",
|
|
64
64
|
"stylelint": "17.15.0",
|
|
65
65
|
"stylelint-config-standard": "40.0.0",
|
|
66
66
|
"typescript": "npm:@typescript/typescript6@6.0.2",
|
|
67
67
|
"typescript-eslint": "8.71.0",
|
|
68
|
-
"vitest": "5.0.
|
|
68
|
+
"vitest": "5.0.3"
|
|
69
69
|
},
|
|
70
70
|
"publishConfig": {
|
|
71
71
|
"access": "public"
|
|
@@ -8,11 +8,11 @@ const TEXT: Partial<Record<ConnState, string>> = {
|
|
|
8
8
|
connecting: "Reconnecting\u2026",
|
|
9
9
|
reconnecting: "Reconnecting\u2026",
|
|
10
10
|
offline: "Offline",
|
|
11
|
-
restarted: "Server restarted
|
|
11
|
+
restarted: "Server restarted. Recent input may be lost",
|
|
12
12
|
// The session's process exited (definitive; nothing is retrying). The final
|
|
13
13
|
// screen stays rendered behind the banner; open a new tab to keep working.
|
|
14
14
|
ended: "Session ended",
|
|
15
|
-
incompatible: "Terminal protocol mismatch
|
|
15
|
+
incompatible: "Terminal protocol mismatch. Update the server or reload this page",
|
|
16
16
|
};
|
|
17
17
|
|
|
18
18
|
/** Build the connectionBanner feature. Exposes no API: it owns one element in
|
|
@@ -247,12 +247,12 @@ async function createSessionHonouringRetry(
|
|
|
247
247
|
if (lastAnnouncedAt === null || elapsed - lastAnnouncedAt >= CREATE_RETRY_REANNOUNCE_MS) {
|
|
248
248
|
lastAnnouncedAt = elapsed;
|
|
249
249
|
const reason = err.serverMessage ?? "Server is not ready yet";
|
|
250
|
-
ctx.toast(`${reason}
|
|
251
|
-
ctx.announce(`${reason}
|
|
250
|
+
ctx.toast(`${reason}, waiting…`, 8000);
|
|
251
|
+
ctx.announce(`${reason}, waiting…`);
|
|
252
252
|
}
|
|
253
253
|
// Unthrottled: the overlay is the only surface visible before the first
|
|
254
254
|
// frame, and it replaces text in place.
|
|
255
|
-
ctx.loadingReason(`${err.serverMessage ?? "Server is not ready yet"}
|
|
255
|
+
ctx.loadingReason(`${err.serverMessage ?? "Server is not ready yet"}, waiting…`);
|
|
256
256
|
await retryWait(win, err.retryAfterMs ?? CREATE_RETRY_FALLBACK_MS, signal);
|
|
257
257
|
continue;
|
|
258
258
|
}
|
|
@@ -281,27 +281,6 @@ interface ShowOptions {
|
|
|
281
281
|
type SplitOpening =
|
|
282
282
|
{ readonly by: "button" } | { readonly by: "snap"; readonly id: string; readonly side: PaneSide };
|
|
283
283
|
|
|
284
|
-
function looksLikeHardwareKey(ev: KeyboardEvent): boolean {
|
|
285
|
-
if (ev.ctrlKey || ev.metaKey || ev.altKey) {
|
|
286
|
-
return true;
|
|
287
|
-
}
|
|
288
|
-
switch (ev.key) {
|
|
289
|
-
case "ArrowUp":
|
|
290
|
-
case "ArrowDown":
|
|
291
|
-
case "ArrowLeft":
|
|
292
|
-
case "ArrowRight":
|
|
293
|
-
case "Escape":
|
|
294
|
-
case "Tab":
|
|
295
|
-
case "Home":
|
|
296
|
-
case "End":
|
|
297
|
-
case "PageUp":
|
|
298
|
-
case "PageDown":
|
|
299
|
-
return true;
|
|
300
|
-
default:
|
|
301
|
-
return /^F\d{1,2}$/.test(ev.key); // F1–F12
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
|
|
305
284
|
/** Build the tabs feature.
|
|
306
285
|
* Requires a server that speaks the session API (`GET`/`POST`/`DELETE` on
|
|
307
286
|
* `/api/sessions`, `?session=<id>` on the WebSocket, ideally the status SSE).
|
|
@@ -852,13 +831,6 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
852
831
|
let creatingTab = false;
|
|
853
832
|
let collapseClearTimer: number | null = null;
|
|
854
833
|
let hintShown = false;
|
|
855
|
-
// No web API reports a hardware keyboard, so two proxies: a fine pointer
|
|
856
|
-
// (read live, since a keyboard folio with a trackpad can be detached) and a
|
|
857
|
-
// keydown only a hardware keyboard emits (a trackpad-less folio).
|
|
858
|
-
let sawHardwareKey = false;
|
|
859
|
-
const hasFinePointer = (): boolean =>
|
|
860
|
-
typeof win.matchMedia === "function" && win.matchMedia("(any-pointer: fine)").matches;
|
|
861
|
-
const physicalKeyboardLikely = (): boolean => sawHardwareKey || hasFinePointer();
|
|
862
834
|
const prefersReduce = (): boolean =>
|
|
863
835
|
win.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
864
836
|
|
|
@@ -984,7 +956,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
984
956
|
return;
|
|
985
957
|
}
|
|
986
958
|
if (
|
|
987
|
-
|
|
959
|
+
ctx.shell.keyboard.likely() ||
|
|
988
960
|
(inputFocusedAtPress && keyboardParkedOnChrome()) ||
|
|
989
961
|
keyboardInOtherPane()
|
|
990
962
|
) {
|
|
@@ -2460,8 +2432,8 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2460
2432
|
// became ready: say so with the server's own words when it gave any.
|
|
2461
2433
|
ctx.toast(
|
|
2462
2434
|
err instanceof SessionAPIError && err.serverMessage !== undefined
|
|
2463
|
-
? `
|
|
2464
|
-
: "
|
|
2435
|
+
? `Could not open a terminal. ${err.serverMessage}`
|
|
2436
|
+
: "Could not open a terminal",
|
|
2465
2437
|
);
|
|
2466
2438
|
return;
|
|
2467
2439
|
}
|
|
@@ -2601,7 +2573,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2601
2573
|
await api.close(id);
|
|
2602
2574
|
} catch {
|
|
2603
2575
|
if (!tornDown()) {
|
|
2604
|
-
ctx.toast("
|
|
2576
|
+
ctx.toast("Could not close the terminal on the server");
|
|
2605
2577
|
}
|
|
2606
2578
|
}
|
|
2607
2579
|
}
|
|
@@ -2683,7 +2655,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2683
2655
|
await api.close(t.id);
|
|
2684
2656
|
} catch {
|
|
2685
2657
|
if (!tornDown()) {
|
|
2686
|
-
ctx.toast("
|
|
2658
|
+
ctx.toast("Could not close a terminal on the server");
|
|
2687
2659
|
}
|
|
2688
2660
|
}
|
|
2689
2661
|
}
|
|
@@ -2729,7 +2701,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2729
2701
|
el.focus();
|
|
2730
2702
|
return;
|
|
2731
2703
|
}
|
|
2732
|
-
if (
|
|
2704
|
+
if (ctx.shell.keyboard.likely()) {
|
|
2733
2705
|
focusInput();
|
|
2734
2706
|
}
|
|
2735
2707
|
}
|
|
@@ -2801,7 +2773,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2801
2773
|
}
|
|
2802
2774
|
cur.pinnedTitle = before;
|
|
2803
2775
|
syncChrome();
|
|
2804
|
-
ctx.toast("
|
|
2776
|
+
ctx.toast("Could not save the terminal name");
|
|
2805
2777
|
})
|
|
2806
2778
|
.finally(() => {
|
|
2807
2779
|
const left = (namesInFlight.get(id) ?? 0) - 1;
|
|
@@ -3374,14 +3346,6 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3374
3346
|
});
|
|
3375
3347
|
}
|
|
3376
3348
|
|
|
3377
|
-
// A hardware-only key latches sawHardwareKey, which upgrades focus-on-switch
|
|
3378
|
-
// for a keyboard folio with no trackpad.
|
|
3379
|
-
ctx.registerKeydown((ev) => {
|
|
3380
|
-
if (!sawHardwareKey && looksLikeHardwareKey(ev)) {
|
|
3381
|
-
sawHardwareKey = true;
|
|
3382
|
-
}
|
|
3383
|
-
return false;
|
|
3384
|
-
});
|
|
3385
3349
|
bar.addEventListener("dragover", (e) => {
|
|
3386
3350
|
if (!draggingEl) {
|
|
3387
3351
|
return;
|
|
@@ -4220,8 +4184,8 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
4220
4184
|
// can retry, as the runtime create() does.
|
|
4221
4185
|
ctx.toast(
|
|
4222
4186
|
err instanceof SessionAPIError && err.serverMessage !== undefined
|
|
4223
|
-
? `
|
|
4224
|
-
: "
|
|
4187
|
+
? `Could not open a terminal. ${err.serverMessage}`
|
|
4188
|
+
: "Could not open a terminal",
|
|
4225
4189
|
);
|
|
4226
4190
|
}
|
|
4227
4191
|
}
|
|
@@ -167,10 +167,9 @@ export function tabAccessibleName(v: {
|
|
|
167
167
|
}): string {
|
|
168
168
|
const state = statusPhrase(v.status);
|
|
169
169
|
const progress = v.progress ?? PROGRESS_ABSENT;
|
|
170
|
-
const head =
|
|
171
|
-
progress < 0 ? `${v.label} — ${state}` : `${v.label} — ${state}, ${String(progress)}%`;
|
|
170
|
+
const head = progress < 0 ? `${v.label}, ${state}` : `${v.label}, ${state}, ${String(progress)}%`;
|
|
172
171
|
const activity = activityPhrase(v.activity ?? "", v.activityCount ?? 0);
|
|
173
|
-
return activity === "" ? head : `${head}
|
|
172
|
+
return activity === "" ? head : `${head}, ${activity}`;
|
|
174
173
|
}
|
|
175
174
|
|
|
176
175
|
// One-time "swipe to switch" hint, remembered across loads.
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// No web API reports a hardware keyboard, so the shell infers one from the keys
|
|
2
|
+
// it sees and keeps that inference in one place for every pane and feature.
|
|
3
|
+
|
|
4
|
+
import type { KeyboardPresence } from "./types.js";
|
|
5
|
+
|
|
6
|
+
/** An on-screen keyboard at least this tall is docked; the hardware-keyboard
|
|
7
|
+
* shortcut bar iPadOS shows is well under it. */
|
|
8
|
+
const SOFT_KEYBOARD_MIN_PX = 150;
|
|
9
|
+
|
|
10
|
+
/** Whether the PRIMARY pointer is fine. False on every iPad and iPhone whatever
|
|
11
|
+
* is attached, true on a desktop (`pointerCharacteristicsOfPrimaryPointingDevice`,
|
|
12
|
+
* https://github.com/WebKit/WebKit/blob/main/Source/WebKit/WebProcess/WebPage/ios/WebPageIOS.mm). */
|
|
13
|
+
export function primaryPointerFine(win: Window): boolean {
|
|
14
|
+
return typeof win.matchMedia === "function" && win.matchMedia("(pointer: fine)").matches;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Whether a mouse or trackpad is attached; a Pencil never answers it, unlike
|
|
18
|
+
* `any-pointer: fine` (`hoverSupportedByAnyAvailablePointingDevice`, same file). */
|
|
19
|
+
function accessoryMouse(win: Window): boolean {
|
|
20
|
+
return typeof win.matchMedia === "function" && win.matchMedia("(any-hover: hover)").matches;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface KeyboardPresenceTracker extends KeyboardPresence {
|
|
24
|
+
/** Whether the visual-viewport keyboard inset is to be ignored. */
|
|
25
|
+
insetSuppressed(): boolean;
|
|
26
|
+
noteKeydown(ev: KeyboardEvent): void;
|
|
27
|
+
noteSoftKeyboard(heightPx: number, editableFocused: boolean): void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface KeyboardPresenceOptions {
|
|
31
|
+
readonly win: Window & typeof globalThis;
|
|
32
|
+
/** Never the suppressed inset: the suppression itself reads this tracker. */
|
|
33
|
+
readonly softKeyboardHeight: () => number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A key that is not text: a modifier chord, navigation, Escape, Tab or a
|
|
37
|
+
* function key. An on-screen keyboard can send some of these too (Android's
|
|
38
|
+
* `InputConnection.sendKeyEvent`, https://developer.android.com/reference/android/view/inputmethod/InputConnection#sendKeyEvent(android.view.KeyEvent)),
|
|
39
|
+
* which the empty `code`, the height gate and `isOnScreenTab` answer. */
|
|
40
|
+
function looksLikeHardwareKey(ev: KeyboardEvent): boolean {
|
|
41
|
+
if (ev.ctrlKey || ev.metaKey || ev.altKey) {
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
switch (ev.key) {
|
|
45
|
+
case "ArrowUp":
|
|
46
|
+
case "ArrowDown":
|
|
47
|
+
case "ArrowLeft":
|
|
48
|
+
case "ArrowRight":
|
|
49
|
+
case "Escape":
|
|
50
|
+
case "Tab":
|
|
51
|
+
case "Home":
|
|
52
|
+
case "End":
|
|
53
|
+
case "PageUp":
|
|
54
|
+
case "PageDown":
|
|
55
|
+
return true;
|
|
56
|
+
default:
|
|
57
|
+
return /^F\d{1,2}$/.test(ev.key);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** An unmodified Tab: the one hardware-only key on the iPad's full on-screen
|
|
62
|
+
* keyboard, which reports no inset while undocked or split (`_keyboardChangedWithInfo:`,
|
|
63
|
+
* https://github.com/WebKit/WebKit/blob/main/Source/WebKit/UIProcess/API/ios/WKWebViewIOS.mm).
|
|
64
|
+
* Its code cannot tell: WebKit derives a soft key's code from its character
|
|
65
|
+
* (`initWithKeyEventType:`, https://github.com/WebKit/WebKit/blob/main/Source/WebCore/platform/ios/WebEvent.mm). */
|
|
66
|
+
function isOnScreenTab(ev: KeyboardEvent): boolean {
|
|
67
|
+
return ev.key === "Tab" && !ev.ctrlKey && !ev.metaKey && !ev.altKey;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function createKeyboardPresence(opts: KeyboardPresenceOptions): KeyboardPresenceTracker {
|
|
71
|
+
const { win } = opts;
|
|
72
|
+
let seen = false;
|
|
73
|
+
const isEditable = (target: EventTarget | null): boolean =>
|
|
74
|
+
target instanceof win.HTMLElement &&
|
|
75
|
+
(target.isContentEditable ||
|
|
76
|
+
target instanceof win.HTMLInputElement ||
|
|
77
|
+
target instanceof win.HTMLTextAreaElement);
|
|
78
|
+
return {
|
|
79
|
+
hardwareSeen: () => seen,
|
|
80
|
+
likely: () => seen || primaryPointerFine(win),
|
|
81
|
+
// createKeyboardInsets owns the phantom iPad inset this defends against; it
|
|
82
|
+
// comes with a Magic Keyboard, whose trackpad any-hover sees.
|
|
83
|
+
insetSuppressed: () => seen || primaryPointerFine(win) || accessoryMouse(win),
|
|
84
|
+
noteKeydown(ev) {
|
|
85
|
+
// An on-screen key has no physical key, so Chromium Android leaves `code`
|
|
86
|
+
// empty (it fills it from the scan code alone:
|
|
87
|
+
// https://chromium.googlesource.com/chromium/src/+/main/components/input/web_input_event_builders_android.cc),
|
|
88
|
+
// and any such key clears a latch. A keyboard app that stamps a real scan
|
|
89
|
+
// code on its own arrow can latch until its next letter. WebKit never
|
|
90
|
+
// reports an empty code (`codeForKeyEvent`,
|
|
91
|
+
// https://github.com/WebKit/WebKit/blob/main/Source/WebCore/platform/ios/PlatformEventFactoryIOS.mm),
|
|
92
|
+
// so on iOS "Unidentified", the height gate and the Tab rule below decide.
|
|
93
|
+
if (ev.code === "" || ev.key === "Unidentified") {
|
|
94
|
+
seen = false;
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (
|
|
98
|
+
looksLikeHardwareKey(ev) &&
|
|
99
|
+
(!isEditable(ev.target) ||
|
|
100
|
+
(opts.softKeyboardHeight() < SOFT_KEYBOARD_MIN_PX && !isOnScreenTab(ev)))
|
|
101
|
+
) {
|
|
102
|
+
seen = true;
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
noteSoftKeyboard(heightPx, editableFocused) {
|
|
106
|
+
// With a mouse or trackpad attached the reading may be that phantom.
|
|
107
|
+
if (heightPx >= SOFT_KEYBOARD_MIN_PX && editableFocused && !accessoryMouse(win)) {
|
|
108
|
+
seen = false;
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
}
|
|
@@ -19,10 +19,10 @@ export interface LoadingMessages {
|
|
|
19
19
|
export const DEFAULT_LOADING_MESSAGES: LoadingMessages = {
|
|
20
20
|
initial: "Loading terminal…",
|
|
21
21
|
waiting: [
|
|
22
|
-
"Still working
|
|
23
|
-
"Still working
|
|
24
|
-
"Still working
|
|
25
|
-
"Still working
|
|
22
|
+
"Still working. This can take a while on first start.",
|
|
23
|
+
"Still working, preparing the environment.",
|
|
24
|
+
"Still working, almost there.",
|
|
25
|
+
"Still working, thanks for your patience.",
|
|
26
26
|
],
|
|
27
27
|
};
|
|
28
28
|
|
package/src/kernel/pane.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { createRegions } from "./regions.js";
|
|
|
14
14
|
import { createAnnouncer, createPaneTablist, type PaneTablist } from "./a11y.js";
|
|
15
15
|
import { createConnState } from "./conn-state.js";
|
|
16
16
|
import { selectionTextWithin } from "./selection.js";
|
|
17
|
+
import { primaryPointerFine } from "./keyboard-presence.js";
|
|
17
18
|
import { windowOf } from "./realm.js";
|
|
18
19
|
import {
|
|
19
20
|
createCleanupScope,
|
|
@@ -23,6 +24,7 @@ import {
|
|
|
23
24
|
} from "./feature-host.js";
|
|
24
25
|
import type {
|
|
25
26
|
CreateTerminalOptions,
|
|
27
|
+
KeyboardPresence,
|
|
26
28
|
PaneHandle,
|
|
27
29
|
PaneSide,
|
|
28
30
|
SessionRef,
|
|
@@ -148,6 +150,9 @@ export interface PaneServices {
|
|
|
148
150
|
/** The document's keyboard geometry, published on the shell root, which this
|
|
149
151
|
* pane's root inherits rather than publishes. */
|
|
150
152
|
readonly keyboardInsets: KeyboardInsets;
|
|
153
|
+
readonly keyboard: KeyboardPresence;
|
|
154
|
+
/** The document's last press landed inside the shell root. */
|
|
155
|
+
pressedInsideShell(): boolean;
|
|
151
156
|
/** This pane's base title (the served `<title>`, or its program's OSC 0/2). */
|
|
152
157
|
titleBase(text: string): void;
|
|
153
158
|
readonly loading: PaneLoading;
|
|
@@ -226,13 +231,6 @@ export interface PaneKernel extends PaneHandle {
|
|
|
226
231
|
destroy(): void;
|
|
227
232
|
}
|
|
228
233
|
|
|
229
|
-
/** Whether `win` has a fine pointer, and with it a hardware keyboard. Read as
|
|
230
|
-
* `any-pointer: fine` rather than from pointerType because iPadOS reports a
|
|
231
|
-
* COARSE primary pointer even with a trackpad attached. */
|
|
232
|
-
export function hasFinePointer(win: Window): boolean {
|
|
233
|
-
return typeof win.matchMedia === "function" && win.matchMedia("(any-pointer: fine)").matches;
|
|
234
|
-
}
|
|
235
|
-
|
|
236
234
|
/** The consumer's theme as custom properties on `root`, so the whole subtree
|
|
237
235
|
* inherits them over the token defaults of css/00-tokens.css. */
|
|
238
236
|
export function applyTheme(root: HTMLElement, theme: CreateTerminalOptions["theme"]): void {
|
|
@@ -808,12 +806,49 @@ function buildPaneInto(
|
|
|
808
806
|
let pointerDownX = 0;
|
|
809
807
|
let pointerDownY = 0;
|
|
810
808
|
let pointerDownTime = 0;
|
|
809
|
+
let pressMadeSelection = false;
|
|
810
|
+
const ownsSelection = (): boolean => selectionTextWithin(outputEl) !== "";
|
|
811
811
|
function focusTerminal(): void {
|
|
812
812
|
if (destroyed) {
|
|
813
813
|
return;
|
|
814
814
|
}
|
|
815
815
|
input.focus({ preventScroll: true });
|
|
816
816
|
}
|
|
817
|
+
/** A gesture's focus, never over a selection: iOS refuses every selection
|
|
818
|
+
* gesture outside a focused editable (`textInteractionGesture:shouldBeginAtPoint:`,
|
|
819
|
+
* https://github.com/WebKit/WebKit/blob/main/Source/WebKit/UIProcess/ios/WKContentViewInteraction.mm). */
|
|
820
|
+
function focusFromGesture(): void {
|
|
821
|
+
if (pressMadeSelection || ownsSelection()) {
|
|
822
|
+
return;
|
|
823
|
+
}
|
|
824
|
+
focusTerminal();
|
|
825
|
+
}
|
|
826
|
+
// At the PRESS, not the tap: WebKit decides about 500ms into a long-press
|
|
827
|
+
// whether it may select (focusFromGesture), and the input must be gone by then.
|
|
828
|
+
function releaseFocusOnPress(e: PointerEvent): void {
|
|
829
|
+
if (!services.keyboard.hardwareSeen() || doc.activeElement !== input) {
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
832
|
+
if (e.pointerType !== "touch" && primaryPointerFine(win)) {
|
|
833
|
+
return;
|
|
834
|
+
}
|
|
835
|
+
if (!(e.target instanceof win.Node) || !outputEl.contains(e.target)) {
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
if (isLinkTarget(win, e.target)) {
|
|
839
|
+
return;
|
|
840
|
+
}
|
|
841
|
+
input.blur();
|
|
842
|
+
}
|
|
843
|
+
doc.addEventListener(
|
|
844
|
+
"selectionchange",
|
|
845
|
+
() => {
|
|
846
|
+
if (pressHoldsFocus && ownsSelection()) {
|
|
847
|
+
pressMadeSelection = true;
|
|
848
|
+
}
|
|
849
|
+
},
|
|
850
|
+
{ signal },
|
|
851
|
+
);
|
|
817
852
|
// A passive listener on the pane root: a touch that starts a native selection
|
|
818
853
|
// never focuses the textarea, so focus alone would leave the touched pane
|
|
819
854
|
// unselected.
|
|
@@ -832,6 +867,8 @@ function buildPaneInto(
|
|
|
832
867
|
pointerDownY = e.clientY;
|
|
833
868
|
pointerDownTime = e.timeStamp;
|
|
834
869
|
pressHoldsFocus = true;
|
|
870
|
+
pressMadeSelection = false;
|
|
871
|
+
releaseFocusOnPress(e);
|
|
835
872
|
},
|
|
836
873
|
{ passive: true, signal },
|
|
837
874
|
);
|
|
@@ -841,7 +878,9 @@ function buildPaneInto(
|
|
|
841
878
|
termWrap.addEventListener(
|
|
842
879
|
"pointerup",
|
|
843
880
|
(e) => {
|
|
844
|
-
|
|
881
|
+
// With a hardware keyboard seen, a tap is the platform's: the press already
|
|
882
|
+
// released focus, and the uncancelled mousedown lets it deselect natively.
|
|
883
|
+
if (e.pointerType !== "touch" || services.keyboard.hardwareSeen()) {
|
|
845
884
|
return;
|
|
846
885
|
}
|
|
847
886
|
if (isLinkTarget(win, e.target)) {
|
|
@@ -856,19 +895,15 @@ function buildPaneInto(
|
|
|
856
895
|
if (e.timeStamp - pointerDownTime > TAP_MAX_MS) {
|
|
857
896
|
return;
|
|
858
897
|
}
|
|
859
|
-
// A clean tap while text is selected means "done selecting":
|
|
860
|
-
//
|
|
861
|
-
//
|
|
862
|
-
// must not pop the soft keyboard, unless a hardware keyboard is present.
|
|
898
|
+
// A clean tap while text is selected means "done selecting": the synthetic
|
|
899
|
+
// mousedown cancelled below also suppresses iOS's own tap-to-deselect. It
|
|
900
|
+
// does not pop the soft keyboard.
|
|
863
901
|
const sel = doc.getSelection();
|
|
864
902
|
if (sel && !sel.isCollapsed) {
|
|
865
903
|
sel.removeAllRanges();
|
|
866
|
-
if (hasFinePointer(win)) {
|
|
867
|
-
focusTerminal();
|
|
868
|
-
}
|
|
869
904
|
return;
|
|
870
905
|
}
|
|
871
|
-
|
|
906
|
+
focusFromGesture();
|
|
872
907
|
},
|
|
873
908
|
{ passive: true, signal },
|
|
874
909
|
);
|
|
@@ -876,10 +911,10 @@ function buildPaneInto(
|
|
|
876
911
|
"mousedown",
|
|
877
912
|
(e) => {
|
|
878
913
|
if (lastPointerType === "touch") {
|
|
879
|
-
//
|
|
880
|
-
// keyboard
|
|
881
|
-
//
|
|
882
|
-
if (!
|
|
914
|
+
// Cancelling the synthetic mousedown after a tap keeps the soft keyboard
|
|
915
|
+
// up. With a hardware keyboard likely there is none to keep, and the
|
|
916
|
+
// uncancelled default is what moves focus off the textarea.
|
|
917
|
+
if (!services.keyboard.likely()) {
|
|
883
918
|
e.preventDefault();
|
|
884
919
|
}
|
|
885
920
|
return;
|
|
@@ -908,23 +943,30 @@ function buildPaneInto(
|
|
|
908
943
|
win.open(link.href, "_blank", "noopener,noreferrer");
|
|
909
944
|
return;
|
|
910
945
|
}
|
|
911
|
-
|
|
946
|
+
// A touch's synthetic click: the pointerup handler owned that gesture.
|
|
947
|
+
if (lastPointerType === "touch") {
|
|
948
|
+
return;
|
|
949
|
+
}
|
|
950
|
+
// An iPad trackpad click releases focus as a tap does once a hardware
|
|
951
|
+
// keyboard is seen; a desktop mouse keeps click-to-focus.
|
|
952
|
+
if (services.keyboard.hardwareSeen() && !primaryPointerFine(win)) {
|
|
912
953
|
return;
|
|
913
954
|
}
|
|
914
955
|
const sel = doc.getSelection();
|
|
915
956
|
if (sel && sel.toString().length > 0) {
|
|
916
957
|
return;
|
|
917
958
|
}
|
|
918
|
-
|
|
959
|
+
focusFromGesture();
|
|
919
960
|
},
|
|
920
961
|
{ signal },
|
|
921
962
|
);
|
|
922
963
|
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
964
|
+
const keysUnclaimed = (): boolean =>
|
|
965
|
+
(doc.getSelection()?.toString() ?? "") === "" &&
|
|
966
|
+
services.shell.selected() === services.side &&
|
|
967
|
+
services.pressedInsideShell();
|
|
968
|
+
// Focusing on the press would collapse a selection, so the browser's model
|
|
926
969
|
// stands and typing takes the keyboard back, as Windows Terminal does.
|
|
927
|
-
const ownsSelection = (): boolean => selectionTextWithin(outputEl) !== "";
|
|
928
970
|
doc.addEventListener(
|
|
929
971
|
"keydown",
|
|
930
972
|
(ev: KeyboardEvent) => {
|
|
@@ -945,7 +987,7 @@ function buildPaneInto(
|
|
|
945
987
|
if (composition.isComposing()) {
|
|
946
988
|
return;
|
|
947
989
|
}
|
|
948
|
-
if (!ownsSelection()) {
|
|
990
|
+
if (!ownsSelection() && !keysUnclaimed()) {
|
|
949
991
|
return;
|
|
950
992
|
}
|
|
951
993
|
// Features while the selection is STILL INTACT and before the modifier
|
|
@@ -953,6 +995,24 @@ function buildPaneInto(
|
|
|
953
995
|
if (runKeydownChain(ev)) {
|
|
954
996
|
return;
|
|
955
997
|
}
|
|
998
|
+
// Composition happens only in a focused editable (a body target gets no
|
|
999
|
+
// beforeinput or input, `_tryToHandlePressesEvent:` in
|
|
1000
|
+
// https://github.com/WebKit/WebKit/blob/main/Source/WebKit/UIProcess/ios/WKContentViewInteraction.mm),
|
|
1001
|
+
// so a dead key or an IME takes focus and passes through, ahead of the
|
|
1002
|
+
// modifier gate that would keep Option+e.
|
|
1003
|
+
if (
|
|
1004
|
+
ev.key === "Dead" ||
|
|
1005
|
+
ev.key === "Process" ||
|
|
1006
|
+
ev.isComposing ||
|
|
1007
|
+
// eslint-disable-next-line @typescript-eslint/no-deprecated -- the only carrier of the IME-commit fact; see IME_COMMIT_KEYCODE
|
|
1008
|
+
ev.keyCode === IME_COMMIT_KEYCODE
|
|
1009
|
+
) {
|
|
1010
|
+
if (ownsSelection()) {
|
|
1011
|
+
doc.getSelection()?.removeAllRanges();
|
|
1012
|
+
}
|
|
1013
|
+
focusTerminal();
|
|
1014
|
+
return;
|
|
1015
|
+
}
|
|
956
1016
|
// Decided before taking focus, because a bare modifier press (Shift on its
|
|
957
1017
|
// way to Shift+click) must leave the selection alone. Computed only for an
|
|
958
1018
|
// "ignore", so the encoder always wins a key it maps.
|
|
@@ -980,7 +1040,9 @@ function buildPaneInto(
|
|
|
980
1040
|
}
|
|
981
1041
|
if (char !== null) {
|
|
982
1042
|
// No `input` event is coming for a key that targeted the body, so send it
|
|
983
|
-
// here
|
|
1043
|
+
// here. Cancelling the keydown cancels its insertion
|
|
1044
|
+
// (https://w3c.github.io/uievents/#event-type-keydown), which Chromium
|
|
1045
|
+
// would otherwise route into the textarea just focused: one copy, not two.
|
|
984
1046
|
ev.preventDefault();
|
|
985
1047
|
sendText(normalizeTypedText(char));
|
|
986
1048
|
return;
|
|
@@ -1143,7 +1205,7 @@ function buildPaneInto(
|
|
|
1143
1205
|
engine.connection.reconnectNow();
|
|
1144
1206
|
}
|
|
1145
1207
|
if (services.shell.selected() === services.side) {
|
|
1146
|
-
|
|
1208
|
+
focusFromGesture();
|
|
1147
1209
|
}
|
|
1148
1210
|
return;
|
|
1149
1211
|
}
|
|
@@ -1158,7 +1220,7 @@ function buildPaneInto(
|
|
|
1158
1220
|
engine.connection.reconnectNow();
|
|
1159
1221
|
}
|
|
1160
1222
|
if (services.shell.selected() === services.side) {
|
|
1161
|
-
|
|
1223
|
+
focusFromGesture();
|
|
1162
1224
|
}
|
|
1163
1225
|
},
|
|
1164
1226
|
{ signal },
|