@cplieger/web-terminal-ui 7.3.2 → 8.0.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/NOTICE +3 -0
- package/README.md +114 -18
- package/THIRD_PARTY_NOTICES.md +7 -0
- package/css/30-tabs.css +21 -0
- package/css/31-switcher.css +15 -0
- package/css/32-split.css +176 -0
- package/css/MANIFEST +1 -0
- package/css/MANIFEST.tabbed +1 -0
- package/package.json +15 -12
- package/src/composition.ts +165 -237
- package/src/features/activity-monitor.ts +20 -55
- package/src/features/animations.ts +12 -30
- package/src/features/clipboard.ts +36 -64
- package/src/features/connection-banner.ts +10 -19
- package/src/features/context-menu.ts +70 -145
- package/src/features/dom.ts +44 -54
- package/src/features/menu-position.ts +24 -28
- package/src/features/mobile-toolbar.ts +32 -62
- package/src/features/predictive-echo.ts +15 -49
- package/src/features/scroll-to-bottom.ts +12 -24
- package/src/features/tabs/index.ts +1398 -1806
- package/src/features/tabs/ink-centre.ts +45 -54
- package/src/features/tabs/model.ts +301 -383
- package/src/features/tabs/strip.ts +45 -97
- package/src/features/tabs/switcher.ts +70 -48
- package/src/index.ts +13 -13
- package/src/kernel/a11y.ts +106 -27
- package/src/kernel/attention.ts +157 -0
- package/src/kernel/conn-state.ts +28 -22
- package/src/kernel/fatal.ts +76 -0
- package/src/kernel/feature-host.ts +136 -0
- package/src/kernel/gesture.ts +9 -13
- package/src/kernel/kernel.ts +57 -2139
- package/src/kernel/layout-policy.ts +44 -0
- package/src/kernel/loading-status.ts +30 -88
- package/src/kernel/navigation.ts +11 -17
- package/src/kernel/notify.ts +158 -0
- package/src/kernel/pane.ts +1415 -0
- package/src/kernel/realm.ts +11 -0
- package/src/kernel/regions.ts +8 -11
- package/src/kernel/scrollback.ts +88 -168
- package/src/kernel/selection.ts +25 -0
- package/src/kernel/shell.ts +1434 -0
- package/src/kernel/split-handle.ts +280 -0
- package/src/kernel/status-share.ts +100 -0
- package/src/kernel/types.ts +457 -441
- package/src/predict.ts +144 -170
- package/src/viewport.ts +124 -197
- package/src/features/tabs/attention.ts +0 -250
- package/src/features/tabs/notify.ts +0 -265
package/src/features/dom.ts
CHANGED
|
@@ -4,12 +4,14 @@
|
|
|
4
4
|
// share. No interpolation ever passes through here, so it carries no injection
|
|
5
5
|
// surface.
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
import { windowOf } from "../kernel/realm.js";
|
|
8
|
+
|
|
9
|
+
/** Parse a static HTML string in `doc` and return its first element. */
|
|
10
|
+
export function fromHTML(doc: Document, html: string): HTMLElement {
|
|
11
|
+
const tpl = doc.createElement("template");
|
|
10
12
|
tpl.innerHTML = html.trim();
|
|
11
13
|
const el = tpl.content.firstElementChild;
|
|
12
|
-
if (!(el instanceof HTMLElement)) {
|
|
14
|
+
if (!(el instanceof windowOf(doc).HTMLElement)) {
|
|
13
15
|
throw new Error("web-terminal-ui: fromHTML produced no element");
|
|
14
16
|
}
|
|
15
17
|
return el;
|
|
@@ -21,55 +23,43 @@ export function fromHTML(html: string): HTMLElement {
|
|
|
21
23
|
* Firefox. */
|
|
22
24
|
export const PRESSED_CLASS = "wt-pressed";
|
|
23
25
|
|
|
24
|
-
/**
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
* Press feedback: cancelling that default is also what suppresses the browser's
|
|
35
|
-
* OWN activation state — Firefox ties `:active` to the mousedown default action,
|
|
36
|
-
* and preventDefault on pointerdown suppresses the compatibility mousedown
|
|
37
|
-
* entirely (measured on Firefox 140: no mousedown event, `matches(":active")`
|
|
38
|
-
* false, background unchanged for as long as the button is held; Chromium and
|
|
39
|
-
* WebKit still apply it). So these buttons — and ONLY these — carry the press
|
|
40
|
-
* class, and their CSS press rules match either. That is why the two jobs live
|
|
41
|
-
* in one helper: a caller that cancels the default without painting the class
|
|
42
|
-
* ships a control with no press feedback in one engine, which is exactly the
|
|
43
|
-
* defect this replaced.
|
|
44
|
-
*
|
|
45
|
-
* The class is dropped on release, on cancel, and when the pointer leaves while
|
|
46
|
-
* held (a press dragged off the button is not an activation), mirroring what
|
|
47
|
-
* `:active` does on its own. A non-primary button press paints nothing, also
|
|
48
|
-
* matching it. The release listeners live on `window` for the life of the press
|
|
49
|
-
* (an `AbortController`, the pattern the switcher gesture uses) rather than on
|
|
50
|
-
* the button: `.wt-scroll-bottom` acts on the press and hides itself, so by the
|
|
51
|
-
* time the release lands the button is `display: none` and receives no
|
|
52
|
-
* `pointerup` at all — bound to the button, the class would stay painted and the
|
|
53
|
-
* control would come back already lit. */
|
|
54
|
-
export function holdFocusOnPress(btn: HTMLElement): void {
|
|
26
|
+
/** Wire a chrome button that must NOT take the keyboard off the terminal, and
|
|
27
|
+
* paint its own press state because of it: cancelling pointerdown's default
|
|
28
|
+
* keeps the hidden textarea focused (iOS otherwise spends the first tap on a
|
|
29
|
+
* blur; a desktop parks the arrows on the button) and suppresses Firefox's
|
|
30
|
+
* `:active` (tied to the mousedown default, measured on Firefox 140), so the
|
|
31
|
+
* press class replaces it. The release listeners live on the window because
|
|
32
|
+
* `.wt-scroll-bottom` hides itself on press and never sees its own `pointerup`.
|
|
33
|
+
* Returns the release that unwires the button and ends a held press. */
|
|
34
|
+
export function holdFocusOnPress(btn: HTMLElement): () => void {
|
|
35
|
+
const wired = new AbortController();
|
|
55
36
|
let release: (() => void) | null = null;
|
|
56
|
-
btn.addEventListener(
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
held
|
|
65
|
-
release =
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
37
|
+
btn.addEventListener(
|
|
38
|
+
"pointerdown",
|
|
39
|
+
(e) => {
|
|
40
|
+
e.preventDefault();
|
|
41
|
+
if (e.button !== 0 || release !== null) {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
btn.classList.add(PRESSED_CLASS);
|
|
45
|
+
const held = new AbortController();
|
|
46
|
+
release = () => {
|
|
47
|
+
held.abort();
|
|
48
|
+
release = null;
|
|
49
|
+
btn.classList.remove(PRESSED_CLASS);
|
|
50
|
+
};
|
|
51
|
+
const end = (): void => {
|
|
52
|
+
release?.();
|
|
53
|
+
};
|
|
54
|
+
const win = windowOf(btn.ownerDocument);
|
|
55
|
+
win.addEventListener("pointerup", end, { signal: held.signal });
|
|
56
|
+
win.addEventListener("pointercancel", end, { signal: held.signal });
|
|
57
|
+
btn.addEventListener("pointerleave", end, { signal: held.signal });
|
|
58
|
+
},
|
|
59
|
+
{ signal: wired.signal },
|
|
60
|
+
);
|
|
61
|
+
return () => {
|
|
62
|
+
wired.abort();
|
|
63
|
+
release?.();
|
|
64
|
+
};
|
|
75
65
|
}
|
|
@@ -1,11 +1,9 @@
|
|
|
1
|
-
// menu-position.ts — shared point-anchored placement for the fixed-position
|
|
2
|
-
// menus (the terminal context menu and the tab-strip menu), which previously
|
|
3
|
-
// carried two hand-synced copies of this exact clamp/flip math.
|
|
4
|
-
//
|
|
5
1
|
// position:fixed means x/y are viewport coordinates; the VISUAL viewport
|
|
6
2
|
// (when present) is the area above the on-screen keyboard, so clamping to it
|
|
7
3
|
// keeps a menu off the keyboard too.
|
|
8
4
|
|
|
5
|
+
import { windowOf } from "../kernel/realm.js";
|
|
6
|
+
|
|
9
7
|
/** Clamp margin from the visible viewport edges (px). */
|
|
10
8
|
const EDGE_MARGIN = 8;
|
|
11
9
|
|
|
@@ -20,31 +18,28 @@ const FLIP_GAP = 16;
|
|
|
20
18
|
* the release without eating a deliberate follow-up tap. */
|
|
21
19
|
const SWALLOW_MS = 350;
|
|
22
20
|
|
|
23
|
-
/**
|
|
24
|
-
*
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
* to stop. Arm it only for touch: a mouse right-click emits no trailing click,
|
|
40
|
-
* and swallowing there would eat a genuine dismiss. */
|
|
41
|
-
export function createClickSwallow(): { arm: () => void; swallowing: () => boolean } {
|
|
21
|
+
/** The two halves a point-anchored menu needs to survive the
|
|
22
|
+
* contextmenu-then-touchend race. */
|
|
23
|
+
interface ClickSwallow {
|
|
24
|
+
/** Start the swallow window. Call on the RELEASE edge of the opening touch,
|
|
25
|
+
* not when the menu opens: the window is a fixed 350 ms from here, so armed
|
|
26
|
+
* mid-press it only covers a release that comes within 350 ms. Touch only: a
|
|
27
|
+
* mouse right-click emits no trailing click, and swallowing there would eat a
|
|
28
|
+
* genuine dismiss. */
|
|
29
|
+
arm: () => void;
|
|
30
|
+
/** Whether a click arriving now is the opening gesture's own release; the
|
|
31
|
+
* first guard in the outside-click dismiss. */
|
|
32
|
+
swallowing: () => boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Build a swallow on `win`'s clock, the clock the menu's document runs on. */
|
|
36
|
+
export function createClickSwallow(win: Pick<Window, "performance">): ClickSwallow {
|
|
42
37
|
let until = 0;
|
|
43
38
|
return {
|
|
44
39
|
arm: (): void => {
|
|
45
|
-
until = performance.now() + SWALLOW_MS;
|
|
40
|
+
until = win.performance.now() + SWALLOW_MS;
|
|
46
41
|
},
|
|
47
|
-
swallowing: (): boolean => performance.now() < until,
|
|
42
|
+
swallowing: (): boolean => win.performance.now() < until,
|
|
48
43
|
};
|
|
49
44
|
}
|
|
50
45
|
|
|
@@ -57,11 +52,12 @@ export function createClickSwallow(): { arm: () => void; swallowing: () => boole
|
|
|
57
52
|
* (wt-container). The menu must be measurable (rendered, not display:none)
|
|
58
53
|
* when called. */
|
|
59
54
|
export function placeMenuAt(menu: HTMLElement, x: number, y: number): void {
|
|
60
|
-
const
|
|
55
|
+
const win = windowOf(menu.ownerDocument);
|
|
56
|
+
const vv = win.visualViewport;
|
|
61
57
|
const viewLeft = vv ? vv.offsetLeft : 0;
|
|
62
58
|
const viewTop = vv ? vv.offsetTop : 0;
|
|
63
|
-
const viewWidth = vv ? vv.width :
|
|
64
|
-
const viewHeight = vv ? vv.height :
|
|
59
|
+
const viewWidth = vv ? vv.width : win.innerWidth;
|
|
60
|
+
const viewHeight = vv ? vv.height : win.innerHeight;
|
|
65
61
|
const menuW = menu.offsetWidth;
|
|
66
62
|
const menuH = menu.offsetHeight;
|
|
67
63
|
const left = Math.max(
|
|
@@ -1,48 +1,30 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* mobileToolbar feature: the on-screen key toolbar (Tab/Esc/arrows/Enter + a
|
|
3
|
-
* collapse toggle and sticky-Ctrl) in the thumb-zone region.
|
|
4
|
-
* The engine's toolbar.bindMobileToolbar wires the buttons (DECCKM-aware
|
|
5
|
-
* arrows, sticky-Ctrl state; engine >= v3, where the widget moved out of the
|
|
6
|
-
* keyboard module); this feature builds the toolbar chrome, routes its
|
|
7
|
-
* output through the kernel funnel, and registers sticky-Ctrl as an input
|
|
8
|
-
* transform so a typed character is rewritten to its Ctrl byte when armed.
|
|
9
|
-
*
|
|
10
|
-
* @module
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
1
|
import { toolbar } from "@cplieger/web-terminal-engine";
|
|
2
|
+
import { windowOf } from "../kernel/realm.js";
|
|
14
3
|
import type { TerminalFeature } from "../kernel/types.js";
|
|
15
4
|
import { fromHTML } from "./dom.js";
|
|
16
5
|
|
|
17
6
|
const { bindMobileToolbar } = toolbar;
|
|
18
7
|
|
|
19
|
-
/** mobileToolbar's
|
|
20
|
-
*
|
|
8
|
+
/** mobileToolbar's API, so a peer feature (tabs) can drive the key grid from a
|
|
9
|
+
* button it owns instead of the toolbar's own toggle. */
|
|
21
10
|
export interface MobileToolbarApi {
|
|
22
|
-
/** Show/hide the key grid
|
|
11
|
+
/** Show/hide the key grid. */
|
|
23
12
|
toggle(): void;
|
|
24
|
-
/** Whether the key grid is currently open. */
|
|
25
13
|
isOpen(): boolean;
|
|
26
|
-
/** Whether sticky-Ctrl is
|
|
14
|
+
/** Whether sticky-Ctrl is armed (a pending Ctrl press). */
|
|
27
15
|
isCtrlArmed(): boolean;
|
|
28
|
-
/**
|
|
29
|
-
*
|
|
30
|
-
* key grid — and its own Ctrl button — is closed. */
|
|
16
|
+
/** Sticky-Ctrl arm/disarm, so a peer can show the pending modifier while the
|
|
17
|
+
* key grid and its own Ctrl button are closed. */
|
|
31
18
|
onCtrlArmedChange(fn: (armed: boolean) => void): () => void;
|
|
32
19
|
}
|
|
33
20
|
|
|
34
21
|
/** Options for the mobileToolbar feature. */
|
|
35
22
|
export interface MobileToolbarOptions {
|
|
36
|
-
/**
|
|
37
|
-
* the
|
|
38
|
-
* externally through the returned API (tabs renders a keyboard button in the
|
|
39
|
-
* switcher bar and calls toggle()). The tabbed presets set this; presetTouch
|
|
40
|
-
* leaves it off and keeps the self-contained top-right toggle. */
|
|
23
|
+
/** Hide the toolbar's own toggle and open the grid above the mobile tab bar;
|
|
24
|
+
* the grid is then driven through the returned API. The tabbed presets set it. */
|
|
41
25
|
externalToggle?: boolean;
|
|
42
26
|
}
|
|
43
27
|
|
|
44
|
-
// The key grid, without the scroll-to-bottom button (that is the scrollToBottom
|
|
45
|
-
// feature, composed into the same region's "scroll" slot).
|
|
46
28
|
const TOOLBAR_HTML = `
|
|
47
29
|
<div class="key-toolbar collapsed no-transition" aria-label="Navigation keys" role="toolbar">
|
|
48
30
|
<button type="button" id="kb-toggle" class="kb-toggle" aria-label="Toggle key toolbar"><svg class="icon-hamburger" viewBox="0 0 24 24"><line x1="4" y1="6" x2="20" y2="6"/><line x1="4" y1="12" x2="20" y2="12"/><line x1="4" y1="18" x2="20" y2="18"/></svg><svg class="icon-close" viewBox="0 0 24 24"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg></button>
|
|
@@ -56,53 +38,46 @@ const TOOLBAR_HTML = `
|
|
|
56
38
|
<button type="button" id="kb-right" class="kb-key kb-r2c4" aria-label="Right"><svg viewBox="0 0 24 24"><polyline points="9 6 15 12 9 18"/></svg></button>
|
|
57
39
|
</div>`;
|
|
58
40
|
|
|
59
|
-
/** Build the mobileToolbar feature.
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
* tabs reads the returned API through `ctx.use` to drive the grid from the tab
|
|
66
|
-
* bar's keyboard button.
|
|
67
|
-
*
|
|
68
|
-
* Sticky-Ctrl is registered as a kernel INPUT TRANSFORM rather than handled in
|
|
69
|
-
* the toolbar's own click path, so it rewrites a character typed on the soft
|
|
70
|
-
* keyboard as well as one produced by these buttons. Teardown removes the
|
|
71
|
-
* toolbar, drops the transform, and clears every subscriber. */
|
|
41
|
+
/** Build the mobileToolbar feature. The chrome is always built; only a coarse
|
|
42
|
+
* pointer makes it visible (`css/23-toolbar.css`). Order it before `tabs` when
|
|
43
|
+
* `externalToggle` is set, since tabs reads the API through `ctx.use`.
|
|
44
|
+
* Sticky-Ctrl is a kernel INPUT TRANSFORM rather than a toolbar click path, so
|
|
45
|
+
* it rewrites a character typed on the soft keyboard as well as one from these
|
|
46
|
+
* buttons. */
|
|
72
47
|
export function mobileToolbar(opts: MobileToolbarOptions = {}): TerminalFeature<MobileToolbarApi> {
|
|
73
48
|
return {
|
|
74
49
|
name: "mobileToolbar",
|
|
50
|
+
scope: "shell",
|
|
75
51
|
setup(ctx) {
|
|
76
52
|
const encoder = new TextEncoder();
|
|
77
53
|
const decoder = new TextDecoder();
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
// toggle and opens the grid above the mobile tab bar; tabs drives it via
|
|
81
|
-
// the returned API. The class gates both behaviors in CSS.
|
|
54
|
+
const win = windowOf(ctx.shell.root.ownerDocument);
|
|
55
|
+
const toolbar = fromHTML(ctx.shell.root.ownerDocument, TOOLBAR_HTML);
|
|
82
56
|
if (opts.externalToggle) {
|
|
83
57
|
toolbar.classList.add("wt-toolbar-external");
|
|
84
58
|
}
|
|
85
59
|
ctx.region("bottom-inset-end", "keys").appendChild(toolbar);
|
|
86
60
|
|
|
87
|
-
// Fan sticky-Ctrl arm/disarm out to subscribers (the tab bar's keyboard
|
|
88
|
-
// button mirrors it, so a pending Ctrl is visible with the grid closed).
|
|
89
61
|
const armedListeners = new Set<(armed: boolean) => void>();
|
|
90
62
|
const ctrl = bindMobileToolbar({
|
|
91
63
|
toolbar,
|
|
92
64
|
send: (bytes) => {
|
|
93
65
|
ctx.send(encoder.encode(bytes));
|
|
94
66
|
},
|
|
67
|
+
modes: ctx.modes,
|
|
95
68
|
onCtrlChange: (armed) => {
|
|
96
69
|
for (const fn of [...armedListeners]) {
|
|
97
70
|
fn(armed);
|
|
98
71
|
}
|
|
99
72
|
},
|
|
100
73
|
});
|
|
74
|
+
ctx.defer(() => {
|
|
75
|
+
ctrl.dispose();
|
|
76
|
+
});
|
|
101
77
|
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
|
|
105
|
-
const offTransform = ctx.registerInputTransform((bytes) => {
|
|
78
|
+
// The toolbar's own arrow/Tab/Enter/Esc sends disarm before sending, so
|
|
79
|
+
// they pass through untouched.
|
|
80
|
+
ctx.registerInputTransform((bytes) => {
|
|
106
81
|
if (!ctrl.isCtrlArmed()) {
|
|
107
82
|
return bytes;
|
|
108
83
|
}
|
|
@@ -111,18 +86,17 @@ export function mobileToolbar(opts: MobileToolbarOptions = {}): TerminalFeature<
|
|
|
111
86
|
return mapped === text ? bytes : encoder.encode(mapped);
|
|
112
87
|
});
|
|
113
88
|
|
|
114
|
-
//
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
requestAnimationFrame(() => {
|
|
89
|
+
// Two frames without the slide transition, so the first paint cannot flash.
|
|
90
|
+
let settleFrame = win.requestAnimationFrame(() => {
|
|
91
|
+
settleFrame = win.requestAnimationFrame(() => {
|
|
118
92
|
toolbar.classList.remove("no-transition");
|
|
119
93
|
});
|
|
120
94
|
});
|
|
95
|
+
ctx.defer(() => {
|
|
96
|
+
win.cancelAnimationFrame(settleFrame);
|
|
97
|
+
});
|
|
121
98
|
|
|
122
99
|
return {
|
|
123
|
-
// Expose grid open/close so a peer (tabs) can drive it from the tab bar.
|
|
124
|
-
// Toggling the same `.collapsed` class the engine's own kb-toggle flips
|
|
125
|
-
// keeps a single source of truth for the grid's visibility.
|
|
126
100
|
api: {
|
|
127
101
|
toggle() {
|
|
128
102
|
toolbar.classList.toggle("collapsed");
|
|
@@ -139,17 +113,13 @@ export function mobileToolbar(opts: MobileToolbarOptions = {}): TerminalFeature<
|
|
|
139
113
|
},
|
|
140
114
|
},
|
|
141
115
|
onDetach() {
|
|
142
|
-
// A
|
|
143
|
-
// not fire against the incoming session (e.g. an accidental Ctrl+C to
|
|
144
|
-
// the wrong agent, design 5.1 / ui-ux review D).
|
|
116
|
+
// A pending Ctrl must not fire against the incoming session.
|
|
145
117
|
if (ctrl.isCtrlArmed()) {
|
|
146
118
|
ctrl.setCtrlArmed(false);
|
|
147
119
|
}
|
|
148
120
|
},
|
|
149
121
|
teardown() {
|
|
150
|
-
offTransform();
|
|
151
122
|
armedListeners.clear();
|
|
152
|
-
ctrl.dispose();
|
|
153
123
|
toolbar.remove();
|
|
154
124
|
},
|
|
155
125
|
};
|
|
@@ -1,88 +1,54 @@
|
|
|
1
|
-
|
|
2
|
-
* predictiveEcho feature: Mosh-style local echo. Wires the
|
|
3
|
-
* predict.ts mini-VT to the kernel input funnel: it advances a predicted cursor
|
|
4
|
-
* on accepted input (an input observer) and owns the col-0 backspace brake (an
|
|
5
|
-
* input transform that drops a lone DEL at the true origin, since the predicted
|
|
6
|
-
* cursor is the authoritative position for that decision). It re-pushes the
|
|
7
|
-
* predicted-cursor overlay on prediction changes and after each render flush.
|
|
8
|
-
*
|
|
9
|
-
* @module
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
import * as predict from "../predict.js";
|
|
1
|
+
import { createPredictor } from "../predict.js";
|
|
13
2
|
import type { TerminalFeature } from "../kernel/types.js";
|
|
14
3
|
|
|
15
|
-
/** Build the predictiveEcho feature
|
|
16
|
-
* renderer's predicted-cursor overlay.
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* cursor between them. That is within the kernel's existing "call createTerminal
|
|
21
|
-
* exactly once" contract, but it is the reason this feature cannot be made
|
|
22
|
-
* per-instance by composing it twice.
|
|
23
|
-
*
|
|
24
|
-
* Predictions are advisory: `predict` suspends itself on any byte it cannot
|
|
25
|
-
* model, because a wrong prediction is worse than a missing one. The feature
|
|
26
|
-
* resets it on a server restart and on a tab switch (`onDetach`), so the outgoing
|
|
27
|
-
* session's ghost cursor never paints over the incoming session's screen; the
|
|
28
|
-
* next server frame re-arms it. Teardown drops every listener, hides the overlay,
|
|
29
|
-
* and clears the prediction. */
|
|
4
|
+
/** Build the predictiveEcho feature: Mosh-style local echo through one predictor
|
|
5
|
+
* per pane, drawn as the renderer's predicted-cursor overlay. The predictor
|
|
6
|
+
* suspends itself on any byte it cannot model, because a wrong prediction is
|
|
7
|
+
* worse than a missing one; a server restart and a tab switch reset it so the
|
|
8
|
+
* outgoing session's ghost cursor never paints over the incoming screen. */
|
|
30
9
|
export function predictiveEcho(): TerminalFeature {
|
|
31
10
|
return {
|
|
32
11
|
name: "predictiveEcho",
|
|
33
12
|
setup(ctx) {
|
|
13
|
+
const predict = createPredictor();
|
|
14
|
+
ctx.defer(() => {
|
|
15
|
+
predict.dispose();
|
|
16
|
+
});
|
|
34
17
|
function push(): void {
|
|
35
18
|
const p = predict.get();
|
|
36
19
|
ctx.render.setPredictedCursor(p.row, p.col, p.active);
|
|
37
20
|
}
|
|
38
21
|
predict.subscribe(push);
|
|
39
22
|
|
|
40
|
-
|
|
41
|
-
// col 0) so held-Backspace on an empty line does not flood the server.
|
|
42
|
-
// Owned here because the predicted cursor is the position it keys off.
|
|
43
|
-
const offTransform = ctx.registerInputTransform((bytes) => {
|
|
23
|
+
ctx.registerInputTransform((bytes) => {
|
|
44
24
|
const p = predict.get();
|
|
45
25
|
if (bytes.length === 1 && bytes[0] === 0x7f && p.active && p.row === 0 && p.col === 0) {
|
|
46
26
|
return new Uint8Array(0);
|
|
47
27
|
}
|
|
48
28
|
return bytes;
|
|
49
29
|
});
|
|
50
|
-
|
|
30
|
+
ctx.registerInputObserver((bytes) => {
|
|
51
31
|
predict.applyInput(bytes);
|
|
52
32
|
});
|
|
53
|
-
|
|
54
|
-
const offCursor = ctx.on("render:cursor", () => {
|
|
33
|
+
ctx.on("render:cursor", () => {
|
|
55
34
|
push();
|
|
56
35
|
});
|
|
57
|
-
|
|
36
|
+
ctx.on("wire:screen", (msg) => {
|
|
58
37
|
const sz = ctx.session.size();
|
|
59
38
|
predict.setDimensions(sz.cols, sz.rows);
|
|
60
39
|
predict.onScreenFrame(msg.cursor[0], msg.cursor[1], msg.cursorHidden);
|
|
61
40
|
});
|
|
62
|
-
|
|
41
|
+
ctx.on("connection:state", (s) => {
|
|
63
42
|
if (s === "restarted") {
|
|
64
43
|
predict.reset();
|
|
65
44
|
}
|
|
66
45
|
});
|
|
67
46
|
|
|
68
47
|
return {
|
|
69
|
-
// A tab switch (kernel notifySwitch -> onDetach) must drop the predicted
|
|
70
|
-
// cursor, so the outgoing session's ghost cursor does not paint on the
|
|
71
|
-
// incoming session's freshly-bound screen until its first server frame
|
|
72
|
-
// re-arms prediction. Mirrors composition.cancelComposition on the same
|
|
73
|
-
// detach: both local-echo paths reset on switch. predict.reset() hides the
|
|
74
|
-
// overlay (its onChange -> push -> setPredictedCursor(0,0,false)); the next
|
|
75
|
-
// wire:screen re-arms via onScreenFrame.
|
|
76
48
|
onDetach() {
|
|
77
49
|
predict.reset();
|
|
78
50
|
},
|
|
79
51
|
teardown() {
|
|
80
|
-
offTransform();
|
|
81
|
-
offObserver();
|
|
82
|
-
offCursor();
|
|
83
|
-
offScreen();
|
|
84
|
-
offState();
|
|
85
|
-
// Hide the overlay and drop stale prediction state.
|
|
86
52
|
predict.reset();
|
|
87
53
|
ctx.render.setPredictedCursor(0, 0, false);
|
|
88
54
|
},
|
|
@@ -1,13 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
* scrollToBottom feature: a scroll-to-bottom control in the thumb-zone region,
|
|
3
|
-
* shown only while the user has scrolled up. The
|
|
4
|
-
* scroll-state signal comes from the kernel's scroll:state event (the kernel
|
|
5
|
-
* owns scroll.init); this feature just renders the affordance and drives
|
|
6
|
-
* scroll.scrollToBottom.
|
|
7
|
-
*
|
|
8
|
-
* @module
|
|
9
|
-
*/
|
|
10
|
-
|
|
1
|
+
import { windowOf } from "../kernel/realm.js";
|
|
11
2
|
import type { TerminalFeature } from "../kernel/types.js";
|
|
12
3
|
import { fromHTML, holdFocusOnPress } from "./dom.js";
|
|
13
4
|
|
|
@@ -16,22 +7,19 @@ const BUTTON_HTML = `
|
|
|
16
7
|
<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="none" d="M7 13l5 5 5-5M7 6l5 5 5-5"/></svg>
|
|
17
8
|
</button>`;
|
|
18
9
|
|
|
19
|
-
/** Build the scrollToBottom feature. Exposes no API
|
|
20
|
-
* surface, and a peer that wants the
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* exactly that state). The jump is smooth unless the user asked for reduced
|
|
26
|
-
* motion, and it is idempotent, so binding both `pointerdown` (touch presses feel
|
|
27
|
-
* immediate) and `click` (keyboard activation) is safe. Teardown unsubscribes and
|
|
28
|
-
* removes the button. */
|
|
10
|
+
/** Build the scrollToBottom feature. Exposes no API: the button is the whole
|
|
11
|
+
* surface, and a peer that wants the effect calls `ctx.scroll.scrollToBottom`.
|
|
12
|
+
* Its job is resuming FOLLOW, not just moving the viewport, so it also acts
|
|
13
|
+
* when the reader is at the bottom but holding (an ED3 clamp leaves that
|
|
14
|
+
* state). The jump is idempotent, so `pointerdown` (touch feels immediate) and
|
|
15
|
+
* `click` (keyboard) are both bound. Teardown unsubscribes and removes it. */
|
|
29
16
|
export function scrollToBottom(): TerminalFeature {
|
|
30
17
|
return {
|
|
31
18
|
name: "scrollToBottom",
|
|
32
19
|
setup(ctx) {
|
|
20
|
+
const win = windowOf(ctx.shell.root.ownerDocument);
|
|
33
21
|
const slot = ctx.region("bottom-inset-end", "scroll");
|
|
34
|
-
const btn = fromHTML(BUTTON_HTML);
|
|
22
|
+
const btn = fromHTML(ctx.shell.root.ownerDocument, BUTTON_HTML);
|
|
35
23
|
slot.appendChild(btn);
|
|
36
24
|
|
|
37
25
|
// Jump to the bottom. With motion allowed, smooth-scroll the surface so
|
|
@@ -43,8 +31,8 @@ export function scrollToBottom(): TerminalFeature {
|
|
|
43
31
|
// scrollToBottom (which re-engages following synchronously).
|
|
44
32
|
const jump = (): void => {
|
|
45
33
|
const reduce =
|
|
46
|
-
typeof
|
|
47
|
-
|
|
34
|
+
typeof win.matchMedia === "function" &&
|
|
35
|
+
win.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
48
36
|
if (reduce) {
|
|
49
37
|
ctx.scroll.scrollToBottom();
|
|
50
38
|
return;
|
|
@@ -78,7 +66,7 @@ export function scrollToBottom(): TerminalFeature {
|
|
|
78
66
|
// jump is idempotent, so the pair is safe. holdFocusOnPress keeps the
|
|
79
67
|
// keyboard on the terminal across the press and paints the press class the
|
|
80
68
|
// cancelled default costs this button.
|
|
81
|
-
holdFocusOnPress(btn);
|
|
69
|
+
ctx.defer(holdFocusOnPress(btn));
|
|
82
70
|
btn.addEventListener("pointerdown", jump);
|
|
83
71
|
btn.addEventListener("click", jump);
|
|
84
72
|
|