@cplieger/web-terminal-ui 8.0.1 → 8.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,7 +6,7 @@ import {
6
6
  type TerminalEngine,
7
7
  } from "@cplieger/web-terminal-engine";
8
8
  import { createComposition } from "../composition.js";
9
- import { createViewport } from "../viewport.js";
9
+ import { createViewport, type KeyboardInsets } from "../viewport.js";
10
10
  import { INPUT_PLACEHOLDER, resetToPlaceholder } from "../input-placeholder.js";
11
11
  import { createBus } from "./bus.js";
12
12
  import { TAP_MAX_MS, TAP_MOVEMENT_PX, isLinkTarget } from "./gesture.js";
@@ -143,6 +143,9 @@ export interface PaneServices {
143
143
  /** The narrow classification for this pane (the shell root's width, this
144
144
  * pane's height). */
145
145
  narrowProbe(): boolean;
146
+ /** The document's keyboard geometry, published on the shell root, which this
147
+ * pane's root inherits rather than publishes. */
148
+ readonly keyboardInsets: KeyboardInsets;
146
149
  /** This pane's base title (the served `<title>`, or its program's OSC 0/2). */
147
150
  titleBase(text: string): void;
148
151
  readonly loading: PaneLoading;
@@ -209,6 +212,9 @@ export interface PaneKernel extends PaneHandle {
209
212
  * from the moment the split closes rather than from the class the display
210
213
  * gets when the closing transition ends. */
211
214
  setHidden(value: boolean): void;
215
+ /** The shell is about to slide this pane's box: nothing it measures from now
216
+ * until its viewport settles is final, and the settle sends the size. */
217
+ holdGeometry(): void;
212
218
  /** Whether this pane's input is a stop in the document's Tab order. On while
213
219
  * the split is open and the pane shows a tab, so Tab runs left input, divider,
214
220
  * right input, chrome; off otherwise, where typing or a click enters the one
@@ -218,6 +224,13 @@ export interface PaneKernel extends PaneHandle {
218
224
  destroy(): void;
219
225
  }
220
226
 
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
+
221
234
  /** The consumer's theme as custom properties on `root`, so the whole subtree
222
235
  * inherits them over the token defaults of css/00-tokens.css. */
223
236
  export function applyTheme(root: HTMLElement, theme: CreateTerminalOptions["theme"]): void {
@@ -456,7 +469,9 @@ function buildPaneInto(
456
469
  termWrap.classList.toggle("wt-mouse-app", engine.modes.getMouseMode() !== 0);
457
470
  }
458
471
  function measurableSize(): { cols: number; rows: number } | null {
459
- if (!fontsLoaded || viewport.isInTransition()) {
472
+ // A pane hidden by a closed split is `display: none`: the cell width it would
473
+ // measure is 0, and the first render after a reveal would lay out on it.
474
+ if (!fontsLoaded || hidden || viewport.isInTransition()) {
460
475
  return null;
461
476
  }
462
477
  engine.renderer.updateFontMetrics();
@@ -764,10 +779,7 @@ function buildPaneInto(
764
779
 
765
780
  // On touch the output is the native text-selection surface, so this does the
766
781
  // MINIMUM: it opens the keyboard on a clean tap and otherwise gets out of the
767
- // browser's way. `any-pointer: fine` rather than pointerType because iPadOS
768
- // reports a COARSE primary pointer even with a trackpad attached.
769
- const hasFinePointer = (): boolean =>
770
- typeof win.matchMedia === "function" && win.matchMedia("(any-pointer: fine)").matches;
782
+ // browser's way.
771
783
  let lastPointerType = "mouse";
772
784
  let pointerDownX = 0;
773
785
  let pointerDownY = 0;
@@ -827,7 +839,7 @@ function buildPaneInto(
827
839
  const sel = doc.getSelection();
828
840
  if (sel && !sel.isCollapsed) {
829
841
  sel.removeAllRanges();
830
- if (hasFinePointer()) {
842
+ if (hasFinePointer(win)) {
831
843
  focusTerminal();
832
844
  }
833
845
  return;
@@ -843,7 +855,7 @@ function buildPaneInto(
843
855
  // Cancel the synthetic mousedown after a touch tap so iOS keeps the
844
856
  // keyboard up, except with a fine pointer, where suppressing it defeated
845
857
  // the native focus.
846
- if (!hasFinePointer()) {
858
+ if (!hasFinePointer(win)) {
847
859
  e.preventDefault();
848
860
  }
849
861
  return;
@@ -872,7 +884,7 @@ function buildPaneInto(
872
884
  win.open(link.href, "_blank", "noopener,noreferrer");
873
885
  return;
874
886
  }
875
- if (lastPointerType === "touch" && !hasFinePointer()) {
887
+ if (lastPointerType === "touch" && !hasFinePointer(win)) {
876
888
  return;
877
889
  }
878
890
  const sel = doc.getSelection();
@@ -956,9 +968,9 @@ function buildPaneInto(
956
968
 
957
969
  const viewport = createViewport({
958
970
  termWrap,
959
- root,
971
+ box: root,
972
+ keyboard: services.keyboardInsets,
960
973
  scroll: engine.scroll,
961
- suppressKeyboardInset: hasFinePointer,
962
974
  onSettled() {
963
975
  // The settle is the one resize a keyboard slide or rotation should cost;
964
976
  // sendResize deduplicates.
@@ -1157,10 +1169,13 @@ function buildPaneInto(
1157
1169
  return;
1158
1170
  }
1159
1171
  detachSession();
1172
+ if (activeSession !== null) {
1173
+ services.noteEpoch(activeSession.id, engine.connection.serverEpochOf(activeSession.id));
1174
+ }
1160
1175
  activeSession = session;
1161
- // A session another pane forgot arrives with the epoch that pane knew, or the
1162
- // first resumeAck has nothing to compare against and a server restart between
1163
- // the two attaches goes undetected.
1176
+ // A session that left another pane, emptied or replaced, arrives with the epoch
1177
+ // that pane knew, or the first resumeAck has nothing to compare against and a
1178
+ // server restart between the two attaches goes undetected.
1164
1179
  const epoch = services.knownEpoch(session.id);
1165
1180
  if (epoch !== 0) {
1166
1181
  engine.connection.adoptPersistedEpoch(session.id, epoch);
@@ -1398,6 +1413,11 @@ function buildPaneInto(
1398
1413
  setHidden(value) {
1399
1414
  hidden = value;
1400
1415
  },
1416
+ holdGeometry() {
1417
+ if (!destroyed) {
1418
+ viewport.beginTransition();
1419
+ }
1420
+ },
1401
1421
  setTabStop(value) {
1402
1422
  input.tabIndex = value ? 0 : -1;
1403
1423
  },
@@ -6,6 +6,7 @@ import {
6
6
  import {
7
7
  applyTheme,
8
8
  buildPane,
9
+ hasFinePointer,
9
10
  type PaneKernel,
10
11
  type PaneServices,
11
12
  type StoreRegistry,
@@ -28,6 +29,7 @@ import { createRegions, type Regions } from "./regions.js";
28
29
  import { createAnnouncer, createTablist, type Announcer, type PaneTablist } from "./a11y.js";
29
30
  import { createSplitHandle, type SplitHandle } from "./split-handle.js";
30
31
  import { windowOf } from "./realm.js";
32
+ import { createKeyboardInsets } from "../viewport.js";
31
33
  import type {
32
34
  AttentionOptions,
33
35
  AttentionReporter,
@@ -132,16 +134,19 @@ type PaneFailure =
132
134
  | { readonly phase: "kernel-init"; readonly cause: unknown }
133
135
  | { readonly phase: "feature-setup"; readonly feature: string; readonly cause: unknown };
134
136
 
135
- /** A close whose columns are still sliding: the model is closed already and the
136
- * display finishes when the transition ends, on the fallback timer, or on
137
- * `settle()`. */
138
- interface ClosingTransition {
139
- /** The ratio the columns slide to: 1 when the left pane survives, 0 when the right does. */
140
- readonly toward: 0 | 1;
137
+ /** An open or a close whose columns are still sliding: the model is in its new
138
+ * state already and the display finishes when the transition ends, on the
139
+ * fallback timer, or on `settle()`. */
140
+ interface Slide {
141
141
  /** Finish the display now. */
142
- settle(): void;
142
+ readonly settle: () => void;
143
143
  /** Drop the pending finish without running it. */
144
- abort(): void;
144
+ readonly abort: () => void;
145
+ }
146
+
147
+ interface ClosingTransition extends Slide {
148
+ /** The ratio the columns slide to: 1 when the left pane survives, 0 when the right does. */
149
+ readonly toward: 0 | 1;
145
150
  }
146
151
 
147
152
  /** A shell-scoped feature's registration on every built pane and on every pane
@@ -246,6 +251,7 @@ function createShellInto(
246
251
  "wt-narrow",
247
252
  "wt-split-open",
248
253
  "wt-split-collapsed",
254
+ "wt-split-opening",
249
255
  "wt-split-closing",
250
256
  );
251
257
  shellRoot.style.removeProperty("--wt-split-ratio");
@@ -263,6 +269,17 @@ function createShellInto(
263
269
  });
264
270
  }
265
271
  const paneOpts: CreateTerminalOptions = splitEnabled ? { ...opts, layout: "container" } : opts;
272
+ // The visual viewport is the document's, so its geometry is read once and
273
+ // published on the outermost root, which the shell's chrome and every pane
274
+ // inherit; published on a pane root it would never reach the chrome beside
275
+ // the panes.
276
+ const keyboardInsets = createKeyboardInsets({
277
+ root: shellRoot,
278
+ suppressKeyboardInset: () => hasFinePointer(win),
279
+ });
280
+ held.push(() => {
281
+ keyboardInsets.teardown();
282
+ });
266
283
 
267
284
  const loadingStatus = attachLoadingStatus(opts.loading, {
268
285
  ...DEFAULT_LOADING_MESSAGES,
@@ -308,9 +325,11 @@ function createShellInto(
308
325
  const selectionListeners = new Set<(side: PaneSide) => void>();
309
326
  const panesListeners = new Set<() => void>();
310
327
  let closing: ClosingTransition | null = null;
328
+ let opening: Slide | null = null;
311
329
  held.push(() => {
312
330
  closing?.abort();
313
331
  closing = null;
332
+ opening?.abort();
314
333
  });
315
334
  const selectedPane = (): PaneKernel => {
316
335
  const p = paneAt(selectedSide) ?? slots[0]?.kernel ?? undefined;
@@ -420,20 +439,18 @@ function createShellInto(
420
439
  function applyRatioVar(): void {
421
440
  shellRoot.style.setProperty("--wt-split-ratio", String(closing?.toward ?? effectiveRatio()));
422
441
  }
423
- /** How long a close slides, from the same token the stylesheet's transition
424
- * reads; 0 without `wt-animate`, and the change is then instant. */
425
- function closingDurationMs(): number {
442
+ /** How long an open or a close slides, from the same token the stylesheet's
443
+ * transition reads; 0 without `wt-animate`, and the change is then instant. */
444
+ function slideDurationMs(): number {
426
445
  if (!shellRoot.classList.contains("wt-animate")) {
427
446
  return 0;
428
447
  }
429
448
  return cssTimeMs(win.getComputedStyle(shellRoot).getPropertyValue("--dur-standard"));
430
449
  }
431
- function beginClosing(toward: 0 | 1, durationMs: number, tail: () => void): void {
432
- shellRoot.classList.add("wt-split-closing");
450
+ function slideFor(durationMs: number, tail: () => void): Slide {
433
451
  const stop = (): void => {
434
452
  win.clearTimeout(timer);
435
453
  shellRoot.removeEventListener("transitionend", onEnd);
436
- closing = null;
437
454
  };
438
455
  const settle = (): void => {
439
456
  stop();
@@ -447,7 +464,52 @@ function createShellInto(
447
464
  shellRoot.addEventListener("transitionend", onEnd);
448
465
  // An engine that applies a track-list change at once fires no transitionend.
449
466
  const timer = win.setTimeout(settle, durationMs);
450
- closing = { toward, settle, abort: stop };
467
+ return { settle, abort: stop };
468
+ }
469
+ function beginClosing(toward: 0 | 1, durationMs: number, tail: () => void): void {
470
+ shellRoot.classList.add("wt-split-closing");
471
+ const slide = slideFor(durationMs, () => {
472
+ closing = null;
473
+ tail();
474
+ });
475
+ closing = {
476
+ toward,
477
+ settle: slide.settle,
478
+ abort: () => {
479
+ slide.abort();
480
+ closing = null;
481
+ },
482
+ };
483
+ }
484
+ /** The divider comes in from the row's right edge to the share the model
485
+ * already holds, and the sizes were announced at that share: the slide's
486
+ * start shows the new pane 0 px wide, so the panes hold their geometry until
487
+ * it is over, or a socket opening meanwhile would size its PTY to that. */
488
+ function beginOpening(durationMs: number): void {
489
+ for (const p of builtPanes()) {
490
+ p.holdGeometry();
491
+ }
492
+ shellRoot.style.setProperty("--wt-split-ratio", "1");
493
+ // The read commits the start as a style of its own; without it the slide
494
+ // would start from the closed view's single column.
495
+ shellRoot.getBoundingClientRect();
496
+ shellRoot.classList.add("wt-split-opening");
497
+ const slide = slideFor(durationMs, () => {
498
+ opening = null;
499
+ shellRoot.classList.remove("wt-split-opening");
500
+ for (const p of builtPanes()) {
501
+ p.announceSize();
502
+ }
503
+ });
504
+ opening = {
505
+ settle: slide.settle,
506
+ abort: () => {
507
+ slide.abort();
508
+ opening = null;
509
+ shellRoot.classList.remove("wt-split-opening");
510
+ },
511
+ };
512
+ applyRatioVar();
451
513
  }
452
514
  function fireSplitChange(): void {
453
515
  if (changeListeners.size === 0) {
@@ -745,6 +807,9 @@ function createShellInto(
745
807
  knownEpoch.delete(sessionId);
746
808
  keeper?.forget(sessionId);
747
809
  },
810
+ chromeResized() {
811
+ keyboardInsets.refresh();
812
+ },
748
813
  attention,
749
814
  subscribeStatus: (path, callbacks) => statusShare.subscribe(path, callbacks),
750
815
  notifications,
@@ -775,6 +840,7 @@ function createShellInto(
775
840
  managed,
776
841
  scrollbackLines,
777
842
  narrowProbe: () => isNarrow(shellRoot.clientWidth, slot.root.clientHeight),
843
+ keyboardInsets,
778
844
  titleBase(text) {
779
845
  slot.baseTitle = text;
780
846
  paintTitle();
@@ -1053,6 +1119,10 @@ function createShellInto(
1053
1119
  for (const p of builtPanes()) {
1054
1120
  p.announceSize();
1055
1121
  }
1122
+ const slideMs = collapsed ? 0 : slideDurationMs();
1123
+ if (slideMs > 0) {
1124
+ beginOpening(slideMs);
1125
+ }
1056
1126
  announcer?.announce("Split open");
1057
1127
  firePanesChange();
1058
1128
  fireSplitChange();
@@ -1065,7 +1135,10 @@ function createShellInto(
1065
1135
  keepFocusOn(survivor, (el) => leaving.root.contains(el) || isHandle(el));
1066
1136
  leaving.kernel?.clearActiveSession();
1067
1137
  leaving.kernel?.setHidden(true);
1068
- const durationMs = leaving.failed || collapsed ? 0 : closingDurationMs();
1138
+ // The closing class carries the same transition, so a close during the
1139
+ // open's slide turns it around from where the columns are.
1140
+ opening?.abort();
1141
+ const durationMs = leaving.failed || collapsed ? 0 : slideDurationMs();
1069
1142
  const tail = (): void => {
1070
1143
  if (leaving.failed) {
1071
1144
  teardownSlot(leaving);
@@ -1131,6 +1204,8 @@ function createShellInto(
1131
1204
  if (!isRatio(ratio) || !splitOpen || collapsed || destroyed) {
1132
1205
  return false;
1133
1206
  }
1207
+ // A drag or a key moves the divider at once, not through the open's slide.
1208
+ opening?.settle();
1134
1209
  if (commit) {
1135
1210
  committedRatio = ratio;
1136
1211
  previewRatio = null;
@@ -9,6 +9,9 @@ const DRAG_START_PX = 8;
9
9
  const RESIZE_INTERVAL_MS = 100;
10
10
  /** One arrow press moves the divider this far: two fallback cells of 8 px. */
11
11
  const KEY_STEP_PX = 16;
12
+ /** Pointer travel past a pane's minimum width that holds the pane AT the
13
+ * minimum, so a drag can set it; only past this does a release close. */
14
+ const GRACE_PX = 120;
12
15
 
13
16
  const NUDGE_KEYS: ReadonlySet<string> = new Set(["ArrowLeft", "ArrowRight", "Home", "End"]);
14
17
 
@@ -31,12 +34,20 @@ interface Drag {
31
34
  readonly span: number;
32
35
  /** Whether the pointer has travelled DRAG_START_PX yet. */
33
36
  moving: boolean;
34
- /** The pane under the minimum right now: dimmed, and closed on release. */
37
+ /** The pane past the grace zone right now: dimmed, and closed on release. */
35
38
  squeezed: PaneSide | null;
36
- /** Whether any move of this drag put a pane under the minimum. */
39
+ /** Whether the divider is held at a pane's minimum right now. */
40
+ held: boolean;
41
+ /** Whether any move of this drag went past the grace zone. */
37
42
  dipped: boolean;
38
43
  }
39
44
 
45
+ interface Placement {
46
+ readonly ratio: number;
47
+ readonly squeezed: PaneSide | null;
48
+ readonly held: boolean;
49
+ }
50
+
40
51
  function clamp01(value: number): number {
41
52
  return Math.min(Math.max(value, 0), 1);
42
53
  }
@@ -107,12 +118,22 @@ export function createSplitHandle(shell: SplitHandleShell): SplitHandle {
107
118
  announceNow();
108
119
  }
109
120
 
110
- function squeezedAt(ratio: number, span: number): PaneSide | null {
111
- const leftPx = ratio * span;
112
- if (leftPx < MIN_PANE_PX) {
113
- return "left";
121
+ function placement(leftPx: number, span: number): Placement {
122
+ const leftPast = MIN_PANE_PX - leftPx;
123
+ const rightPast = MIN_PANE_PX - (span - leftPx);
124
+ if (leftPast > GRACE_PX) {
125
+ return { ratio: clamp01(leftPx / span), squeezed: "left", held: false };
126
+ }
127
+ if (rightPast > GRACE_PX) {
128
+ return { ratio: clamp01(leftPx / span), squeezed: "right", held: false };
129
+ }
130
+ if (leftPast > 0) {
131
+ return { ratio: MIN_PANE_PX / span, squeezed: null, held: true };
132
+ }
133
+ if (rightPast > 0) {
134
+ return { ratio: (span - MIN_PANE_PX) / span, squeezed: null, held: true };
114
135
  }
115
- return span - leftPx < MIN_PANE_PX ? "right" : null;
136
+ return { ratio: leftPx / span, squeezed: null, held: false };
116
137
  }
117
138
  function paintSqueezed(side: PaneSide | null): void {
118
139
  for (const s of ["left", "right"] as const) {
@@ -156,6 +177,7 @@ export function createSplitHandle(shell: SplitHandleShell): SplitHandle {
156
177
  span,
157
178
  moving: false,
158
179
  squeezed: null,
180
+ held: false,
159
181
  dipped: false,
160
182
  };
161
183
  el.classList.add("wt-handle-held");
@@ -177,9 +199,10 @@ export function createSplitHandle(shell: SplitHandleShell): SplitHandle {
177
199
  }
178
200
  d.moving = true;
179
201
  }
180
- const ratio = clamp01((d.startLeftPx + dx) / d.span);
202
+ const { ratio, squeezed, held } = placement(d.startLeftPx + dx, d.span);
181
203
  split.setRatio(ratio, false);
182
- d.squeezed = squeezedAt(ratio, d.span);
204
+ d.squeezed = squeezed;
205
+ d.held = held;
183
206
  if (d.squeezed !== null) {
184
207
  d.dipped = true;
185
208
  }
@@ -200,11 +223,12 @@ export function createSplitHandle(shell: SplitHandleShell): SplitHandle {
200
223
  if (d.squeezed !== null && split.closeSide(d.squeezed)) {
201
224
  return;
202
225
  }
203
- // A drag that dipped under the minimum was a close gesture, so releasing
204
- // above it, or a close the split refuses, cancels the drag rather than
205
- // committing a share at or past the threshold.
226
+ // A drag that went past the grace zone was a close gesture, so releasing
227
+ // above the minimum, or a close the split refuses, cancels the drag rather
228
+ // than committing a share at or past the threshold. A release held at the
229
+ // minimum sets it.
206
230
  const state = split.state();
207
- split.setRatio(d.dipped ? state.committedRatio : state.ratio, true);
231
+ split.setRatio(d.dipped && !d.held ? state.committedRatio : state.ratio, true);
208
232
  announceNow();
209
233
  }
210
234
  let nudgePending = false;
@@ -199,7 +199,9 @@ export interface SplitController {
199
199
  isOpen(): boolean;
200
200
  /** Enabled, closed, and the pane row is wide enough for two panes. */
201
201
  canOpen(): boolean;
202
- /** True when the split is now open at a 50/50 share; false when nothing changed. */
202
+ /** True when the split is now open at a 50/50 share; false when nothing changed.
203
+ * The state is open at once; with `wt-animate` on the shell root the divider
204
+ * slides in from the right edge over `--dur-standard`. */
203
205
  open(): boolean;
204
206
  /** The selected pane fills the view; false when nothing changed. The state is
205
207
  * closed at once; with `wt-animate` on the shell root the grid columns slide
@@ -303,6 +305,11 @@ export interface ShellContext {
303
305
  * built pane's connection other than `side` (whose own `clearActiveSession()`
304
306
  * already did it), and the scrollback keeper's forget. */
305
307
  dropSessionExcept(sessionId: string, side: PaneSide): void;
308
+ /** Call after changing what the panes' term wraps clear at the bottom (the
309
+ * `--wt-tabbar-h` or `--wt-reserve-bottom` set on `root`): every pane re-pins
310
+ * its term wrap and sends its size once it settles. Nothing observes a term
311
+ * wrap, so this call is how such a change reaches the panes. */
312
+ chromeResized(): void;
306
313
  /** Bind the page's attention surfaces (the document-title prefix, the app
307
314
  * badge, the icon links) and get the reporter that drives them. The terminal
308
315
  * is the only writer of `document.title`: a program's OSC 0/2 title replaces