detached f49c70a

fix(html): keep long text runs on the column grid in Chromium

Chromium rounds grid column widths to 1/64 px while text advances by the
exact glyph width, so a 240-column run overshot its box by up to 3.7 px at
1.25 zoom and clipped its last glyph. The terminal measures the advance on
mount, resize and font load and sets letter-spacing to the difference; the
drift is under 0.05 px. A spec checks a 238-column run against the next cell.

applyAll's in-order fallback now replays the retained-text budget for the
whole batch before applying, so an overflow leaves the DOM unchanged. A spec
covers the maxStyles fallback, the overflow, empty and non-array batches and
an incremental first update. Remove the space-merge loop that schema v2 made
unreachable.
commit
f49c70a8b6c5a1fdae51cfbc7c909c7c87088771
author
brice · · 2026-10-02 16:17 UTC
parent
9174bfd
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +164 -26
M html/browser/terminal.js +53 -24
@@ -293,23 +293,8 @@
293293 node.className = 'vev-terminal__row';
294294 node.dataset.row = String(row.row);
295295 let text = '';
296- for (let index = 0; index < row.cells.length; index += 1) {
297- let cell = row.cells[index];
296+ for (const cell of row.cells) {
298297 const style = styles[cell.style];
299- // A run of spaces needs one grid item, not one item per column. Only
300- // merge plain spaces: arbitrary text still needs explicit cell widths.
301- if (cell.text === ' ' && cell.width === 1 && !style.underline && !style.strikethrough) {
302- let width = cell.width;
303- let spaces = cell.text;
304- while (index + 1 < row.cells.length) {
305- const next = row.cells[index + 1];
306- if (next.text !== ' ' || next.width !== 1 || next.style !== cell.style || next.column !== cell.column + width) break;
307- width += next.width;
308- spaces += next.text;
309- index += 1;
310- }
311- cell = { ...cell, width, text: spaces };
312- }
313298 const child = document.createElement('span');
314299 child.className = 'vev-terminal__cell';
315300 child.dataset.column = String(cell.column);
@@ -402,7 +387,15 @@
402387 accessible.setAttribute('aria-live', 'off');
403388 accessible.setAttribute('aria-label', `${label} output`);
404389
405- root.append(input, viewport, accessible);
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);
406399
407400 let destroyed = false;
408401 let composing = false;
@@ -420,6 +413,7 @@
420413 let pointerFrame = 0;
421414 let pendingPointer = null;
422415 let lastResize = '';
416+ let letterSpacing = '';
423417 const previousProperties = new Map();
424418
425419 function setOwnedProperty(name, value) {
@@ -492,10 +486,26 @@
492486 const mounted = rowNodes.length === 0 ? null : { width, height };
493487 const merged = coalesce(updates, mounted, configured.maxStyles);
494488 if (merged) applyValidated(merged);
495- // Rare: the merged rows need more distinct styles than one update may
496- // carry. Each update is valid on its own and the geometry chain was
497- // checked above, so apply them in order.
498- else for (const update of updates) applyValidated(update);
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+ }
499509 }
500510
501511 function applyValidated(update) {
@@ -520,8 +530,8 @@
520530 const built = update.rows.map(row => {
521531 const old = previousRows[row.row];
522532 const node = rowNodes[row.row];
523- // Text-only updates preserve geometry and styling. Space runs use
524- // merged nodes and deliberately take the full rebuilding path.
533+ // Text-only updates preserve geometry and styling. Blank cells skip
534+ // clipping, so a change to or from blanks takes the rebuilding path.
525535 const reuse = reusable && node?.parentNode === viewport && old &&
526536 old.cells.length === row.cells.length && node.children.length === row.cells.length &&
527537 row.cells.every((cell, index) => {
@@ -620,10 +630,26 @@
620630 dispatch(payload, nativeEvent);
621631 }
622632
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+
623647 function scheduleResize() {
624- if (resizeFrame || width === 0 || height === 0) return;
648+ if (resizeFrame) return;
625649 resizeFrame = requestAnimationFrame(() => {
626650 resizeFrame = 0;
651+ measureAdvance();
652+ if (width === 0 || height === 0) return;
627653 const bounds = viewport.getBoundingClientRect();
628654 const cellWidth = bounds.width / width;
629655 const cellHeight = bounds.height / height;
@@ -728,6 +754,7 @@
728754
729755 const observer = new ResizeObserver(scheduleResize);
730756 observer.observe(root);
757+ document.fonts?.addEventListener('loadingdone', scheduleResize, { signal });
731758
732759 return Object.freeze({
733760 apply,
@@ -761,6 +788,7 @@
761788 input.remove();
762789 viewport.remove();
763790 accessible.remove();
791+ probe.remove();
764792 rowNodes = [];
765793 rowText = [];
766794 rowBytes = [];
@@ -771,6 +799,7 @@
771799 width = 0;
772800 height = 0;
773801 lastResize = '';
802+ letterSpacing = '';
774803 decide = () => ({ emit: false, preventDefault: false });
775804 send = () => {};
776805 for (const [name, previous] of previousProperties) {
@@ -293,23 +293,8 @@
293 node.className = 'vev-terminal__row';293 node.className = 'vev-terminal__row';
294 node.dataset.row = String(row.row);294 node.dataset.row = String(row.row);
295 let text = '';295 let text = '';
296- for (let index = 0; index < row.cells.length; index += 1) {296+ for (const cell of row.cells) {
297- let cell = row.cells[index];
298 const style = styles[cell.style];297 const style = styles[cell.style];
299- // A run of spaces needs one grid item, not one item per column. Only
300- // merge plain spaces: arbitrary text still needs explicit cell widths.
301- if (cell.text === ' ' && cell.width === 1 && !style.underline && !style.strikethrough) {
302- let width = cell.width;
303- let spaces = cell.text;
304- while (index + 1 < row.cells.length) {
305- const next = row.cells[index + 1];
306- if (next.text !== ' ' || next.width !== 1 || next.style !== cell.style || next.column !== cell.column + width) break;
307- width += next.width;
308- spaces += next.text;
309- index += 1;
310- }
311- cell = { ...cell, width, text: spaces };
312- }
313 const child = document.createElement('span');298 const child = document.createElement('span');
314 child.className = 'vev-terminal__cell';299 child.className = 'vev-terminal__cell';
315 child.dataset.column = String(cell.column);300 child.dataset.column = String(cell.column);
@@ -402,7 +387,15 @@
402 accessible.setAttribute('aria-live', 'off');387 accessible.setAttribute('aria-live', 'off');
403 accessible.setAttribute('aria-label', `${label} output`);388 accessible.setAttribute('aria-label', `${label} output`);
404 389
405- root.append(input, viewport, accessible);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);
406 399
407 let destroyed = false;400 let destroyed = false;
408 let composing = false;401 let composing = false;
@@ -420,6 +413,7 @@
420 let pointerFrame = 0;413 let pointerFrame = 0;
421 let pendingPointer = null;414 let pendingPointer = null;
422 let lastResize = '';415 let lastResize = '';
416+ let letterSpacing = '';
423 const previousProperties = new Map();417 const previousProperties = new Map();
424 418
425 function setOwnedProperty(name, value) {419 function setOwnedProperty(name, value) {
@@ -492,10 +486,26 @@
492 const mounted = rowNodes.length === 0 ? null : { width, height };486 const mounted = rowNodes.length === 0 ? null : { width, height };
493 const merged = coalesce(updates, mounted, configured.maxStyles);487 const merged = coalesce(updates, mounted, configured.maxStyles);
494 if (merged) applyValidated(merged);488 if (merged) applyValidated(merged);
495- // Rare: the merged rows need more distinct styles than one update may489+ else {
496- // carry. Each update is valid on its own and the geometry chain was490+ // Rare: the merged rows need more distinct styles than one update may
497- // checked above, so apply them in order.491+ // carry. Each update is valid on its own and the geometry chain was
498- else for (const update of updates) applyValidated(update);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+ }
499 }509 }
500 510
501 function applyValidated(update) {511 function applyValidated(update) {
@@ -520,8 +530,8 @@
520 const built = update.rows.map(row => {530 const built = update.rows.map(row => {
521 const old = previousRows[row.row];531 const old = previousRows[row.row];
522 const node = rowNodes[row.row];532 const node = rowNodes[row.row];
523- // Text-only updates preserve geometry and styling. Space runs use533+ // Text-only updates preserve geometry and styling. Blank cells skip
524- // merged nodes and deliberately take the full rebuilding path.534+ // clipping, so a change to or from blanks takes the rebuilding path.
525 const reuse = reusable && node?.parentNode === viewport && old &&535 const reuse = reusable && node?.parentNode === viewport && old &&
526 old.cells.length === row.cells.length && node.children.length === row.cells.length &&536 old.cells.length === row.cells.length && node.children.length === row.cells.length &&
527 row.cells.every((cell, index) => {537 row.cells.every((cell, index) => {
@@ -620,10 +630,26 @@
620 dispatch(payload, nativeEvent);630 dispatch(payload, nativeEvent);
621 }631 }
622 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+
623 function scheduleResize() {647 function scheduleResize() {
624- if (resizeFrame || width === 0 || height === 0) return;648+ if (resizeFrame) return;
625 resizeFrame = requestAnimationFrame(() => {649 resizeFrame = requestAnimationFrame(() => {
626 resizeFrame = 0;650 resizeFrame = 0;
651+ measureAdvance();
652+ if (width === 0 || height === 0) return;
627 const bounds = viewport.getBoundingClientRect();653 const bounds = viewport.getBoundingClientRect();
628 const cellWidth = bounds.width / width;654 const cellWidth = bounds.width / width;
629 const cellHeight = bounds.height / height;655 const cellHeight = bounds.height / height;
@@ -728,6 +754,7 @@
728 754
729 const observer = new ResizeObserver(scheduleResize);755 const observer = new ResizeObserver(scheduleResize);
730 observer.observe(root);756 observer.observe(root);
757+ document.fonts?.addEventListener('loadingdone', scheduleResize, { signal });
731 758
732 return Object.freeze({759 return Object.freeze({
733 apply,760 apply,
@@ -761,6 +788,7 @@
761 input.remove();788 input.remove();
762 viewport.remove();789 viewport.remove();
763 accessible.remove();790 accessible.remove();
791+ probe.remove();
764 rowNodes = [];792 rowNodes = [];
765 rowText = [];793 rowText = [];
766 rowBytes = [];794 rowBytes = [];
@@ -771,6 +799,7 @@
771 width = 0;799 width = 0;
772 height = 0;800 height = 0;
773 lastResize = '';801 lastResize = '';
802+ letterSpacing = '';
774 decide = () => ({ emit: false, preventDefault: false });803 decide = () => ({ emit: false, preventDefault: false });
775 send = () => {};804 send = () => {};
776 for (const [name, previous] of previousProperties) {805 for (const [name, previous] of previousProperties) {
M html/terminal.css +12 -0
@@ -64,6 +64,18 @@
6464 color: var(--vev-cell-fg, var(--vev-fg));
6565 background: var(--vev-cell-bg, var(--vev-bg));
6666 text-decoration-color: var(--vev-cell-underline, currentColor);
67+ letter-spacing: var(--vev-letter-spacing, 0);
68+}
69+
70+.vev-terminal__measure {
71+ position: absolute;
72+ top: 0;
73+ left: 0;
74+ display: block;
75+ width: var(--vev-cell-width);
76+ white-space: pre;
77+ visibility: hidden;
78+ pointer-events: none;
6779 }
6880
6981 .vev-terminal__cell--bold { font-weight: 700; }
@@ -64,6 +64,18 @@
64 color: var(--vev-cell-fg, var(--vev-fg));64 color: var(--vev-cell-fg, var(--vev-fg));
65 background: var(--vev-cell-bg, var(--vev-bg));65 background: var(--vev-cell-bg, var(--vev-bg));
66 text-decoration-color: var(--vev-cell-underline, currentColor);66 text-decoration-color: var(--vev-cell-underline, currentColor);
67+ letter-spacing: var(--vev-letter-spacing, 0);
68+}
69+
70+.vev-terminal__measure {
71+ position: absolute;
72+ top: 0;
73+ left: 0;
74+ display: block;
75+ width: var(--vev-cell-width);
76+ white-space: pre;
77+ visibility: hidden;
78+ pointer-events: none;
67 }79 }
68 80
69 .vev-terminal__cell--bold { font-weight: 700; }81 .vev-terminal__cell--bold { font-weight: 700; }
M internal/htmlharness/terminal.spec.js +99 -2
@@ -90,8 +90,7 @@ test('plain spaces retain grid boxes without per-cell clipping', async ({ page }
9090 {column:0,width:1,text:' ',style:0},
9191 {column:1,width:1,text:'A',style:0},
9292 {column:2,width:1,text:' ',style:1},
93- {column:3,width:1,text:' ',style:0},
94- {column:4,width:1,text:' ',style:0}
93+ {column:3,width:2,text:' ',style:0}
9594 ]}],
9695 cursor: {row:0,column:0,visible:false,style:0,styleSet:false}
9796 }));
@@ -280,6 +279,34 @@ test('renders ASCII text runs on the column grid and rejects mismatched runs', a
280279 expect(result.wide).toContain('text run does not match its width');
281280 });
282281
282+test('keeps long text runs on the column grid at any font size and zoom', async ({ page }) => {
283+ await mount(page);
284+ const drift = await page.evaluate(async () => {
285+ const plain = { foreground: { kind: 0 }, background: { kind: 0 }, underlineColor: { kind: 0 } };
286+ const root = document.querySelector('#terminal');
287+ const results = [];
288+ for (const [size, zoom] of [[13, 1], [14, 1], [16, 1], [14, 1.25]]) {
289+ root.style.setProperty('--vev-font-size', `${size}px`);
290+ document.body.style.zoom = String(zoom);
291+ terminal.apply({
292+ schemaVersion: 2, width: 240, height: 1, snapshot: true, styles: [plain],
293+ rows: [{ row: 0, cells: [
294+ { column: 0, width: 238, text: 'x'.repeat(238), style: 0 },
295+ { column: 238, width: 2, text: '界', style: 0 }
296+ ] }],
297+ cursor: { row: 0, column: 0, visible: false, style: 0, styleSet: false }
298+ });
299+ await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
300+ const [run, wide] = document.querySelectorAll('.vev-terminal__cell');
301+ const range = document.createRange();
302+ range.selectNodeContents(run);
303+ results.push(Math.abs(range.getBoundingClientRect().right - wide.getBoundingClientRect().left));
304+ }
305+ return results;
306+ });
307+ for (const value of drift) expect(value).toBeLessThan(0.5);
308+});
309+
283310 test('applyAll coalesces queued updates into the same final DOM as sequential apply', async ({ page }) => {
284311 await mount(page);
285312 const result = await page.evaluate(() => {
@@ -339,6 +366,76 @@ test('applyAll coalesces queued updates into the same final DOM as sequential ap
339366 expect(result.unchanged).toBe(true);
340367 });
341368
369+test('applyAll applies in order when merged styles exceed maxStyles, atomically', async ({ page }) => {
370+ await page.setContent('<main><div id="terminal"></div></main>');
371+ await page.addStyleTag({ path: path.join(root, 'html/terminal.css') });
372+ await page.addScriptTag({ path: path.join(root, 'html/browser/terminal.js') });
373+ const result = await page.evaluate(() => {
374+ const plain = { foreground: { kind: 0 }, background: { kind: 0 }, underlineColor: { kind: 0 } };
375+ const bold = { ...plain, bold: true };
376+ const cursor = column => ({ row: 0, column, visible: true, style: 0, styleSet: false });
377+ const update = (snapshot, rows, style, column) => ({
378+ schemaVersion: 2, width: 4, height: 2, snapshot, styles: [style],
379+ rows: rows.map(([row, text]) => ({ row, cells: [{ column: 0, width: 4, text, style: 0 }] })),
380+ cursor: cursor(column)
381+ });
382+ const capture = () => ({
383+ html: document.querySelector('.vev-terminal__viewport').innerHTML,
384+ text: document.querySelector('.vev-terminal__accessible-output').textContent
385+ });
386+ const mount = maxUpdateBytes => VevTerminal.mount(document.querySelector('#terminal'), {
387+ label: 'Fallback terminal', limits: { maxStyles: 1, maxUpdateBytes }
388+ });
389+ const snapshot = update(true, [[0, 'aaaa'], [1, 'bbbb']], plain, 0);
390+ const queue = [update(false, [[0, 'A1 ']], plain, 1), update(false, [[1, 'B2 ']], bold, 2)];
391+
392+ let terminal = mount(16);
393+ terminal.apply(snapshot);
394+ for (const value of queue) terminal.apply(value);
395+ const sequential = capture();
396+ terminal.destroy();
397+
398+ terminal = mount(16);
399+ terminal.apply(snapshot);
400+ terminal.applyAll(queue);
401+ const merged = capture();
402+ const errors = [];
403+ const attempt = values => {
404+ try { terminal.applyAll(values); return ''; } catch (error) { return error.message; }
405+ };
406+ errors.push(attempt([]), attempt('nope'));
407+ const beforeOverflow = capture();
408+ terminal.destroy();
409+
410+ // A row starting with 'é' holds 5 bytes. The snapshot retains 8 of 9
411+ // bytes; each update alone retains 9, but the batch reaches 10 on its
412+ // second update, after the first would already have changed the DOM.
413+ const accented = (row, style) => ({
414+ schemaVersion: 2, width: 4, height: 2, snapshot: false, styles: [style],
415+ rows: [{ row, cells: [{ column: 0, width: 1, text: 'é', style: 0 }, { column: 1, width: 3, text: ' ', style: 0 }] }],
416+ cursor: cursor(0)
417+ });
418+ terminal = mount(9);
419+ terminal.apply(snapshot);
420+ const before = capture();
421+ const overflow = attempt([accented(0, plain), accented(1, bold)]);
422+ const unchanged = JSON.stringify(capture()) === JSON.stringify(before);
423+ terminal.destroy();
424+
425+ terminal = mount(16);
426+ const unmounted = attempt([queue[0]]);
427+ terminal.destroy();
428+ return { sequential, merged, errors, beforeOverflow, overflow, unchanged, unmounted };
429+ });
430+ expect(result.merged).toEqual(result.sequential);
431+ expect(result.errors[0]).toBe('');
432+ expect(result.errors[1]).toContain('updates must be an array');
433+ expect(result.beforeOverflow).toEqual(result.merged);
434+ expect(result.overflow).toContain('retained text exceeds');
435+ expect(result.unchanged).toBe(true);
436+ expect(result.unmounted).toContain('dimensions do not match');
437+});
438+
342439 test('rejects colors with a payload from another color kind', async ({ page }) => {
343440 await mount(page);
344441 const message = await page.evaluate(() => {
@@ -90,8 +90,7 @@ test('plain spaces retain grid boxes without per-cell clipping', async ({ page }
90 {column:0,width:1,text:' ',style:0},90 {column:0,width:1,text:' ',style:0},
91 {column:1,width:1,text:'A',style:0},91 {column:1,width:1,text:'A',style:0},
92 {column:2,width:1,text:' ',style:1},92 {column:2,width:1,text:' ',style:1},
93- {column:3,width:1,text:' ',style:0},93+ {column:3,width:2,text:' ',style:0}
94- {column:4,width:1,text:' ',style:0}
95 ]}],94 ]}],
96 cursor: {row:0,column:0,visible:false,style:0,styleSet:false}95 cursor: {row:0,column:0,visible:false,style:0,styleSet:false}
97 }));96 }));
@@ -280,6 +279,34 @@ test('renders ASCII text runs on the column grid and rejects mismatched runs', a
280 expect(result.wide).toContain('text run does not match its width');279 expect(result.wide).toContain('text run does not match its width');
281 });280 });
282 281
282+test('keeps long text runs on the column grid at any font size and zoom', async ({ page }) => {
283+ await mount(page);
284+ const drift = await page.evaluate(async () => {
285+ const plain = { foreground: { kind: 0 }, background: { kind: 0 }, underlineColor: { kind: 0 } };
286+ const root = document.querySelector('#terminal');
287+ const results = [];
288+ for (const [size, zoom] of [[13, 1], [14, 1], [16, 1], [14, 1.25]]) {
289+ root.style.setProperty('--vev-font-size', `${size}px`);
290+ document.body.style.zoom = String(zoom);
291+ terminal.apply({
292+ schemaVersion: 2, width: 240, height: 1, snapshot: true, styles: [plain],
293+ rows: [{ row: 0, cells: [
294+ { column: 0, width: 238, text: 'x'.repeat(238), style: 0 },
295+ { column: 238, width: 2, text: '界', style: 0 }
296+ ] }],
297+ cursor: { row: 0, column: 0, visible: false, style: 0, styleSet: false }
298+ });
299+ await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
300+ const [run, wide] = document.querySelectorAll('.vev-terminal__cell');
301+ const range = document.createRange();
302+ range.selectNodeContents(run);
303+ results.push(Math.abs(range.getBoundingClientRect().right - wide.getBoundingClientRect().left));
304+ }
305+ return results;
306+ });
307+ for (const value of drift) expect(value).toBeLessThan(0.5);
308+});
309+
283 test('applyAll coalesces queued updates into the same final DOM as sequential apply', async ({ page }) => {310 test('applyAll coalesces queued updates into the same final DOM as sequential apply', async ({ page }) => {
284 await mount(page);311 await mount(page);
285 const result = await page.evaluate(() => {312 const result = await page.evaluate(() => {
@@ -339,6 +366,76 @@ test('applyAll coalesces queued updates into the same final DOM as sequential ap
339 expect(result.unchanged).toBe(true);366 expect(result.unchanged).toBe(true);
340 });367 });
341 368
369+test('applyAll applies in order when merged styles exceed maxStyles, atomically', async ({ page }) => {
370+ await page.setContent('<main><div id="terminal"></div></main>');
371+ await page.addStyleTag({ path: path.join(root, 'html/terminal.css') });
372+ await page.addScriptTag({ path: path.join(root, 'html/browser/terminal.js') });
373+ const result = await page.evaluate(() => {
374+ const plain = { foreground: { kind: 0 }, background: { kind: 0 }, underlineColor: { kind: 0 } };
375+ const bold = { ...plain, bold: true };
376+ const cursor = column => ({ row: 0, column, visible: true, style: 0, styleSet: false });
377+ const update = (snapshot, rows, style, column) => ({
378+ schemaVersion: 2, width: 4, height: 2, snapshot, styles: [style],
379+ rows: rows.map(([row, text]) => ({ row, cells: [{ column: 0, width: 4, text, style: 0 }] })),
380+ cursor: cursor(column)
381+ });
382+ const capture = () => ({
383+ html: document.querySelector('.vev-terminal__viewport').innerHTML,
384+ text: document.querySelector('.vev-terminal__accessible-output').textContent
385+ });
386+ const mount = maxUpdateBytes => VevTerminal.mount(document.querySelector('#terminal'), {
387+ label: 'Fallback terminal', limits: { maxStyles: 1, maxUpdateBytes }
388+ });
389+ const snapshot = update(true, [[0, 'aaaa'], [1, 'bbbb']], plain, 0);
390+ const queue = [update(false, [[0, 'A1 ']], plain, 1), update(false, [[1, 'B2 ']], bold, 2)];
391+
392+ let terminal = mount(16);
393+ terminal.apply(snapshot);
394+ for (const value of queue) terminal.apply(value);
395+ const sequential = capture();
396+ terminal.destroy();
397+
398+ terminal = mount(16);
399+ terminal.apply(snapshot);
400+ terminal.applyAll(queue);
401+ const merged = capture();
402+ const errors = [];
403+ const attempt = values => {
404+ try { terminal.applyAll(values); return ''; } catch (error) { return error.message; }
405+ };
406+ errors.push(attempt([]), attempt('nope'));
407+ const beforeOverflow = capture();
408+ terminal.destroy();
409+
410+ // A row starting with 'é' holds 5 bytes. The snapshot retains 8 of 9
411+ // bytes; each update alone retains 9, but the batch reaches 10 on its
412+ // second update, after the first would already have changed the DOM.
413+ const accented = (row, style) => ({
414+ schemaVersion: 2, width: 4, height: 2, snapshot: false, styles: [style],
415+ rows: [{ row, cells: [{ column: 0, width: 1, text: 'é', style: 0 }, { column: 1, width: 3, text: ' ', style: 0 }] }],
416+ cursor: cursor(0)
417+ });
418+ terminal = mount(9);
419+ terminal.apply(snapshot);
420+ const before = capture();
421+ const overflow = attempt([accented(0, plain), accented(1, bold)]);
422+ const unchanged = JSON.stringify(capture()) === JSON.stringify(before);
423+ terminal.destroy();
424+
425+ terminal = mount(16);
426+ const unmounted = attempt([queue[0]]);
427+ terminal.destroy();
428+ return { sequential, merged, errors, beforeOverflow, overflow, unchanged, unmounted };
429+ });
430+ expect(result.merged).toEqual(result.sequential);
431+ expect(result.errors[0]).toBe('');
432+ expect(result.errors[1]).toContain('updates must be an array');
433+ expect(result.beforeOverflow).toEqual(result.merged);
434+ expect(result.overflow).toContain('retained text exceeds');
435+ expect(result.unchanged).toBe(true);
436+ expect(result.unmounted).toContain('dimensions do not match');
437+});
438+
342 test('rejects colors with a payload from another color kind', async ({ page }) => {439 test('rejects colors with a payload from another color kind', async ({ page }) => {
343 await mount(page);440 await mount(page);
344 const message = await page.evaluate(() => {441 const message = await page.evaluate(() => {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 13

↑↓ moveenter openesc close