@cplieger/web-terminal-ui 8.2.0 → 8.3.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/package.json +6 -6
- package/src/features/tabs/index.ts +2 -38
- package/src/index.ts +1 -0
- package/src/kernel/keyboard-presence.ts +112 -0
- package/src/kernel/pane.ts +139 -39
- package/src/kernel/shell.ts +45 -2
- package/src/kernel/types.ts +9 -0
- package/src/viewport.ts +18 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cplieger/web-terminal-ui",
|
|
3
|
-
"version": "8.
|
|
3
|
+
"version": "8.3.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",
|
|
@@ -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"
|
|
@@ -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
|
) {
|
|
@@ -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
|
}
|
|
@@ -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;
|
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
|
+
}
|
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,
|
|
@@ -36,6 +38,8 @@ import type {
|
|
|
36
38
|
|
|
37
39
|
const { mapKeyboardEvent, bracketTextForPaste, prepareTextForTerminal } = keyboard;
|
|
38
40
|
|
|
41
|
+
type GridSize = ReturnType<TerminalEngine["renderer"]["computeSize"]>;
|
|
42
|
+
|
|
39
43
|
const DEFAULT_WS_PATH = "/ws";
|
|
40
44
|
// The family that carries the CELL METRICS, alone, never --font-mono's list:
|
|
41
45
|
// WebKit settles load() over a list as soon as the FIRST family covering the
|
|
@@ -146,6 +150,9 @@ export interface PaneServices {
|
|
|
146
150
|
/** The document's keyboard geometry, published on the shell root, which this
|
|
147
151
|
* pane's root inherits rather than publishes. */
|
|
148
152
|
readonly keyboardInsets: KeyboardInsets;
|
|
153
|
+
readonly keyboard: KeyboardPresence;
|
|
154
|
+
/** The document's last press landed inside the shell root. */
|
|
155
|
+
pressedInsideShell(): boolean;
|
|
149
156
|
/** This pane's base title (the served `<title>`, or its program's OSC 0/2). */
|
|
150
157
|
titleBase(text: string): void;
|
|
151
158
|
readonly loading: PaneLoading;
|
|
@@ -224,13 +231,6 @@ export interface PaneKernel extends PaneHandle {
|
|
|
224
231
|
destroy(): void;
|
|
225
232
|
}
|
|
226
233
|
|
|
227
|
-
/** Whether `win` has a fine pointer, and with it a hardware keyboard. Read as
|
|
228
|
-
* `any-pointer: fine` rather than from pointerType because iPadOS reports a
|
|
229
|
-
* COARSE primary pointer even with a trackpad attached. */
|
|
230
|
-
export function hasFinePointer(win: Window): boolean {
|
|
231
|
-
return typeof win.matchMedia === "function" && win.matchMedia("(any-pointer: fine)").matches;
|
|
232
|
-
}
|
|
233
|
-
|
|
234
234
|
/** The consumer's theme as custom properties on `root`, so the whole subtree
|
|
235
235
|
* inherits them over the token defaults of css/00-tokens.css. */
|
|
236
236
|
export function applyTheme(root: HTMLElement, theme: CreateTerminalOptions["theme"]): void {
|
|
@@ -468,7 +468,7 @@ function buildPaneInto(
|
|
|
468
468
|
function updateMousePointer(): void {
|
|
469
469
|
termWrap.classList.toggle("wt-mouse-app", engine.modes.getMouseMode() !== 0);
|
|
470
470
|
}
|
|
471
|
-
function measurableSize():
|
|
471
|
+
function measurableSize(): GridSize | null {
|
|
472
472
|
// A pane hidden by a closed split is `display: none`: the cell width it would
|
|
473
473
|
// measure is 0, and the first render after a reveal would lay out on it.
|
|
474
474
|
if (!fontsLoaded || hidden || viewport.isInTransition()) {
|
|
@@ -477,11 +477,25 @@ function buildPaneInto(
|
|
|
477
477
|
engine.renderer.updateFontMetrics();
|
|
478
478
|
return engine.renderer.computeSize();
|
|
479
479
|
}
|
|
480
|
+
let announced: GridSize | null = null;
|
|
481
|
+
let heldGrow = false;
|
|
482
|
+
function announce(size: GridSize): void {
|
|
483
|
+
announced = size;
|
|
484
|
+
heldGrow = false;
|
|
485
|
+
engine.connection.sendResize();
|
|
486
|
+
}
|
|
487
|
+
function releaseHeldGrow(): void {
|
|
488
|
+
const size = measurableSize();
|
|
489
|
+
if (size !== null) {
|
|
490
|
+
announce(size);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
480
493
|
function maybeSendFirstResize(): void {
|
|
481
|
-
|
|
494
|
+
const size = wsOpen ? measurableSize() : null;
|
|
495
|
+
if (size === null) {
|
|
482
496
|
return;
|
|
483
497
|
}
|
|
484
|
-
|
|
498
|
+
announce(size);
|
|
485
499
|
}
|
|
486
500
|
|
|
487
501
|
const engine: TerminalEngine = createTerminalEngine({
|
|
@@ -496,6 +510,9 @@ function buildPaneInto(
|
|
|
496
510
|
},
|
|
497
511
|
onUserScrollChange: (scrolledUp) => {
|
|
498
512
|
bus.emit("scroll:state", { scrolledUp });
|
|
513
|
+
if (heldGrow && !scrolledUp) {
|
|
514
|
+
releaseHeldGrow();
|
|
515
|
+
}
|
|
499
516
|
},
|
|
500
517
|
callbacks: {
|
|
501
518
|
computeSize: () => engine.renderer.computeSize(),
|
|
@@ -513,6 +530,11 @@ function buildPaneInto(
|
|
|
513
530
|
if (fontsLoaded) {
|
|
514
531
|
markReady();
|
|
515
532
|
}
|
|
533
|
+
// The engine applies a frame to the bound store before this callback,
|
|
534
|
+
// and the alternate screen has no scrollback to hold a size for.
|
|
535
|
+
if (heldGrow && engine.renderer.boundStore().isAlt()) {
|
|
536
|
+
releaseHeldGrow();
|
|
537
|
+
}
|
|
516
538
|
bus.emit("wire:screen", msg);
|
|
517
539
|
} else if (msg.type === "title") {
|
|
518
540
|
// A shell clears its window title when it redraws its prompt after
|
|
@@ -784,12 +806,49 @@ function buildPaneInto(
|
|
|
784
806
|
let pointerDownX = 0;
|
|
785
807
|
let pointerDownY = 0;
|
|
786
808
|
let pointerDownTime = 0;
|
|
809
|
+
let pressMadeSelection = false;
|
|
810
|
+
const ownsSelection = (): boolean => selectionTextWithin(outputEl) !== "";
|
|
787
811
|
function focusTerminal(): void {
|
|
788
812
|
if (destroyed) {
|
|
789
813
|
return;
|
|
790
814
|
}
|
|
791
815
|
input.focus({ preventScroll: true });
|
|
792
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
|
+
);
|
|
793
852
|
// A passive listener on the pane root: a touch that starts a native selection
|
|
794
853
|
// never focuses the textarea, so focus alone would leave the touched pane
|
|
795
854
|
// unselected.
|
|
@@ -808,6 +867,8 @@ function buildPaneInto(
|
|
|
808
867
|
pointerDownY = e.clientY;
|
|
809
868
|
pointerDownTime = e.timeStamp;
|
|
810
869
|
pressHoldsFocus = true;
|
|
870
|
+
pressMadeSelection = false;
|
|
871
|
+
releaseFocusOnPress(e);
|
|
811
872
|
},
|
|
812
873
|
{ passive: true, signal },
|
|
813
874
|
);
|
|
@@ -817,7 +878,9 @@ function buildPaneInto(
|
|
|
817
878
|
termWrap.addEventListener(
|
|
818
879
|
"pointerup",
|
|
819
880
|
(e) => {
|
|
820
|
-
|
|
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()) {
|
|
821
884
|
return;
|
|
822
885
|
}
|
|
823
886
|
if (isLinkTarget(win, e.target)) {
|
|
@@ -832,19 +895,15 @@ function buildPaneInto(
|
|
|
832
895
|
if (e.timeStamp - pointerDownTime > TAP_MAX_MS) {
|
|
833
896
|
return;
|
|
834
897
|
}
|
|
835
|
-
// A clean tap while text is selected means "done selecting":
|
|
836
|
-
//
|
|
837
|
-
//
|
|
838
|
-
// 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.
|
|
839
901
|
const sel = doc.getSelection();
|
|
840
902
|
if (sel && !sel.isCollapsed) {
|
|
841
903
|
sel.removeAllRanges();
|
|
842
|
-
if (hasFinePointer(win)) {
|
|
843
|
-
focusTerminal();
|
|
844
|
-
}
|
|
845
904
|
return;
|
|
846
905
|
}
|
|
847
|
-
|
|
906
|
+
focusFromGesture();
|
|
848
907
|
},
|
|
849
908
|
{ passive: true, signal },
|
|
850
909
|
);
|
|
@@ -852,10 +911,10 @@ function buildPaneInto(
|
|
|
852
911
|
"mousedown",
|
|
853
912
|
(e) => {
|
|
854
913
|
if (lastPointerType === "touch") {
|
|
855
|
-
//
|
|
856
|
-
// keyboard
|
|
857
|
-
//
|
|
858
|
-
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()) {
|
|
859
918
|
e.preventDefault();
|
|
860
919
|
}
|
|
861
920
|
return;
|
|
@@ -884,23 +943,30 @@ function buildPaneInto(
|
|
|
884
943
|
win.open(link.href, "_blank", "noopener,noreferrer");
|
|
885
944
|
return;
|
|
886
945
|
}
|
|
887
|
-
|
|
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)) {
|
|
888
953
|
return;
|
|
889
954
|
}
|
|
890
955
|
const sel = doc.getSelection();
|
|
891
956
|
if (sel && sel.toString().length > 0) {
|
|
892
957
|
return;
|
|
893
958
|
}
|
|
894
|
-
|
|
959
|
+
focusFromGesture();
|
|
895
960
|
},
|
|
896
961
|
{ signal },
|
|
897
962
|
);
|
|
898
963
|
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
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
|
|
902
969
|
// stands and typing takes the keyboard back, as Windows Terminal does.
|
|
903
|
-
const ownsSelection = (): boolean => selectionTextWithin(outputEl) !== "";
|
|
904
970
|
doc.addEventListener(
|
|
905
971
|
"keydown",
|
|
906
972
|
(ev: KeyboardEvent) => {
|
|
@@ -921,7 +987,7 @@ function buildPaneInto(
|
|
|
921
987
|
if (composition.isComposing()) {
|
|
922
988
|
return;
|
|
923
989
|
}
|
|
924
|
-
if (!ownsSelection()) {
|
|
990
|
+
if (!ownsSelection() && !keysUnclaimed()) {
|
|
925
991
|
return;
|
|
926
992
|
}
|
|
927
993
|
// Features while the selection is STILL INTACT and before the modifier
|
|
@@ -929,6 +995,24 @@ function buildPaneInto(
|
|
|
929
995
|
if (runKeydownChain(ev)) {
|
|
930
996
|
return;
|
|
931
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
|
+
}
|
|
932
1016
|
// Decided before taking focus, because a bare modifier press (Shift on its
|
|
933
1017
|
// way to Shift+click) must leave the selection alone. Computed only for an
|
|
934
1018
|
// "ignore", so the encoder always wins a key it maps.
|
|
@@ -956,7 +1040,9 @@ function buildPaneInto(
|
|
|
956
1040
|
}
|
|
957
1041
|
if (char !== null) {
|
|
958
1042
|
// No `input` event is coming for a key that targeted the body, so send it
|
|
959
|
-
// 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.
|
|
960
1046
|
ev.preventDefault();
|
|
961
1047
|
sendText(normalizeTypedText(char));
|
|
962
1048
|
return;
|
|
@@ -971,11 +1057,24 @@ function buildPaneInto(
|
|
|
971
1057
|
box: root,
|
|
972
1058
|
keyboard: services.keyboardInsets,
|
|
973
1059
|
scroll: engine.scroll,
|
|
974
|
-
onSettled() {
|
|
1060
|
+
onSettled(wasAtBottom) {
|
|
975
1061
|
// The settle is the one resize a keyboard slide or rotation should cost;
|
|
976
1062
|
// sendResize deduplicates.
|
|
977
|
-
|
|
978
|
-
|
|
1063
|
+
const size = measurableSize();
|
|
1064
|
+
if (size !== null) {
|
|
1065
|
+
// A program repaints on resize, and that repaint can erase or reflow the
|
|
1066
|
+
// scrollback being read, so a taller box waits until the reader follows.
|
|
1067
|
+
if (
|
|
1068
|
+
!wasAtBottom &&
|
|
1069
|
+
announced !== null &&
|
|
1070
|
+
size.cols === announced.cols &&
|
|
1071
|
+
size.rows > announced.rows &&
|
|
1072
|
+
!engine.renderer.boundStore().isAlt()
|
|
1073
|
+
) {
|
|
1074
|
+
heldGrow = true;
|
|
1075
|
+
} else {
|
|
1076
|
+
announce(size);
|
|
1077
|
+
}
|
|
979
1078
|
}
|
|
980
1079
|
composition.positionCompositionView();
|
|
981
1080
|
},
|
|
@@ -1039,10 +1138,11 @@ function buildPaneInto(
|
|
|
1039
1138
|
// The gate opened on fallback metrics and the swap period is infinite, so
|
|
1040
1139
|
// one corrective announce once the bytes land; sendResize deduplicates.
|
|
1041
1140
|
void doc.fonts.ready.then(() => {
|
|
1042
|
-
|
|
1141
|
+
const size = destroyed ? null : measurableSize();
|
|
1142
|
+
if (size === null) {
|
|
1043
1143
|
return;
|
|
1044
1144
|
}
|
|
1045
|
-
|
|
1145
|
+
announce(size);
|
|
1046
1146
|
});
|
|
1047
1147
|
}, onFontSettled);
|
|
1048
1148
|
} catch (err) {
|
|
@@ -1105,7 +1205,7 @@ function buildPaneInto(
|
|
|
1105
1205
|
engine.connection.reconnectNow();
|
|
1106
1206
|
}
|
|
1107
1207
|
if (services.shell.selected() === services.side) {
|
|
1108
|
-
|
|
1208
|
+
focusFromGesture();
|
|
1109
1209
|
}
|
|
1110
1210
|
return;
|
|
1111
1211
|
}
|
|
@@ -1120,7 +1220,7 @@ function buildPaneInto(
|
|
|
1120
1220
|
engine.connection.reconnectNow();
|
|
1121
1221
|
}
|
|
1122
1222
|
if (services.shell.selected() === services.side) {
|
|
1123
|
-
|
|
1223
|
+
focusFromGesture();
|
|
1124
1224
|
}
|
|
1125
1225
|
},
|
|
1126
1226
|
{ signal },
|
|
@@ -1353,7 +1453,7 @@ function buildPaneInto(
|
|
|
1353
1453
|
clearActiveSession,
|
|
1354
1454
|
announceSize() {
|
|
1355
1455
|
if (!destroyed && fontsLoaded) {
|
|
1356
|
-
engine.
|
|
1456
|
+
announce(engine.renderer.computeSize());
|
|
1357
1457
|
}
|
|
1358
1458
|
},
|
|
1359
1459
|
focus: focusTerminal,
|
package/src/kernel/shell.ts
CHANGED
|
@@ -6,11 +6,11 @@ import {
|
|
|
6
6
|
import {
|
|
7
7
|
applyTheme,
|
|
8
8
|
buildPane,
|
|
9
|
-
hasFinePointer,
|
|
10
9
|
type PaneKernel,
|
|
11
10
|
type PaneServices,
|
|
12
11
|
type StoreRegistry,
|
|
13
12
|
} from "./pane.js";
|
|
13
|
+
import { createKeyboardPresence } from "./keyboard-presence.js";
|
|
14
14
|
import { createStatusShare } from "./status-share.js";
|
|
15
15
|
import { browserNotifierEnv, createNotifier } from "./notify.js";
|
|
16
16
|
import { attachLoadingStatus, DEFAULT_LOADING_MESSAGES } from "./loading-status.js";
|
|
@@ -269,17 +269,54 @@ function createShellInto(
|
|
|
269
269
|
});
|
|
270
270
|
}
|
|
271
271
|
const paneOpts: CreateTerminalOptions = splitEnabled ? { ...opts, layout: "container" } : opts;
|
|
272
|
+
// Called only from a keydown, so keyboardInsets below exists by then.
|
|
273
|
+
const keyboardPresence = createKeyboardPresence({
|
|
274
|
+
win,
|
|
275
|
+
softKeyboardHeight: () => keyboardInsets.softKeyboardHeight(),
|
|
276
|
+
});
|
|
277
|
+
const paneInputFocused = (): boolean => {
|
|
278
|
+
const active = doc.activeElement;
|
|
279
|
+
return (
|
|
280
|
+
active instanceof win.HTMLTextAreaElement &&
|
|
281
|
+
active.classList.contains("term-input") &&
|
|
282
|
+
shellRoot.contains(active)
|
|
283
|
+
);
|
|
284
|
+
};
|
|
272
285
|
// The visual viewport is the document's, so its geometry is read once and
|
|
273
286
|
// published on the outermost root, which the shell's chrome and every pane
|
|
274
287
|
// inherit; published on a pane root it would never reach the chrome beside
|
|
275
288
|
// the panes.
|
|
276
289
|
const keyboardInsets = createKeyboardInsets({
|
|
277
290
|
root: shellRoot,
|
|
278
|
-
suppressKeyboardInset: () =>
|
|
291
|
+
suppressKeyboardInset: () => keyboardPresence.insetSuppressed(),
|
|
292
|
+
onSoftKeyboard(heightPx) {
|
|
293
|
+
keyboardPresence.noteSoftKeyboard(heightPx, paneInputFocused());
|
|
294
|
+
},
|
|
279
295
|
});
|
|
280
296
|
held.push(() => {
|
|
281
297
|
keyboardInsets.teardown();
|
|
282
298
|
});
|
|
299
|
+
const docListeners = new AbortController();
|
|
300
|
+
held.push(() => {
|
|
301
|
+
docListeners.abort();
|
|
302
|
+
});
|
|
303
|
+
doc.addEventListener(
|
|
304
|
+
"keydown",
|
|
305
|
+
(ev) => {
|
|
306
|
+
keyboardPresence.noteKeydown(ev);
|
|
307
|
+
},
|
|
308
|
+
{ capture: true, signal: docListeners.signal },
|
|
309
|
+
);
|
|
310
|
+
// A press in the host page leaves the keys with the host, so the panes' body
|
|
311
|
+
// keydown path arms only after a press inside the terminal.
|
|
312
|
+
let pressedInsideShell = false;
|
|
313
|
+
doc.addEventListener(
|
|
314
|
+
"pointerdown",
|
|
315
|
+
(ev) => {
|
|
316
|
+
pressedInsideShell = ev.target instanceof win.Node && shellRoot.contains(ev.target);
|
|
317
|
+
},
|
|
318
|
+
{ capture: true, passive: true, signal: docListeners.signal },
|
|
319
|
+
);
|
|
283
320
|
|
|
284
321
|
const loadingStatus = attachLoadingStatus(opts.loading, {
|
|
285
322
|
...DEFAULT_LOADING_MESSAGES,
|
|
@@ -785,6 +822,10 @@ function createShellInto(
|
|
|
785
822
|
: disabledSplit();
|
|
786
823
|
const shellContext: ShellContext = {
|
|
787
824
|
root: shellRoot,
|
|
825
|
+
keyboard: {
|
|
826
|
+
hardwareSeen: () => keyboardPresence.hardwareSeen(),
|
|
827
|
+
likely: () => keyboardPresence.likely(),
|
|
828
|
+
},
|
|
788
829
|
pane: (side) => paneAt(side) ?? null,
|
|
789
830
|
panes: builtPanes,
|
|
790
831
|
selected: () => selectedSide,
|
|
@@ -841,6 +882,8 @@ function createShellInto(
|
|
|
841
882
|
scrollbackLines,
|
|
842
883
|
narrowProbe: () => isNarrow(shellRoot.clientWidth, slot.root.clientHeight),
|
|
843
884
|
keyboardInsets,
|
|
885
|
+
keyboard: shellContext.keyboard,
|
|
886
|
+
pressedInsideShell: () => pressedInsideShell,
|
|
844
887
|
titleBase(text) {
|
|
845
888
|
slot.baseTitle = text;
|
|
846
889
|
paintTitle();
|
package/src/kernel/types.ts
CHANGED
|
@@ -280,11 +280,20 @@ export interface Notifier {
|
|
|
280
280
|
forget(id: string): void;
|
|
281
281
|
}
|
|
282
282
|
|
|
283
|
+
/** Whether the page has a hardware keyboard, as far as its key events tell. */
|
|
284
|
+
export interface KeyboardPresence {
|
|
285
|
+
/** A key no on-screen keyboard produces is the latest keyboard evidence seen. */
|
|
286
|
+
hardwareSeen(): boolean;
|
|
287
|
+
/** `hardwareSeen()`, or the primary pointer is fine. */
|
|
288
|
+
likely(): boolean;
|
|
289
|
+
}
|
|
290
|
+
|
|
283
291
|
/** The shell a feature reaches through `ctx.shell`: the pane kernels and the
|
|
284
292
|
* facts shared between them. */
|
|
285
293
|
export interface ShellContext {
|
|
286
294
|
/** The shell root; the pane root in the direct-root topology. */
|
|
287
295
|
readonly root: HTMLElement;
|
|
296
|
+
readonly keyboard: KeyboardPresence;
|
|
288
297
|
/** Null while that side has no built kernel. */
|
|
289
298
|
pane(side: PaneSide): PaneHandle | null;
|
|
290
299
|
/** Built panes, left first. */
|
package/src/viewport.ts
CHANGED
|
@@ -27,12 +27,17 @@ export interface KeyboardInsetsOptions {
|
|
|
27
27
|
/** Ignore the visualViewport keyboard geometry (a hardware-keyboard device
|
|
28
28
|
* has no soft keyboard to accommodate). */
|
|
29
29
|
suppressKeyboardInset?: () => boolean;
|
|
30
|
+
/** Called with `softKeyboardHeight()` at every reading, before
|
|
31
|
+
* `suppressKeyboardInset` is consulted for it. */
|
|
32
|
+
onSoftKeyboard?: (heightPx: number) => void;
|
|
30
33
|
}
|
|
31
34
|
|
|
32
35
|
/** The document's one reading of its visual viewport. */
|
|
33
36
|
export interface KeyboardInsets {
|
|
34
37
|
/** The latest geometry; zero where the window has no visual viewport. */
|
|
35
38
|
current(): KeyboardGeometry;
|
|
39
|
+
/** The visual-viewport keyboard inset now in CSS px, never suppressed. */
|
|
40
|
+
softKeyboardHeight(): number;
|
|
36
41
|
/** Call `fn` with the geometry on every visual-viewport resize and scroll, on
|
|
37
42
|
* every window focus and bfcache restore, and on every `refresh()`; returns
|
|
38
43
|
* the release. */
|
|
@@ -110,16 +115,21 @@ export function createKeyboardInsets(opts: KeyboardInsetsOptions): KeyboardInset
|
|
|
110
115
|
root.style.removeProperty("--vv-top");
|
|
111
116
|
}
|
|
112
117
|
|
|
118
|
+
function readVisual(): KeyboardGeometry {
|
|
119
|
+
return vv
|
|
120
|
+
? {
|
|
121
|
+
top: Math.max(0, Math.round(vv.offsetTop)),
|
|
122
|
+
bottom: Math.max(0, Math.round(win.innerHeight - vv.offsetTop - vv.height)),
|
|
123
|
+
}
|
|
124
|
+
: { top: 0, bottom: 0 };
|
|
125
|
+
}
|
|
126
|
+
|
|
113
127
|
function update(): void {
|
|
128
|
+
const raw = readVisual();
|
|
129
|
+
opts.onSoftKeyboard?.(raw.bottom);
|
|
114
130
|
// iPadOS has been seen to report a keyboard-sized shrink with no keyboard
|
|
115
131
|
// shown and pin it, which is what suppressKeyboardInset defends against.
|
|
116
|
-
geometry =
|
|
117
|
-
!vv || suppressKeyboardInset()
|
|
118
|
-
? { top: 0, bottom: 0 }
|
|
119
|
-
: {
|
|
120
|
-
top: Math.max(0, Math.round(vv.offsetTop)),
|
|
121
|
-
bottom: Math.max(0, Math.round(win.innerHeight - vv.offsetTop - vv.height)),
|
|
122
|
-
};
|
|
132
|
+
geometry = suppressKeyboardInset() ? { top: 0, bottom: 0 } : raw;
|
|
123
133
|
root.style.setProperty("--kb-inset", `${geometry.bottom}px`);
|
|
124
134
|
root.style.setProperty("--vv-top", `${geometry.top}px`);
|
|
125
135
|
for (const fn of [...subscribers]) {
|
|
@@ -144,6 +154,7 @@ export function createKeyboardInsets(opts: KeyboardInsetsOptions): KeyboardInset
|
|
|
144
154
|
|
|
145
155
|
return {
|
|
146
156
|
current: () => geometry,
|
|
157
|
+
softKeyboardHeight: () => readVisual().bottom,
|
|
147
158
|
onChange(fn) {
|
|
148
159
|
subscribers.add(fn);
|
|
149
160
|
return () => {
|