@cplieger/web-terminal-ui 5.2.3 → 5.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +149 -11
- package/css/00-tokens.css +23 -17
- package/css/02-terminal.css +6 -0
- package/css/30-tabs.css +105 -31
- package/css/31-switcher.css +31 -7
- package/css/40-animations.css +18 -0
- package/css/page.css +46 -9
- package/package.json +22 -6
- package/src/features/context-menu.ts +159 -197
- package/src/features/menu-position.ts +13 -7
- package/src/features/scroll-to-bottom.ts +16 -5
- package/src/features/tabs/index.ts +177 -51
- package/src/features/tabs/ink-centre.ts +38 -30
- package/src/features/tabs/model.ts +15 -2
- package/src/index.ts +4 -0
- package/src/kernel/gesture.ts +32 -0
- package/src/kernel/kernel.ts +431 -18
- package/src/kernel/options.ts +58 -0
- package/src/kernel/scrollback-storage.ts +316 -0
- package/src/kernel/scrollback.ts +333 -0
- package/src/kernel/types.ts +209 -4
- package/src/viewport.ts +29 -12
|
@@ -21,16 +21,22 @@ const FLIP_GAP = 16;
|
|
|
21
21
|
const SWALLOW_MS = 350;
|
|
22
22
|
|
|
23
23
|
/** createClickSwallow returns the two halves a point-anchored menu needs to
|
|
24
|
-
* survive the contextmenu-then-touchend race: `arm()`
|
|
25
|
-
*
|
|
26
|
-
*
|
|
24
|
+
* survive the contextmenu-then-touchend race: `arm()` and `swallowing()` as the
|
|
25
|
+
* first guard in the outside-click dismiss.
|
|
26
|
+
*
|
|
27
|
+
* **Arm on the RELEASE edge, not when the menu opens.** The window is a fixed
|
|
28
|
+
* 350ms from `arm()`, so arming it while the finger is still down only covers
|
|
29
|
+
* the release if the user lifts within 350ms. Both menus shipped that way and
|
|
30
|
+
* both dismissed themselves on release whenever the press was held a beat
|
|
31
|
+
* longer: the terminal menu opened from a 550ms hold timer, so anything past
|
|
32
|
+
* ~900ms of hold lost its guard, and the tab menu armed from a mid-press
|
|
33
|
+
* `contextmenu`. The terminal menu now opens from `touchend` itself, so its
|
|
34
|
+
* arm() is on the release by construction; the tab menu opens mid-press and
|
|
35
|
+
* re-arms when the opening gesture's pointer goes up.
|
|
27
36
|
*
|
|
28
37
|
* It lives here, beside the shared placement math, because both point-anchored
|
|
29
38
|
* menus need it and a hand-synced second copy is what this module was extracted
|
|
30
|
-
* to stop.
|
|
31
|
-
* — which fires `contextmenu` on a long-press, and where the desktop tab strip
|
|
32
|
-
* is the chrome that shows — a long-pressed tab menu dismissed itself on
|
|
33
|
-
* release. Arm it only for touch: a mouse right-click emits no trailing click,
|
|
39
|
+
* to stop. Arm it only for touch: a mouse right-click emits no trailing click,
|
|
34
40
|
* and swallowing there would eat a genuine dismiss. */
|
|
35
41
|
export function createClickSwallow(): { arm: () => void; swallowing: () => boolean } {
|
|
36
42
|
let until = 0;
|
|
@@ -22,11 +22,11 @@ export function scrollToBottom(): TerminalFeature {
|
|
|
22
22
|
|
|
23
23
|
// Jump to the bottom. With motion allowed, smooth-scroll the surface so
|
|
24
24
|
// the jump is animated rather than an instant teleport; the engine's
|
|
25
|
-
// scroll controller re-
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
25
|
+
// scroll controller then re-engages follow when it sees the animation land
|
|
26
|
+
// at the bottom (a downward move reaching the tail), so the animation needs
|
|
27
|
+
// no programmatic flag and the render pin does not fight it mid-flight.
|
|
28
|
+
// Under prefers-reduced-motion, fall back to the engine's instant
|
|
29
|
+
// scrollToBottom (which re-engages following synchronously).
|
|
30
30
|
const jump = (): void => {
|
|
31
31
|
const reduce =
|
|
32
32
|
typeof window.matchMedia === "function" &&
|
|
@@ -36,6 +36,17 @@ export function scrollToBottom(): TerminalFeature {
|
|
|
36
36
|
return;
|
|
37
37
|
}
|
|
38
38
|
const surface = ctx.surface();
|
|
39
|
+
// Already AT the bottom but possibly holding: a program erasing its
|
|
40
|
+
// scrollback (ED3) clamps a scrolled-up reader to the bottom without
|
|
41
|
+
// engaging follow, which is a durable state since the engine stopped
|
|
42
|
+
// letting an upward clamp engage it. A smooth scroll with zero distance
|
|
43
|
+
// to travel fires no scroll event, so nothing would re-derive the state
|
|
44
|
+
// and this button — whose entire job is resuming follow — would be inert
|
|
45
|
+
// until new output happened to arrive. State the intent directly instead.
|
|
46
|
+
if (surface.scrollHeight - surface.clientHeight - surface.scrollTop <= 0) {
|
|
47
|
+
ctx.scroll.scrollToBottom();
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
39
50
|
surface.scrollTo({ top: surface.scrollHeight, behavior: "smooth" });
|
|
40
51
|
};
|
|
41
52
|
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// (ctx.notifySwitch), so the last-known screen paints instantly and the
|
|
9
9
|
// background delta arrives after.
|
|
10
10
|
|
|
11
|
-
import {
|
|
11
|
+
import { modes } from "@cplieger/web-terminal-engine";
|
|
12
12
|
import type { SessionRef, TerminalContext, TerminalFeature } from "../../kernel/types.js";
|
|
13
13
|
import type { ActivityMonitorApi } from "../activity-monitor.js";
|
|
14
14
|
import type { MobileToolbarApi } from "../mobile-toolbar.js";
|
|
@@ -167,6 +167,22 @@ const CREATE_RETRY_MAX_TOTAL_MS = 1200000;
|
|
|
167
167
|
const CREATE_RETRY_FALLBACK_MS = 5000;
|
|
168
168
|
const CREATE_RETRY_REANNOUNCE_MS = 60000;
|
|
169
169
|
|
|
170
|
+
/** How long the catching-up cue may wait for the render backlog to drain before
|
|
171
|
+
* retiring itself. A backlog that never drains (the server stops mid-replay, the
|
|
172
|
+
* socket drops) must not leave a "Catching up" badge on screen forever, and the
|
|
173
|
+
* reconnect that follows will arm a fresh one. */
|
|
174
|
+
const CATCHUP_MAX_MS = 30000;
|
|
175
|
+
/** How long the render queue must stay empty before the restore counts as
|
|
176
|
+
* finished. The queue empties BETWEEN the server's replay chunks, so a bare
|
|
177
|
+
* "queue is empty" test declares victory several times per restore; this
|
|
178
|
+
* hysteresis is what turns it into one honest completion signal. */
|
|
179
|
+
const CATCHUP_SETTLE_MS = 250;
|
|
180
|
+
/** Backlog that arms the cue, in queued rows. The renderer builds at most 300
|
|
181
|
+
* rows per frame, so a backlog above this needs multiple frames and is worth
|
|
182
|
+
* telling the user about; ordinary streaming queues a handful of rows per frame
|
|
183
|
+
* and must never arm it (nor pay for the completion poll). */
|
|
184
|
+
const CATCHUP_MIN_BACKLOG = 400;
|
|
185
|
+
|
|
170
186
|
/** orderSignature collapses an arrangement into one comparable string, so the
|
|
171
187
|
* persist path can decide "did the order change?" with a string compare instead
|
|
172
188
|
* of re-serializing and re-writing on every status tick. NUL is the separator
|
|
@@ -646,6 +662,10 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
646
662
|
catchup.textContent = "Catching up\u2026";
|
|
647
663
|
ctx.region("banner", "catchup").appendChild(catchup);
|
|
648
664
|
let catchupTimer: ReturnType<typeof setTimeout> | null = null;
|
|
665
|
+
// Completion-poll state for the catching-up cue (see armCatchup).
|
|
666
|
+
let catchupPoll: number | null = null;
|
|
667
|
+
let catchupEmptySince = 0;
|
|
668
|
+
let catchupDeadline = 0;
|
|
649
669
|
|
|
650
670
|
// --- Desktop right-click tab context menu (overlay region) ---
|
|
651
671
|
// Replaces the old bar "Close all" button with a richer per-tab menu. Built
|
|
@@ -1283,7 +1303,12 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1283
1303
|
nameSeq: 0,
|
|
1284
1304
|
display: "",
|
|
1285
1305
|
createdAt: info.createdAt,
|
|
1286
|
-
|
|
1306
|
+
// Through the kernel factory, never `new LineStore()`: the factory
|
|
1307
|
+
// applies the consumer's scrollbackLines cap, so per-tab caches and
|
|
1308
|
+
// the kernel's implicit store share one retained-line budget. The id
|
|
1309
|
+
// is what lets it come back HYDRATED when the consumer enabled
|
|
1310
|
+
// persistScrollback, and registers it to be saved.
|
|
1311
|
+
store: ctx.newLineStore(info.id),
|
|
1287
1312
|
el,
|
|
1288
1313
|
label,
|
|
1289
1314
|
dot,
|
|
@@ -1292,8 +1317,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1292
1317
|
// the REST list starts with none; the first status event fills it in.
|
|
1293
1318
|
progress: normalizeProgress(info.progressValue),
|
|
1294
1319
|
aria,
|
|
1295
|
-
|
|
1296
|
-
following: true,
|
|
1320
|
+
view: null,
|
|
1297
1321
|
reports: reportsOf(info.reportsActivity),
|
|
1298
1322
|
};
|
|
1299
1323
|
paintProgress(progressEl, renderedProgress(info.status, tab.progress));
|
|
@@ -1474,13 +1498,15 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1474
1498
|
slide = to > from ? "next" : "prev";
|
|
1475
1499
|
}
|
|
1476
1500
|
}
|
|
1477
|
-
// Detach the current tab: save its
|
|
1478
|
-
// Read through the engine's
|
|
1479
|
-
//
|
|
1501
|
+
// Detach the current tab: save its reading position (keep its cache).
|
|
1502
|
+
// Read through the engine's view seam, never surface.scrollTop — the
|
|
1503
|
+
// renderer owns the mapping from scroll position to absolute line, and a
|
|
1504
|
+
// LINE is what survives the rebuild and the background output this tab's
|
|
1505
|
+
// session may produce before we come back (engine
|
|
1506
|
+
// docs/scroll-position-fidelity.md §3.1).
|
|
1480
1507
|
const cur = tabList.find((t) => t.id === activeId);
|
|
1481
1508
|
if (cur) {
|
|
1482
|
-
cur.
|
|
1483
|
-
cur.following = !ctx.scroll.isUserScrolledUp();
|
|
1509
|
+
cur.view = ctx.render.captureViewMemory();
|
|
1484
1510
|
}
|
|
1485
1511
|
// Decide whether to animate the expanded list as a rotation: only a
|
|
1486
1512
|
// swipe to an adjacent tab while the list is open. prepareReel snapshots
|
|
@@ -1504,6 +1530,21 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1504
1530
|
// Attach the next tab: point the renderer at its cached store and
|
|
1505
1531
|
// rebuild viewport-first, so the last-known screen paints with no
|
|
1506
1532
|
// round-trip. Then let the kernel reconnect the WS to it (resume delta).
|
|
1533
|
+
//
|
|
1534
|
+
// The view goes in WITH the bind, which is what makes the swap atomic.
|
|
1535
|
+
// The engine's follow flag is GLOBAL (one per kernel), so the first
|
|
1536
|
+
// flush after a bind is gated on whatever state the tab we LEFT was in:
|
|
1537
|
+
// binding a following tab right after being scrolled up in another left
|
|
1538
|
+
// the controller holding, the post-flush stickToBottom() no-op'd, and the
|
|
1539
|
+
// cached screen rendered above the viewport — a black gap until a touch
|
|
1540
|
+
// scrolled it and re-engaged follow (the "content pops down when I
|
|
1541
|
+
// wiggle it" symptom). bind adopts the incoming follow state
|
|
1542
|
+
// synchronously, before the wipe, and re-asserts the incoming reading
|
|
1543
|
+
// POSITION across the rebuild's frames until the line it names has
|
|
1544
|
+
// actually been built. That second half is why this is no longer a
|
|
1545
|
+
// fire-and-forget rAF: a single deferred write landed while only ~301 of
|
|
1546
|
+
// up to 5000 rows existed, so the browser clamped it away and nothing
|
|
1547
|
+
// retried (engine docs/scroll-position-fidelity.md §1.1, §3.3, §3.4).
|
|
1507
1548
|
activeId = next.id;
|
|
1508
1549
|
// Arriving on the tab that raised the switch-button cue resolves it
|
|
1509
1550
|
// (a swipe through the tabs must dismiss the dot, not only opening the
|
|
@@ -1517,9 +1558,19 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1517
1558
|
} catch {
|
|
1518
1559
|
/* storage unavailable (private mode / disabled) — non-fatal */
|
|
1519
1560
|
}
|
|
1520
|
-
ctx.render.bind(next.store);
|
|
1561
|
+
ctx.render.bind(next.store, { view: next.view });
|
|
1521
1562
|
ctx.notifySwitch({ id: next.id });
|
|
1522
|
-
|
|
1563
|
+
// Arm on a switch only when the user is actually about to wait: either
|
|
1564
|
+
// the bind queued a backlog worth several frames, or the incoming tab has
|
|
1565
|
+
// no cached content at all (a first visit, so its whole screen is coming
|
|
1566
|
+
// over the network). A revisited tab with a warm store paints from cache
|
|
1567
|
+
// in one frame and must not flash a cue at every switch.
|
|
1568
|
+
if (
|
|
1569
|
+
ctx.render.pendingRowCount() > CATCHUP_MIN_BACKLOG ||
|
|
1570
|
+
ctx.render.getHighestIndex() < 0
|
|
1571
|
+
) {
|
|
1572
|
+
armCatchup();
|
|
1573
|
+
}
|
|
1523
1574
|
flashSwitch(slide);
|
|
1524
1575
|
// Mark the reconcile as reel-driven so renderSwitcherList suppresses its
|
|
1525
1576
|
// add/remove row animation (the reel owns row motion here).
|
|
@@ -1527,49 +1578,89 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1527
1578
|
syncChrome(); // reconciles the expanded list into the new order
|
|
1528
1579
|
reelReconcile = false;
|
|
1529
1580
|
playReel?.(); // FLIP the rows so the reorder reads as a rotation, not a reload
|
|
1530
|
-
// Restore scroll memory best-effort after the async rebuild. The engine
|
|
1531
|
-
// scroll controller's follow flag is GLOBAL (one per kernel) and still
|
|
1532
|
-
// reflects the tab we just left, so binding a following tab right after
|
|
1533
|
-
// being scrolled up in another tab left the controller in "holding":
|
|
1534
|
-
// the renderer's post-flush stickToBottom() no-op'd and the cached
|
|
1535
|
-
// screen rendered above the viewport (a black gap until a touch scrolled
|
|
1536
|
-
// it and re-engaged follow — the "content pops down when I wiggle it"
|
|
1537
|
-
// symptom). Re-assert here: scrollToBottom() snaps down AND re-engages
|
|
1538
|
-
// follow (so the resume delta then pins correctly); a scrolled-up tab
|
|
1539
|
-
// restores its saved read position instead.
|
|
1540
|
-
const savedTop = next.scrollTop;
|
|
1541
|
-
const following = next.following;
|
|
1542
|
-
requestAnimationFrame(() => {
|
|
1543
|
-
if (following) {
|
|
1544
|
-
ctx.scroll.scrollToBottom();
|
|
1545
|
-
} else {
|
|
1546
|
-
// The write half of scroll memory goes through the same seam; the
|
|
1547
|
-
// controller re-derives hold from the resulting scroll event.
|
|
1548
|
-
ctx.scroll.restoreScrollTop(savedTop);
|
|
1549
|
-
}
|
|
1550
|
-
});
|
|
1551
1581
|
ctx.announce(`Switched to ${next.display}`);
|
|
1552
1582
|
focusAfterSwitch();
|
|
1553
1583
|
}
|
|
1554
1584
|
|
|
1555
|
-
// armCatchup shows the "catching up" cue
|
|
1556
|
-
//
|
|
1557
|
-
//
|
|
1585
|
+
// armCatchup shows the "catching up" cue while the surface still has a
|
|
1586
|
+
// large backlog of rows to build, and clearCatchup hides it and stops the
|
|
1587
|
+
// completion poll.
|
|
1588
|
+
//
|
|
1589
|
+
// What it measures is the RENDER backlog (render.pendingRowCount): rows the
|
|
1590
|
+
// store already holds that have not been built into DOM yet. That is the
|
|
1591
|
+
// thing the user is actually waiting on, whatever produced it — a resume
|
|
1592
|
+
// replay after a wake, the rebuild after a tab switch, or a program that
|
|
1593
|
+
// printed a few thousand lines at once.
|
|
1594
|
+
//
|
|
1595
|
+
// Two earlier conditions were tried and are wrong, so do not go back to
|
|
1596
|
+
// them. Clearing on the first screen frame fires long before a large
|
|
1597
|
+
// restore has landed (the frame that carries the live window arrives
|
|
1598
|
+
// first). Comparing the store's highest index against the resumeAck's
|
|
1599
|
+
// `committed` fails for the same reason and more sharply: the window frame
|
|
1600
|
+
// delivers the HIGHEST indices, so highest reaches the target while every
|
|
1601
|
+
// history line below it is still in flight. Measured on a 4000-line phone
|
|
1602
|
+
// restore: the cue cleared immediately and stayed clear for the whole fill.
|
|
1603
|
+
// catchupWarranted: the surface still owes the user content. Either rows are
|
|
1604
|
+
// queued for building (a resume replay, a switch rebuild, a large burst), or
|
|
1605
|
+
// the tab holds nothing at all, which means its screen is still on the
|
|
1606
|
+
// network. The second half matters as much as the first: without it, the
|
|
1607
|
+
// case the cue is most needed for — switching into a tab that has never been
|
|
1608
|
+
// viewed — has an empty queue and would never show it.
|
|
1609
|
+
const catchupWarranted = (): boolean =>
|
|
1610
|
+
ctx.render.pendingRowCount() > 0 || ctx.render.getHighestIndex() < 0;
|
|
1611
|
+
|
|
1558
1612
|
function armCatchup(): void {
|
|
1559
|
-
if (catchupTimer
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1613
|
+
if (catchupTimer === null && !catchup.classList.contains("visible")) {
|
|
1614
|
+
catchupTimer = setTimeout(() => {
|
|
1615
|
+
catchupTimer = null;
|
|
1616
|
+
// Re-check before showing. The delay is anti-flicker, so it has to
|
|
1617
|
+
// ask again at the end of it: a burst that arms the cue and then
|
|
1618
|
+
// drains inside the delay has nothing to report, and showing it
|
|
1619
|
+
// anyway guaranteed a visible flash on every large-but-fast burst
|
|
1620
|
+
// (the clear path needs CATCHUP_SETTLE_MS of quiet, so the flash
|
|
1621
|
+
// outlived the backlog it described).
|
|
1622
|
+
if (catchupWarranted()) {
|
|
1623
|
+
catchup.classList.add("visible");
|
|
1624
|
+
}
|
|
1625
|
+
}, 150);
|
|
1626
|
+
}
|
|
1627
|
+
catchupDeadline = Date.now() + CATCHUP_MAX_MS;
|
|
1628
|
+
catchupEmptySince = 0;
|
|
1629
|
+
if (catchupPoll === null) {
|
|
1630
|
+
pollCatchup();
|
|
1631
|
+
}
|
|
1632
|
+
}
|
|
1633
|
+
// pollCatchup runs on rAF because completion is a RENDER condition and the
|
|
1634
|
+
// renderer has no event for "queue drained". It stops itself, so the loop
|
|
1635
|
+
// only exists while the cue does.
|
|
1636
|
+
function pollCatchup(): void {
|
|
1637
|
+
catchupPoll = requestAnimationFrame(() => {
|
|
1638
|
+
catchupPoll = null;
|
|
1639
|
+
if (catchupWarranted()) {
|
|
1640
|
+
catchupEmptySince = 0;
|
|
1641
|
+
} else if (catchupEmptySince === 0) {
|
|
1642
|
+
catchupEmptySince = Date.now();
|
|
1643
|
+
} else if (Date.now() - catchupEmptySince >= CATCHUP_SETTLE_MS) {
|
|
1644
|
+
clearCatchup();
|
|
1645
|
+
return;
|
|
1646
|
+
}
|
|
1647
|
+
if (Date.now() > catchupDeadline) {
|
|
1648
|
+
clearCatchup();
|
|
1649
|
+
return;
|
|
1650
|
+
}
|
|
1651
|
+
pollCatchup();
|
|
1652
|
+
});
|
|
1567
1653
|
}
|
|
1568
1654
|
function clearCatchup(): void {
|
|
1569
1655
|
if (catchupTimer !== null) {
|
|
1570
1656
|
clearTimeout(catchupTimer);
|
|
1571
1657
|
catchupTimer = null;
|
|
1572
1658
|
}
|
|
1659
|
+
if (catchupPoll !== null) {
|
|
1660
|
+
cancelAnimationFrame(catchupPoll);
|
|
1661
|
+
catchupPoll = null;
|
|
1662
|
+
}
|
|
1663
|
+
catchupEmptySince = 0;
|
|
1573
1664
|
catchup.classList.remove("visible");
|
|
1574
1665
|
}
|
|
1575
1666
|
// flashSwitch plays the switch animation the animations feature keys off
|
|
@@ -1594,9 +1685,17 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1594
1685
|
}, 360);
|
|
1595
1686
|
}
|
|
1596
1687
|
|
|
1597
|
-
//
|
|
1688
|
+
// Any frame can reveal a backlog worth telling the user about: a resume
|
|
1689
|
+
// replay after a wake or reconnect, the rebuild after a tab switch, or a
|
|
1690
|
+
// program that dumped thousands of lines at once. Arming off the backlog
|
|
1691
|
+
// itself is what makes the cue fire on a wake, which is where it used to be
|
|
1692
|
+
// blind (it was armed only by an explicit tab switch). The threshold keeps
|
|
1693
|
+
// ordinary streaming out of it entirely, so the completion poll only exists
|
|
1694
|
+
// while there is something to complete.
|
|
1598
1695
|
ctx.on("wire:screen", () => {
|
|
1599
|
-
|
|
1696
|
+
if (ctx.render.pendingRowCount() > CATCHUP_MIN_BACKLOG) {
|
|
1697
|
+
armCatchup();
|
|
1698
|
+
}
|
|
1600
1699
|
});
|
|
1601
1700
|
|
|
1602
1701
|
// Live window-title updates for the active session: the engine sends a
|
|
@@ -1968,6 +2067,12 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1968
2067
|
// closed tab -- mirrors the single-close path in dropTab (h-f2).
|
|
1969
2068
|
tombstones.add(t.id);
|
|
1970
2069
|
acknowledgeSwitchNotify(t.id); // a cue for a closed tab is moot
|
|
2070
|
+
// ...and its stored acknowledgement goes with it, for the reason dropTab
|
|
2071
|
+
// gives: the session is gone, so the entry would only sit in storage
|
|
2072
|
+
// until the 200-entry cap evicted it. The bulk path drops tabs itself
|
|
2073
|
+
// rather than through dropTab, so every per-session store it touches has
|
|
2074
|
+
// to be listed here too.
|
|
2075
|
+
forgetCueSeen(t.id);
|
|
1971
2076
|
t.aria.remove();
|
|
1972
2077
|
t.el.remove();
|
|
1973
2078
|
ctx.dropSession(t.id);
|
|
@@ -2260,14 +2365,20 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2260
2365
|
//
|
|
2261
2366
|
// menuSwallow covers the contextmenu-then-touchend race: iPadOS Safari
|
|
2262
2367
|
// raises a context menu from a LONG-PRESS, and the same gesture emits a
|
|
2263
|
-
// click on release which onDocClickMenu would read as a click-away.
|
|
2264
|
-
//
|
|
2265
|
-
//
|
|
2266
|
-
//
|
|
2267
|
-
//
|
|
2268
|
-
//
|
|
2368
|
+
// click on release which onDocClickMenu would read as a click-away.
|
|
2369
|
+
// menuTouch records whether the press that raised the menu was a finger,
|
|
2370
|
+
// since only touch emits that trailing click.
|
|
2371
|
+
//
|
|
2372
|
+
// The window is a fixed 350ms, so arming it here — mid-press, when the
|
|
2373
|
+
// platform delivers `contextmenu` — only covers a release that comes within
|
|
2374
|
+
// 350ms; hold the chip a beat longer and the menu still dismissed itself on
|
|
2375
|
+
// release. menuOpenedInPress carries the fact that THIS press opened the
|
|
2376
|
+
// menu through to its pointerup, which re-arms on the release edge. The
|
|
2377
|
+
// flag is what keeps that re-arm off an unrelated later tap, whose click is
|
|
2378
|
+
// a genuine dismiss.
|
|
2269
2379
|
const menuSwallow = createClickSwallow();
|
|
2270
2380
|
let menuTouch = false;
|
|
2381
|
+
let menuOpenedInPress = false;
|
|
2271
2382
|
// One listener on the bar rather than one per chip: chips are created and
|
|
2272
2383
|
// destroyed as sessions come and go, and both facts recorded here (the
|
|
2273
2384
|
// gesture's pointer type, and whether it took the keyboard off the
|
|
@@ -2276,11 +2387,23 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2276
2387
|
"pointerdown",
|
|
2277
2388
|
(e) => {
|
|
2278
2389
|
menuTouch = e.pointerType === "touch";
|
|
2390
|
+
menuOpenedInPress = false;
|
|
2279
2391
|
noteChromePress();
|
|
2280
2392
|
notifier.gesture();
|
|
2281
2393
|
},
|
|
2282
2394
|
{ passive: true },
|
|
2283
2395
|
);
|
|
2396
|
+
// The release edge of the press that opened the menu. pointercancel counts:
|
|
2397
|
+
// iPadOS cancels the pointer when it takes the gesture over for its own
|
|
2398
|
+
// long-press handling, and that release still emits the trailing click.
|
|
2399
|
+
const onBarPointerRelease = (): void => {
|
|
2400
|
+
if (menuOpenedInPress) {
|
|
2401
|
+
menuOpenedInPress = false;
|
|
2402
|
+
menuSwallow.arm();
|
|
2403
|
+
}
|
|
2404
|
+
};
|
|
2405
|
+
bar.addEventListener("pointerup", onBarPointerRelease, { passive: true });
|
|
2406
|
+
bar.addEventListener("pointercancel", onBarPointerRelease, { passive: true });
|
|
2284
2407
|
// The switcher's half of that snapshot (its rows and the x are buttons, so
|
|
2285
2408
|
// pressing one focuses it and blurs the terminal exactly as a chip does).
|
|
2286
2409
|
// It is also the mobile half of the notification-permission gesture.
|
|
@@ -2366,7 +2489,10 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2366
2489
|
tabMenu.classList.add("visible");
|
|
2367
2490
|
placeMenuAt(tabMenu, x, y);
|
|
2368
2491
|
if (menuTouch) {
|
|
2492
|
+
// A floor for the case where no pointerup follows (the release edge is
|
|
2493
|
+
// where onBarPointerRelease re-arms it).
|
|
2369
2494
|
menuSwallow.arm();
|
|
2495
|
+
menuOpenedInPress = true;
|
|
2370
2496
|
}
|
|
2371
2497
|
}
|
|
2372
2498
|
|
|
@@ -3,28 +3,37 @@
|
|
|
3
3
|
// font's tables.
|
|
4
4
|
//
|
|
5
5
|
// Flex centring centres a label's LINE BOX. A font's metrics box reaches much
|
|
6
|
-
// further above the baseline than below (the bundled
|
|
7
|
-
// over, 0.250em under)
|
|
8
|
-
//
|
|
9
|
-
//
|
|
6
|
+
// further above the baseline than below (the bundled Monaspace Neon NF: 0.995em
|
|
7
|
+
// over, 0.250em under), so the visible text sits low inside the box it is
|
|
8
|
+
// centred by, and the correction is the gap between the box's centre and the
|
|
9
|
+
// text's own.
|
|
10
10
|
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
11
|
+
// WHICH BAND is the text's own is the load-bearing decision, and the first
|
|
12
|
+
// version of this got it wrong. It centred cap-top-to-DESCENDER-bottom, which
|
|
13
|
+
// reserves descender room below the letters and reads high: measured on the real
|
|
14
|
+
// chip in Blink, the title's cap band sat 1.320px above the chip's centre while
|
|
15
|
+
// the status dot and the close glyph beside it sat at exactly 0.000px, and a
|
|
16
|
+
// title is judged against those two neighbours. So the band is cap-top to
|
|
17
|
+
// BASELINE — the visible mass of a UI label, and the same pair of edges CSS
|
|
18
|
+
// spells `text-box-edge: cap alphabetic`. Descenders overhang it deliberately;
|
|
19
|
+
// the label's `padding-block` (30-tabs.css) is the allowance that keeps them
|
|
20
|
+
// clear of `overflow: hidden`, so nothing is clipped by the choice.
|
|
21
|
+
//
|
|
22
|
+
// The correction is NOT a font-relative constant, which is what four earlier
|
|
23
|
+
// fixes assumed. Engines round the font's ascent and descent to whole CSS pixels
|
|
24
|
+
// before building the line box, so the ideal shift jumps whenever a size crosses
|
|
25
|
+
// a rounding boundary: 0.25em x 13 = 3.25 rounds to 3 (a 16px box) while
|
|
26
|
+
// 0.25em x 14 = 3.5 rounds to 4 (an 18px box), and the shift moves with it.
|
|
27
|
+
// Engines disagree with each other too (Gecko makes that same 14px box 17px, not
|
|
28
|
+
// 18px), and so do platforms within one engine: iOS resolves metrics through
|
|
22
29
|
// CoreText, Linux WebKit through FreeType, which is why the strip read correct
|
|
23
|
-
// in every local check and high on an iPad.
|
|
30
|
+
// in every local check and high on an iPad. Measured across 11-20px the cap-band
|
|
31
|
+
// shift sawtooths between about -0.010em and +0.028em — small, but still wider
|
|
32
|
+
// than the 0.33px error that was reported as a defect, so it stays measured.
|
|
24
33
|
//
|
|
25
34
|
// So nothing here predicts. Each measurement asks the engine for the line box
|
|
26
|
-
// and baseline it actually produced, asks the font for its actual ink
|
|
27
|
-
//
|
|
35
|
+
// and baseline it actually produced, asks the font for its actual cap ink, and
|
|
36
|
+
// writes the difference to --label-ink-shift as pixels. The CSS keeps an em
|
|
28
37
|
// default for the pre-measurement paint (see 00-tokens.css); this only ever
|
|
29
38
|
// narrows the error.
|
|
30
39
|
import { fromHTML } from "../dom.js";
|
|
@@ -42,7 +51,6 @@ const PROBE_CLASS = "wt-ink-probe";
|
|
|
42
51
|
/** Cap ink, x-height ink and descender ink in one string, so the DOM probe's
|
|
43
52
|
* line box is the one a real mixed-case title produces. */
|
|
44
53
|
const PROBE_TEXT = "Hxp";
|
|
45
|
-
|
|
46
54
|
/** Class chains that resolve each site's label font. Deliberately the shortest
|
|
47
55
|
* chain that carries the font-size (`.wt-tab` and `.wt-switcher-current`) and
|
|
48
56
|
* not the real ancestry: `.wt-switcher` is `display: none` off a coarse pointer
|
|
@@ -59,16 +67,17 @@ export interface InkMetrics {
|
|
|
59
67
|
readonly baselinePx: number;
|
|
60
68
|
/** Cap ink above the baseline, em. */
|
|
61
69
|
readonly capInkEm: number;
|
|
62
|
-
/** Descender ink below the baseline, em. */
|
|
63
|
-
readonly descInkEm: number;
|
|
64
70
|
readonly fontSizePx: number;
|
|
65
71
|
}
|
|
66
72
|
|
|
67
|
-
/** How far a label's visible ink sits BELOW its line box's centre
|
|
68
|
-
*
|
|
73
|
+
/** How far a label's visible ink sits BELOW its line box's centre, where
|
|
74
|
+
* "visible ink" is the cap band (cap top to baseline). Positive is the normal
|
|
75
|
+
* case (ink low), and the CSS lifts the box by exactly this much.
|
|
76
|
+
*
|
|
77
|
+
* Descender ink is deliberately NOT part of the band; see the header. */
|
|
69
78
|
export function inkShiftPx(m: InkMetrics): number {
|
|
70
|
-
const
|
|
71
|
-
return
|
|
79
|
+
const capCentre = m.baselinePx - (m.capInkEm * m.fontSizePx) / 2;
|
|
80
|
+
return capCentre - m.fontBoxPx / 2;
|
|
72
81
|
}
|
|
73
82
|
|
|
74
83
|
let ctx2d: CanvasRenderingContext2D | null | undefined;
|
|
@@ -78,21 +87,20 @@ function canvas2d(): CanvasRenderingContext2D | null {
|
|
|
78
87
|
return ctx2d;
|
|
79
88
|
}
|
|
80
89
|
|
|
81
|
-
/** The font's own ink
|
|
90
|
+
/** The font's own cap ink, in em. Canvas rather than the DOM because only
|
|
82
91
|
* TextMetrics reports where the glyphs' ink actually starts and stops; a DOM
|
|
83
92
|
* rect only ever reports the box around it. */
|
|
84
|
-
function inkExtents(font: string): Pick<InkMetrics, "capInkEm"
|
|
93
|
+
function inkExtents(font: string): Pick<InkMetrics, "capInkEm"> | null {
|
|
85
94
|
const ctx = canvas2d();
|
|
86
95
|
if (!ctx) {
|
|
87
96
|
return null;
|
|
88
97
|
}
|
|
89
98
|
ctx.font = font;
|
|
90
99
|
const capInk = ctx.measureText("H").actualBoundingBoxAscent;
|
|
91
|
-
|
|
92
|
-
if (!(capInk > 0) || !(descInk > 0)) {
|
|
100
|
+
if (!(capInk > 0)) {
|
|
93
101
|
return null;
|
|
94
102
|
}
|
|
95
|
-
return { capInkEm: capInk / REF_PX
|
|
103
|
+
return { capInkEm: capInk / REF_PX };
|
|
96
104
|
}
|
|
97
105
|
|
|
98
106
|
/** The engine's line box and baseline for a laid-out element's own font. The
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import type { LineStore } from "@cplieger/web-terminal-engine";
|
|
9
9
|
import type { SessionInfo } from "@cplieger/web-terminal-engine";
|
|
10
|
+
import type { ViewMemory } from "@cplieger/web-terminal-engine";
|
|
10
11
|
import type { TabHandle } from "../../kernel/types.js";
|
|
11
12
|
|
|
12
13
|
// One session's wire shape (SessionInfo) is the ENGINE's exported type — the
|
|
@@ -399,8 +400,20 @@ export interface Tab {
|
|
|
399
400
|
* any title field. */
|
|
400
401
|
progress: number;
|
|
401
402
|
aria: TabHandle;
|
|
402
|
-
|
|
403
|
-
|
|
403
|
+
/** This tab's saved reading position: the absolute LINE at the viewport top
|
|
404
|
+
* plus its on-screen offset and the follow state, as captureViewMemory()
|
|
405
|
+
* returned it when the tab was last left. Null for a tab never viewed (and
|
|
406
|
+
* for one left on the alternate screen, which has no absolute indices worth
|
|
407
|
+
* remembering) — restoring null means "follow the tail", which is the right
|
|
408
|
+
* default for a first visit.
|
|
409
|
+
*
|
|
410
|
+
* NOT a pixel scrollTop, which is what this was until 2026-08: a rebuild has
|
|
411
|
+
* built only ~301 of up to 5000 rows when the restore lands, so the browser
|
|
412
|
+
* clamped the offset away, and it stopped meaning the same line as soon as
|
|
413
|
+
* the tab's session produced output while backgrounded
|
|
414
|
+
* (engine docs/scroll-position-fidelity.md §1.1). In memory only, like
|
|
415
|
+
* before: a reload starts every tab following. */
|
|
416
|
+
view: ViewMemory | null;
|
|
404
417
|
/** Sticky: true once this session emitted a genuine activity signal (OSC 9;4).
|
|
405
418
|
* Its activity dot is shown only while true; a session that never reports
|
|
406
419
|
* activity (a plain shell) keeps a clean, dot-less tab. Fed from the server's
|
package/src/index.ts
CHANGED
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
// their own DOM.
|
|
13
13
|
|
|
14
14
|
export { createTerminal } from "./kernel/kernel.js";
|
|
15
|
+
export { localScrollbackStorage } from "./kernel/scrollback-storage.js";
|
|
16
|
+
export type { LocalScrollbackStorageOptions } from "./kernel/scrollback-storage.js";
|
|
15
17
|
export { STARTUP_FAILURE_COPY } from "./kernel/startup-copy.js";
|
|
16
18
|
export { LOADING_OVERLAY_CLASSES, PUBLIC_THEME_TOKENS } from "./kernel/style-contract.js";
|
|
17
19
|
export type { PublicThemeToken } from "./kernel/style-contract.js";
|
|
@@ -21,6 +23,8 @@ export type {
|
|
|
21
23
|
TerminalHandle,
|
|
22
24
|
TerminalFeature,
|
|
23
25
|
FeatureInstance,
|
|
26
|
+
PersistedScrollback,
|
|
27
|
+
ScrollbackPersistence,
|
|
24
28
|
SessionOwnerRegistration,
|
|
25
29
|
TerminalContext,
|
|
26
30
|
TerminalEvents,
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// gesture.ts — the single boundary between a TAP and a LONG-PRESS on touch.
|
|
2
|
+
//
|
|
3
|
+
// Two handlers split one gesture and must not overlap or leave a gap (a tap that
|
|
4
|
+
// also opens a menu, a hold that does nothing): the kernel's tap-to-focus claims
|
|
5
|
+
// a short, low-movement press and opens the soft keyboard or clears a selection,
|
|
6
|
+
// and the contextMenu feature claims anything held past the ceiling. One
|
|
7
|
+
// threshold pair, imported by both, is what keeps that split exact — two copies
|
|
8
|
+
// of the number would drift and re-open the overlap.
|
|
9
|
+
|
|
10
|
+
/** Movement ceiling for a stationary press (px, compared per axis). Beyond it
|
|
11
|
+
* the gesture is a scroll or a selection-extend and belongs to the browser. */
|
|
12
|
+
export const TAP_MOVEMENT_PX = 10;
|
|
13
|
+
|
|
14
|
+
/** Duration ceiling for a tap (ms). A stationary press at or below it is a tap
|
|
15
|
+
* (tap-to-focus / tap-to-deselect); strictly above it, it is a long-press and
|
|
16
|
+
* belongs to native text selection or the context menu. */
|
|
17
|
+
export const TAP_MAX_MS = 500;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Whether a press landed on a LINK, in which case neither handler claims it: the
|
|
21
|
+
* platform's own link affordances (preview on hold, activate on tap) win.
|
|
22
|
+
*
|
|
23
|
+
* Shared for the same reason the thresholds are, and it was NOT: the kernel
|
|
24
|
+
* matched `.term-link` only while the menu matched `a[href], .term-link`, so an
|
|
25
|
+
* ordinary anchor in the output was a link to one half of the boundary and not the
|
|
26
|
+
* other — the menu stood down for the platform while tap-to-focus stole the press.
|
|
27
|
+
* `.term-link` is the engine linkifier's class; `a[href]` covers anchors a consumer
|
|
28
|
+
* or an application wrote itself.
|
|
29
|
+
*/
|
|
30
|
+
export function isLinkTarget(target: EventTarget | null): boolean {
|
|
31
|
+
return target instanceof Element && target.closest("a[href], .term-link") !== null;
|
|
32
|
+
}
|