@cplieger/web-terminal-ui 8.2.1 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/web-terminal-ui",
3
- "version": "8.2.1",
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.2",
56
- "@vitest/browser-playwright": "5.0.2",
57
- "@vitest/coverage-v8": "5.0.2",
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.38.0",
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.2"
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
- physicalKeyboardLikely() ||
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 (physicalKeyboardLikely()) {
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
@@ -17,6 +17,7 @@ export type {
17
17
  PaneSide,
18
18
  PaneHandle,
19
19
  ShellContext,
20
+ KeyboardPresence,
20
21
  AttentionOptions,
21
22
  AttentionState,
22
23
  AttentionReporter,
@@ -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
+ }
@@ -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
- if (e.pointerType !== "touch") {
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": iOS otherwise
860
- // leaves the selection stuck, because the synthetic mousedown cancelled
861
- // below also suppresses the platform's own tap-to-deselect. A deselect tap
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
- focusTerminal();
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
- // Cancel the synthetic mousedown after a touch tap so iOS keeps the
880
- // keyboard up, except with a fine pointer, where suppressing it defeated
881
- // the native focus.
882
- if (!hasFinePointer(win)) {
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
- if (lastPointerType === "touch" && !hasFinePointer(win)) {
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
- focusTerminal();
959
+ focusFromGesture();
919
960
  },
920
961
  { signal },
921
962
  );
922
963
 
923
- // A mouse gesture over `.term-output` leaves focus on the body, so typing
924
- // silently does nothing (and the two shortcuts on the keydown chain with it).
925
- // Focusing on the press would collapse the selection, so the browser's model
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; preventDefault makes that exactly once BY SPEC.
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
- focusTerminal();
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
- focusTerminal();
1223
+ focusFromGesture();
1162
1224
  }
1163
1225
  },
1164
1226
  { signal },
@@ -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: () => hasFinePointer(win),
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();
@@ -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 () => {