@cplieger/web-terminal-engine 1.2.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/LICENSE +692 -0
- package/README.md +97 -0
- package/jsr.json +18 -0
- package/package.json +42 -0
- package/src/connection.ts +584 -0
- package/src/index.ts +10 -0
- package/src/keyboard.ts +630 -0
- package/src/modes.ts +95 -0
- package/src/mouse.ts +203 -0
- package/src/reconnect.ts +64 -0
- package/src/render.ts +823 -0
- package/src/scroll.ts +103 -0
- package/src/store.ts +389 -0
- package/src/types.ts +165 -0
- package/src/wire-binary.ts +238 -0
- package/src/wire.ts +36 -0
- package/src/wsurl.ts +19 -0
package/src/render.ts
ADDED
|
@@ -0,0 +1,823 @@
|
|
|
1
|
+
// Render layer: store-backed, absolute-index DOM rows.
|
|
2
|
+
//
|
|
3
|
+
// The renderer owns a LineStore (the authoritative client model) and reflects
|
|
4
|
+
// it to the DOM. Every terminal line is a `div.term-row` carrying a `data-abs`
|
|
5
|
+
// attribute equal to its absolute index; the rows sit in one natively-scrolled
|
|
6
|
+
// container in absolute order. There is no separate "live zone" and
|
|
7
|
+
// "scrollback": the live window is simply the last `height` absolute indices,
|
|
8
|
+
// and a line that scrolls into history just stops being updated. This is what
|
|
9
|
+
// removes the live/history reconciliation that caused the duplicate-rows and
|
|
10
|
+
// view-jumping bugs (see the #web-terminal-engine steering doc, "Design rationale").
|
|
11
|
+
//
|
|
12
|
+
// Decode frames feed the store (handleScreen/handleScroll); a single
|
|
13
|
+
// requestAnimationFrame flush drains the store's change set and applies it:
|
|
14
|
+
// evicted indices drop their row, dirty indices build/update their row in
|
|
15
|
+
// place. The window block always has `height` rows, so scrollHeight only grows
|
|
16
|
+
// when real history commits — never oscillating mid-redraw.
|
|
17
|
+
|
|
18
|
+
import type { ScreenMessage, ScrollMessage, WireRun } from "./types.js";
|
|
19
|
+
import { LineStore } from "./store.js";
|
|
20
|
+
import * as scroll from "./scroll.js";
|
|
21
|
+
import { isReverseVideo } from "./modes.js";
|
|
22
|
+
|
|
23
|
+
// --- Width cache (two-tier, xterm.js style) ---
|
|
24
|
+
const WIDTH_FLAT_SIZE = 256;
|
|
25
|
+
const WIDTH_FLAT_UNSET = -9999;
|
|
26
|
+
const widthFlat = new Float32Array(WIDTH_FLAT_SIZE).fill(WIDTH_FLAT_UNSET);
|
|
27
|
+
const widthMap = new Map<string, number>();
|
|
28
|
+
|
|
29
|
+
const VARIANT_REGULAR = 0;
|
|
30
|
+
const VARIANT_BOLD = 1;
|
|
31
|
+
const VARIANT_ITALIC = 2;
|
|
32
|
+
const variantCtx: (CanvasRenderingContext2D | null)[] = [null, null, null, null];
|
|
33
|
+
let fontString = "";
|
|
34
|
+
|
|
35
|
+
function variantContext(variant: number): CanvasRenderingContext2D {
|
|
36
|
+
let ctx = variantCtx[variant];
|
|
37
|
+
if (ctx) {
|
|
38
|
+
return ctx;
|
|
39
|
+
}
|
|
40
|
+
const canvas = document.createElement("canvas");
|
|
41
|
+
canvas.width = 1;
|
|
42
|
+
canvas.height = 1;
|
|
43
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- 2d context always available on fresh canvas
|
|
44
|
+
ctx = canvas.getContext("2d")!;
|
|
45
|
+
let f = "";
|
|
46
|
+
if (variant & VARIANT_ITALIC) {
|
|
47
|
+
f += "italic ";
|
|
48
|
+
}
|
|
49
|
+
if (variant & VARIANT_BOLD) {
|
|
50
|
+
f += "bold ";
|
|
51
|
+
}
|
|
52
|
+
f += fontString;
|
|
53
|
+
ctx.font = f;
|
|
54
|
+
variantCtx[variant] = ctx;
|
|
55
|
+
return ctx;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function resetVariantContexts(): void {
|
|
59
|
+
for (let i = 0; i < variantCtx.length; i++) {
|
|
60
|
+
variantCtx[i] = null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function measureChar(ch: string, bold: boolean, italic: boolean): number {
|
|
65
|
+
if (!bold && !italic && ch.length === 1) {
|
|
66
|
+
const cp = ch.charCodeAt(0);
|
|
67
|
+
if (cp < WIDTH_FLAT_SIZE) {
|
|
68
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounds checked above
|
|
69
|
+
const cached = widthFlat[cp]!;
|
|
70
|
+
if (cached !== WIDTH_FLAT_UNSET) {
|
|
71
|
+
return cached;
|
|
72
|
+
}
|
|
73
|
+
const w = variantContext(VARIANT_REGULAR).measureText(ch).width;
|
|
74
|
+
widthFlat[cp] = w;
|
|
75
|
+
return w;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const key = (bold ? "B" : "") + (italic ? "I" : "") + ch;
|
|
79
|
+
const cached = widthMap.get(key);
|
|
80
|
+
if (cached !== undefined) {
|
|
81
|
+
return cached;
|
|
82
|
+
}
|
|
83
|
+
let variant = 0;
|
|
84
|
+
if (bold) {
|
|
85
|
+
variant |= VARIANT_BOLD;
|
|
86
|
+
}
|
|
87
|
+
if (italic) {
|
|
88
|
+
variant |= VARIANT_ITALIC;
|
|
89
|
+
}
|
|
90
|
+
const w = variantContext(variant).measureText(ch).width;
|
|
91
|
+
widthMap.set(key, w);
|
|
92
|
+
return w;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function measureCellWidth(): number {
|
|
96
|
+
// Measure using a span appended to termWrap (which already has the
|
|
97
|
+
// font applied via CSS). This ensures the web font is used if loaded.
|
|
98
|
+
const span = document.createElement("span");
|
|
99
|
+
span.style.visibility = "hidden";
|
|
100
|
+
span.style.position = "absolute";
|
|
101
|
+
span.style.whiteSpace = "pre";
|
|
102
|
+
span.textContent = "MMMMMMMMMM";
|
|
103
|
+
termWrap.appendChild(span);
|
|
104
|
+
const width = span.getBoundingClientRect().width / 10;
|
|
105
|
+
termWrap.removeChild(span);
|
|
106
|
+
return width;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// --- State ---
|
|
110
|
+
let output: HTMLElement;
|
|
111
|
+
let termWrap: HTMLElement;
|
|
112
|
+
|
|
113
|
+
const store = new LineStore();
|
|
114
|
+
// abs index -> its row element. The DOM children of `output` are these
|
|
115
|
+
// elements, kept in ascending data-abs order.
|
|
116
|
+
const rowEls = new Map<number, HTMLDivElement>();
|
|
117
|
+
|
|
118
|
+
// The "earlier output trimmed" marker (a non-data-abs first child of output,
|
|
119
|
+
// shown when the store reports history older than it holds was trimmed). Kept
|
|
120
|
+
// as a module ref so it is reused rather than recreated each flush.
|
|
121
|
+
let trimMarkerEl: HTMLDivElement | null = null;
|
|
122
|
+
|
|
123
|
+
// Rows awaiting a DOM (re)build, processed in budgeted batches across frames.
|
|
124
|
+
// A session restore (kiro-cli's /chat) or a `cat bigfile` dumps thousands of
|
|
125
|
+
// lines in one wire frame; building them all in a single rAF janks or, on a
|
|
126
|
+
// constrained device, hangs the tab. The store still ingests the whole burst
|
|
127
|
+
// at once (it is cheap, pure data); the renderer drains this queue at most
|
|
128
|
+
// MAX_ROWS_PER_FRAME per frame and reschedules until it is empty, so each
|
|
129
|
+
// frame stays short and the terminal fills smoothly. The cursor row is always
|
|
130
|
+
// built regardless of the budget so the caret never lags.
|
|
131
|
+
const renderQueue = new Set<number>();
|
|
132
|
+
const MAX_ROWS_PER_FRAME = 300;
|
|
133
|
+
|
|
134
|
+
// Cursor state, refreshed from the store window on each flush. Kept as module
|
|
135
|
+
// vars because buildRowSpans/cursorClassName read them.
|
|
136
|
+
let cursorAbs = -1; // absolute index of the row the cursor is on
|
|
137
|
+
let cursorCol = 0;
|
|
138
|
+
let cursorHidden = false;
|
|
139
|
+
let cursorStyleVal = 0; // 0-6: DECSCUSR
|
|
140
|
+
|
|
141
|
+
function cursorClassName(): string {
|
|
142
|
+
// DECSCUSR: 0/1=blinking block, 2=steady block, 3=blinking underline,
|
|
143
|
+
// 4=steady underline, 5=blinking bar, 6=steady bar
|
|
144
|
+
if (cursorStyleVal === 3 || cursorStyleVal === 4) {
|
|
145
|
+
return "term-cursor-underline";
|
|
146
|
+
}
|
|
147
|
+
if (cursorStyleVal === 5 || cursorStyleVal === 6) {
|
|
148
|
+
return "term-cursor-bar";
|
|
149
|
+
}
|
|
150
|
+
return "term-cursor";
|
|
151
|
+
}
|
|
152
|
+
let cellWidth = 8;
|
|
153
|
+
let cellHeight = 17;
|
|
154
|
+
let defaultSpacing = 0;
|
|
155
|
+
let onCursorMove: (() => void) | null = null;
|
|
156
|
+
let pendingFrame: number | undefined;
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Initialize the renderer by attaching it to a pair of DOM elements: the
|
|
160
|
+
* scrollable terminal wrapper and the inner output container that receives
|
|
161
|
+
* row elements. Must be called once before any handleScreen/handleScroll call.
|
|
162
|
+
*
|
|
163
|
+
* @param opts.output Inner element that holds row children.
|
|
164
|
+
* @param opts.termWrap Outer scroll container.
|
|
165
|
+
* @param opts.onCursorMove Optional callback invoked when the cursor moves.
|
|
166
|
+
*/
|
|
167
|
+
export function init(opts: {
|
|
168
|
+
output: HTMLElement;
|
|
169
|
+
termWrap: HTMLElement;
|
|
170
|
+
onCursorMove?: () => void;
|
|
171
|
+
}): void {
|
|
172
|
+
output = opts.output;
|
|
173
|
+
termWrap = opts.termWrap;
|
|
174
|
+
onCursorMove = opts.onCursorMove ?? null;
|
|
175
|
+
// Fresh attach: drop any prior model + DOM so re-init (and vitest's
|
|
176
|
+
// non-isolated module reuse) starts clean.
|
|
177
|
+
store.reset();
|
|
178
|
+
rowEls.clear();
|
|
179
|
+
renderQueue.clear();
|
|
180
|
+
trimMarkerEl = null;
|
|
181
|
+
output.replaceChildren();
|
|
182
|
+
cursorAbs = -1;
|
|
183
|
+
if (pendingFrame !== undefined) {
|
|
184
|
+
cancelAnimationFrame(pendingFrame);
|
|
185
|
+
pendingFrame = undefined;
|
|
186
|
+
}
|
|
187
|
+
startCursorBlink();
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Reset internal screen state so the next frame performs a full repaint.
|
|
192
|
+
* With the store model this is a full reset (used on server restart): the
|
|
193
|
+
* store clears and the next flush wipes and rebuilds the DOM.
|
|
194
|
+
*/
|
|
195
|
+
export function resetScreen(): void {
|
|
196
|
+
store.reset();
|
|
197
|
+
scheduleFlush();
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Clear all rows (history + window). Used on server restart alongside
|
|
202
|
+
* resetScreen; both reset the store, so this is equivalent.
|
|
203
|
+
*/
|
|
204
|
+
export function resetScrollback(): void {
|
|
205
|
+
store.reset();
|
|
206
|
+
scheduleFlush();
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Highest absolute line index the client holds, or -1 if empty. This is the
|
|
211
|
+
* resume `haveThrough` value (it replaces the old DOM-row count). Exposed so
|
|
212
|
+
* the connection layer can request only the lines the client is missing.
|
|
213
|
+
*/
|
|
214
|
+
export function getHighestIndex(): number {
|
|
215
|
+
return store.highestIndex();
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Record the server's retained-history bounds from a resumeAck (committed =
|
|
220
|
+
* one past the newest retained, oldest = oldest retained absolute index). The
|
|
221
|
+
* store uses these to tell a genuine history trim (the server evicted lines
|
|
222
|
+
* the client was missing) from a still-loading state, which drives the
|
|
223
|
+
* "earlier output trimmed" marker. Resync guard 8.2.2.
|
|
224
|
+
*/
|
|
225
|
+
export function noteResumeBounds(committed: number, oldest: number): void {
|
|
226
|
+
store.noteResumeBounds(committed, oldest);
|
|
227
|
+
scheduleFlush();
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// --- Color helpers ---
|
|
231
|
+
function colorHex(c: number | undefined): string | null {
|
|
232
|
+
if (c === undefined || c < 0) {
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
return "#" + c.toString(16).padStart(6, "0");
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// --- URL detection (xterm.js addon-web-links pattern) ---
|
|
239
|
+
const URL_RE = /(https?|HTTPS?):\/\/[^\s"'!*(){}|\\^<>`]*[^\s"':,.!?{}|\\^~[\]`()<>]/g;
|
|
240
|
+
|
|
241
|
+
function linkifySpans(
|
|
242
|
+
spans: (HTMLSpanElement | HTMLAnchorElement)[],
|
|
243
|
+
): (HTMLSpanElement | HTMLAnchorElement)[] {
|
|
244
|
+
const out: (HTMLSpanElement | HTMLAnchorElement)[] = [];
|
|
245
|
+
for (const span of spans) {
|
|
246
|
+
// Pass anchors through untouched. A span may already be an <a> from an
|
|
247
|
+
// OSC 8 hyperlink emitted by the application (see buildRowSpans). The
|
|
248
|
+
// app-provided href is authoritative and takes precedence over heuristic
|
|
249
|
+
// autolinking — re-scanning it with URL_RE would rebuild the link from
|
|
250
|
+
// the *visible* text, which for a URL that wraps across rows is only a
|
|
251
|
+
// fragment. That truncates the href and defeats the terminal's
|
|
252
|
+
// clickable-across-line-wraps behavior. Skip; only autolink plain text.
|
|
253
|
+
if (span.tagName === "A") {
|
|
254
|
+
out.push(span);
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
const text = span.textContent;
|
|
258
|
+
URL_RE.lastIndex = 0;
|
|
259
|
+
let match: RegExpExecArray | null;
|
|
260
|
+
let last = 0;
|
|
261
|
+
let found = false;
|
|
262
|
+
while ((match = URL_RE.exec(text)) !== null) {
|
|
263
|
+
found = true;
|
|
264
|
+
if (match.index > last) {
|
|
265
|
+
const pre = span.cloneNode(false) as HTMLSpanElement;
|
|
266
|
+
pre.textContent = text.slice(last, match.index);
|
|
267
|
+
out.push(pre);
|
|
268
|
+
}
|
|
269
|
+
const a = document.createElement("a");
|
|
270
|
+
a.href = match[0];
|
|
271
|
+
a.target = "_blank";
|
|
272
|
+
a.rel = "noopener noreferrer";
|
|
273
|
+
a.className = "term-link";
|
|
274
|
+
a.textContent = match[0];
|
|
275
|
+
// Copy inline styles from the source span
|
|
276
|
+
a.style.cssText = span.style.cssText;
|
|
277
|
+
out.push(a);
|
|
278
|
+
last = match.index + match[0].length;
|
|
279
|
+
}
|
|
280
|
+
if (!found) {
|
|
281
|
+
out.push(span);
|
|
282
|
+
} else if (last < text.length) {
|
|
283
|
+
const post = span.cloneNode(false) as HTMLSpanElement;
|
|
284
|
+
post.textContent = text.slice(last);
|
|
285
|
+
out.push(post);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return out;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// --- Build row DOM ---
|
|
292
|
+
function buildRowSpans(runs: WireRun[], cursorAt: number): (HTMLSpanElement | HTMLAnchorElement)[] {
|
|
293
|
+
const out: (HTMLSpanElement | HTMLAnchorElement)[] = [];
|
|
294
|
+
let col = 0;
|
|
295
|
+
for (const run of runs) {
|
|
296
|
+
if (!run.t) {
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
const runStartIdx = out.length;
|
|
300
|
+
const attrs = run.a ?? 0;
|
|
301
|
+
const isBold = (attrs & 1) !== 0;
|
|
302
|
+
const isItalic = (attrs & 2) !== 0;
|
|
303
|
+
const isUnderline = (attrs & 4) !== 0;
|
|
304
|
+
const isInverse = (attrs & 8) !== 0;
|
|
305
|
+
const isStrike = (attrs & 16) !== 0;
|
|
306
|
+
const isDim = (attrs & 32) !== 0;
|
|
307
|
+
const isHidden = (attrs & 64) !== 0;
|
|
308
|
+
const isBlink = (attrs & 128) !== 0;
|
|
309
|
+
const isOverline = (attrs & 256) !== 0;
|
|
310
|
+
const isDoubleUnderline = (attrs & 512) !== 0;
|
|
311
|
+
|
|
312
|
+
// Server swaps FG/BG for inverse in wire.go, but when both are
|
|
313
|
+
// default (-1) the swap is a no-op. Detect inverse + defaults and
|
|
314
|
+
// apply theme-inverted colors so the inverted space is visible.
|
|
315
|
+
let fg = colorHex(run.f);
|
|
316
|
+
let bg = colorHex(run.b);
|
|
317
|
+
if (isInverse && fg === null && bg === null) {
|
|
318
|
+
fg = "var(--bg)";
|
|
319
|
+
bg = "var(--text)";
|
|
320
|
+
}
|
|
321
|
+
const ucColor = colorHex(run.uc);
|
|
322
|
+
|
|
323
|
+
const applyStyle = (span: HTMLSpanElement, spacing: number): void => {
|
|
324
|
+
if (isHidden) {
|
|
325
|
+
span.style.visibility = "hidden";
|
|
326
|
+
}
|
|
327
|
+
if (fg !== null) {
|
|
328
|
+
span.style.color = fg;
|
|
329
|
+
}
|
|
330
|
+
if (bg !== null) {
|
|
331
|
+
span.style.background = bg;
|
|
332
|
+
}
|
|
333
|
+
if (isBold) {
|
|
334
|
+
span.style.fontWeight = "bold";
|
|
335
|
+
}
|
|
336
|
+
if (isItalic) {
|
|
337
|
+
span.style.fontStyle = "italic";
|
|
338
|
+
}
|
|
339
|
+
if (isDim) {
|
|
340
|
+
span.style.opacity = ".5";
|
|
341
|
+
}
|
|
342
|
+
// Build text-decoration combining all line types.
|
|
343
|
+
const decoLines: string[] = [];
|
|
344
|
+
if (isDoubleUnderline) {
|
|
345
|
+
decoLines.push("underline");
|
|
346
|
+
} else if (isUnderline) {
|
|
347
|
+
decoLines.push("underline");
|
|
348
|
+
}
|
|
349
|
+
if (isOverline) {
|
|
350
|
+
decoLines.push("overline");
|
|
351
|
+
}
|
|
352
|
+
if (isStrike) {
|
|
353
|
+
decoLines.push("line-through");
|
|
354
|
+
}
|
|
355
|
+
if (decoLines.length > 0) {
|
|
356
|
+
let deco = decoLines.join(" ");
|
|
357
|
+
if (isDoubleUnderline) {
|
|
358
|
+
deco += " double";
|
|
359
|
+
}
|
|
360
|
+
span.style.textDecoration = deco;
|
|
361
|
+
}
|
|
362
|
+
if (ucColor !== null) {
|
|
363
|
+
span.style.textDecorationColor = ucColor;
|
|
364
|
+
}
|
|
365
|
+
if (spacing !== defaultSpacing) {
|
|
366
|
+
span.style.letterSpacing = `${spacing}px`;
|
|
367
|
+
}
|
|
368
|
+
if (isBlink) {
|
|
369
|
+
span.classList.add("term-blink");
|
|
370
|
+
}
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
let prevSpacing: number | null = null;
|
|
374
|
+
let buffer = "";
|
|
375
|
+
const flush = (): void => {
|
|
376
|
+
if (buffer.length === 0) {
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
const span = document.createElement("span");
|
|
380
|
+
span.textContent = buffer;
|
|
381
|
+
applyStyle(span, prevSpacing ?? 0);
|
|
382
|
+
out.push(span);
|
|
383
|
+
buffer = "";
|
|
384
|
+
};
|
|
385
|
+
for (const ch of run.t) {
|
|
386
|
+
if (ch === "\uFFFF") {
|
|
387
|
+
// Wide-char continuation placeholder: mark previous span as double-width.
|
|
388
|
+
// Flush any buffered text first so the wide char is in its own span.
|
|
389
|
+
flush();
|
|
390
|
+
if (out.length > 0) {
|
|
391
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- length checked above
|
|
392
|
+
const prev = out[out.length - 1]!;
|
|
393
|
+
|
|
394
|
+
const prevText = prev.textContent;
|
|
395
|
+
if (prevText.length > 0) {
|
|
396
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion, @typescript-eslint/no-misused-spread -- terminal text is ASCII/CJK, safe to spread; .at(-1) guaranteed by length check
|
|
397
|
+
const lastChar = [...prevText].at(-1)!;
|
|
398
|
+
const w = measureChar(lastChar, isBold, isItalic);
|
|
399
|
+
prev.style.letterSpacing = `${cellWidth * 2 - w}px`;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
if (col === cursorAt) {
|
|
405
|
+
flush();
|
|
406
|
+
const w = measureChar(ch, isBold, isItalic);
|
|
407
|
+
const spacing = cellWidth - w;
|
|
408
|
+
const span = document.createElement("span");
|
|
409
|
+
span.className = cursorClassName();
|
|
410
|
+
span.textContent = ch;
|
|
411
|
+
if (spacing !== defaultSpacing) {
|
|
412
|
+
span.style.letterSpacing = `${spacing}px`;
|
|
413
|
+
}
|
|
414
|
+
out.push(span);
|
|
415
|
+
col++;
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
const w = measureChar(ch, isBold, isItalic);
|
|
419
|
+
const spacing = cellWidth - w;
|
|
420
|
+
if (prevSpacing === null) {
|
|
421
|
+
prevSpacing = spacing;
|
|
422
|
+
} else if (spacing !== prevSpacing) {
|
|
423
|
+
flush();
|
|
424
|
+
prevSpacing = spacing;
|
|
425
|
+
}
|
|
426
|
+
buffer += ch;
|
|
427
|
+
col++;
|
|
428
|
+
}
|
|
429
|
+
flush();
|
|
430
|
+
// Wrap spans from this run in an <a> if it has a hyperlink URL.
|
|
431
|
+
const href = run.u && /^https?:\/\//i.test(run.u) ? run.u : null;
|
|
432
|
+
if (href) {
|
|
433
|
+
const a = document.createElement("a");
|
|
434
|
+
a.href = href;
|
|
435
|
+
a.target = "_blank";
|
|
436
|
+
a.rel = "noopener";
|
|
437
|
+
a.className = "term-link";
|
|
438
|
+
// Move spans from this run into the anchor.
|
|
439
|
+
const runSpans = out.splice(runStartIdx);
|
|
440
|
+
for (const s of runSpans) {
|
|
441
|
+
a.appendChild(s);
|
|
442
|
+
}
|
|
443
|
+
out.push(a);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
if (cursorAt >= 0 && col <= cursorAt) {
|
|
447
|
+
while (col < cursorAt) {
|
|
448
|
+
const span = document.createElement("span");
|
|
449
|
+
span.textContent = " ";
|
|
450
|
+
out.push(span);
|
|
451
|
+
col++;
|
|
452
|
+
}
|
|
453
|
+
const cursor = document.createElement("span");
|
|
454
|
+
cursor.className = cursorClassName();
|
|
455
|
+
cursor.textContent = " ";
|
|
456
|
+
out.push(cursor);
|
|
457
|
+
}
|
|
458
|
+
if (out.length === 0) {
|
|
459
|
+
const span = document.createElement("span");
|
|
460
|
+
span.innerHTML = " ";
|
|
461
|
+
out.push(span);
|
|
462
|
+
}
|
|
463
|
+
return linkifySpans(out);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// --- Frame handling: feed the store, then flush to DOM ---
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Apply a `ScreenMessage`: update the store's window + changed rows, then
|
|
470
|
+
* schedule a render flush. The store handles merging changed rows by absolute
|
|
471
|
+
* index, so no client-side frame coalescing is needed.
|
|
472
|
+
*/
|
|
473
|
+
export function handleScreen(msg: ScreenMessage): void {
|
|
474
|
+
store.applyScreen(msg);
|
|
475
|
+
scheduleFlush();
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Apply a `ScrollMessage`: commit history lines into the store by absolute
|
|
480
|
+
* index, then schedule a render flush.
|
|
481
|
+
*/
|
|
482
|
+
export function handleScroll(msg: ScrollMessage): void {
|
|
483
|
+
store.applyScroll(msg);
|
|
484
|
+
scheduleFlush();
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function scheduleFlush(): void {
|
|
488
|
+
if (pendingFrame !== undefined) {
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
pendingFrame = requestAnimationFrame(flushRender);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function flushRender(): void {
|
|
495
|
+
pendingFrame = undefined;
|
|
496
|
+
try {
|
|
497
|
+
flushRenderInner();
|
|
498
|
+
} catch (err) {
|
|
499
|
+
console.error("vterm: render error", err);
|
|
500
|
+
}
|
|
501
|
+
// Single auto-follow invariant, applied after every DOM mutation.
|
|
502
|
+
stickToBottomIfFollowing();
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function flushRenderInner(): void {
|
|
506
|
+
const ch = store.drainChanges();
|
|
507
|
+
|
|
508
|
+
if (ch.fullReset) {
|
|
509
|
+
output.replaceChildren();
|
|
510
|
+
rowEls.clear();
|
|
511
|
+
renderQueue.clear();
|
|
512
|
+
trimMarkerEl = null;
|
|
513
|
+
cursorAbs = -1;
|
|
514
|
+
} else {
|
|
515
|
+
for (const abs of ch.evictedLines) {
|
|
516
|
+
const el = rowEls.get(abs);
|
|
517
|
+
if (el) {
|
|
518
|
+
el.remove();
|
|
519
|
+
}
|
|
520
|
+
rowEls.delete(abs);
|
|
521
|
+
renderQueue.delete(abs);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// Refresh cursor state from the window.
|
|
526
|
+
const win = store.getWindow();
|
|
527
|
+
const newCursorAbs = win.base + win.cursorRow;
|
|
528
|
+
const prevCursorAbs = cursorAbs;
|
|
529
|
+
cursorAbs = newCursorAbs;
|
|
530
|
+
cursorCol = win.cursorCol;
|
|
531
|
+
cursorHidden = win.cursorHidden;
|
|
532
|
+
cursorStyleVal = win.cursorStyle;
|
|
533
|
+
setCursorBlink(win.cursorBlink);
|
|
534
|
+
|
|
535
|
+
// Alt screen: render the ephemeral grid instead of the absolute buffer.
|
|
536
|
+
if (store.isAlt()) {
|
|
537
|
+
renderAlt(store.getAltRows());
|
|
538
|
+
if (onCursorMove) {
|
|
539
|
+
onCursorMove();
|
|
540
|
+
}
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
if (altRendered) {
|
|
544
|
+
// Just exited alt: drop the ephemeral rows and rebuild from the store.
|
|
545
|
+
altRendered = false;
|
|
546
|
+
output.replaceChildren();
|
|
547
|
+
rowEls.clear();
|
|
548
|
+
renderQueue.clear();
|
|
549
|
+
trimMarkerEl = null;
|
|
550
|
+
store.forEachLine((abs) => renderQueue.add(abs));
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// Queue this frame's changed rows for building.
|
|
554
|
+
for (const abs of ch.dirtyLines) {
|
|
555
|
+
renderQueue.add(abs);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// The cursor rows are built every frame regardless of the budget so the
|
|
559
|
+
// inline cursor span is always current (it moves off the old row and onto
|
|
560
|
+
// the new one); a huge backlog must never make the caret lag.
|
|
561
|
+
if (prevCursorAbs !== newCursorAbs && prevCursorAbs >= 0) {
|
|
562
|
+
upsertRow(prevCursorAbs);
|
|
563
|
+
renderQueue.delete(prevCursorAbs);
|
|
564
|
+
}
|
|
565
|
+
upsertRow(newCursorAbs);
|
|
566
|
+
renderQueue.delete(newCursorAbs);
|
|
567
|
+
|
|
568
|
+
// Drain up to MAX_ROWS_PER_FRAME queued rows this frame; the rest carry over
|
|
569
|
+
// to the next frame (scheduled below) so one big burst never blocks paint.
|
|
570
|
+
let built = 0;
|
|
571
|
+
for (const abs of renderQueue) {
|
|
572
|
+
if (built >= MAX_ROWS_PER_FRAME) {
|
|
573
|
+
break;
|
|
574
|
+
}
|
|
575
|
+
upsertRow(abs);
|
|
576
|
+
renderQueue.delete(abs);
|
|
577
|
+
built++;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
updateTrimMarker();
|
|
581
|
+
|
|
582
|
+
// More rows pending: keep draining on subsequent frames.
|
|
583
|
+
if (renderQueue.size > 0) {
|
|
584
|
+
scheduleFlush();
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
if (onCursorMove) {
|
|
588
|
+
onCursorMove();
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// updateTrimMarker shows or hides the "earlier output trimmed" marker as the
|
|
593
|
+
// first child of output, driven by the store. It appears when history older
|
|
594
|
+
// than the store holds was trimmed — either the client evicted its oldest
|
|
595
|
+
// lines at the cap, or the server reported (via resumeAck bounds) that it no
|
|
596
|
+
// longer retains history the client was missing on resume. The marker carries
|
|
597
|
+
// no data-abs, so insertRowInOrder (which compares numeric data-abs) never
|
|
598
|
+
// places a row before it; it stays pinned at the top.
|
|
599
|
+
function updateTrimMarker(): void {
|
|
600
|
+
if (store.hasTrimmedHistory()) {
|
|
601
|
+
if (trimMarkerEl === null) {
|
|
602
|
+
trimMarkerEl = document.createElement("div");
|
|
603
|
+
trimMarkerEl.className = "term-trim-marker";
|
|
604
|
+
trimMarkerEl.setAttribute("role", "status");
|
|
605
|
+
trimMarkerEl.setAttribute("aria-label", "earlier output trimmed");
|
|
606
|
+
trimMarkerEl.textContent = "earlier output trimmed";
|
|
607
|
+
}
|
|
608
|
+
if (trimMarkerEl.parentElement !== output || output.firstChild !== trimMarkerEl) {
|
|
609
|
+
output.insertBefore(trimMarkerEl, output.firstChild);
|
|
610
|
+
}
|
|
611
|
+
} else if (trimMarkerEl !== null && trimMarkerEl.parentElement === output) {
|
|
612
|
+
trimMarkerEl.remove();
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// upsertRow builds or updates the DOM row for an absolute index, or removes it
|
|
617
|
+
// if the store no longer holds it. New rows are inserted in ascending data-abs
|
|
618
|
+
// order.
|
|
619
|
+
function upsertRow(abs: number): void {
|
|
620
|
+
const runs = store.getLine(abs);
|
|
621
|
+
if (runs === undefined) {
|
|
622
|
+
const stale = rowEls.get(abs);
|
|
623
|
+
if (stale) {
|
|
624
|
+
stale.remove();
|
|
625
|
+
rowEls.delete(abs);
|
|
626
|
+
}
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
const cursorAt = !cursorHidden && abs === cursorAbs ? cursorCol : -1;
|
|
630
|
+
const spans = buildRowSpans(runs, cursorAt);
|
|
631
|
+
let el = rowEls.get(abs);
|
|
632
|
+
if (el === undefined) {
|
|
633
|
+
el = document.createElement("div");
|
|
634
|
+
el.className = "term-row";
|
|
635
|
+
el.dataset["abs"] = String(abs);
|
|
636
|
+
el.replaceChildren(...spans);
|
|
637
|
+
insertRowInOrder(el, abs);
|
|
638
|
+
rowEls.set(abs, el);
|
|
639
|
+
} else {
|
|
640
|
+
el.replaceChildren(...spans);
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// insertRowInOrder places a freshly-created row element among output's
|
|
645
|
+
// children so they stay in ascending data-abs order. The common case (a new
|
|
646
|
+
// highest index) is an O(1) append; out-of-order inserts scan for the slot.
|
|
647
|
+
function insertRowInOrder(el: HTMLDivElement, abs: number): void {
|
|
648
|
+
const last = output.lastElementChild as HTMLElement | null;
|
|
649
|
+
if (last === null || rowAbs(last) < abs) {
|
|
650
|
+
output.appendChild(el);
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
for (const child of output.children) {
|
|
654
|
+
if (rowAbs(child as HTMLElement) > abs) {
|
|
655
|
+
output.insertBefore(el, child);
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
output.appendChild(el);
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function rowAbs(el: HTMLElement): number {
|
|
663
|
+
const v = el.dataset["abs"];
|
|
664
|
+
return v === undefined ? -1 : Number(v);
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// --- Alt screen (ephemeral grid; no history) ---
|
|
668
|
+
let altRendered = false;
|
|
669
|
+
|
|
670
|
+
function renderAlt(rows: WireRun[][]): void {
|
|
671
|
+
altRendered = true;
|
|
672
|
+
rowEls.clear();
|
|
673
|
+
const els: HTMLDivElement[] = [];
|
|
674
|
+
for (let y = 0; y < rows.length; y++) {
|
|
675
|
+
const div = document.createElement("div");
|
|
676
|
+
div.className = "term-row";
|
|
677
|
+
const cursorAt = !cursorHidden && y === cursorAbs - store.getWindow().base ? cursorCol : -1;
|
|
678
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- y < rows.length
|
|
679
|
+
div.replaceChildren(...buildRowSpans(rows[y]!, cursorAt));
|
|
680
|
+
els.push(div);
|
|
681
|
+
}
|
|
682
|
+
output.replaceChildren(...els);
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/** Pin the viewport to the bottom iff the user is following. The scroll
|
|
686
|
+
* controller (scroll.ts) owns scrollTop and the follow/hold decision. */
|
|
687
|
+
function stickToBottomIfFollowing(): void {
|
|
688
|
+
scroll.stickToBottom();
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// --- Cursor blink ---
|
|
692
|
+
const CURSOR_BLINK_MS = 530;
|
|
693
|
+
let blinkInterval: ReturnType<typeof setInterval> | null = null;
|
|
694
|
+
let blinkEnabled = true;
|
|
695
|
+
|
|
696
|
+
function startCursorBlink(): void {
|
|
697
|
+
if (blinkInterval !== null) {
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
700
|
+
output.classList.remove("cursor-blink-off");
|
|
701
|
+
blinkInterval = setInterval(() => {
|
|
702
|
+
output.classList.toggle("cursor-blink-off");
|
|
703
|
+
}, CURSOR_BLINK_MS);
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
function stopCursorBlink(): void {
|
|
707
|
+
if (blinkInterval !== null) {
|
|
708
|
+
clearInterval(blinkInterval);
|
|
709
|
+
blinkInterval = null;
|
|
710
|
+
}
|
|
711
|
+
output.classList.remove("cursor-blink-off");
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/** Called from the flush when cursorBlink state changes. */
|
|
715
|
+
function setCursorBlink(enabled: boolean): void {
|
|
716
|
+
if (enabled === blinkEnabled) {
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
blinkEnabled = enabled;
|
|
720
|
+
if (enabled) {
|
|
721
|
+
startCursorBlink();
|
|
722
|
+
} else {
|
|
723
|
+
stopCursorBlink();
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// --- Font metrics & sizing ---
|
|
728
|
+
/**
|
|
729
|
+
* Re-measure the cell width/height from the rendered DOM. Call after any font
|
|
730
|
+
* or zoom change so subsequent `computeSize()` and `getCursorPx()` use fresh
|
|
731
|
+
* metrics.
|
|
732
|
+
*/
|
|
733
|
+
export function updateFontMetrics(): void {
|
|
734
|
+
const cs = window.getComputedStyle(termWrap);
|
|
735
|
+
const fontSize = cs.fontSize;
|
|
736
|
+
const family = cs.fontFamily;
|
|
737
|
+
fontString = `${fontSize} ${family}`;
|
|
738
|
+
widthFlat.fill(WIDTH_FLAT_UNSET);
|
|
739
|
+
widthMap.clear();
|
|
740
|
+
resetVariantContexts();
|
|
741
|
+
const measuredW = measureCellWidth();
|
|
742
|
+
cellWidth = Math.round(measuredW);
|
|
743
|
+
cellHeight = parseFloat(cs.lineHeight) || 17;
|
|
744
|
+
defaultSpacing = cellWidth - measuredW;
|
|
745
|
+
output.style.letterSpacing = `${defaultSpacing}px`;
|
|
746
|
+
document.documentElement.style.setProperty("--char-w", `${cellWidth}px`);
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
const MIN_COLS = 20;
|
|
750
|
+
const MIN_ROWS = 5;
|
|
751
|
+
|
|
752
|
+
/**
|
|
753
|
+
* Compute the integer (cols, rows) the terminal element can fit at current
|
|
754
|
+
* font metrics, clamped to a minimum of 20×5. Used to decide what dimensions
|
|
755
|
+
* to send to the server in a `resize` control message.
|
|
756
|
+
*/
|
|
757
|
+
export function computeSize(): { cols: number; rows: number } {
|
|
758
|
+
const cs = window.getComputedStyle(termWrap);
|
|
759
|
+
const padX = parseFloat(cs.paddingLeft) + parseFloat(cs.paddingRight);
|
|
760
|
+
const padY = parseFloat(cs.paddingTop) + parseFloat(cs.paddingBottom);
|
|
761
|
+
const contentW = termWrap.clientWidth - padX;
|
|
762
|
+
const contentH = termWrap.clientHeight - padY;
|
|
763
|
+
const cols = Math.max(MIN_COLS, Math.floor(contentW / cellWidth));
|
|
764
|
+
const rows = Math.max(MIN_ROWS, Math.floor(contentH / cellHeight));
|
|
765
|
+
return { cols, rows };
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/**
|
|
769
|
+
* Returns the cursor's pixel position relative to the output element, plus
|
|
770
|
+
* the current cell height, for positioning custom overlays (predicted-cursor,
|
|
771
|
+
* IME composition, etc.). Uses the cursor row's actual DOM offset.
|
|
772
|
+
*/
|
|
773
|
+
export function getCursorPx(): { left: number; top: number; cellH: number } {
|
|
774
|
+
const cs = window.getComputedStyle(termWrap);
|
|
775
|
+
const padL = parseFloat(cs.paddingLeft);
|
|
776
|
+
const padT = parseFloat(cs.paddingTop);
|
|
777
|
+
const el = rowEls.get(cursorAbs);
|
|
778
|
+
const top = el ? el.offsetTop : padT;
|
|
779
|
+
return {
|
|
780
|
+
left: Math.round(padL + cursorCol * cellWidth),
|
|
781
|
+
top: Math.round(top),
|
|
782
|
+
cellH: cellHeight,
|
|
783
|
+
};
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
let predCursorEl: HTMLElement | null = null;
|
|
787
|
+
|
|
788
|
+
/**
|
|
789
|
+
* Show or hide a "predicted" cursor overlay at window-relative (row, col).
|
|
790
|
+
* Useful for client-side echo of typed characters before the server
|
|
791
|
+
* acknowledges them, over high-latency connections.
|
|
792
|
+
*/
|
|
793
|
+
export function setPredictedCursor(row: number, col: number, active: boolean): void {
|
|
794
|
+
const el = predCursorEl ?? (predCursorEl = document.getElementById("pred-cursor"));
|
|
795
|
+
if (!el) {
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
const win = store.getWindow();
|
|
799
|
+
const predAbs = win.base + row;
|
|
800
|
+
if (!active || (predAbs === cursorAbs && col === cursorCol)) {
|
|
801
|
+
el.classList.remove("visible");
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
804
|
+
const cs = window.getComputedStyle(termWrap);
|
|
805
|
+
const padL = parseFloat(cs.paddingLeft);
|
|
806
|
+
const rowEl = rowEls.get(predAbs);
|
|
807
|
+
const top = rowEl ? rowEl.offsetTop : parseFloat(cs.paddingTop) + row * cellHeight;
|
|
808
|
+
el.style.left = `${Math.round(padL + col * cellWidth)}px`;
|
|
809
|
+
el.style.top = `${Math.round(top)}px`;
|
|
810
|
+
el.style.width = `${cellWidth}px`;
|
|
811
|
+
el.style.height = `${cellHeight}px`;
|
|
812
|
+
el.classList.add("visible");
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
/** Apply or remove the reverse-video class on the terminal output.
|
|
816
|
+
* When DECSCNM (mode 5) is active, default fg/bg are swapped via CSS. */
|
|
817
|
+
export function updateReverseVideo(): void {
|
|
818
|
+
if (isReverseVideo()) {
|
|
819
|
+
termWrap.classList.add("term-reverse-video");
|
|
820
|
+
} else {
|
|
821
|
+
termWrap.classList.remove("term-reverse-video");
|
|
822
|
+
}
|
|
823
|
+
}
|