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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +164 -26
M html/browser/terminal.js +53 -24
| @@ -293,23 +293,8 @@ | |||
| 293 | 293 | node.className = 'vev-terminal__row'; | |
| 294 | 294 | node.dataset.row = String(row.row); | |
| 295 | 295 | 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) { | |
| 298 | 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 | 298 | const child = document.createElement('span'); | |
| 314 | 299 | child.className = 'vev-terminal__cell'; | |
| 315 | 300 | child.dataset.column = String(cell.column); | |
| @@ -402,7 +387,15 @@ | |||
| 402 | 387 | accessible.setAttribute('aria-live', 'off'); | |
| 403 | 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 | 400 | let destroyed = false; | |
| 408 | 401 | let composing = false; | |
| @@ -420,6 +413,7 @@ | |||
| 420 | 413 | let pointerFrame = 0; | |
| 421 | 414 | let pendingPointer = null; | |
| 422 | 415 | let lastResize = ''; | |
| 416 | + | let letterSpacing = ''; | |
| 423 | 417 | const previousProperties = new Map(); | |
| 424 | 418 | ||
| 425 | 419 | function setOwnedProperty(name, value) { | |
| @@ -492,10 +486,26 @@ | |||
| 492 | 486 | const mounted = rowNodes.length === 0 ? null : { width, height }; | |
| 493 | 487 | const merged = coalesce(updates, mounted, configured.maxStyles); | |
| 494 | 488 | 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 | + | } | |
| 499 | 509 | } | |
| 500 | 510 | ||
| 501 | 511 | function applyValidated(update) { | |
| @@ -520,8 +530,8 @@ | |||
| 520 | 530 | const built = update.rows.map(row => { | |
| 521 | 531 | const old = previousRows[row.row]; | |
| 522 | 532 | 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. | |
| 525 | 535 | const reuse = reusable && node?.parentNode === viewport && old && | |
| 526 | 536 | old.cells.length === row.cells.length && node.children.length === row.cells.length && | |
| 527 | 537 | row.cells.every((cell, index) => { | |
| @@ -620,10 +630,26 @@ | |||
| 620 | 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 | 647 | function scheduleResize() { | |
| 624 | - | if (resizeFrame || width === 0 || height === 0) return; | |
| 648 | + | if (resizeFrame) return; | |
| 625 | 649 | resizeFrame = requestAnimationFrame(() => { | |
| 626 | 650 | resizeFrame = 0; | |
| 651 | + | measureAdvance(); | |
| 652 | + | if (width === 0 || height === 0) return; | |
| 627 | 653 | const bounds = viewport.getBoundingClientRect(); | |
| 628 | 654 | const cellWidth = bounds.width / width; | |
| 629 | 655 | const cellHeight = bounds.height / height; | |
| @@ -728,6 +754,7 @@ | |||
| 728 | 754 | ||
| 729 | 755 | const observer = new ResizeObserver(scheduleResize); | |
| 730 | 756 | observer.observe(root); | |
| 757 | + | document.fonts?.addEventListener('loadingdone', scheduleResize, { signal }); | |
| 731 | 758 | ||
| 732 | 759 | return Object.freeze({ | |
| 733 | 760 | apply, | |
| @@ -761,6 +788,7 @@ | |||
| 761 | 788 | input.remove(); | |
| 762 | 789 | viewport.remove(); | |
| 763 | 790 | accessible.remove(); | |
| 791 | + | probe.remove(); | |
| 764 | 792 | rowNodes = []; | |
| 765 | 793 | rowText = []; | |
| 766 | 794 | rowBytes = []; | |
| @@ -771,6 +799,7 @@ | |||
| 771 | 799 | width = 0; | |
| 772 | 800 | height = 0; | |
| 773 | 801 | lastResize = ''; | |
| 802 | + | letterSpacing = ''; | |
| 774 | 803 | decide = () => ({ emit: false, preventDefault: false }); | |
| 775 | 804 | send = () => {}; | |
| 776 | 805 | 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 may | 489 | + | else { |
| 496 | - | // carry. Each update is valid on its own and the geometry chain was | 490 | + | // 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 use | 533 | + | // 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 @@ | |||
| 64 | 64 | color: var(--vev-cell-fg, var(--vev-fg)); | |
| 65 | 65 | background: var(--vev-cell-bg, var(--vev-bg)); | |
| 66 | 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 | 81 | .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 } | |||
| 90 | 90 | {column:0,width:1,text:' ',style:0}, | |
| 91 | 91 | {column:1,width:1,text:'A',style:0}, | |
| 92 | 92 | {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} | |
| 95 | 94 | ]}], | |
| 96 | 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 | 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 | 310 | test('applyAll coalesces queued updates into the same final DOM as sequential apply', async ({ page }) => { | |
| 284 | 311 | await mount(page); | |
| 285 | 312 | const result = await page.evaluate(() => { | |
| @@ -339,6 +366,76 @@ test('applyAll coalesces queued updates into the same final DOM as sequential ap | |||
| 339 | 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 | 439 | test('rejects colors with a payload from another color kind', async ({ page }) => { | |
| 343 | 440 | await mount(page); | |
| 344 | 441 | 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(() => { | ||