@cplieger/web-terminal-ui 8.1.2 → 8.2.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 CHANGED
@@ -469,7 +469,8 @@ terminal with its own engine, so an open split holds up to two WebSocket
469
469
  connections, one per shown pane. Closing and resizing the split is a display
470
470
  re-arrangement only: no tab or session is ever created or closed by it, and a tab
471
471
  that leaves a pane stays in the row as an ordinary tab. Opening it is one too,
472
- except from the split button with a single tab, which creates the second tab.
472
+ except with a single tab: the split button, a snap or a drop that opens it then
473
+ creates the second tab.
473
474
 
474
475
  Three ways in, all in the tab chrome, none a key chord:
475
476
 
@@ -490,12 +491,17 @@ Three ways in, all in the tab chrome, none a key chord:
490
491
  an assistive technology's own command). A snapped tab shows on the named side
491
492
  and its pane is selected. When the snap opens the split, the other pane keeps
492
493
  the tab the single view showed (or, when that is the tab being moved, its
493
- split partner), so neither pane opens empty. A tab the other pane shows trades
494
- places with the tab on the named side, so both stay on screen (into an empty
495
- pane it simply moves); any other tab there becomes an ordinary tab. Snapping a
496
- tab onto the side that already shows it changes nothing.
494
+ split partner), so neither pane opens empty. With a single tab, a snap that
495
+ opens the split creates a new tab for the other pane, and the split opens
496
+ once that tab exists; a failed create leaves the single view as it was. A tab
497
+ the other pane shows trades places with the tab on the named side, so both
498
+ stay on screen (into an empty pane it simply moves); any other tab there
499
+ becomes an ordinary tab. In an open split, snapping a tab onto the side that
500
+ already shows it changes nothing.
497
501
  - **Dragging a tab** out of the row onto either pane of the terminal area,
498
- following the same rule. While the drag lasts the pane under the pointer is
502
+ following the same rule. By touch or pen, hold the tab still for a moment
503
+ before moving it; a swipe that moves straight away scrolls the row. While
504
+ the drag lasts the pane under the pointer is
499
505
  highlighted; each highlight covers that pane's terminal area at the divider's
500
506
  position (the two halves while the split is closed), inset a little with the
501
507
  tab chips' rounded border, and a release decides by the divider's centre.
package/css/30-tabs.css CHANGED
@@ -154,11 +154,10 @@
154
154
  white-space: nowrap;
155
155
  -webkit-user-select: none;
156
156
  user-select: none;
157
- /* Kills the 300ms double-tap-to-zoom delay, which is what makes a double-TAP
158
- usable as the touch rename entry: on iPadOS a long-press on this chip starts
159
- a reorder drag (it is draggable), so the context menu is unreachable by touch
160
- and the double-tap is the only path in. Also removes the tap delay for the
161
- ordinary switch tap. */
157
+ /* Kills the 300ms double-tap-to-zoom delay, which keeps a double-TAP usable as
158
+ the touch rename entry and removes the tap delay for the ordinary switch
159
+ tap. Panning stays allowed, so a swipe along the strip scrolls it; a touch
160
+ reorder cancels the pan itself once its hold has lifted the chip. */
162
161
  touch-action: manipulation;
163
162
  }
164
163
 
@@ -616,15 +615,13 @@
616
615
  }
617
616
 
618
617
  /* The drag preview clone (setDragGhost): a full-opacity copy of the chip parked
619
- under .wt-root — OUTSIDE the bar's backdrop-filter (embedded wt-container
620
- roots only, since 5.3.0; the full-page bar no longer carries one), which is
621
- what stops WebKit from rendering the automatic drag image (webkit.org/b/22787)
622
- — and laid exactly
623
- over the real chip via inline left/top/width/height. It exists for less than a
624
- frame (the browser snapshots it, then a rAF removes it), so it must not be
625
- hidden (an invisible element makes no snapshot) and must not intercept the
626
- pointer. z-index clears the bar so the copy, not the dimmed original, is what
627
- would show if it ever painted. */
618
+ under .wt-root, outside any backdrop-filter on the bar, which would stop WebKit
619
+ rendering the drag image (webkit.org/b/22787), and laid over the real chip via
620
+ inline left/top/width/height. A native drag snapshots it within a frame, so it
621
+ must not be hidden (an invisible element makes no snapshot); a touch or pen
622
+ drag keeps it under the finger and hit-tests the point beneath, so it must
623
+ never intercept the pointer. z-index clears the bar so the copy, not the
624
+ dimmed original, is what shows. */
628
625
  :where(.wt-root) .wt-tab-ghost {
629
626
  position: absolute;
630
627
  z-index: 16;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/web-terminal-ui",
3
- "version": "8.1.2",
3
+ "version": "8.2.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",
@@ -50,21 +50,28 @@ import {
50
50
  tabAccessibleName,
51
51
  } from "./model.js";
52
52
  import {
53
- REORDER_MOVE_EPS_PX,
54
53
  REORDER_REST_MS,
55
54
  REORDER_STILL_MS,
56
55
  REORDER_SETTLE_MS,
57
56
  REORDER_SHIFT_TRANS,
58
57
  REORDER_SLOT_FADE_MS,
59
58
  TAB_HTML,
59
+ exceedsSlop,
60
60
  kbButtonHTML,
61
61
  newButtonHTML,
62
+ noteRestSample,
62
63
  paintActivityMark,
63
64
  paintProgress,
64
65
  paintStatusDot,
65
66
  pick,
67
+ pointerDragActivation,
66
68
  splitButtonHTML,
67
69
  switchButtonHTML,
70
+ viewportBox,
71
+ viewportMoved,
72
+ type DragActivation,
73
+ type RestState,
74
+ type ViewportBox,
68
75
  } from "./strip.js";
69
76
  import {
70
77
  AXIS_LOCK_PX,
@@ -106,13 +113,12 @@ export interface TabsApi {
106
113
  * pane already shows has that pane selected instead. */
107
114
  switchTo(id: string): void;
108
115
  /** Show a tab on one side of the split, opening it when closed, and select its
109
- * pane. Opening the split keeps the single view's tab on the other side — or,
110
- * when the moved tab IS it, its split partner — so neither pane opens empty. A
111
- * tab the other pane shows trades places with the tab on that side (or leaves
112
- * its pane empty when the side showed none); any other tab there becomes
113
- * ordinary. A tab already shown on that side changes nothing. False when
114
- * refused: an unknown id, an invalid side, or a split the pane row is too
115
- * narrow to open. */
116
+ * pane. Opening keeps the single view's tab, or the moved tab's split partner,
117
+ * on the other side; with no other tab, one is created and the split opens
118
+ * once it exists. A tab the other pane shows trades places with the one on
119
+ * that side; any other tab there becomes ordinary. In an open split, a tab
120
+ * already shown on that side changes nothing. False when refused: an unknown
121
+ * id, an invalid side, or a split the pane row is too narrow to open. */
116
122
  snap(id: string, side: PaneSide): boolean;
117
123
  /** The current tabs, first-to-last by creation; `active` marks a tab a pane
118
124
  * shows. */
@@ -269,6 +275,12 @@ interface ShowOptions {
269
275
  readonly keepSelection?: boolean;
270
276
  }
271
277
 
278
+ /** How a create opens the split once its tab exists. `button`: the single view
279
+ * keeps its tab and the new tab fills the other pane, selected. `snap`: `id`
280
+ * lands on `side`, selected, and the new tab fills the other pane. */
281
+ type SplitOpening =
282
+ { readonly by: "button" } | { readonly by: "snap"; readonly id: string; readonly side: PaneSide };
283
+
272
284
  function looksLikeHardwareKey(ev: KeyboardEvent): boolean {
273
285
  if (ev.ctrlKey || ev.metaKey || ev.altKey) {
274
286
  return true;
@@ -781,6 +793,9 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
781
793
  // The record is read once and never written back by a read.
782
794
  let applyingRecord = false;
783
795
  let warnedLayout = false;
796
+ // Twin of marotte static-src/tabs-drag.ts; when changing drag behaviour or its
797
+ // constants, check the other app. Both judge a live drag's viewport against the
798
+ // PRESS-time frame, so a move a hold lifted across still ends the drag.
784
799
  let draggingEl: HTMLElement | null = null;
785
800
  // The chips currently carrying an inline displacement from the commit slide.
786
801
  // A set plus one settle function, so whatever a slide wrote can always be handed
@@ -789,18 +804,35 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
789
804
  let shiftTimer: number | null = null;
790
805
  // The no-events fallback timer. It carries its own target, so there is
791
806
  // deliberately NO pending-slot field here: one was the cause of the reorder
792
- // dropping commits (see trackRest). restX is the last pointer position seen, which
793
- // is how movement is told from stillness.
807
+ // dropping commits (see trackRest). `rest` is how movement is told from
808
+ // stillness.
794
809
  let restTimer: number | null = null;
795
- let restX: number | null = null;
796
- // When the pointer last actually MOVED. A stationary dragover this long after it
797
- // is believed as a stop (see trackRest).
798
- let restMovedAt = 0;
810
+ const rest: RestState = { at: null, movedAt: 0 };
799
811
  // Whether a `drop` fired for the drag in flight. It is the exact signal for
800
812
  // "the user released deliberately" as opposed to "the drag was abandoned":
801
813
  // Escape and a refused release fire dragend with no drop at all. dragend
802
814
  // without it reverts the preview, which is the cancel this reorder never had.
803
815
  let dropped = false;
816
+ // A touch or pen press on a chip, from pointerdown to the gesture's end.
817
+ // `live` once the hold lifted it into a drag; `moved` once it then left the
818
+ // slop, which is what makes the release a drop rather than a tap.
819
+ interface PointerPress {
820
+ readonly id: number;
821
+ readonly el: HTMLElement;
822
+ readonly x0: number;
823
+ readonly y0: number;
824
+ readonly rule: DragActivation;
825
+ readonly frame: ViewportBox;
826
+ readonly draggable: boolean;
827
+ readonly off: AbortController;
828
+ x: number;
829
+ y: number;
830
+ hold: number | null;
831
+ tick: number | null;
832
+ live: boolean;
833
+ moved: boolean;
834
+ }
835
+ let press: PointerPress | null = null;
804
836
  // The chip mid slot-fade, and the timer that ends it.
805
837
  let slotFadeEl: HTMLElement | null = null;
806
838
  let slotFadeTimer: number | null = null;
@@ -1419,9 +1451,8 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1419
1451
  }
1420
1452
  switchTo(tab.id);
1421
1453
  });
1422
- // Also the TOUCH rename path: a long-press on a draggable chip starts a
1423
- // reorder drag on iPadOS instead of opening the menu. The chip's
1424
- // touch-action: manipulation keeps iOS from reading the second tap as a zoom.
1454
+ // Also the TOUCH rename path. The chip's touch-action: manipulation keeps
1455
+ // iOS from reading the second tap as a zoom.
1425
1456
  el.addEventListener("dblclick", (e) => {
1426
1457
  if ((e.target as HTMLElement).closest(".wt-tab-close")) {
1427
1458
  return;
@@ -1503,10 +1534,15 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1503
1534
  void close_(tab.id);
1504
1535
  }
1505
1536
  });
1506
- // Right-click opens the tab context menu (desktop). preventDefault stops
1507
- // the browser's own menu; the strip is hidden on a coarse pointer, so
1508
- // this is desktop-only in practice.
1537
+ // Right-click opens the tab context menu, and so does a touch long-press
1538
+ // that has not dragged anywhere; one that has is a drop in progress, so
1539
+ // the platform's menu is refused and ours stays shut.
1509
1540
  el.addEventListener("contextmenu", (e) => {
1541
+ if (press?.moved === true) {
1542
+ e.preventDefault();
1543
+ return;
1544
+ }
1545
+ cancelPointerPress();
1510
1546
  if (editingId === tab.id) {
1511
1547
  // Let the browser's own text menu open: it is where Paste, Select All
1512
1548
  // and Undo live, and the tab menu would offer Close and Move mid-edit.
@@ -1517,20 +1553,19 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1517
1553
  });
1518
1554
  // Drag-and-drop reorder on the desktop strip. The bar's dragover arms a
1519
1555
  // slot, the hold (or a drop) commits it, and drop/dragend commit the
1520
- // resulting order into tabList. See the reorder-preview block below.
1556
+ // resulting order into tabList. See the reorder-preview block below. A
1557
+ // touch or pen press takes the pointer path instead (beginPointerPress),
1558
+ // so the platform's own drag must not start as well.
1521
1559
  el.draggable = true;
1522
1560
  el.addEventListener("dragstart", (e) => {
1523
- if (editingId === tab.id) {
1524
- e.preventDefault(); // renaming: a drag would fight caret selection
1561
+ if (editingId === tab.id || press !== null) {
1562
+ e.preventDefault(); // a rename owns the caret; a pointer press owns the drag
1525
1563
  return;
1526
1564
  }
1527
- endReorderPreview(); // no residue from a previous drag
1528
- endShift(); // ...and nothing mid-slide, so this gesture starts from rest
1529
- dropped = false;
1530
- draggingEl = el;
1531
1565
  // Snapshot the preview from the PRISTINE chip, before the slot class.
1532
- setDragGhost(e, el);
1533
- el.classList.add("wt-tab-dragging");
1566
+ startTabDrag(el, () => {
1567
+ setDragGhost(e, el);
1568
+ });
1534
1569
  if (e.dataTransfer) {
1535
1570
  e.dataTransfer.effectAllowed = "move";
1536
1571
  // Firefox requires drag data to be set for the drag to start; the
@@ -1538,22 +1573,9 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1538
1573
  // TAB_DRAG_TYPE).
1539
1574
  e.dataTransfer.setData(TAB_DRAG_TYPE, tab.id);
1540
1575
  }
1541
- hideTabMenu();
1542
1576
  });
1543
1577
  el.addEventListener("dragend", () => {
1544
- el.classList.remove("wt-tab-dragging");
1545
- paintDropHalf(null);
1546
- if (!dropped) {
1547
- // No drop fired, so the drag was abandoned: Escape, or a release the
1548
- // browser refused. Put the strip back — tabList still holds the order
1549
- // the gesture started from, because only a drop writes to it.
1550
- revertPreview();
1551
- }
1552
- draggingEl = null;
1553
- // The gesture's state goes; a slide started by the revert above is left to
1554
- // finish under its own settle timer (see endReorderPreview).
1555
- endReorderPreview();
1556
- clearDragGhost(); // belt-and-braces: the rAF normally got there first
1578
+ endTabDrag(el);
1557
1579
  });
1558
1580
  return tab;
1559
1581
  }
@@ -1675,13 +1697,22 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1675
1697
  if (!isPaneSide(side) || !tabList.some((t) => t.id === id)) {
1676
1698
  return false;
1677
1699
  }
1700
+ const split = ctx.shell.split;
1701
+ const opening = !split.isOpen();
1678
1702
  const from = sideOf(id);
1679
- if (from === side) {
1703
+ if (from === side && !opening) {
1680
1704
  return true;
1681
1705
  }
1682
- const split = ctx.shell.split;
1683
- const opening = !split.isOpen();
1684
- if (opening && (!split.canOpen() || !split.open())) {
1706
+ if (opening && !split.canOpen()) {
1707
+ return false;
1708
+ }
1709
+ // Nothing to pair the moved tab with: the split opens once a created tab
1710
+ // can fill the other pane.
1711
+ if (opening && splitPartner(id) === null) {
1712
+ void create({ by: "snap", id, side });
1713
+ return true;
1714
+ }
1715
+ if (opening && !split.open()) {
1685
1716
  return false;
1686
1717
  }
1687
1718
  // Emptying the tab's old pane drops a keyboard its input held to the body.
@@ -1693,13 +1724,9 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1693
1724
  // Opening pairs the moved tab with the single view's tab, or its partner
1694
1725
  // when they are one, so no pane opens empty; an open split only re-homes
1695
1726
  // the displaced tab, into a pane the move emptied.
1696
- const back = displaced ?? (opening ? splitPartner(id) : null);
1697
- if (
1698
- (from !== null || opening) &&
1699
- back !== null &&
1700
- back !== id &&
1701
- shownIn(otherSide(side)) === null
1702
- ) {
1727
+ const kept = displaced === id ? null : displaced;
1728
+ const back = kept ?? (opening ? splitPartner(id) : null);
1729
+ if ((from !== null || opening) && back !== null && shownIn(otherSide(side)) === null) {
1703
1730
  showIn(otherSide(side), back, { keepSelection: true });
1704
1731
  }
1705
1732
  if (typing) {
@@ -1716,7 +1743,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1716
1743
  }
1717
1744
  const partner = splitPartner(selectedId());
1718
1745
  if (partner === null) {
1719
- void create(true);
1746
+ void create({ by: "button" });
1720
1747
  } else if (split.open()) {
1721
1748
  showIn("right", partner, { keepSelection: true });
1722
1749
  }
@@ -2016,12 +2043,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2016
2043
  if (!dragged) {
2017
2044
  return;
2018
2045
  }
2019
- const now = win.Date.now();
2020
- const moved = restX === null || Math.abs(clientX - restX) > REORDER_MOVE_EPS_PX;
2021
- restX = clientX;
2022
- if (moved) {
2023
- restMovedAt = now;
2024
- }
2046
+ const still = noteRestSample(rest, clientX, win.Date.now());
2025
2047
  // Recomputed from THIS event, never a stored pending target: a stored one
2026
2048
  // was nulled by the already-there branch on the way past, so a stop after it
2027
2049
  // committed NOTHING until the mouse was jiggled.
@@ -2030,9 +2052,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2030
2052
  endRestNet();
2031
2053
  return;
2032
2054
  }
2033
- // The elapsed check filters one event of a sweep landing within the epsilon
2034
- // of the previous (a reversal, or a mostly vertical frame).
2035
- if (!moved && now - restMovedAt >= REORDER_STILL_MS) {
2055
+ if (still) {
2036
2056
  endRestNet();
2037
2057
  commitSlot(before);
2038
2058
  return;
@@ -2188,15 +2208,45 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2188
2208
  // revert snap home instead of run. dragstart and teardown call endShift().
2189
2209
  function endReorderPreview(): void {
2190
2210
  endRestNet();
2191
- restX = null;
2211
+ rest.at = null;
2192
2212
  endSlotFade();
2193
2213
  }
2194
2214
 
2215
+ // The lift both drag paths share. `ghost` builds the preview copy, and runs
2216
+ // before the slot class so it copies the pristine chip.
2217
+ function startTabDrag(el: HTMLElement, ghost: () => void): void {
2218
+ endReorderPreview(); // no residue from a previous drag
2219
+ endShift(); // ...and nothing mid-slide, so this gesture starts from rest
2220
+ dropped = false;
2221
+ draggingEl = el;
2222
+ ghost();
2223
+ el.classList.add("wt-tab-dragging");
2224
+ hideTabMenu();
2225
+ }
2226
+ function endTabDrag(el: HTMLElement): void {
2227
+ el.classList.remove("wt-tab-dragging");
2228
+ paintDropHalf(null);
2229
+ if (!dropped) {
2230
+ // No drop fired, so the drag was abandoned: Escape, or a release the
2231
+ // browser refused. Put the strip back — tabList still holds the order
2232
+ // the gesture started from, because only a drop writes to it.
2233
+ revertPreview();
2234
+ }
2235
+ draggingEl = null;
2236
+ // The gesture's state goes; a slide started by the revert above is left to
2237
+ // finish under its own settle timer (see endReorderPreview).
2238
+ endReorderPreview();
2239
+ clearDragGhost(); // belt-and-braces: the rAF normally got there first
2240
+ }
2241
+
2195
2242
  // For every site that REMOVES a chip while a drag may be open. A browser is
2196
2243
  // not obliged to fire dragend for a removed source, and a `draggingEl` left
2197
2244
  // pointing at a detached node would make the document-level guard
2198
2245
  // preventDefault every unrelated file or text drop from then on.
2199
2246
  function abortReorderFor(removed: HTMLElement): void {
2247
+ if (press?.el === removed) {
2248
+ endPointerPress();
2249
+ }
2200
2250
  if (removed !== draggingEl) {
2201
2251
  return;
2202
2252
  }
@@ -2253,21 +2303,22 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2253
2303
  // transform (webkit.org/b/22787), and the strip's backdrop-filter is one, so
2254
2304
  // the preview came out as broken white geometry on iPadOS. The clone is
2255
2305
  // parked under .wt-root, outside the filtered subtree but inside the styling
2256
- // boundary, laid exactly over the chip, and dropped on the next frame, by
2257
- // which time the browser has snapshotted it.
2306
+ // boundary, and laid exactly over the chip. A native drag drops it on the
2307
+ // next frame, by which time the browser has snapshotted it; the pointer path
2308
+ // keeps it under the finger for the whole drag.
2258
2309
  let dragGhost: HTMLElement | null = null;
2259
2310
  function clearDragGhost(): void {
2260
2311
  dragGhost?.remove();
2261
2312
  dragGhost = null;
2262
2313
  }
2263
- function setDragGhost(e: DragEvent, el: HTMLElement): void {
2314
+ function placeDragGhost(el: HTMLElement): { ghost: HTMLElement; rect: DOMRect } {
2264
2315
  clearDragGhost();
2265
2316
  const rect = el.getBoundingClientRect();
2266
2317
  const rootRect = varRoot.getBoundingClientRect();
2267
2318
  const ghost = el.cloneNode(true) as HTMLElement;
2268
2319
  ghost.classList.add("wt-tab-ghost");
2269
- // A duplicated role="tab" must not reach assistive tech for the frame the
2270
- // clone exists (CSS keeps it non-interactive).
2320
+ // A duplicated role="tab" must not reach assistive tech while the clone
2321
+ // exists (CSS keeps it non-interactive).
2271
2322
  ghost.setAttribute("aria-hidden", "true");
2272
2323
  ghost.style.left = `${String(rect.left - rootRect.left)}px`;
2273
2324
  ghost.style.top = `${String(rect.top - rootRect.top)}px`;
@@ -2277,6 +2328,10 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2277
2328
  ghost.style.height = `${String(rect.height)}px`;
2278
2329
  varRoot.appendChild(ghost);
2279
2330
  dragGhost = ghost;
2331
+ return { ghost, rect };
2332
+ }
2333
+ function setDragGhost(e: DragEvent, el: HTMLElement): void {
2334
+ const { ghost, rect } = placeDragGhost(el);
2280
2335
  // Offset from the chip's own box, not e.offsetX/offsetY: those are
2281
2336
  // relative to the event TARGET, which is the label or the close button
2282
2337
  // when the drag starts on one of them.
@@ -2352,17 +2407,18 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2352
2407
  // promise needs no threshold: a duplicate lands while the POST is open and
2353
2408
  // collapses into it, while a deliberate second tap still opens a second
2354
2409
  // terminal. It also stops "+" mashing from queueing sessions across the
2355
- // server's retry window. Any coalesced caller asking for the split gets it.
2410
+ // server's retry window. The first coalesced caller asking for the split
2411
+ // decides how it opens.
2356
2412
  let creating: Promise<void> | null = null;
2357
- let createIntoSplit = false;
2358
- function create(intoSplit = false): Promise<void> {
2413
+ let createOpening: SplitOpening | null = null;
2414
+ function create(opening: SplitOpening | null = null): Promise<void> {
2359
2415
  if (creating === null) {
2360
- createIntoSplit = intoSplit;
2416
+ createOpening = opening;
2361
2417
  creating = openNewSession().finally(() => {
2362
2418
  creating = null;
2363
2419
  });
2364
2420
  } else {
2365
- createIntoSplit ||= intoSplit;
2421
+ createOpening ??= opening;
2366
2422
  }
2367
2423
  return creating;
2368
2424
  }
@@ -2417,9 +2473,28 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2417
2473
  tabList.push(tab);
2418
2474
  }
2419
2475
  // Opened once the tab exists, so a failed create leaves the single view
2420
- // as it was rather than an empty pane.
2421
- if (createIntoSplit && !ctx.shell.split.isOpen()) {
2422
- ctx.shell.split.open();
2476
+ // as it was rather than an empty pane. A snap whose tab closed meanwhile
2477
+ // has nothing left to pair, so the new tab takes the single view.
2478
+ const opening = createOpening;
2479
+ const split = ctx.shell.split;
2480
+ if (opening?.by === "button" && !split.isOpen()) {
2481
+ split.open();
2482
+ } else if (
2483
+ opening?.by === "snap" &&
2484
+ !split.isOpen() &&
2485
+ tabList.some((t) => t.id === opening.id) &&
2486
+ split.open()
2487
+ ) {
2488
+ const typing = keyboardInPaneInput();
2489
+ if (showIn(opening.side, opening.id)) {
2490
+ creatingTab = true;
2491
+ showIn(otherSide(opening.side), tab.id, { keepSelection: true });
2492
+ creatingTab = false;
2493
+ if (typing) {
2494
+ focusInput();
2495
+ }
2496
+ return;
2497
+ }
2423
2498
  }
2424
2499
  showNewTab(tab.id);
2425
2500
  }
@@ -2951,7 +3026,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2951
3026
  moveTab(id, 1);
2952
3027
  });
2953
3028
  if (ctx.shell.split.enabled) {
2954
- const shownOn = sideOf(id);
3029
+ const shownOn = ctx.shell.split.isOpen() ? sideOf(id) : null;
2955
3030
  const fits = rowFitsSplit();
2956
3031
  tabMenuItem("Snap to left", !fits || shownOn === "left", () => {
2957
3032
  snap(id, "left");
@@ -3321,18 +3396,24 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
3321
3396
  // drop is to LOAD the payload as a URL, so dropping a tab on iPadOS
3322
3397
  // navigated the page to /<session-id>.
3323
3398
  bar.addEventListener("drop", (e) => {
3399
+ if (!draggingEl) {
3400
+ return;
3401
+ }
3402
+ e.preventDefault();
3403
+ dropOnStrip(e.clientX);
3404
+ });
3405
+ function dropOnStrip(clientX: number): void {
3324
3406
  const moved = draggingEl;
3325
3407
  if (!moved) {
3326
3408
  return;
3327
3409
  }
3328
- e.preventDefault();
3329
3410
  dropped = true;
3330
3411
  // A release decides by POSITION, not by whatever slot a timer had pending.
3331
3412
  endRestNet();
3332
- commitSlot(dropTargetBefore(e.clientX));
3413
+ commitSlot(dropTargetBefore(clientX));
3333
3414
  syncOrderFromDom();
3334
3415
  announceMoved(moved);
3335
- });
3416
+ }
3336
3417
  // The strip is docked at the viewport EDGE, so a pointer can leave it by
3337
3418
  // leaving the window, and then no document dragover fires to withdraw the
3338
3419
  // pending slot; the rest window would open a slot the pointer abandoned.
@@ -3349,9 +3430,8 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
3349
3430
  });
3350
3431
  // The left width rounds as `--wt-split-left` does in 32-split.css, so the
3351
3432
  // boundary is the one the highlights draw.
3352
- function dropHalf(e: DragEvent): PaneSide | null {
3433
+ function dropHalf(clientX: number, target: Node): PaneSide | null {
3353
3434
  const root = ctx.shell.root;
3354
- const target = e.target as Node;
3355
3435
  if (
3356
3436
  !ctx.shell.split.enabled ||
3357
3437
  !rowFitsSplit() ||
@@ -3362,7 +3442,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
3362
3442
  }
3363
3443
  const { left, width } = root.getBoundingClientRect();
3364
3444
  const leftPane = Math.round((width - SPLIT_GUTTER_PX) * ctx.shell.split.state().ratio);
3365
- return e.clientX < left + leftPane + SPLIT_GUTTER_PX / 2 ? "left" : "right";
3445
+ return clientX < left + leftPane + SPLIT_GUTTER_PX / 2 ? "left" : "right";
3366
3446
  }
3367
3447
  function paintDropHalf(half: PaneSide | null): void {
3368
3448
  ctx.shell.root.classList.toggle("wt-drop-left", half === "left");
@@ -3381,35 +3461,283 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
3381
3461
  paintDropHalf(null);
3382
3462
  return;
3383
3463
  }
3384
- const half = dropHalf(e);
3464
+ const half = dropHalf(e.clientX, e.target as Node);
3385
3465
  if (e.type === "drop") {
3386
- // A release off the strip CANCELS the reorder: `dropped` stays false and
3387
- // dragend runs the revert. Committing a slot here would persist an
3388
- // arrangement chosen at a position the user visibly left, and would
3389
- // depend on whether the browser emitted `drop` at all.
3390
- endRestNet();
3391
- paintDropHalf(null);
3392
- if (half !== null) {
3393
- const id = tabList.find((t) => t.el === draggingEl)?.id;
3394
- if (id !== undefined) {
3395
- snap(id, half);
3396
- }
3397
- }
3466
+ dropOffStrip(half);
3398
3467
  return;
3399
3468
  }
3400
3469
  if (e.dataTransfer && half !== null) {
3401
3470
  e.dataTransfer.dropEffect = "move";
3402
3471
  }
3472
+ overOffStrip(half);
3473
+ };
3474
+ // A release off the strip CANCELS the reorder: `dropped` stays false and
3475
+ // the drag's end runs the revert. Committing a slot here would persist an
3476
+ // arrangement chosen at a position the user visibly left, and would depend
3477
+ // on whether the browser emitted `drop` at all.
3478
+ function dropOffStrip(half: PaneSide | null): void {
3479
+ endRestNet();
3480
+ paintDropHalf(null);
3481
+ if (half !== null) {
3482
+ const id = tabList.find((t) => t.el === draggingEl)?.id;
3483
+ if (id !== undefined) {
3484
+ snap(id, half);
3485
+ }
3486
+ }
3487
+ }
3488
+ function overOffStrip(half: PaneSide | null): void {
3403
3489
  paintDropHalf(half);
3404
3490
  // No candidate slot exists off the strip; a COMMITTED slot stays put.
3405
3491
  endRestNet();
3406
- };
3492
+ }
3407
3493
  doc.addEventListener("dragover", onDocTabDrop);
3408
3494
  doc.addEventListener("drop", onDocTabDrop);
3409
3495
  ctx.defer(() => {
3410
3496
  doc.removeEventListener("dragover", onDocTabDrop);
3411
3497
  doc.removeEventListener("drop", onDocTabDrop);
3412
3498
  });
3499
+
3500
+ // Touch and pen reorder by Pointer Events: a native drag on those lifts only
3501
+ // at the platform's long press, which page script cannot shorten. A short
3502
+ // hold inside the slop lifts the chip (pointerDragActivation); leaving the
3503
+ // slop first leaves the gesture to the strip's own scroll. From the lift on,
3504
+ // every position runs through the handlers the native drag uses.
3505
+ bar.addEventListener("pointerdown", beginPointerPress, { passive: true });
3506
+ // Capture routes events but does not stop a pan, and touch-action is fixed
3507
+ // when the gesture starts, so only a cancelled touchmove keeps the strip
3508
+ // still under a lifted chip. Registered up front: Safari ignores a
3509
+ // non-passive listener added mid-gesture.
3510
+ bar.addEventListener(
3511
+ "touchmove",
3512
+ (e) => {
3513
+ if (press?.live === true) {
3514
+ e.preventDefault();
3515
+ }
3516
+ },
3517
+ { passive: false },
3518
+ );
3519
+ // The release of a drop that moved is not a tap on whatever lies under it.
3520
+ const dropSwallow = createClickSwallow(win);
3521
+ let dropClickPending = false;
3522
+ bar.addEventListener(
3523
+ "click",
3524
+ (e) => {
3525
+ if (dropClickPending && dropSwallow.swallowing()) {
3526
+ e.preventDefault();
3527
+ e.stopPropagation();
3528
+ }
3529
+ dropClickPending = false;
3530
+ },
3531
+ { capture: true },
3532
+ );
3533
+ ctx.defer(endPointerPress);
3534
+ function beginPointerPress(e: PointerEvent): void {
3535
+ const rule = pointerDragActivation(e.pointerType);
3536
+ const el =
3537
+ e.target instanceof win.Element ? e.target.closest<HTMLElement>(".wt-tab") : null;
3538
+ if (rule === null || !e.isPrimary || press !== null || el?.parentNode !== scroller) {
3539
+ return;
3540
+ }
3541
+ const tab = tabList.find((t) => t.el === el);
3542
+ if (!tab || editingId === tab.id) {
3543
+ return;
3544
+ }
3545
+ dropClickPending = false;
3546
+ const off = new AbortController();
3547
+ press = {
3548
+ id: e.pointerId,
3549
+ el,
3550
+ x0: e.clientX,
3551
+ y0: e.clientY,
3552
+ rule,
3553
+ frame: viewportBox(win),
3554
+ draggable: el.draggable,
3555
+ off,
3556
+ x: e.clientX,
3557
+ y: e.clientY,
3558
+ hold: win.setTimeout(liftPointerPress, rule.holdMs),
3559
+ tick: null,
3560
+ live: false,
3561
+ moved: false,
3562
+ };
3563
+ el.draggable = false;
3564
+ const opts = { capture: true, signal: off.signal };
3565
+ win.addEventListener("pointermove", onPressMove, opts);
3566
+ win.addEventListener("pointerup", onPressUp, opts);
3567
+ win.addEventListener("pointercancel", onPressCancel, opts);
3568
+ win.addEventListener("pointerdown", onPressSecond, opts);
3569
+ win.addEventListener("keydown", onPressKey, opts);
3570
+ win.addEventListener("blur", cancelPointerPress, { signal: off.signal });
3571
+ doc.addEventListener("visibilitychange", onPressVisibility, { signal: off.signal });
3572
+ bar.addEventListener("lostpointercapture", onPressLostCapture, { signal: off.signal });
3573
+ scroller.addEventListener("scroll", onPressScroll, { passive: true, signal: off.signal });
3574
+ win.visualViewport?.addEventListener("resize", onPressViewport, { signal: off.signal });
3575
+ win.visualViewport?.addEventListener("scroll", onPressViewport, { signal: off.signal });
3576
+ win.addEventListener("resize", onPressViewport, { signal: off.signal });
3577
+ }
3578
+ function liftPointerPress(): void {
3579
+ const p = press;
3580
+ if (p === null) {
3581
+ return;
3582
+ }
3583
+ p.hold = null;
3584
+ p.live = true;
3585
+ startTabDrag(p.el, () => {
3586
+ placeDragGhost(p.el);
3587
+ });
3588
+ try {
3589
+ bar.setPointerCapture(p.id);
3590
+ } catch {
3591
+ // An inactive pointer refuses capture; the window listeners still see it.
3592
+ }
3593
+ }
3594
+ function onPressMove(e: PointerEvent): void {
3595
+ const p = press;
3596
+ if (p?.id !== e.pointerId) {
3597
+ return;
3598
+ }
3599
+ p.x = e.clientX;
3600
+ p.y = e.clientY;
3601
+ const away = exceedsSlop(p.x - p.x0, p.y - p.y0, p.rule);
3602
+ if (!p.live) {
3603
+ // A viewport event waits for the next rendering step, so this move can read
3604
+ // the moved geometry before it arrives.
3605
+ if (away || viewportMoved(viewportBox(win), p.frame)) {
3606
+ cancelPointerPress();
3607
+ }
3608
+ return;
3609
+ }
3610
+ if (dragGhost) {
3611
+ dragGhost.style.translate = `${String(p.x - p.x0)}px ${String(p.y - p.y0)}px`;
3612
+ }
3613
+ if (!p.moved && away) {
3614
+ p.moved = true;
3615
+ // A still pointer delivers no pointermove, so the position is re-reported
3616
+ // on a drag loop's cadence: that repeat is how trackRest sees a stop.
3617
+ p.tick = win.setInterval(() => {
3618
+ pointerDragAt(p.x, p.y, false);
3619
+ }, REORDER_STILL_MS);
3620
+ }
3621
+ if (p.moved) {
3622
+ pointerDragAt(p.x, p.y, false);
3623
+ }
3624
+ }
3625
+ function onPressUp(e: PointerEvent): void {
3626
+ const p = press;
3627
+ if (p?.id !== e.pointerId) {
3628
+ return;
3629
+ }
3630
+ if (p.moved) {
3631
+ pointerDragAt(e.clientX, e.clientY, true);
3632
+ dropClickPending = true;
3633
+ dropSwallow.arm();
3634
+ }
3635
+ endPointerPress();
3636
+ // Unmoved, the release is a tap: no drop, nothing to revert, and the click
3637
+ // that follows activates the tab as any tap does.
3638
+ if (p.live) {
3639
+ endTabDrag(p.el);
3640
+ }
3641
+ }
3642
+ function onPressCancel(e: PointerEvent): void {
3643
+ if (press?.id === e.pointerId) {
3644
+ cancelPointerPress();
3645
+ }
3646
+ }
3647
+ function onPressSecond(e: PointerEvent): void {
3648
+ if (press !== null && press.id !== e.pointerId) {
3649
+ cancelPointerPress();
3650
+ }
3651
+ }
3652
+ // lostpointercapture bubbles, and the chip's implicit touch capture ending
3653
+ // when the bar takes it over is not a loss.
3654
+ function onPressLostCapture(e: PointerEvent): void {
3655
+ if (e.target === bar && press?.id === e.pointerId) {
3656
+ cancelPointerPress();
3657
+ }
3658
+ }
3659
+ function onPressScroll(): void {
3660
+ if (press?.live === false) {
3661
+ cancelPointerPress();
3662
+ }
3663
+ }
3664
+ // A TRIGGER, not a decision: visualViewport fires `scroll` on an ordinary page
3665
+ // scroll with nothing moved. A press whose frame did move can no longer be
3666
+ // verified (the chip under the finger may not be under it now), so it ends and
3667
+ // the reader presses again.
3668
+ function onPressViewport(): void {
3669
+ if (press !== null && viewportMoved(viewportBox(win), press.frame)) {
3670
+ cancelPointerPress();
3671
+ }
3672
+ }
3673
+ function onPressVisibility(): void {
3674
+ if (doc.visibilityState === "hidden") {
3675
+ cancelPointerPress();
3676
+ }
3677
+ }
3678
+ // Capture on the window, ahead of the terminal's own keydown, so the Escape
3679
+ // that ends a lifted drag is not also typed into the shell.
3680
+ function onPressKey(e: KeyboardEvent): void {
3681
+ if (e.key !== "Escape" || press === null) {
3682
+ return;
3683
+ }
3684
+ if (press.live) {
3685
+ e.preventDefault();
3686
+ e.stopPropagation();
3687
+ }
3688
+ cancelPointerPress();
3689
+ }
3690
+ // The position decides as a native dragover or drop at it would.
3691
+ function pointerDragAt(x: number, y: number, release: boolean): void {
3692
+ if (!draggingEl) {
3693
+ return;
3694
+ }
3695
+ const target = doc.elementFromPoint(x, y);
3696
+ if (target !== null && bar.contains(target)) {
3697
+ paintDropHalf(null);
3698
+ if (release) {
3699
+ dropOnStrip(x);
3700
+ } else {
3701
+ trackRest(x);
3702
+ }
3703
+ return;
3704
+ }
3705
+ const half = target === null ? null : dropHalf(x, target);
3706
+ if (release) {
3707
+ dropOffStrip(half);
3708
+ } else {
3709
+ overOffStrip(half);
3710
+ }
3711
+ }
3712
+ function cancelPointerPress(): void {
3713
+ const p = press;
3714
+ if (p === null) {
3715
+ return;
3716
+ }
3717
+ endPointerPress();
3718
+ if (p.live) {
3719
+ endTabDrag(p.el);
3720
+ }
3721
+ }
3722
+ function endPointerPress(): void {
3723
+ const p = press;
3724
+ if (p === null) {
3725
+ return;
3726
+ }
3727
+ press = null;
3728
+ p.off.abort();
3729
+ if (p.hold !== null) {
3730
+ win.clearTimeout(p.hold);
3731
+ }
3732
+ if (p.tick !== null) {
3733
+ win.clearInterval(p.tick);
3734
+ }
3735
+ if (bar.hasPointerCapture(p.id)) {
3736
+ bar.releasePointerCapture(p.id);
3737
+ }
3738
+ const editing = tabList.find((t) => t.el === p.el)?.id === editingId;
3739
+ p.el.draggable = p.draggable && !editing;
3740
+ }
3413
3741
  // Active-row close (x): closes the current tab (mirrors a listed row's x).
3414
3742
  // stopPropagation so it is not read as a tap/swipe on the row surface.
3415
3743
  swClose.addEventListener("click", (e) => {
@@ -3980,7 +4308,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
3980
4308
 
3981
4309
  return {
3982
4310
  api: {
3983
- create,
4311
+ create: () => create(),
3984
4312
  close: close_,
3985
4313
  switchTo,
3986
4314
  snap,
@@ -14,11 +14,12 @@ import { activityPhrase, normalizeActivity, statusPhrase, statusRevealsDot } fro
14
14
  // motion is spelled in — index.ts owns the mechanism, strip.ts owns the values,
15
15
  // the same split switcher.ts keeps for the mobile swipe.
16
16
 
17
- /** How long (ms) the pointer must have been un-moved before a stationary `dragover` is
18
- * believed as "stopped". Small, because a `dragover` at an UNCHANGED position is
19
- * positive evidence of rest (the drag loop keeps delivering events while the pointer
20
- * is held still); this only filters the coincidence where one event of a sweep lands
21
- * within REORDER_MOVE_EPS_PX of the previous one. */
17
+ // Twin of marotte static-src/tabs-drag.ts's constants; change both or neither.
18
+ /** How long (ms) the pointer must have been un-moved before a position report at an
19
+ * unchanged position (a `dragover`, or the pointer path's re-report on this tick) is
20
+ * believed as "stopped". Small, because such a report is positive evidence of rest;
21
+ * this only filters the coincidence where one report of a sweep lands within
22
+ * REORDER_MOVE_EPS_PX of the previous one. */
22
23
  export const REORDER_STILL_MS = 50;
23
24
 
24
25
  /** Fallback guard (ms): commit the pending slot this long after the last MOVEMENT, for
@@ -34,11 +35,85 @@ export const REORDER_REST_MS = 450;
34
35
  * sweep would keep pushing the commit out for as long as someone held the tab. */
35
36
  export const REORDER_MOVE_EPS_PX = 3;
36
37
 
37
- /** The one transition both preview stages use (the lean, and the slide that
38
- * commits it): --dur-standard and --ease-standard as literals, since JS-driven
39
- * motion here reads no token back out of the cascade (mirror a token edit by
40
- * hand). The property is `translate`, NOT `transform`: declarations from a
41
- * running CSS animation out-rank inline style, so a chip mid `wt-slot-in` or
38
+ /** The last position seen along the strip's axis, and when it last moved by more than
39
+ * REORDER_MOVE_EPS_PX. */
40
+ export interface RestState {
41
+ at: number | null;
42
+ movedAt: number;
43
+ }
44
+
45
+ /** Folds one position report into `rest` and answers whether the pointer has now been
46
+ * still for REORDER_STILL_MS. */
47
+ export function noteRestSample(rest: RestState, pos: number, now: number): boolean {
48
+ const moved = rest.at === null || Math.abs(pos - rest.at) > REORDER_MOVE_EPS_PX;
49
+ rest.at = pos;
50
+ if (moved) {
51
+ rest.movedAt = now;
52
+ }
53
+ return !moved && now - rest.movedAt >= REORDER_STILL_MS;
54
+ }
55
+
56
+ /** How long a press must hold, and how far it may stray meanwhile, to become a drag. */
57
+ export interface DragActivation {
58
+ readonly holdMs: number;
59
+ readonly slopPx: number;
60
+ }
61
+
62
+ // 8px is Android's touch slop; 150ms clears its 100ms tap window and wins the race
63
+ // against the platform long press. Source:
64
+ // https://android.googlesource.com/platform/frameworks/base/+/refs/heads/main/core/java/android/view/ViewConfiguration.java
65
+ export const TOUCH_DRAG: DragActivation = { holdMs: 150, slopPx: 8 };
66
+ export const PEN_DRAG: DragActivation = { holdMs: 150, slopPx: 8 };
67
+
68
+ /** How a press on a chip becomes a drag, by `PointerEvent.pointerType`. A mouse
69
+ * keeps native drag-and-drop and its own drag threshold (null). Touch, pen and an
70
+ * unknown type hold `holdMs` without straying past `slopPx` (exceedsSlop): past
71
+ * the slop first, the gesture is a scroll and stays the browser's. */
72
+ export function pointerDragActivation(pointerType: string): DragActivation | null {
73
+ if (pointerType === "mouse") {
74
+ return null;
75
+ }
76
+ return pointerType === "pen" ? PEN_DRAG : TOUCH_DRAG;
77
+ }
78
+
79
+ /** Whether travel `(dx, dy)` from the press point is past `rule`'s slop: Euclidean,
80
+ * strictly greater, so a tremor of exactly the slop still holds. */
81
+ export function exceedsSlop(dx: number, dy: number, rule: DragActivation): boolean {
82
+ return Math.hypot(dx, dy) > rule.slopPx;
83
+ }
84
+
85
+ /** The visible viewport's box, under the VisualViewport API's own field names. */
86
+ export interface ViewportBox {
87
+ readonly offsetLeft: number;
88
+ readonly offsetTop: number;
89
+ readonly width: number;
90
+ readonly height: number;
91
+ }
92
+
93
+ /** Read `win`'s box now, falling back to the layout viewport where the API is absent. */
94
+ export function viewportBox(win: Window): ViewportBox {
95
+ const vv = win.visualViewport;
96
+ return vv === null
97
+ ? { offsetLeft: 0, offsetTop: 0, width: win.innerWidth, height: win.innerHeight }
98
+ : { offsetLeft: vv.offsetLeft, offsetTop: vv.offsetTop, width: vv.width, height: vv.height };
99
+ }
100
+
101
+ /** Whether two boxes differ by a pixel or more on any field: enough to have moved a
102
+ * stationary pointer's coordinates, which a raised iOS keyboard or a pinch-zoom pan
103
+ * does without the finger moving. Sub-pixel jitter is absorbed. */
104
+ export function viewportMoved(a: ViewportBox, b: ViewportBox): boolean {
105
+ return (
106
+ Math.abs(a.height - b.height) >= 1 ||
107
+ Math.abs(a.offsetTop - b.offsetTop) >= 1 ||
108
+ Math.abs(a.width - b.width) >= 1 ||
109
+ Math.abs(a.offsetLeft - b.offsetLeft) >= 1
110
+ );
111
+ }
112
+
113
+ /** The commit slide's transition: --dur-standard and --ease-standard as literals,
114
+ * since JS-driven motion here reads no token back out of the cascade (mirror a
115
+ * token edit by hand). The property is `translate`, NOT `transform`: declarations
116
+ * from a running CSS animation out-rank inline style, so a chip mid `wt-slot-in` or
42
117
  * `wt-tab-in` (both animate `transform: scale`) would ignore an inline
43
118
  * `transform`; `translate` composes with it instead of competing for it. */
44
119
  export const REORDER_SHIFT_TRANS = "translate 0.2s cubic-bezier(0.2, 0, 0, 1)";
@@ -36,6 +36,8 @@ import type {
36
36
 
37
37
  const { mapKeyboardEvent, bracketTextForPaste, prepareTextForTerminal } = keyboard;
38
38
 
39
+ type GridSize = ReturnType<TerminalEngine["renderer"]["computeSize"]>;
40
+
39
41
  const DEFAULT_WS_PATH = "/ws";
40
42
  // The family that carries the CELL METRICS, alone, never --font-mono's list:
41
43
  // WebKit settles load() over a list as soon as the FIRST family covering the
@@ -468,7 +470,7 @@ function buildPaneInto(
468
470
  function updateMousePointer(): void {
469
471
  termWrap.classList.toggle("wt-mouse-app", engine.modes.getMouseMode() !== 0);
470
472
  }
471
- function measurableSize(): { cols: number; rows: number } | null {
473
+ function measurableSize(): GridSize | null {
472
474
  // A pane hidden by a closed split is `display: none`: the cell width it would
473
475
  // measure is 0, and the first render after a reveal would lay out on it.
474
476
  if (!fontsLoaded || hidden || viewport.isInTransition()) {
@@ -477,11 +479,25 @@ function buildPaneInto(
477
479
  engine.renderer.updateFontMetrics();
478
480
  return engine.renderer.computeSize();
479
481
  }
482
+ let announced: GridSize | null = null;
483
+ let heldGrow = false;
484
+ function announce(size: GridSize): void {
485
+ announced = size;
486
+ heldGrow = false;
487
+ engine.connection.sendResize();
488
+ }
489
+ function releaseHeldGrow(): void {
490
+ const size = measurableSize();
491
+ if (size !== null) {
492
+ announce(size);
493
+ }
494
+ }
480
495
  function maybeSendFirstResize(): void {
481
- if (!wsOpen || measurableSize() === null) {
496
+ const size = wsOpen ? measurableSize() : null;
497
+ if (size === null) {
482
498
  return;
483
499
  }
484
- engine.connection.sendResize();
500
+ announce(size);
485
501
  }
486
502
 
487
503
  const engine: TerminalEngine = createTerminalEngine({
@@ -496,6 +512,9 @@ function buildPaneInto(
496
512
  },
497
513
  onUserScrollChange: (scrolledUp) => {
498
514
  bus.emit("scroll:state", { scrolledUp });
515
+ if (heldGrow && !scrolledUp) {
516
+ releaseHeldGrow();
517
+ }
499
518
  },
500
519
  callbacks: {
501
520
  computeSize: () => engine.renderer.computeSize(),
@@ -513,6 +532,11 @@ function buildPaneInto(
513
532
  if (fontsLoaded) {
514
533
  markReady();
515
534
  }
535
+ // The engine applies a frame to the bound store before this callback,
536
+ // and the alternate screen has no scrollback to hold a size for.
537
+ if (heldGrow && engine.renderer.boundStore().isAlt()) {
538
+ releaseHeldGrow();
539
+ }
516
540
  bus.emit("wire:screen", msg);
517
541
  } else if (msg.type === "title") {
518
542
  // A shell clears its window title when it redraws its prompt after
@@ -971,11 +995,24 @@ function buildPaneInto(
971
995
  box: root,
972
996
  keyboard: services.keyboardInsets,
973
997
  scroll: engine.scroll,
974
- onSettled() {
998
+ onSettled(wasAtBottom) {
975
999
  // The settle is the one resize a keyboard slide or rotation should cost;
976
1000
  // sendResize deduplicates.
977
- if (measurableSize() !== null) {
978
- engine.connection.sendResize();
1001
+ const size = measurableSize();
1002
+ if (size !== null) {
1003
+ // A program repaints on resize, and that repaint can erase or reflow the
1004
+ // scrollback being read, so a taller box waits until the reader follows.
1005
+ if (
1006
+ !wasAtBottom &&
1007
+ announced !== null &&
1008
+ size.cols === announced.cols &&
1009
+ size.rows > announced.rows &&
1010
+ !engine.renderer.boundStore().isAlt()
1011
+ ) {
1012
+ heldGrow = true;
1013
+ } else {
1014
+ announce(size);
1015
+ }
979
1016
  }
980
1017
  composition.positionCompositionView();
981
1018
  },
@@ -1039,10 +1076,11 @@ function buildPaneInto(
1039
1076
  // The gate opened on fallback metrics and the swap period is infinite, so
1040
1077
  // one corrective announce once the bytes land; sendResize deduplicates.
1041
1078
  void doc.fonts.ready.then(() => {
1042
- if (destroyed || measurableSize() === null) {
1079
+ const size = destroyed ? null : measurableSize();
1080
+ if (size === null) {
1043
1081
  return;
1044
1082
  }
1045
- engine.connection.sendResize();
1083
+ announce(size);
1046
1084
  });
1047
1085
  }, onFontSettled);
1048
1086
  } catch (err) {
@@ -1353,7 +1391,7 @@ function buildPaneInto(
1353
1391
  clearActiveSession,
1354
1392
  announceSize() {
1355
1393
  if (!destroyed && fontsLoaded) {
1356
- engine.connection.sendResize();
1394
+ announce(engine.renderer.computeSize());
1357
1395
  }
1358
1396
  },
1359
1397
  focus: focusTerminal,