@cplieger/web-terminal-ui 5.2.2 → 5.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +149 -11
- package/css/00-tokens.css +3 -3
- package/css/02-terminal.css +38 -7
- package/css/30-tabs.css +79 -18
- package/css/31-switcher.css +23 -1
- package/css/40-animations.css +18 -0
- package/css/page.css +8 -8
- package/package.json +23 -7
- 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 +1 -1
- 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
|
@@ -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,7 +3,7 @@
|
|
|
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
|
|
6
|
+
// further above the baseline than below (the bundled Monaspace Neon NF: 0.995em
|
|
7
7
|
// over, 0.250em under) while a mixed-case title's ink runs cap height to
|
|
8
8
|
// descender (0.734em over, 0.203em under), so the visible text sits low inside
|
|
9
9
|
// the box it is centred by. The correction is the gap between those two centres.
|
|
@@ -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
|
+
}
|