1(() => {
2 'use strict';
3
4 /** @typedef {{r: number, g: number, b: number}} RGB */
5 /** @typedef {{kind: 0}|{kind: 1, index: number}|{kind: 2, rgb: RGB}} Color */
6 /** @typedef {{bold?: boolean, italic?: boolean, inverse?: boolean, dim?: boolean, blink?: boolean, strikethrough?: boolean, underline?: boolean, underlineStyle?: number, foreground: Color, background: Color, underlineColor: Color}} TerminalStyle */
7 /** @typedef {{column: number, width: number, text: string, style: number}} CellUpdate */
8 /** @typedef {{row: number, cells: CellUpdate[]}} RowUpdate */
9 /** @typedef {{row: number, column: number, visible: boolean, style: number, styleSet: boolean}} Cursor */
10 /** @typedef {{schemaVersion: number, width: number, height: number, snapshot: boolean, rows: RowUpdate[], styles: TerminalStyle[], cursor: Cursor}} Update */
11 /** @typedef {{emit: boolean, preventDefault: boolean}} CaptureDecision */
12 /** @typedef {{schemaVersion?: number, type: string, [key: string]: unknown}} BrowserEvent */
13 /** @typedef {{maxCells?: number, maxRowsPerUpdate?: number, maxColumns?: number, maxStyles?: number, maxTextBytes?: number, maxUpdateBytes?: number, maxPasteBytes?: number}} BrowserLimits */
14 /** @typedef {{foreground: RGB, background: RGB, cursor: RGB, selection: RGB, selectionText: RGB, palette: RGB[]}} TerminalTheme */
15 /** @typedef {{label: string, decide?: (event: BrowserEvent) => CaptureDecision, send?: (event: BrowserEvent) => void, limits?: BrowserLimits}} MountOptions */
16 /** @typedef {{apply(update: Update): void, applyAll(updates: Update[]): void, focus(): void, setMouseCapture(enabled: boolean): void, setTheme(theme: TerminalTheme): void, destroy(): void}} Terminal */
17
18 const SCHEMA_VERSION = 2;
19 const DEFAULT_LIMITS = Object.freeze({
20 maxCells: 1_000_000,
21 maxRowsPerUpdate: 10_000,
22 maxColumns: 10_000,
23 maxStyles: 65_536,
24 maxTextBytes: 64 << 10,
25 maxUpdateBytes: 64 << 20,
26 maxPasteBytes: 1 << 20
27 });
28 const encoder = new TextEncoder();
29 const PRINTABLE_ASCII = /^[\x20-\x7e]*$/;
30
31 function fail(message) {
32 throw new TypeError(`VevTerminal: ${message}`);
33 }
34
35 function object(value, name) {
36 if (value === null || typeof value !== 'object' || Array.isArray(value)) fail(`${name} must be an object`);
37 return value;
38 }
39
40 function keys(value, allowed, name) {
41 for (const key of Object.keys(value)) {
42 if (!allowed.includes(key)) fail(`${name} contains unknown field ${key}`);
43 }
44 }
45
46 function integer(value, minimum, maximum, name) {
47 if (!Number.isSafeInteger(value) || value < minimum || value > maximum) fail(`${name} is out of range`);
48 return value;
49 }
50
51 function boolean(value, name, fallback = false) {
52 if (value === undefined) return fallback;
53 if (typeof value !== 'boolean') fail(`${name} must be boolean`);
54 return value;
55 }
56
57 /** @returns {{value: string, byteLength: number}} */
58 function boundedString(value, limit, name) {
59 if (typeof value !== 'string') fail(`${name} must be a string`);
60 const byteLength = PRINTABLE_ASCII.test(value) ? value.length : encoder.encode(value).byteLength;
61 if (byteLength > limit) fail(`${name} exceeds its byte limit`);
62 return { value, byteLength };
63 }
64
65 function limits(requested = {}) {
66 object(requested, 'limits');
67 keys(requested, Object.keys(DEFAULT_LIMITS), 'limits');
68 const result = {};
69 for (const [name, fallback] of Object.entries(DEFAULT_LIMITS)) {
70 result[name] = requested[name] === undefined ? fallback : integer(requested[name], 1, Number.MAX_SAFE_INTEGER, `limits.${name}`);
71 }
72 return Object.freeze(result);
73 }
74
75 function validateRGB(value, name) {
76 object(value, name);
77 keys(value, ['r', 'g', 'b'], name);
78 return {
79 r: integer(value.r, 0, 255, `${name}.r`),
80 g: integer(value.g, 0, 255, `${name}.g`),
81 b: integer(value.b, 0, 255, `${name}.b`)
82 };
83 }
84
85 function validateColor(value, name) {
86 object(value, name);
87 keys(value, ['kind', 'index', 'rgb'], name);
88 const kind = integer(value.kind, 0, 2, `${name}.kind`);
89 if (kind === 0) {
90 if (value.index !== undefined || value.rgb !== undefined) fail(`${name} default color has a payload`);
91 return { kind };
92 }
93 if (kind === 1) {
94 if (value.rgb !== undefined) fail(`${name} indexed color has an rgb payload`);
95 return { kind, index: integer(value.index, 0, 255, `${name}.index`) };
96 }
97 if (value.index !== undefined) fail(`${name} rgb color has an index payload`);
98 return { kind, rgb: validateRGB(value.rgb, `${name}.rgb`) };
99 }
100
101 function validateStyle(value, index) {
102 const name = `styles[${index}]`;
103 object(value, name);
104 keys(value, [
105 'bold', 'italic', 'inverse', 'dim', 'blink', 'strikethrough', 'underline',
106 'underlineStyle', 'foreground', 'background', 'underlineColor'
107 ], name);
108 return {
109 bold: boolean(value.bold, `${name}.bold`),
110 italic: boolean(value.italic, `${name}.italic`),
111 inverse: boolean(value.inverse, `${name}.inverse`),
112 dim: boolean(value.dim, `${name}.dim`),
113 blink: boolean(value.blink, `${name}.blink`),
114 strikethrough: boolean(value.strikethrough, `${name}.strikethrough`),
115 underline: boolean(value.underline, `${name}.underline`),
116 underlineStyle: value.underlineStyle === undefined ? 0 : integer(value.underlineStyle, 0, 5, `${name}.underlineStyle`),
117 foreground: validateColor(value.foreground, `${name}.foreground`),
118 background: validateColor(value.background, `${name}.background`),
119 underlineColor: validateColor(value.underlineColor, `${name}.underlineColor`)
120 };
121 }
122
123 function validateCursor(value, width, height) {
124 object(value, 'cursor');
125 keys(value, ['row', 'column', 'visible', 'style', 'styleSet'], 'cursor');
126 const styleSet = boolean(value.styleSet, 'cursor.styleSet');
127 const style = integer(value.style, 0, 6, 'cursor.style');
128 if (!styleSet && style !== 0) fail('cursor style must be zero when unset');
129 return {
130 row: integer(value.row, 0, height - 1, 'cursor.row'),
131 column: integer(value.column, 0, width - 1, 'cursor.column'),
132 visible: boolean(value.visible, 'cursor.visible'),
133 style,
134 styleSet
135 };
136 }
137
138 function validateRow(value, width, height, styleCount, configured, textBudget, seen) {
139 object(value, 'row');
140 keys(value, ['row', 'cells'], 'row');
141 const row = integer(value.row, 0, height - 1, 'row.row');
142 if (seen.has(row)) fail(`duplicate row ${row}`);
143 seen.add(row);
144 if (!Array.isArray(value.cells)) fail(`row ${row} cells must be an array`);
145 let column = 0;
146 let bytes = 0;
147 const cells = value.cells.map((candidate, index) => {
148 const name = `row ${row} cell ${index}`;
149 object(candidate, name);
150 keys(candidate, ['column', 'width', 'text', 'style'], name);
151 const current = integer(candidate.column, 0, width - 1, `${name}.column`);
152 const cellWidth = integer(candidate.width, 1, configured.maxColumns, `${name}.width`);
153 if (current !== column) fail(`row coverage is not contiguous at row ${row} column ${column}`);
154 if (current + cellWidth > width) fail(`row coverage exceeds width at row ${row}`);
155 const text = boundedString(candidate.text, configured.maxTextBytes, `${name}.text`);
156 // Entries wider than one wide character are text runs: printable ASCII,
157 // one character per column, so grid columns and font advance agree.
158 if (cellWidth > 2 && (text.value.length !== cellWidth || !PRINTABLE_ASCII.test(text.value))) fail(`${name} text run does not match its width`);
159 const cell = {
160 column: current,
161 width: cellWidth,
162 text: text.value,
163 style: integer(candidate.style, 0, styleCount - 1, `${name}.style`)
164 };
165 textBudget.value += text.byteLength;
166 if (textBudget.value > configured.maxUpdateBytes) fail('update text exceeds its aggregate byte limit');
167 bytes += text.byteLength;
168 column += cellWidth;
169 return cell;
170 });
171 if (column !== width) fail(`row coverage is ${column}, expected ${width}`);
172 return { row, cells, bytes };
173 }
174
175 /**
176 * Validate an entire update before any DOM mutation.
177 * @param {unknown} value
178 * @param {Readonly<Record<string, number>>} configured
179 * @returns {Update}
180 */
181 function validateUpdate(value, configured) {
182 object(value, 'update');
183 keys(value, ['schemaVersion', 'width', 'height', 'snapshot', 'rows', 'styles', 'cursor'], 'update');
184 if (value.schemaVersion !== SCHEMA_VERSION) fail(`unsupported update schema ${value.schemaVersion}`);
185 const width = integer(value.width, 1, configured.maxColumns, 'update.width');
186 const height = integer(value.height, 1, configured.maxRowsPerUpdate, 'update.height');
187 if (width > Math.floor(configured.maxCells / height)) fail('update exceeds the cell limit');
188 const snapshot = boolean(value.snapshot, 'update.snapshot');
189 if (!Array.isArray(value.styles) || value.styles.length > configured.maxStyles) fail('update styles exceed their limit');
190 const styles = value.styles.map(validateStyle);
191 if (!Array.isArray(value.rows) || value.rows.length > configured.maxRowsPerUpdate) fail('update rows exceed their limit');
192 if (value.rows.length > Math.floor(configured.maxCells / width)) fail('update represented cells exceed their limit');
193 if (value.rows.length > 0 && styles.length === 0) fail('an update with rows requires styles');
194 if (snapshot && value.rows.length !== height) fail('snapshot must contain every row');
195 const seen = new Set();
196 const textBudget = { value: 0 };
197 const rows = value.rows.map(row => validateRow(row, width, height, styles.length, configured, textBudget, seen));
198 if (snapshot) {
199 rows.sort((left, right) => left.row - right.row);
200 rows.forEach((row, index) => { if (row.row !== index) fail('snapshot rows are incomplete'); });
201 }
202 return { schemaVersion: SCHEMA_VERSION, width, height, snapshot, rows, styles, cursor: validateCursor(value.cursor, width, height) };
203 }
204
205 /**
206 * Merge validated updates into one equivalent update, or return null when
207 * the merged styles would exceed maxStyles (callers then apply in order).
208 * Every incremental update must match the geometry established by the
209 * mounted terminal or the latest snapshot, exactly as sequential apply
210 * requires, so a protocol error is reported before any DOM mutation.
211 * The merged update keeps the latest snapshot flag, geometry and cursor;
212 * rows are last-writer-wins and style ids are remapped.
213 */
214 function coalesce(updates, mounted, maxStyles) {
215 let geometry = mounted;
216 for (const update of updates) {
217 if (update.snapshot) geometry = { width: update.width, height: update.height };
218 else if (!geometry || update.width !== geometry.width || update.height !== geometry.height) fail('incremental update dimensions do not match the mounted snapshot');
219 }
220 if (updates.length === 1) return updates[0];
221 let start = 0;
222 for (let index = 0; index < updates.length; index += 1) if (updates[index].snapshot) start = index;
223 const tail = updates.slice(start);
224 const last = tail[tail.length - 1];
225 const rows = new Map();
226 for (const update of tail) for (const row of update.rows) rows.set(row.row, { row, styles: update.styles });
227 const styles = [];
228 const ids = new Map();
229 const remaps = new Map();
230 const merged = [];
231 for (const key of [...rows.keys()].sort((left, right) => left - right)) {
232 const { row, styles: source } = rows.get(key);
233 let remap = remaps.get(source);
234 if (!remap) {
235 remap = new Array(source.length);
236 remaps.set(source, remap);
237 }
238 const cells = row.cells.map(cell => {
239 let id = remap[cell.style];
240 if (id === undefined) {
241 const styleKey = JSON.stringify(source[cell.style]);
242 id = ids.get(styleKey);
243 if (id === undefined) {
244 id = styles.length;
245 ids.set(styleKey, id);
246 styles.push(source[cell.style]);
247 }
248 remap[cell.style] = id;
249 }
250 return id === cell.style ? cell : { ...cell, style: id };
251 });
252 merged.push({ row: row.row, bytes: row.bytes, cells });
253 }
254 if (styles.length > maxStyles) return null;
255 return { schemaVersion: SCHEMA_VERSION, width: last.width, height: last.height, snapshot: tail[0].snapshot, rows: merged, styles, cursor: last.cursor };
256 }
257
258 function xtermColor(index) {
259 if (index < 16) return `var(--vev-color-${index})`;
260 if (index < 232) {
261 const value = index - 16;
262 const levels = [0, 95, 135, 175, 215, 255];
263 const r = levels[Math.floor(value / 36)];
264 const g = levels[Math.floor(value / 6) % 6];
265 const b = levels[value % 6];
266 return `rgb(${r} ${g} ${b})`;
267 }
268 const gray = 8 + (index - 232) * 10;
269 return `rgb(${gray} ${gray} ${gray})`;
270 }
271
272 function colorValue(color, fallback) {
273 if (color.kind === 0) return fallback;
274 if (color.kind === 1) return xtermColor(color.index);
275 return `rgb(${color.rgb.r} ${color.rgb.g} ${color.rgb.b})`;
276 }
277
278 function addStyleClasses(node, style) {
279 if (style.bold) node.classList.add('vev-terminal__cell--bold');
280 if (style.italic) node.classList.add('vev-terminal__cell--italic');
281 if (style.dim) node.classList.add('vev-terminal__cell--dim');
282 if (style.blink) node.classList.add('vev-terminal__cell--blink');
283 if (style.strikethrough) node.classList.add('vev-terminal__cell--strike');
284 if (style.underline) {
285 node.classList.add('vev-terminal__cell--underline');
286 const suffix = ['single', 'single', 'double', 'wavy', 'dotted', 'dashed'][style.underlineStyle];
287 if (suffix !== 'single') node.classList.add(`vev-terminal__cell--underline-${suffix}`);
288 }
289 }
290
291 function buildRow(document, row, styles) {
292 const node = document.createElement('div');
293 node.className = 'vev-terminal__row';
294 node.dataset.row = String(row.row);
295 let text = '';
296 for (const cell of row.cells) {
297 const style = styles[cell.style];
298 const child = document.createElement('span');
299 child.className = 'vev-terminal__cell';
300 child.dataset.column = String(cell.column);
301 child.dataset.width = String(cell.width);
302 child.style.gridColumn = `${cell.column + 1} / span ${cell.width}`;
303 let foreground = colorValue(style.foreground, 'var(--vev-fg)');
304 let background = colorValue(style.background, 'var(--vev-bg)');
305 if (style.inverse) [foreground, background] = [background, foreground];
306 // Default colors already resolve through the stylesheet. Avoid three
307 // redundant custom properties per cell (and their style-resolution cost).
308 if (style.inverse || style.foreground.kind !== 0) child.style.setProperty('--vev-cell-fg', foreground);
309 if (style.inverse || style.background.kind !== 0) child.style.setProperty('--vev-cell-bg', background);
310 if (style.underlineColor.kind !== 0 || style.dim) child.style.setProperty('--vev-cell-underline', colorValue(style.underlineColor, foreground));
311 addStyleClasses(child, style);
312 child.textContent = cell.text;
313 // A plain space has no ink to clip. Avoid a compositor clip for every
314 // blank cell while retaining its grid box, selection and text content.
315 if (/^ +$/.test(cell.text) && !style.underline && !style.strikethrough) {
316 child.style.overflow = 'visible';
317 }
318 node.append(child);
319 text += cell.text;
320 }
321 return { node, text };
322 }
323
324 function validateTheme(theme) {
325 object(theme, 'theme');
326 keys(theme, ['foreground', 'background', 'cursor', 'selection', 'selectionText', 'palette'], 'theme');
327 const result = {
328 foreground: validateRGB(theme.foreground, 'theme.foreground'),
329 background: validateRGB(theme.background, 'theme.background'),
330 cursor: validateRGB(theme.cursor, 'theme.cursor'),
331 selection: validateRGB(theme.selection, 'theme.selection'),
332 selectionText: validateRGB(theme.selectionText, 'theme.selectionText')
333 };
334 if (!Array.isArray(theme.palette) || theme.palette.length !== 16) fail('theme.palette must contain 16 colors');
335 result.palette = theme.palette.map((color, index) => validateRGB(color, `theme.palette[${index}]`));
336 return result;
337 }
338
339 function rgbValue(color) { return `rgb(${color.r} ${color.g} ${color.b})`; }
340
341 /**
342 * Mount one interactive terminal adapter.
343 * @param {Element} root
344 * @param {MountOptions} options
345 * @returns {Terminal}
346 */
347 function mount(root, options = {}) {
348 if (!(root instanceof Element)) fail('mount root must be an Element');
349 if (root.classList.contains('vev-terminal')) fail('mount root already owns a terminal');
350 object(options, 'options');
351 keys(options, ['label', 'decide', 'send', 'limits'], 'options');
352 const label = boundedString(options.label, 1024, 'options.label').value.trim();
353 if (label === '') fail('options.label must not be empty');
354 if (options.decide !== undefined && typeof options.decide !== 'function') fail('options.decide must be a function');
355 if (options.send !== undefined && typeof options.send !== 'function') fail('options.send must be a function');
356 const configured = limits(options.limits);
357 let decide = options.decide || (() => ({ emit: true, preventDefault: false }));
358 let send = options.send || (() => {});
359 const document = root.ownerDocument;
360 const abort = new AbortController();
361 const signal = abort.signal;
362
363 const previousRole = root.hasAttribute('role') ? root.getAttribute('role') : null;
364 const previousLabel = root.hasAttribute('aria-label') ? root.getAttribute('aria-label') : null;
365 root.classList.add('vev-terminal');
366 root.setAttribute('role', 'group');
367 root.setAttribute('aria-label', label);
368
369 const input = document.createElement('textarea');
370 input.className = 'vev-terminal__input';
371 input.setAttribute('aria-label', `${label} input`);
372 input.setAttribute('autocomplete', 'off');
373 input.setAttribute('autocapitalize', 'off');
374 input.setAttribute('spellcheck', 'false');
375
376 const viewport = document.createElement('div');
377 viewport.className = 'vev-terminal__viewport';
378 viewport.setAttribute('aria-hidden', 'true');
379
380 const cursor = document.createElement('div');
381 cursor.className = 'vev-terminal__cursor';
382 cursor.hidden = true;
383 viewport.append(cursor);
384
385 const accessible = document.createElement('pre');
386 accessible.className = 'vev-terminal__accessible-output';
387 accessible.setAttribute('aria-live', 'off');
388 accessible.setAttribute('aria-label', `${label} output`);
389
390 // Grid tracks round the column width (Chromium: to 1/64 px) while text
391 // advances by the exact glyph width, so a long text run drifts out of its
392 // columns. The probe measures both and letter-spacing absorbs the gap.
393 const probe = document.createElement('span');
394 probe.className = 'vev-terminal__measure';
395 probe.setAttribute('aria-hidden', 'true');
396 probe.textContent = 'x'.repeat(64);
397
398 root.append(input, viewport, accessible, probe);
399
400 let destroyed = false;
401 let composing = false;
402 let editingKeyDown = null;
403 let mouseCapture = false;
404 let width = 0;
405 let height = 0;
406 let rowNodes = [];
407 let rowText = [];
408 let rowBytes = [];
409 let retainedBytes = 0;
410 let previousRows = [];
411 let previousStyles = '';
412 let resizeFrame = 0;
413 let pointerFrame = 0;
414 let pendingPointer = null;
415 let lastResize = '';
416 let letterSpacing = '';
417 const previousProperties = new Map();
418
419 function setOwnedProperty(name, value) {
420 if (!previousProperties.has(name)) {
421 previousProperties.set(name, {
422 value: root.style.getPropertyValue(name),
423 priority: root.style.getPropertyPriority(name)
424 });
425 }
426 root.style.setProperty(name, value);
427 }
428
429 function alive() {
430 if (destroyed) fail('instance is destroyed');
431 }
432
433 /** @returns {Readonly<BrowserEvent>|null} */
434 function capture(event, nativeEvent) {
435 const decision = decide(Object.freeze({ ...event }));
436 object(decision, 'capture decision');
437 keys(decision, ['emit', 'preventDefault'], 'capture decision');
438 const emit = boolean(decision.emit, 'capture decision.emit');
439 const preventDefault = boolean(decision.preventDefault, 'capture decision.preventDefault');
440 if (preventDefault && nativeEvent && nativeEvent.cancelable) nativeEvent.preventDefault();
441 return emit ? Object.freeze({ schemaVersion: SCHEMA_VERSION, ...event }) : null;
442 }
443
444 function dispatch(event, nativeEvent) {
445 const captured = capture(event, nativeEvent);
446 if (captured) send(captured);
447 }
448
449 function updateCursor(state) {
450 cursor.hidden = !state.visible;
451 cursor.className = 'vev-terminal__cursor';
452 if (!state.visible) return;
453 const style = state.styleSet ? state.style : 0;
454 const bar = style === 5 || style === 6;
455 const underline = style === 3 || style === 4;
456 if (bar) cursor.classList.add('vev-terminal__cursor--bar');
457 if (underline) cursor.classList.add('vev-terminal__cursor--underline');
458 if (style === 0 || style === 1 || style === 3 || style === 5) cursor.classList.add('vev-terminal__cell--blink');
459 cursor.style.left = `calc(${state.column} * var(--vev-cell-width))`;
460 cursor.style.top = `calc(${state.row} * var(--vev-cell-height))`;
461 cursor.style.width = bar ? '' : 'var(--vev-cell-width)';
462 cursor.style.height = underline ? '' : 'var(--vev-cell-height)';
463 if (underline) cursor.style.marginTop = 'calc(var(--vev-cell-height) * 0.88)';
464 else cursor.style.marginTop = '';
465 }
466
467 /** @param {Update} value */
468 function apply(value) {
469 alive();
470 applyValidated(validateUpdate(value, configured));
471 }
472
473 /**
474 * Validate a queue of updates and their geometry chain, then apply one
475 * merged update: a snapshot supersedes everything before it and later
476 * rows win. A consumer that falls behind rebuilds each changed row once
477 * instead of once per queued update. Invalid input throws before the DOM
478 * changes.
479 * @param {Update[]} values
480 */
481 function applyAll(values) {
482 alive();
483 if (!Array.isArray(values)) fail('updates must be an array');
484 if (values.length === 0) return;
485 const updates = values.map(value => validateUpdate(value, configured));
486 const mounted = rowNodes.length === 0 ? null : { width, height };
487 const merged = coalesce(updates, mounted, configured.maxStyles);
488 if (merged) applyValidated(merged);
489 else {
490 // Rare: the merged rows need more distinct styles than one update may
491 // carry. Each update is valid on its own and the geometry chain was
492 // checked above. Replay the retained-text budget too, then apply them
493 // in order, so a failure still leaves the DOM unchanged.
494 let bytes = rowBytes.slice();
495 let total = retainedBytes;
496 for (const update of updates) {
497 if (update.snapshot) {
498 bytes = [];
499 total = 0;
500 }
501 for (const row of update.rows) {
502 total += row.bytes - (bytes[row.row] || 0);
503 bytes[row.row] = row.bytes;
504 }
505 if (total > configured.maxUpdateBytes) fail('retained text exceeds its aggregate byte limit');
506 }
507 for (const update of updates) applyValidated(update);
508 }
509 }
510
511 function applyValidated(update) {
512 if (!update.snapshot && (update.width !== width || update.height !== height || rowNodes.length === 0)) fail('incremental update dimensions do not match the mounted snapshot');
513 if (!update.snapshot) {
514 for (let row = 0; row < height; row += 1) {
515 if (!rowNodes[row] || rowNodes[row].parentNode !== viewport) fail(`mounted row ${row} is unavailable`);
516 }
517 }
518 // maxUpdateBytes bounds the text retained by the mounted terminal, not
519 // only the current delta: reject the projected retained total before
520 // building or mutating the DOM.
521 let projected = retainedBytes;
522 for (const row of update.rows) {
523 projected -= rowBytes[row.row] || 0;
524 projected += row.bytes;
525 }
526 if (update.snapshot) projected = update.rows.reduce((total, row) => total + row.bytes, 0);
527 if (projected > configured.maxUpdateBytes) fail('retained text exceeds its aggregate byte limit');
528 const styleKey = JSON.stringify(update.styles);
529 const reusable = update.width === width && update.height === height && styleKey === previousStyles;
530 const built = update.rows.map(row => {
531 const old = previousRows[row.row];
532 const node = rowNodes[row.row];
533 // Text-only updates preserve geometry and styling. Blank cells skip
534 // clipping, so a change to or from blanks takes the rebuilding path.
535 const reuse = reusable && node?.parentNode === viewport && old &&
536 old.cells.length === row.cells.length && node.children.length === row.cells.length &&
537 row.cells.every((cell, index) => {
538 const before = old.cells[index];
539 return cell.column === before.column && cell.width === before.width && cell.style === before.style &&
540 !/^ +$/.test(cell.text) && !/^ +$/.test(before.text);
541 });
542 return reuse ? { row: row.row, node, bytes: row.bytes, text: row.cells.map(cell => cell.text).join(''), cells: row.cells } :
543 { row: row.row, bytes: row.bytes, ...buildRow(document, row, update.styles) };
544 });
545 for (const item of built) {
546 if (item.cells) {
547 item.cells.forEach((cell, index) => {
548 const child = item.node.children[index];
549 if (child.textContent !== cell.text) child.textContent = cell.text;
550 });
551 }
552 }
553
554 if (update.snapshot) {
555 const reuseAll = built.every(item => item.cells);
556 const fragment = document.createDocumentFragment();
557 const nextNodes = new Array(update.height);
558 const nextText = new Array(update.height);
559 const nextBytes = new Array(update.height).fill(0);
560 for (const item of built) {
561 nextNodes[item.row] = item.node;
562 nextText[item.row] = item.text;
563 nextBytes[item.row] = item.bytes;
564 if (!reuseAll) fragment.append(item.node);
565 }
566 if (!reuseAll) viewport.replaceChildren(fragment, cursor);
567 rowNodes = nextNodes;
568 rowText = nextText;
569 rowBytes = nextBytes;
570 retainedBytes = projected;
571 } else {
572 for (const item of built) {
573 if (!item.cells) rowNodes[item.row].replaceWith(item.node);
574 rowNodes[item.row] = item.node;
575 retainedBytes += item.bytes - (rowBytes[item.row] || 0);
576 rowBytes[item.row] = item.bytes;
577 rowText[item.row] = item.text;
578 }
579 }
580 if (update.snapshot || styleKey !== previousStyles) previousRows = new Array(update.height);
581 for (const row of update.rows) previousRows[row.row] = row;
582 previousStyles = styleKey;
583 width = update.width;
584 height = update.height;
585 setOwnedProperty('--vev-cols', String(width));
586 setOwnedProperty('--vev-rows', String(height));
587 const accessibleText = rowText.join('\n');
588 if (accessible.textContent !== accessibleText) accessible.textContent = accessibleText;
589 updateCursor(update.cursor);
590 scheduleResize();
591 }
592
593 function position(event) {
594 if (width === 0 || height === 0) return { row: 0, column: 0, x: 0, y: 0 };
595 const bounds = viewport.getBoundingClientRect();
596 if (bounds.width <= 0 || bounds.height <= 0) return { row: 0, column: 0, x: 0, y: 0 };
597 const clientX = Number.isFinite(event.clientX) ? event.clientX : bounds.left;
598 const clientY = Number.isFinite(event.clientY) ? event.clientY : bounds.top;
599 const x = Math.max(0, Math.min(bounds.width, clientX - bounds.left));
600 const y = Math.max(0, Math.min(bounds.height, clientY - bounds.top));
601 const column = Math.min(width - 1, Math.floor(x / (bounds.width / width)));
602 const row = Math.min(height - 1, Math.floor(y / (bounds.height / height)));
603 return { row, column, x, y };
604 }
605
606 function modifierFields(event) {
607 return { alt: event.altKey, ctrl: event.ctrlKey, meta: event.metaKey, shift: event.shiftKey };
608 }
609
610 function pointerPayload(event, action) {
611 const invalidButton = !Number.isInteger(event.button) || event.button < -1 || event.button > 4;
612 const invalidButtons = !Number.isInteger(event.buttons) || event.buttons < 0 || event.buttons > 31;
613 if (invalidButton || invalidButtons) return null;
614 return { type: 'pointer', action, button: event.button, buttons: event.buttons, ...position(event), ...modifierFields(event) };
615 }
616
617 function flushPointer() {
618 pointerFrame = 0;
619 const captured = pendingPointer;
620 pendingPointer = null;
621 if (captured) send(captured);
622 }
623
624 // A discrete pointer event must never be overtaken by an older deferred
625 // move: flush the pending move first so consumers observe event order.
626 function dispatchDiscretePointer(event, action, nativeEvent) {
627 const payload = pointerPayload(event, action);
628 if (!payload) return;
629 if (pendingPointer) flushPointer();
630 dispatch(payload, nativeEvent);
631 }
632
633 function measureAdvance() {
634 const column = probe.getBoundingClientRect().width;
635 const css = parseFloat(getComputedStyle(probe).width);
636 const range = document.createRange();
637 range.selectNodeContents(probe);
638 const advance = range.getBoundingClientRect().width / probe.textContent.length;
639 if (!(column > 0 && css > 0 && advance > 0)) return;
640 const spacing = `${((column - advance) * css / column).toFixed(4)}px`;
641 if (spacing !== letterSpacing) {
642 letterSpacing = spacing;
643 setOwnedProperty('--vev-letter-spacing', spacing);
644 }
645 }
646
647 function scheduleResize() {
648 if (resizeFrame) return;
649 resizeFrame = requestAnimationFrame(() => {
650 resizeFrame = 0;
651 measureAdvance();
652 if (width === 0 || height === 0) return;
653 const bounds = viewport.getBoundingClientRect();
654 const cellWidth = bounds.width / width;
655 const cellHeight = bounds.height / height;
656 if (bounds.width <= 0 || bounds.height <= 0 || cellWidth <= 0 || cellHeight <= 0) return;
657 const payload = {
658 type: 'resize', columns: width, rows: height,
659 pixelWidth: bounds.width, pixelHeight: bounds.height,
660 cellWidth, cellHeight, devicePixelRatio: window.devicePixelRatio
661 };
662 const identity = JSON.stringify(payload);
663 if (identity !== lastResize) {
664 lastResize = identity;
665 dispatch(payload);
666 }
667 });
668 }
669
670 input.addEventListener('beforeinput', event => {
671 if (composing || event.isComposing || event.inputType === 'insertCompositionText' || event.inputType === 'insertFromPaste') return;
672 const editingKey = { deleteContentBackward: 'Backspace', deleteContentForward: 'Delete', insertLineBreak: 'Enter', insertParagraph: 'Enter' }[event.inputType];
673 if (editingKey) {
674 if (editingKeyDown === editingKey) return;
675 dispatch({ type: 'key', key: editingKey, code: editingKey, repeat: false, location: 0, alt: false, ctrl: false, meta: false, shift: false }, event);
676 return;
677 }
678 if (!event.data) return;
679 if (encoder.encode(event.data).byteLength > configured.maxTextBytes) {
680 event.preventDefault();
681 return;
682 }
683 dispatch({ type: 'text', text: event.data }, event);
684 }, { signal });
685 input.addEventListener('input', event => {
686 if (!composing && !event.isComposing) input.value = '';
687 }, { signal });
688 input.addEventListener('compositionstart', () => { composing = true; }, { signal });
689 input.addEventListener('compositionend', event => {
690 composing = false;
691 input.value = '';
692 if (event.data && encoder.encode(event.data).byteLength <= configured.maxTextBytes) {
693 dispatch({ type: 'text', text: event.data }, event);
694 }
695 }, { signal });
696 input.addEventListener('keydown', event => {
697 if (composing || event.isComposing) return;
698 const key = event.key || 'Unidentified';
699 editingKeyDown = ['Backspace', 'Delete', 'Enter'].includes(key) ? key : null;
700 const code = event.code || 'Unidentified';
701 if (key === 'Unidentified' || key === 'Process') return;
702 const nonText = key.length !== 1 || event.ctrlKey || event.altKey || event.metaKey;
703 if (!nonText) return;
704 if (key.length > 128 || code.length > 128) {
705 event.preventDefault();
706 return;
707 }
708 dispatch({ type: 'key', key, code, repeat: event.repeat, location: event.location, ...modifierFields(event) }, event);
709 }, { signal });
710 input.addEventListener('keyup', () => { editingKeyDown = null; }, { signal });
711 input.addEventListener('blur', () => { editingKeyDown = null; }, { signal });
712 input.addEventListener('paste', event => {
713 const text = event.clipboardData?.getData('text/plain') || '';
714 if (encoder.encode(text).byteLength > configured.maxPasteBytes) {
715 event.preventDefault();
716 return;
717 }
718 dispatch({ type: 'paste', text }, event);
719 }, { signal });
720 input.addEventListener('focus', event => dispatch({ type: 'focus', focused: true }, event), { signal });
721 input.addEventListener('blur', event => dispatch({ type: 'focus', focused: false }, event), { signal });
722
723 viewport.addEventListener('pointerdown', event => {
724 if (mouseCapture && viewport.setPointerCapture) {
725 try {
726 if (event.isPrimary !== false) viewport.setPointerCapture(event.pointerId);
727 } catch {
728 // Pointer capture is best-effort; ordering still holds.
729 }
730 }
731 dispatchDiscretePointer(event, 'down', event);
732 }, { signal });
733 viewport.addEventListener('pointerup', event => {
734 dispatchDiscretePointer(event, 'up', event);
735 }, { signal });
736 viewport.addEventListener('pointercancel', event => {
737 dispatchDiscretePointer(event, 'cancel', event);
738 }, { signal });
739 viewport.addEventListener('pointermove', event => {
740 const payload = pointerPayload(event, 'move');
741 if (!payload) return;
742 const captured = capture(payload, event);
743 if (!captured || destroyed) return;
744 pendingPointer = captured;
745 if (!pointerFrame) pointerFrame = requestAnimationFrame(flushPointer);
746 }, { signal });
747 viewport.addEventListener('wheel', event => {
748 const invalidDelta = !Number.isFinite(event.deltaX) || !Number.isFinite(event.deltaY);
749 const invalidMode = !Number.isInteger(event.deltaMode) || event.deltaMode < 0 || event.deltaMode > 2;
750 if (invalidDelta || invalidMode) return;
751 const { row, column } = position(event);
752 dispatch({ type: 'wheel', deltaX: event.deltaX, deltaY: event.deltaY, deltaMode: event.deltaMode, row, column, ...modifierFields(event) }, event);
753 }, { signal, passive: false });
754
755 const observer = new ResizeObserver(scheduleResize);
756 observer.observe(root);
757 document.fonts?.addEventListener('loadingdone', scheduleResize, { signal });
758
759 return Object.freeze({
760 apply,
761 applyAll,
762 focus() { alive(); input.focus(); },
763 setMouseCapture(enabled) {
764 alive();
765 mouseCapture = Boolean(enabled);
766 root.classList.toggle('vev-terminal--mouse-capture', mouseCapture);
767 },
768 setTheme(theme) {
769 alive();
770 const value = validateTheme(theme);
771 setOwnedProperty('--vev-fg', rgbValue(value.foreground));
772 setOwnedProperty('--vev-bg', rgbValue(value.background));
773 setOwnedProperty('--vev-cursor', rgbValue(value.cursor));
774 setOwnedProperty('--vev-selection-bg', rgbValue(value.selection));
775 setOwnedProperty('--vev-selection-fg', rgbValue(value.selectionText));
776 value.palette.forEach((color, index) => setOwnedProperty(`--vev-color-${index}`, rgbValue(color)));
777 },
778 destroy() {
779 if (destroyed) return;
780 destroyed = true;
781 abort.abort();
782 observer.disconnect();
783 if (resizeFrame) cancelAnimationFrame(resizeFrame);
784 if (pointerFrame) cancelAnimationFrame(pointerFrame);
785 input.value = '';
786 accessible.textContent = '';
787 viewport.replaceChildren();
788 input.remove();
789 viewport.remove();
790 accessible.remove();
791 probe.remove();
792 rowNodes = [];
793 rowText = [];
794 rowBytes = [];
795 retainedBytes = 0;
796 previousRows = [];
797 previousStyles = '';
798 pendingPointer = null;
799 width = 0;
800 height = 0;
801 lastResize = '';
802 letterSpacing = '';
803 decide = () => ({ emit: false, preventDefault: false });
804 send = () => {};
805 for (const [name, previous] of previousProperties) {
806 if (previous.value === '') root.style.removeProperty(name);
807 else root.style.setProperty(name, previous.value, previous.priority);
808 }
809 previousProperties.clear();
810 root.classList.remove('vev-terminal', 'vev-terminal--mouse-capture');
811 if (previousRole === null) root.removeAttribute('role');
812 else root.setAttribute('role', previousRole);
813 if (previousLabel === null) root.removeAttribute('aria-label');
814 else root.setAttribute('aria-label', previousLabel);
815 }
816 });
817 }
818
819 globalThis.VevTerminal = Object.freeze({ schemaVersion: SCHEMA_VERSION, mount });
820})();