@cplieger/web-terminal-engine 2.7.0 → 3.0.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 +6 -4
- package/jsr.json +1 -1
- package/package.json +2 -1
- package/src/connection.ts +347 -72
- package/src/index.ts +9 -0
- package/src/keyboard.ts +187 -270
- package/src/modes.ts +76 -2
- package/src/mouse.ts +51 -6
- package/src/render.ts +275 -95
- package/src/scroll.ts +25 -0
- package/src/status-stream.ts +13 -5
- package/src/store.ts +11 -3
- package/src/toolbar.ts +270 -0
- package/src/types.ts +49 -2
- package/src/wire-binary.ts +20 -12
- package/src/wire-compatibility.ts +29 -0
- package/src/wire.ts +14 -6
package/src/mouse.ts
CHANGED
|
@@ -84,7 +84,6 @@ export interface MouseInputHandler {
|
|
|
84
84
|
}
|
|
85
85
|
|
|
86
86
|
let handler: MouseInputHandler | null = null;
|
|
87
|
-
let lastButton = -1;
|
|
88
87
|
// True while a Shift-initiated press is in flight. Shift+click/drag is the
|
|
89
88
|
// xterm convention for "bypass application mouse tracking": the gesture is
|
|
90
89
|
// reserved for the browser's native text selection instead of being reported
|
|
@@ -92,21 +91,62 @@ let lastButton = -1;
|
|
|
92
91
|
// so the move/up of a bypassed drag stay bypassed even if Shift is lifted
|
|
93
92
|
// mid-drag.
|
|
94
93
|
let shiftBypass = false;
|
|
94
|
+
// Last motion report sent, for same-cell dedup: a drag within one cell fires
|
|
95
|
+
// many DOM mousemove events that would all encode to the identical SGR
|
|
96
|
+
// sequence and flood the PTY (xterm.js applies the same suppression). Cleared
|
|
97
|
+
// on press/release so the first motion of a new gesture always reports.
|
|
98
|
+
let lastMotion = "";
|
|
99
|
+
// The element the current init() attached to, so a re-init or dispose can
|
|
100
|
+
// detach the exact listener set (addEventListener dedups identical
|
|
101
|
+
// registrations on the SAME element, but a re-init on a NEW element would
|
|
102
|
+
// otherwise leak the old one's listeners).
|
|
103
|
+
let attachedEl: HTMLElement | null = null;
|
|
104
|
+
|
|
105
|
+
function detach(): void {
|
|
106
|
+
if (!attachedEl) {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
attachedEl.removeEventListener("mousedown", onMouseDown);
|
|
110
|
+
attachedEl.removeEventListener("mouseup", onMouseUp);
|
|
111
|
+
attachedEl.removeEventListener("mousemove", onMouseMove);
|
|
112
|
+
attachedEl.removeEventListener("wheel", onWheel);
|
|
113
|
+
attachedEl.removeEventListener("focusin", onFocusIn);
|
|
114
|
+
attachedEl.removeEventListener("focusout", onFocusOut);
|
|
115
|
+
attachedEl = null;
|
|
116
|
+
}
|
|
95
117
|
|
|
96
118
|
/**
|
|
97
119
|
* Initialize the mouse module by attaching pointer/wheel/focus listeners to
|
|
98
120
|
* the terminal element. Listeners gate on the active mouse mode + SGR 1006
|
|
99
121
|
* encoding; they are no-ops when the server hasn't enabled mouse tracking.
|
|
122
|
+
*
|
|
123
|
+
* Returns an idempotent disposer that detaches the listeners and resets the
|
|
124
|
+
* module state, so a consumer can tear the terminal down (or re-init on a
|
|
125
|
+
* different element) without leaking listeners. Re-initializing without
|
|
126
|
+
* disposing first detaches the previous element automatically.
|
|
100
127
|
*/
|
|
101
|
-
export function init(h: MouseInputHandler): void {
|
|
128
|
+
export function init(h: MouseInputHandler): () => void {
|
|
129
|
+
detach(); // self-heal a re-init on a different element
|
|
102
130
|
handler = h;
|
|
131
|
+
shiftBypass = false;
|
|
132
|
+
lastMotion = "";
|
|
103
133
|
const el = h.termElement();
|
|
134
|
+
attachedEl = el;
|
|
104
135
|
el.addEventListener("mousedown", onMouseDown);
|
|
105
136
|
el.addEventListener("mouseup", onMouseUp);
|
|
106
137
|
el.addEventListener("mousemove", onMouseMove);
|
|
107
138
|
el.addEventListener("wheel", onWheel, { passive: false });
|
|
108
139
|
el.addEventListener("focusin", onFocusIn);
|
|
109
140
|
el.addEventListener("focusout", onFocusOut);
|
|
141
|
+
return function dispose(): void {
|
|
142
|
+
if (attachedEl !== el) {
|
|
143
|
+
return; // superseded by a later init — nothing of ours left to detach
|
|
144
|
+
}
|
|
145
|
+
detach();
|
|
146
|
+
handler = null;
|
|
147
|
+
shiftBypass = false;
|
|
148
|
+
lastMotion = "";
|
|
149
|
+
};
|
|
110
150
|
}
|
|
111
151
|
|
|
112
152
|
// pixelToCell returns the coordinate pair to report for a mouse event. Normally
|
|
@@ -151,12 +191,12 @@ function onMouseDown(e: MouseEvent): void {
|
|
|
151
191
|
return;
|
|
152
192
|
}
|
|
153
193
|
shiftBypass = false;
|
|
194
|
+
lastMotion = ""; // new gesture: next motion always reports
|
|
154
195
|
const pos = pixelToCell(e);
|
|
155
196
|
if (!pos) {
|
|
156
197
|
return;
|
|
157
198
|
}
|
|
158
|
-
|
|
159
|
-
const code = buttonCode(lastButton, false, e.shiftKey, e.altKey, e.ctrlKey);
|
|
199
|
+
const code = buttonCode(domButtonToXterm(e.button), false, e.shiftKey, e.altKey, e.ctrlKey);
|
|
160
200
|
handler.send(encodeSGR(code, pos.col, pos.row, false));
|
|
161
201
|
e.preventDefault();
|
|
162
202
|
}
|
|
@@ -178,7 +218,7 @@ function onMouseUp(e: MouseEvent): void {
|
|
|
178
218
|
const btn = domButtonToXterm(e.button);
|
|
179
219
|
const code = buttonCode(btn, false, e.shiftKey, e.altKey, e.ctrlKey);
|
|
180
220
|
handler.send(encodeSGR(code, pos.col, pos.row, true));
|
|
181
|
-
|
|
221
|
+
lastMotion = ""; // gesture over: next motion always reports
|
|
182
222
|
e.preventDefault();
|
|
183
223
|
}
|
|
184
224
|
|
|
@@ -207,7 +247,12 @@ function onMouseMove(e: MouseEvent): void {
|
|
|
207
247
|
// code 3, not 0 — code 0 would be read as a left-button drag during hover.
|
|
208
248
|
const btn = e.buttons ? (e.buttons & 1 ? 0 : e.buttons & 4 ? 1 : e.buttons & 2 ? 2 : 0) : 3;
|
|
209
249
|
const code = buttonCode(btn, true, e.shiftKey, e.altKey, e.ctrlKey);
|
|
210
|
-
|
|
250
|
+
const seq = encodeSGR(code, pos.col, pos.row, false);
|
|
251
|
+
if (seq === lastMotion) {
|
|
252
|
+
return; // same cell, same buttons/modifiers: suppress the duplicate report
|
|
253
|
+
}
|
|
254
|
+
lastMotion = seq;
|
|
255
|
+
handler.send(seq);
|
|
211
256
|
}
|
|
212
257
|
|
|
213
258
|
function onWheel(e: WheelEvent): void {
|
package/src/render.ts
CHANGED
|
@@ -135,15 +135,17 @@ let trimMarkerEl: HTMLDivElement | null = null;
|
|
|
135
135
|
const renderQueue = new Set<number>();
|
|
136
136
|
const MAX_ROWS_PER_FRAME = 300;
|
|
137
137
|
|
|
138
|
-
// Cursor state, refreshed from the store window on each flush.
|
|
139
|
-
//
|
|
138
|
+
// Cursor state, refreshed from the store window on each flush. The caret is
|
|
139
|
+
// painted by a dedicated overlay element (see positionCursorOverlay), NOT by
|
|
140
|
+
// restyling the span at the cursor cell: rows are pure content, so cursor
|
|
141
|
+
// motion never rewrites row DOM — which preserves any native selection in the
|
|
142
|
+
// old/new cursor rows and deletes the per-keystroke row rebuild entirely
|
|
143
|
+
// (judgement finding: changed-row replaceChildren collapsed a row-local
|
|
144
|
+
// selection).
|
|
140
145
|
let cursorAbs = -1; // absolute index of the row the cursor is on
|
|
141
146
|
let cursorCol = 0;
|
|
142
147
|
let cursorHidden = false;
|
|
143
148
|
let cursorStyleVal = 0; // 0-6: DECSCUSR
|
|
144
|
-
let prevCursorCol = -1;
|
|
145
|
-
let prevCursorHidden = false;
|
|
146
|
-
let prevCursorStyleVal = -1;
|
|
147
149
|
|
|
148
150
|
function cursorClassName(): string {
|
|
149
151
|
// DECSCUSR: 0/1=blinking block, 2=steady block, 3=blinking underline,
|
|
@@ -162,6 +164,28 @@ let defaultSpacing = 0;
|
|
|
162
164
|
let onCursorMove: (() => void) | null = null;
|
|
163
165
|
let pendingFrame: number | undefined;
|
|
164
166
|
|
|
167
|
+
// termWrap's padding, cached: the overlay positioners (caret, predicted
|
|
168
|
+
// cursor, getCursorPx for the IME view) need it EVERY flush, and a live
|
|
169
|
+
// getComputedStyle after the flush's DOM writes forces a style recalc —
|
|
170
|
+
// measured at real cost in the render bench (the caret positioner alone was
|
|
171
|
+
// ~11% of flush CPU under full-screen churn). Lazily read once per attach and
|
|
172
|
+
// refreshed by updateFontMetrics (the same staleness contract as
|
|
173
|
+
// cellWidth/cellHeight: a consumer that restyles the terminal calls
|
|
174
|
+
// updateFontMetrics, which re-reads both).
|
|
175
|
+
let padLeft = 0;
|
|
176
|
+
let padTop = 0;
|
|
177
|
+
let padValid = false;
|
|
178
|
+
|
|
179
|
+
function termPadding(): { padL: number; padT: number } {
|
|
180
|
+
if (!padValid) {
|
|
181
|
+
const cs = window.getComputedStyle(termWrap);
|
|
182
|
+
padLeft = parseFloat(cs.paddingLeft);
|
|
183
|
+
padTop = parseFloat(cs.paddingTop);
|
|
184
|
+
padValid = true;
|
|
185
|
+
}
|
|
186
|
+
return { padL: padLeft, padT: padTop };
|
|
187
|
+
}
|
|
188
|
+
|
|
165
189
|
// Bounded error-path reschedule (l-f28 / d-u4-1). The drain loop deletes a
|
|
166
190
|
// queued row only AFTER upsertRow succeeds, so a row whose build throws stays
|
|
167
191
|
// queued. flushRender's catch reschedules to finish a partial drain and to
|
|
@@ -195,18 +219,24 @@ export function init(opts: {
|
|
|
195
219
|
output = opts.output;
|
|
196
220
|
termWrap = opts.termWrap;
|
|
197
221
|
onCursorMove = opts.onCursorMove ?? null;
|
|
222
|
+
// New attach target: the cached padding belongs to the previous termWrap.
|
|
223
|
+
padValid = false;
|
|
198
224
|
// Fresh attach: drop any prior model + DOM so re-init (and vitest's
|
|
199
225
|
// non-isolated module reuse) starts clean.
|
|
200
226
|
store.reset();
|
|
201
227
|
rowEls.clear();
|
|
202
228
|
renderQueue.clear();
|
|
203
229
|
trimMarkerEl = null;
|
|
204
|
-
// Drop the predicted-cursor
|
|
205
|
-
// termWrap
|
|
230
|
+
// Drop the predicted-cursor + caret overlays (re-created lazily against the
|
|
231
|
+
// new termWrap) so re-init starts clean.
|
|
206
232
|
if (predCursorEl) {
|
|
207
233
|
predCursorEl.remove();
|
|
208
234
|
predCursorEl = null;
|
|
209
235
|
}
|
|
236
|
+
if (cursorEl) {
|
|
237
|
+
cursorEl.remove();
|
|
238
|
+
cursorEl = null;
|
|
239
|
+
}
|
|
210
240
|
output.replaceChildren();
|
|
211
241
|
cursorAbs = -1;
|
|
212
242
|
if (pendingFrame !== undefined) {
|
|
@@ -303,9 +333,7 @@ export function rebuild(): void {
|
|
|
303
333
|
trimMarkerEl = null;
|
|
304
334
|
cursorAbs = -1;
|
|
305
335
|
altRendered = false;
|
|
306
|
-
|
|
307
|
-
prevCursorHidden = false;
|
|
308
|
-
prevCursorStyleVal = -1;
|
|
336
|
+
altPrevRows = [];
|
|
309
337
|
// Fresh surface: a stale give-up streak from a prior store must not deny the
|
|
310
338
|
// rebuilt surface its full transient-retry budget.
|
|
311
339
|
renderNoProgressStreak = 0;
|
|
@@ -390,8 +418,20 @@ function linkifySpans(
|
|
|
390
418
|
// persistent underline would then bleed across the cell/row.
|
|
391
419
|
a.className = "term-link term-autolink";
|
|
392
420
|
a.textContent = match[0];
|
|
393
|
-
// Copy inline styles from the source span
|
|
394
|
-
a
|
|
421
|
+
// Copy inline styles from the source span property-by-property, never
|
|
422
|
+
// via cssText: a cssText assignment is a string-PARSED style write, the
|
|
423
|
+
// one kind a style-src CSP reasons about, and this was the renderer's
|
|
424
|
+
// only such write. With it gone, every style write in the render path
|
|
425
|
+
// is an individually-assigned property (unambiguously ungoverned by
|
|
426
|
+
// style-src), which keeps the consumers' CSP-hardening story clean.
|
|
427
|
+
for (let i = 0; i < span.style.length; i++) {
|
|
428
|
+
const prop = span.style.item(i);
|
|
429
|
+
a.style.setProperty(
|
|
430
|
+
prop,
|
|
431
|
+
span.style.getPropertyValue(prop),
|
|
432
|
+
span.style.getPropertyPriority(prop),
|
|
433
|
+
);
|
|
434
|
+
}
|
|
395
435
|
out.push(a);
|
|
396
436
|
last = match.index + match[0].length;
|
|
397
437
|
}
|
|
@@ -430,9 +470,8 @@ function runHasLinkText(spans: (HTMLSpanElement | HTMLAnchorElement)[]): boolean
|
|
|
430
470
|
}
|
|
431
471
|
|
|
432
472
|
// --- Build row DOM ---
|
|
433
|
-
function buildRowSpans(runs: WireRun[]
|
|
473
|
+
function buildRowSpans(runs: readonly WireRun[]): (HTMLSpanElement | HTMLAnchorElement)[] {
|
|
434
474
|
const out: (HTMLSpanElement | HTMLAnchorElement)[] = [];
|
|
435
|
-
let col = 0;
|
|
436
475
|
for (const run of runs) {
|
|
437
476
|
if (!run.t) {
|
|
438
477
|
continue;
|
|
@@ -540,26 +579,10 @@ function buildRowSpans(runs: WireRun[], cursorAt: number): (HTMLSpanElement | HT
|
|
|
540
579
|
prev.style.letterSpacing = `${cellWidth * 2 - w}px`;
|
|
541
580
|
}
|
|
542
581
|
}
|
|
543
|
-
// The spacer occupies the wide char's second cell
|
|
544
|
-
//
|
|
545
|
-
//
|
|
546
|
-
//
|
|
547
|
-
// wide char lands one cell too far right per preceding wide char.
|
|
548
|
-
col++;
|
|
549
|
-
continue;
|
|
550
|
-
}
|
|
551
|
-
if (col === cursorAt) {
|
|
552
|
-
flush();
|
|
553
|
-
const w = measureChar(ch, isBold, isItalic);
|
|
554
|
-
const spacing = cellWidth - w;
|
|
555
|
-
const span = document.createElement("span");
|
|
556
|
-
span.className = cursorClassName();
|
|
557
|
-
span.textContent = ch;
|
|
558
|
-
if (spacing !== defaultSpacing) {
|
|
559
|
-
span.style.letterSpacing = `${spacing}px`;
|
|
560
|
-
}
|
|
561
|
-
out.push(span);
|
|
562
|
-
col++;
|
|
582
|
+
// The spacer occupies the wide char's second cell. (Column arithmetic
|
|
583
|
+
// for the caret lives in glyphAt, which mirrors this advance rule —
|
|
584
|
+
// the engine reports cursor_col in true cell coordinates, a wide
|
|
585
|
+
// glyph moving curX by 2.)
|
|
563
586
|
continue;
|
|
564
587
|
}
|
|
565
588
|
const w = measureChar(ch, isBold, isItalic);
|
|
@@ -571,7 +594,6 @@ function buildRowSpans(runs: WireRun[], cursorAt: number): (HTMLSpanElement | HT
|
|
|
571
594
|
prevSpacing = spacing;
|
|
572
595
|
}
|
|
573
596
|
buffer += ch;
|
|
574
|
-
col++;
|
|
575
597
|
}
|
|
576
598
|
flush();
|
|
577
599
|
// Wrap this run's spans in an <a> when it has a hyperlink URL — but only if
|
|
@@ -607,18 +629,6 @@ function buildRowSpans(runs: WireRun[], cursorAt: number): (HTMLSpanElement | HT
|
|
|
607
629
|
}
|
|
608
630
|
}
|
|
609
631
|
}
|
|
610
|
-
if (cursorAt >= 0 && col <= cursorAt) {
|
|
611
|
-
while (col < cursorAt) {
|
|
612
|
-
const span = document.createElement("span");
|
|
613
|
-
span.textContent = " ";
|
|
614
|
-
out.push(span);
|
|
615
|
-
col++;
|
|
616
|
-
}
|
|
617
|
-
const cursor = document.createElement("span");
|
|
618
|
-
cursor.className = cursorClassName();
|
|
619
|
-
cursor.textContent = " ";
|
|
620
|
-
out.push(cursor);
|
|
621
|
-
}
|
|
622
632
|
if (out.length === 0) {
|
|
623
633
|
const span = document.createElement("span");
|
|
624
634
|
span.textContent = "\u00a0";
|
|
@@ -721,7 +731,6 @@ function flushRenderInner(): void {
|
|
|
721
731
|
// Refresh cursor state from the window.
|
|
722
732
|
const win = store.getWindow();
|
|
723
733
|
const newCursorAbs = win.base + win.cursorRow;
|
|
724
|
-
const prevCursorAbs = cursorAbs;
|
|
725
734
|
cursorAbs = newCursorAbs;
|
|
726
735
|
cursorCol = win.cursorCol;
|
|
727
736
|
cursorHidden = win.cursorHidden;
|
|
@@ -729,8 +738,14 @@ function flushRenderInner(): void {
|
|
|
729
738
|
setCursorBlink(win.cursorBlink);
|
|
730
739
|
|
|
731
740
|
// Alt screen: render the ephemeral grid instead of the absolute buffer.
|
|
741
|
+
// Returning here skips the dirtyLines queueing below — safe by invariant:
|
|
742
|
+
// main-buffer rows never repaint while alt is active, and the alt-exit
|
|
743
|
+
// branch below rebuilds EVERYTHING via queueRowsViewportFirst, so any line
|
|
744
|
+
// dirtied during the alt session is repainted at exit.
|
|
732
745
|
if (store.isAlt()) {
|
|
733
|
-
|
|
746
|
+
const altRows = store.getAltRows();
|
|
747
|
+
renderAlt(altRows);
|
|
748
|
+
positionCursorOverlay(altRows[win.cursorRow]);
|
|
734
749
|
if (onCursorMove) {
|
|
735
750
|
onCursorMove();
|
|
736
751
|
}
|
|
@@ -741,6 +756,7 @@ function flushRenderInner(): void {
|
|
|
741
756
|
// viewport-first (shared with rebuild()) so the visible viewport fills
|
|
742
757
|
// before deep scrollback on a large-history alt-exit.
|
|
743
758
|
altRendered = false;
|
|
759
|
+
altPrevRows = [];
|
|
744
760
|
output.replaceChildren();
|
|
745
761
|
rowEls.clear();
|
|
746
762
|
renderQueue.clear();
|
|
@@ -753,28 +769,15 @@ function flushRenderInner(): void {
|
|
|
753
769
|
renderQueue.add(abs);
|
|
754
770
|
}
|
|
755
771
|
|
|
756
|
-
// The cursor
|
|
757
|
-
//
|
|
758
|
-
//
|
|
759
|
-
//
|
|
760
|
-
//
|
|
761
|
-
|
|
762
|
-
if (prevCursorAbs !== newCursorAbs && prevCursorAbs >= 0) {
|
|
763
|
-
upsertRow(prevCursorAbs);
|
|
764
|
-
renderQueue.delete(prevCursorAbs);
|
|
765
|
-
}
|
|
766
|
-
const cursorVisualChanged =
|
|
767
|
-
prevCursorAbs !== newCursorAbs ||
|
|
768
|
-
cursorCol !== prevCursorCol ||
|
|
769
|
-
cursorHidden !== prevCursorHidden ||
|
|
770
|
-
cursorStyleVal !== prevCursorStyleVal;
|
|
771
|
-
if (cursorVisualChanged || renderQueue.has(newCursorAbs)) {
|
|
772
|
+
// The cursor's row is built regardless of the budget — the caret overlay
|
|
773
|
+
// positions off the row element's offsetTop, so a huge backlog must never
|
|
774
|
+
// leave it floating over a not-yet-built row. Content-driven only: cursor
|
|
775
|
+
// MOTION no longer touches row DOM (the overlay carries the caret), so a
|
|
776
|
+
// selection anywhere — including on the cursor row — survives typing.
|
|
777
|
+
if (renderQueue.has(newCursorAbs) || !rowEls.has(newCursorAbs)) {
|
|
772
778
|
upsertRow(newCursorAbs);
|
|
779
|
+
renderQueue.delete(newCursorAbs);
|
|
773
780
|
}
|
|
774
|
-
renderQueue.delete(newCursorAbs);
|
|
775
|
-
prevCursorCol = cursorCol;
|
|
776
|
-
prevCursorHidden = cursorHidden;
|
|
777
|
-
prevCursorStyleVal = cursorStyleVal;
|
|
778
781
|
|
|
779
782
|
// Drain up to MAX_ROWS_PER_FRAME queued rows this frame; the rest carry over
|
|
780
783
|
// to the next frame (scheduled below) so one big burst never blocks paint.
|
|
@@ -797,6 +800,8 @@ function flushRenderInner(): void {
|
|
|
797
800
|
scheduleFlush();
|
|
798
801
|
}
|
|
799
802
|
|
|
803
|
+
positionCursorOverlay(store.getLine(cursorAbs));
|
|
804
|
+
|
|
800
805
|
if (onCursorMove) {
|
|
801
806
|
onCursorMove();
|
|
802
807
|
}
|
|
@@ -839,8 +844,7 @@ function upsertRow(abs: number): void {
|
|
|
839
844
|
}
|
|
840
845
|
return;
|
|
841
846
|
}
|
|
842
|
-
const
|
|
843
|
-
const spans = buildRowSpans(runs, cursorAt);
|
|
847
|
+
const spans = buildRowSpans(runs);
|
|
844
848
|
let el = rowEls.get(abs);
|
|
845
849
|
if (el === undefined) {
|
|
846
850
|
el = document.createElement("div");
|
|
@@ -879,20 +883,44 @@ function rowAbs(el: HTMLElement): number {
|
|
|
879
883
|
|
|
880
884
|
// --- Alt screen (ephemeral grid; no history) ---
|
|
881
885
|
let altRendered = false;
|
|
886
|
+
// The alt row arrays rendered by the last flush, by grid index. Row identity
|
|
887
|
+
// is the store's change signal (applyScreen swaps exactly the changed rows'
|
|
888
|
+
// arrays; getAltRows returns the live references), so `prev[y] === rows[y]`
|
|
889
|
+
// means row y's DOM is already current. Cleared on alt exit and re-init.
|
|
890
|
+
let altPrevRows: readonly (readonly WireRun[])[] = [];
|
|
882
891
|
|
|
883
|
-
function renderAlt(rows: WireRun[][]): void {
|
|
884
|
-
altRendered = true;
|
|
892
|
+
function renderAlt(rows: readonly (readonly WireRun[])[]): void {
|
|
885
893
|
rowEls.clear();
|
|
886
|
-
|
|
894
|
+
// Full (re)build: first alt frame, a grid-height change (resize), or a
|
|
895
|
+
// desynced DOM (defensive; e.g. a consumer poked the subtree).
|
|
896
|
+
if (!altRendered || output.children.length !== rows.length) {
|
|
897
|
+
altRendered = true;
|
|
898
|
+
const els: HTMLDivElement[] = [];
|
|
899
|
+
for (const runs of rows) {
|
|
900
|
+
const div = document.createElement("div");
|
|
901
|
+
div.className = "term-row";
|
|
902
|
+
div.replaceChildren(...buildRowSpans(runs));
|
|
903
|
+
els.push(div);
|
|
904
|
+
}
|
|
905
|
+
output.replaceChildren(...els);
|
|
906
|
+
altPrevRows = rows.slice();
|
|
907
|
+
return;
|
|
908
|
+
}
|
|
909
|
+
// Reconcile in place: rebuild only rows whose array identity changed. A
|
|
910
|
+
// full-screen TUI that repaints everything (htop) rebuilds every row either
|
|
911
|
+
// way; one that repaints a few lines (vim editing, a progress bar) now
|
|
912
|
+
// touches only those rows' DOM — measured ~50x less flush CPU in the render
|
|
913
|
+
// bench's partial-update scenario, and the browser skips layout for
|
|
914
|
+
// untouched rows.
|
|
887
915
|
for (let y = 0; y < rows.length; y++) {
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
916
|
+
if (altPrevRows[y] === rows[y]) {
|
|
917
|
+
continue;
|
|
918
|
+
}
|
|
919
|
+
const div = output.children[y] as HTMLDivElement;
|
|
920
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- y < rows.length === children.length
|
|
921
|
+
div.replaceChildren(...buildRowSpans(rows[y]!));
|
|
894
922
|
}
|
|
895
|
-
|
|
923
|
+
altPrevRows = rows.slice();
|
|
896
924
|
}
|
|
897
925
|
|
|
898
926
|
/** Pin the viewport to the bottom iff the user is following. The scroll
|
|
@@ -906,13 +934,24 @@ const CURSOR_BLINK_MS = 530;
|
|
|
906
934
|
let blinkInterval: ReturnType<typeof setInterval> | null = null;
|
|
907
935
|
let blinkEnabled = true;
|
|
908
936
|
|
|
937
|
+
// hiddenDoc reports whether the page is currently background/hidden. The blink
|
|
938
|
+
// interval is gated on it: rAF-driven flushes already freeze in a hidden tab,
|
|
939
|
+
// but a plain setInterval keeps firing (throttled), so an idle hidden terminal
|
|
940
|
+
// would keep toggling a class — pointless wakeups that cost battery on mobile.
|
|
941
|
+
function hiddenDoc(): boolean {
|
|
942
|
+
return typeof document !== "undefined" && document.visibilityState === "hidden";
|
|
943
|
+
}
|
|
944
|
+
|
|
909
945
|
function startCursorBlink(): void {
|
|
910
|
-
if (blinkInterval !== null) {
|
|
946
|
+
if (blinkInterval !== null || !blinkEnabled || hiddenDoc()) {
|
|
911
947
|
return;
|
|
912
948
|
}
|
|
913
|
-
|
|
949
|
+
// The blink class lives on termWrap: the caret overlay is a termWrap child
|
|
950
|
+
// (not inside output), and the `.cursor-blink-off .term-cursor` descendant
|
|
951
|
+
// selector must reach it.
|
|
952
|
+
termWrap.classList.remove("cursor-blink-off");
|
|
914
953
|
blinkInterval = setInterval(() => {
|
|
915
|
-
|
|
954
|
+
termWrap.classList.toggle("cursor-blink-off");
|
|
916
955
|
}, CURSOR_BLINK_MS);
|
|
917
956
|
}
|
|
918
957
|
|
|
@@ -921,7 +960,30 @@ function stopCursorBlink(): void {
|
|
|
921
960
|
clearInterval(blinkInterval);
|
|
922
961
|
blinkInterval = null;
|
|
923
962
|
}
|
|
924
|
-
|
|
963
|
+
termWrap.classList.remove("cursor-blink-off");
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
// Pause the interval while hidden; resume (cursor solid, phase reset) when the
|
|
967
|
+
// tab is foregrounded again. Registered once at module load — init() re-runs
|
|
968
|
+
// on re-attach, and a per-init registration would stack listeners.
|
|
969
|
+
if (typeof document !== "undefined") {
|
|
970
|
+
document.addEventListener("visibilitychange", () => {
|
|
971
|
+
// Pre-init (no termWrap yet) there is nothing to pause or resume.
|
|
972
|
+
if ((termWrap as HTMLElement | undefined) === undefined) {
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
975
|
+
if (hiddenDoc()) {
|
|
976
|
+
// Stop the timer only: blinkEnabled keeps the server-driven state so
|
|
977
|
+
// foregrounding can restart without waiting for the next mode frame.
|
|
978
|
+
if (blinkInterval !== null) {
|
|
979
|
+
clearInterval(blinkInterval);
|
|
980
|
+
blinkInterval = null;
|
|
981
|
+
}
|
|
982
|
+
termWrap.classList.remove("cursor-blink-off");
|
|
983
|
+
return;
|
|
984
|
+
}
|
|
985
|
+
startCursorBlink();
|
|
986
|
+
});
|
|
925
987
|
}
|
|
926
988
|
|
|
927
989
|
/** Called from the flush when cursorBlink state changes. */
|
|
@@ -945,6 +1007,11 @@ function setCursorBlink(enabled: boolean): void {
|
|
|
945
1007
|
*/
|
|
946
1008
|
export function updateFontMetrics(): void {
|
|
947
1009
|
const cs = window.getComputedStyle(termWrap);
|
|
1010
|
+
// Refresh the overlay-positioning padding cache from the same computed
|
|
1011
|
+
// style (one staleness contract for all cached box metrics).
|
|
1012
|
+
padLeft = parseFloat(cs.paddingLeft);
|
|
1013
|
+
padTop = parseFloat(cs.paddingTop);
|
|
1014
|
+
padValid = true;
|
|
948
1015
|
const fontSize = cs.fontSize;
|
|
949
1016
|
const family = cs.fontFamily;
|
|
950
1017
|
fontString = `${fontSize} ${family}`;
|
|
@@ -978,17 +1045,54 @@ export function computeSize(): { cols: number; rows: number } {
|
|
|
978
1045
|
return { cols, rows };
|
|
979
1046
|
}
|
|
980
1047
|
|
|
1048
|
+
// rowTopInTermWrap returns a row element's top in termWrap's coordinate space
|
|
1049
|
+
// (the space every absolutely-positioned overlay — caret, predicted cursor,
|
|
1050
|
+
// composition view — resolves against). A bare `offsetTop` is NOT that: it is
|
|
1051
|
+
// relative to the element's offsetParent, and the UI package's CSS makes
|
|
1052
|
+
// `.term-output` a positioned element (position: relative, for its own
|
|
1053
|
+
// reasons), so rows report offsets in output-space,
|
|
1054
|
+
// one padding/offset short of termWrap-space (the caret floated 4px above
|
|
1055
|
+
// every glyph under the real stylesheet — invisible to the harness, whose
|
|
1056
|
+
// unpositioned #out made both spaces coincide). Walk the offsetParent chain
|
|
1057
|
+
// and accumulate until termWrap so the math is correct under EITHER
|
|
1058
|
+
// stylesheet, and under any future wrapper the UI grows between the two.
|
|
1059
|
+
function rowTopInTermWrap(el: HTMLElement): number {
|
|
1060
|
+
let top = 0;
|
|
1061
|
+
let node: Element | null = el;
|
|
1062
|
+
while (node instanceof HTMLElement && node !== termWrap) {
|
|
1063
|
+
top += node.offsetTop;
|
|
1064
|
+
node = node.offsetParent;
|
|
1065
|
+
// offsetTop is measured from the offsetParent's padding edge, but an
|
|
1066
|
+
// intermediate parent's own offsetTop locates its BORDER box — add its
|
|
1067
|
+
// top border so the accumulation stays exact if a wrapper ever grows one
|
|
1068
|
+
// (both current stylesheets use border: 0 here). termWrap itself is
|
|
1069
|
+
// excluded: absolute children resolve from its padding edge already.
|
|
1070
|
+
if (node instanceof HTMLElement && node !== termWrap) {
|
|
1071
|
+
top += node.clientTop;
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
// Chain ended off termWrap (display:none, detached, or termWrap unpositioned
|
|
1075
|
+
// with the row offset-rooted elsewhere): fall back to rect delta, which is
|
|
1076
|
+
// space-independent. scrollTop re-bases the viewport-relative delta into
|
|
1077
|
+
// content space.
|
|
1078
|
+
if (node !== termWrap) {
|
|
1079
|
+
return (
|
|
1080
|
+
el.getBoundingClientRect().top - termWrap.getBoundingClientRect().top + termWrap.scrollTop
|
|
1081
|
+
);
|
|
1082
|
+
}
|
|
1083
|
+
return top;
|
|
1084
|
+
}
|
|
1085
|
+
|
|
981
1086
|
/**
|
|
982
|
-
* Returns the cursor's pixel position relative to the
|
|
983
|
-
* the
|
|
984
|
-
* IME composition
|
|
1087
|
+
* Returns the cursor's pixel position relative to the scroll container
|
|
1088
|
+
* (termWrap) — the coordinate space of the absolutely-positioned overlays
|
|
1089
|
+
* (predicted-cursor, IME composition view) — plus the current cell height.
|
|
1090
|
+
* Uses the cursor row's actual DOM offset.
|
|
985
1091
|
*/
|
|
986
1092
|
export function getCursorPx(): { left: number; top: number; cellH: number } {
|
|
987
|
-
const
|
|
988
|
-
const padL = parseFloat(cs.paddingLeft);
|
|
989
|
-
const padT = parseFloat(cs.paddingTop);
|
|
1093
|
+
const { padL, padT } = termPadding();
|
|
990
1094
|
const el = rowEls.get(cursorAbs);
|
|
991
|
-
const top = el ? el
|
|
1095
|
+
const top = el ? rowTopInTermWrap(el) : padT;
|
|
992
1096
|
return {
|
|
993
1097
|
left: Math.round(padL + cursorCol * cellWidth),
|
|
994
1098
|
top: Math.round(top),
|
|
@@ -996,6 +1100,83 @@ export function getCursorPx(): { left: number; top: number; cellH: number } {
|
|
|
996
1100
|
};
|
|
997
1101
|
}
|
|
998
1102
|
|
|
1103
|
+
// --- Caret overlay ---
|
|
1104
|
+
//
|
|
1105
|
+
// The real cursor is a single absolutely-positioned element appended to
|
|
1106
|
+
// termWrap (the positioned scroll container, same coordinate system as the
|
|
1107
|
+
// predicted-cursor overlay), NOT a restyled span inside the cursor row. Rows
|
|
1108
|
+
// stay pure content: cursor motion repositions this element and never
|
|
1109
|
+
// rewrites row DOM, so a native selection survives typing — including on the
|
|
1110
|
+
// cursor row itself — and the per-keystroke row rebuild is gone.
|
|
1111
|
+
let cursorEl: HTMLElement | null = null;
|
|
1112
|
+
|
|
1113
|
+
function ensureCursorEl(): HTMLElement {
|
|
1114
|
+
if (cursorEl === null) {
|
|
1115
|
+
cursorEl = document.createElement("div");
|
|
1116
|
+
cursorEl.setAttribute("aria-hidden", "true");
|
|
1117
|
+
termWrap.appendChild(cursorEl);
|
|
1118
|
+
}
|
|
1119
|
+
return cursorEl;
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
// glyphAt returns the character occupying `col` in a row's runs, advancing
|
|
1123
|
+
// columns exactly like buildRowSpans (each char one cell; the \uFFFF
|
|
1124
|
+
// continuation placeholder occupies the wide char's second cell). A miss —
|
|
1125
|
+
// cursor past end of text, empty row, or a continuation cell — reads as a
|
|
1126
|
+
// space (the block cursor then paints an inverted blank, matching a real
|
|
1127
|
+
// terminal).
|
|
1128
|
+
function glyphAt(runs: readonly WireRun[] | undefined, col: number): string {
|
|
1129
|
+
if (!runs) {
|
|
1130
|
+
return " ";
|
|
1131
|
+
}
|
|
1132
|
+
let c = 0;
|
|
1133
|
+
for (const run of runs) {
|
|
1134
|
+
if (!run.t) {
|
|
1135
|
+
continue;
|
|
1136
|
+
}
|
|
1137
|
+
for (const ch of run.t) {
|
|
1138
|
+
if (c === col) {
|
|
1139
|
+
return ch === "\uFFFF" ? " " : ch;
|
|
1140
|
+
}
|
|
1141
|
+
c++;
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
return " ";
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
// positionCursorOverlay moves/styles the caret overlay for the current cursor
|
|
1148
|
+
// state. `runs` is the cursor row's content (main: store.getLine; alt: the
|
|
1149
|
+
// grid row) — the block style copies the glyph under the cursor so the
|
|
1150
|
+
// inverted cell looks exactly like the old inline-span cursor. Called at the
|
|
1151
|
+
// end of every flush; hidden when the cursor is hidden or the screen is empty.
|
|
1152
|
+
function positionCursorOverlay(runs: readonly WireRun[] | undefined): void {
|
|
1153
|
+
const el = ensureCursorEl();
|
|
1154
|
+
if (cursorHidden || cursorAbs < 0) {
|
|
1155
|
+
el.className = "term-cursor-overlay";
|
|
1156
|
+
return;
|
|
1157
|
+
}
|
|
1158
|
+
const win = store.getWindow();
|
|
1159
|
+
const { padL, padT } = termPadding();
|
|
1160
|
+
const rowEl = rowEls.get(cursorAbs);
|
|
1161
|
+
// Alt-screen rows are not registered in rowEls; their grid is uniform, so
|
|
1162
|
+
// the row offset derives from the window-relative row index. Main-screen
|
|
1163
|
+
// rows exist by the time this runs (the flush force-builds the cursor row).
|
|
1164
|
+
// rowTopInTermWrap (not bare offsetTop) so the top is in termWrap space
|
|
1165
|
+
// regardless of which ancestor is the rows' offsetParent.
|
|
1166
|
+
const top = rowEl ? rowTopInTermWrap(rowEl) : padT + (cursorAbs - win.base) * cellHeight;
|
|
1167
|
+
const ch = glyphAt(runs, cursorCol);
|
|
1168
|
+
// A wide glyph (CJK, emoji) owns two cells; the overlay covers both, like
|
|
1169
|
+
// the old inline cursor span did via its continuation-spacer adjustment.
|
|
1170
|
+
const wide = measureChar(ch, false, false) > cellWidth * 1.5;
|
|
1171
|
+
el.textContent = ch === " " ? "\u00a0" : ch;
|
|
1172
|
+
el.className = `term-cursor-overlay visible ${cursorClassName()}`;
|
|
1173
|
+
el.style.left = `${Math.round(padL + cursorCol * cellWidth)}px`;
|
|
1174
|
+
el.style.top = `${Math.round(top)}px`;
|
|
1175
|
+
el.style.width = `${wide ? cellWidth * 2 : cellWidth}px`;
|
|
1176
|
+
el.style.height = `${cellHeight}px`;
|
|
1177
|
+
el.style.lineHeight = `${cellHeight}px`;
|
|
1178
|
+
}
|
|
1179
|
+
|
|
999
1180
|
let predCursorEl: HTMLElement | null = null;
|
|
1000
1181
|
|
|
1001
1182
|
// Create the predicted-cursor overlay the renderer owns. Appended to termWrap
|
|
@@ -1026,10 +1207,9 @@ export function setPredictedCursor(row: number, col: number, active: boolean): v
|
|
|
1026
1207
|
el.classList.remove("visible");
|
|
1027
1208
|
return;
|
|
1028
1209
|
}
|
|
1029
|
-
const
|
|
1030
|
-
const padL = parseFloat(cs.paddingLeft);
|
|
1210
|
+
const { padL, padT } = termPadding();
|
|
1031
1211
|
const rowEl = rowEls.get(predAbs);
|
|
1032
|
-
const top = rowEl ? rowEl
|
|
1212
|
+
const top = rowEl ? rowTopInTermWrap(rowEl) : padT + row * cellHeight;
|
|
1033
1213
|
el.style.left = `${Math.round(padL + col * cellWidth)}px`;
|
|
1034
1214
|
el.style.top = `${Math.round(top)}px`;
|
|
1035
1215
|
el.style.width = `${cellWidth}px`;
|