` with per-character grid positioning.
+ * Create a styled line `
` with one `` per character on the cell grid.
*
- * Each character gets its own `` positioned by visual column offset.
- * CJK wide characters occupy 2 cell widths. Characters at or after
- * `compositionFrom` (a code-point index into `text`, may be negative) are IME
- * composition text: underlined, like xterm's own composition view, and marked
- * `data-zerolag-composition` + `aria-hidden` since they are provisional.
+ * The spans sit side by side in a flex row, each exactly as wide as its cells
+ * (CJK wide characters occupy 2), with every edge snapped by cellEdge() so the
+ * widths add up to the grid without drifting. They are deliberately not
+ * absolutely positioned: a positioned element gets its own paint layer, and
+ * with one per character a long prompt left the browser re-layerizing and
+ * repainting thousands of them on every keystroke. Everything the spans share
+ * (font, colour, line height, centring, ligatures off) is set once on the
+ * line and inherited.
+ *
+ * Characters at or after `compositionFrom` (a code-point index into `text`)
+ * are IME composition text: underlined, like xterm's own composition view, and
+ * marked `data-zerolag-composition` + `aria-hidden` since they are provisional.
*/
function makeLine(
text: string,
@@ -190,7 +284,9 @@ function makeLine(
compositionFrom?: number
): HTMLDivElement {
const el = document.createElement('div');
- el.style.cssText = 'position:absolute;pointer-events:none';
+ // No ligatures: the canvas renders each glyph independently.
+ el.style.cssText =
+ "position:absolute;display:flex;pointer-events:none;text-align:center;font-feature-settings:'liga' 0,'calt' 0";
el.style.backgroundColor = font.backgroundColor;
el.style.left = leftPx + 'px';
el.style.top = topPx + 'px';
@@ -200,27 +296,24 @@ function makeLine(
// The extra 1px lands in the next row's charTop gap (empty area before
// text rendering starts), so no canvas content is obscured.
el.style.height = cellH + 1 + 'px';
+ el.style.lineHeight = cellH + 'px';
+ el.style.fontFamily = font.fontFamily;
+ el.style.fontSize = font.fontSize;
+ el.style.fontWeight = font.fontWeight;
+ el.style.color = font.color;
+ if (font.letterSpacing) el.style.letterSpacing = font.letterSpacing;
- // CJK wide chars occupy 2 cells — position by visual column offset
- let colOffset = 0;
+ // Each span runs from the previous snapped edge to its own, so the spans
+ // land on the grid by visual column (CJK wide chars span 2 cells).
+ const spanHeight = ';height:' + cellH + 'px';
+ let col = 0;
+ let edge = 0;
let index = 0;
for (const ch of text) {
- const cw = charCellWidth(terminal, ch);
+ col += charCellWidth(terminal, ch);
+ const nextEdge = cellEdge(col, cellW);
const span = document.createElement('span');
- // No ligatures — canvas renders each glyph independently.
- span.style.cssText =
- 'position:absolute;display:inline-block;text-align:center;pointer-events:none;' +
- "font-feature-settings:'liga' 0,'calt' 0";
- span.style.left = colOffset * cellW + 'px';
- span.style.top = '0px';
- span.style.width = cw * cellW + 'px';
- span.style.height = cellH + 'px';
- span.style.lineHeight = cellH + 'px';
- span.style.fontFamily = font.fontFamily;
- span.style.fontSize = font.fontSize;
- span.style.fontWeight = font.fontWeight;
- span.style.color = font.color;
- if (font.letterSpacing) span.style.letterSpacing = font.letterSpacing;
+ span.style.cssText = 'flex:none;width:' + (nextEdge - edge) + 'px' + spanHeight;
if (compositionFrom !== undefined && index >= compositionFrom) {
span.style.textDecoration = 'underline';
span.setAttribute('data-zerolag-composition', '');
@@ -228,7 +321,7 @@ function makeLine(
}
span.textContent = ch;
el.appendChild(span);
- colOffset += cw;
+ edge = nextEdge;
index++;
}
diff --git a/packages/xterm-zerolag-input/test/cjk-testplan.test.ts b/packages/xterm-zerolag-input/test/cjk-testplan.test.ts
index 57b815e9..d13fa789 100644
--- a/packages/xterm-zerolag-input/test/cjk-testplan.test.ts
+++ b/packages/xterm-zerolag-input/test/cjk-testplan.test.ts
@@ -14,7 +14,7 @@
import { describe, it, expect, afterEach } from 'vitest';
import { renderOverlay, charCellWidth, stringCellWidth } from '../src/overlay-renderer.js';
import type { RenderParams, FontStyle } from '../src/types.js';
-import { createMockTerminal } from './helpers.js';
+import { createMockTerminal, lineSpans } from './helpers.js';
import { ZerolagInputAddon } from '../src/zerolag-input-addon.js';
// ─── Shared fixtures ─────────────────────────────────────────────────
@@ -47,16 +47,7 @@ function makeParams(overrides: Partial = {}): RenderParams {
/** Extract span data from a rendered line div */
function getSpans(lineDiv: HTMLDivElement) {
- const spans: { text: string; left: number; width: number }[] = [];
- for (let i = 0; i < lineDiv.children.length; i++) {
- const span = lineDiv.children[i] as HTMLSpanElement;
- spans.push({
- text: span.textContent || '',
- left: parseFloat(span.style.left),
- width: parseFloat(span.style.width),
- });
- }
- return spans;
+ return lineSpans(lineDiv).map(({ text, left, width }) => ({ text, left, width }));
}
// ─── Addon setup helpers ─────────────────────────────────────────────
@@ -199,18 +190,27 @@ describe('Test 2: CJK double-width spacing', () => {
it('CJK span widths are exactly 2 * cellW pixels', () => {
const container = document.createElement('div');
- renderOverlay(container, makeParams({ lines: ['你'], cellW: 8.4 }));
+ renderOverlay(container, makeParams({ lines: ['你'], cellW: 8.5 }));
const lineDiv = container.children[0] as HTMLDivElement;
const span = lineDiv.children[0] as HTMLSpanElement;
- expect(span.style.width).toBe('16.8px'); // 2 * 8.4
+ expect(span.style.width).toBe('17px'); // 2 * 8.5
});
it('ASCII span widths remain 1 * cellW pixels', () => {
const container = document.createElement('div');
- renderOverlay(container, makeParams({ lines: ['a'], cellW: 8.4 }));
+ renderOverlay(container, makeParams({ lines: ['a'], cellW: 8.5 }));
const lineDiv = container.children[0] as HTMLDivElement;
const span = lineDiv.children[0] as HTMLSpanElement;
- expect(span.style.width).toBe('8.4px'); // 1 * 8.4
+ expect(span.style.width).toBe('8.5px'); // 1 * 8.5
+ });
+
+ it('at a fractional cell width, CJK and ASCII spans still cover 2 and 1 cells to within 1/8 px', () => {
+ const container = document.createElement('div');
+ renderOverlay(container, makeParams({ lines: ['你a'], cellW: 8.4 }));
+ const [wide, narrow] = getSpans(container.children[0] as HTMLDivElement);
+ expect(Math.abs(wide.width - 2 * 8.4)).toBeLessThanOrEqual(0.125);
+ expect(Math.abs(narrow.left - 2 * 8.4)).toBeLessThanOrEqual(0.125);
+ expect(Math.abs(narrow.left + narrow.width - 3 * 8.4)).toBeLessThanOrEqual(0.125);
});
it('terminal unicode addon is preferred over fallback when available', () => {
@@ -644,8 +644,9 @@ describe('Test 6: ASCII input — no regression', () => {
const spans = getSpans(lineDiv);
expect(spans[0].left).toBe(0);
- expect(spans[1].left).toBeCloseTo(8.4, 5);
- expect(spans[2].left).toBeCloseTo(16.8, 5);
+ // Edges are snapped to a quarter pixel, so within 1/8 px of the grid.
+ expect(Math.abs(spans[1].left - 8.4)).toBeLessThanOrEqual(0.125);
+ expect(Math.abs(spans[2].left - 16.8)).toBeLessThanOrEqual(0.125);
});
it('ASCII cursor positions at correct column', () => {
diff --git a/packages/xterm-zerolag-input/test/composition.test.ts b/packages/xterm-zerolag-input/test/composition.test.ts
index ceacfc46..9a06b708 100644
--- a/packages/xterm-zerolag-input/test/composition.test.ts
+++ b/packages/xterm-zerolag-input/test/composition.test.ts
@@ -1,5 +1,5 @@
import { describe, it, expect, afterEach } from 'vitest';
-import { createMockTerminal } from './helpers.js';
+import { createMockTerminal, lineSpans } from './helpers.js';
import { ZerolagInputAddon } from '../src/zerolag-input-addon.js';
// setComposition(): IME text still being composed, drawn as an underlined tail
@@ -68,8 +68,9 @@ describe('setComposition', () => {
expect(span.style.textDecoration).toBe('underline');
}
// Grid positions continue straight on from the pending text.
- expect(spans[3].style.left).toBe(3 * CELL_W + 'px');
- expect(spans[4].style.left).toBe(4 * CELL_W + 'px');
+ const cells = lineSpans(line);
+ expect(cells[3].left).toBe(3 * CELL_W);
+ expect(cells[4].left).toBe(4 * CELL_W);
expect(overlay.style.display).toBe('');
});
@@ -79,9 +80,10 @@ describe('setComposition', () => {
addon.setComposition('天気');
const spans = Array.from(lineDivs(overlay)[0].children) as HTMLSpanElement[];
expect(spans.map((s) => s.textContent).join('')).toBe('今日は天気');
- expect(spans[3].style.left).toBe(6 * CELL_W + 'px');
- expect(spans[3].style.width).toBe(2 * CELL_W + 'px');
- expect(spans[4].style.left).toBe(8 * CELL_W + 'px');
+ const cells = lineSpans(lineDivs(overlay)[0]);
+ expect(cells[3].left).toBe(6 * CELL_W);
+ expect(cells[3].width).toBe(2 * CELL_W);
+ expect(cells[4].left).toBe(8 * CELL_W);
});
it('does not touch pendingText, hasPending, flushed state or the state snapshot', () => {
@@ -115,7 +117,8 @@ describe('setComposition', () => {
expect(compositionText(overlay)).toBe('WXYZ');
const second = Array.from(lines[1].children) as HTMLSpanElement[];
expect(second.every((s) => s.hasAttribute('data-zerolag-composition'))).toBe(true);
- expect(second[0].style.left).toBe('0px');
+ expect(lineSpans(lines[1])[0].left).toBe(0);
+ expect(lines[1].style.left).toBe('0px');
});
it('keeps the composition styling when only the tail of a tall prompt fits', () => {
diff --git a/packages/xterm-zerolag-input/test/helpers.ts b/packages/xterm-zerolag-input/test/helpers.ts
index d2229576..878dffb9 100644
--- a/packages/xterm-zerolag-input/test/helpers.ts
+++ b/packages/xterm-zerolag-input/test/helpers.ts
@@ -204,3 +204,21 @@ export function createMockTerminal(opts: MockTerminalOptions = {}) {
},
};
}
+
+/**
+ * The glyph spans of a rendered overlay line, with each one's left edge and
+ * width in px. The spans sit side by side in a flex row, so a span's left
+ * edge is the sum of the widths before it (what the browser lays out), not a
+ * style property of its own; jsdom does no layout, so this adds them up.
+ */
+export function lineSpans(lineDiv: Element): { text: string; left: number; width: number; el: HTMLSpanElement }[] {
+ const spans: { text: string; left: number; width: number; el: HTMLSpanElement }[] = [];
+ let left = 0;
+ for (const child of Array.from(lineDiv.children)) {
+ const el = child as HTMLSpanElement;
+ const width = parseFloat(el.style.width);
+ spans.push({ text: el.textContent || '', left, width, el });
+ left += width;
+ }
+ return spans;
+}
diff --git a/packages/xterm-zerolag-input/test/overlay-renderer.test.ts b/packages/xterm-zerolag-input/test/overlay-renderer.test.ts
index a95846a8..a83d0901 100644
--- a/packages/xterm-zerolag-input/test/overlay-renderer.test.ts
+++ b/packages/xterm-zerolag-input/test/overlay-renderer.test.ts
@@ -1,6 +1,7 @@
import { describe, it, expect } from 'vitest';
import { renderOverlay, charCellWidth, stringCellWidth } from '../src/overlay-renderer.js';
import type { RenderParams, FontStyle } from '../src/types.js';
+import { lineSpans } from './helpers.js';
const FONT: FontStyle = {
fontFamily: 'monospace',
@@ -38,7 +39,7 @@ describe('renderOverlay', () => {
it('creates per-character spans in a line div', () => {
const container = document.createElement('div');
- renderOverlay(container, makeParams({ lines: ['abc'] }));
+ renderOverlay(container, makeParams({ lines: ['abc'], cellW: 10 }));
// Line div + cursor span
expect(container.children.length).toBe(2);
@@ -46,17 +47,43 @@ describe('renderOverlay', () => {
const lineDiv = container.children[0] as HTMLDivElement;
expect(lineDiv.children.length).toBe(3); // a, b, c
- const spanA = lineDiv.children[0] as HTMLSpanElement;
- expect(spanA.textContent).toBe('a');
- expect(spanA.style.left).toBe('0px');
+ const [a, b, c] = lineSpans(lineDiv);
+ expect([a.text, a.left, a.width]).toEqual(['a', 0, 10]);
+ expect([b.text, b.left, b.width]).toEqual(['b', 10, 10]);
+ expect([c.text, c.left, c.width]).toEqual(['c', 20, 10]);
+ });
- const spanB = lineDiv.children[1] as HTMLSpanElement;
- expect(spanB.textContent).toBe('b');
- expect(spanB.style.left).toBe('8.4px');
+ it('lays the spans out in a flex row, not absolutely positioned (one paint layer per line)', () => {
+ const container = document.createElement('div');
+ renderOverlay(container, makeParams({ lines: ['ab'] }));
+ const lineDiv = container.children[0] as HTMLDivElement;
+ expect(lineDiv.style.position).toBe('absolute');
+ expect(lineDiv.style.display).toBe('flex');
+ for (const { el } of lineSpans(lineDiv)) {
+ expect(el.style.position).toBe('');
+ expect(el.style.left).toBe('');
+ // flex: none, so a span is never stretched or squeezed off its cells.
+ expect(el.style.flexGrow).toBe('0');
+ expect(el.style.flexShrink).toBe('0');
+ }
+ });
- const spanC = lineDiv.children[2] as HTMLSpanElement;
- expect(spanC.textContent).toBe('c');
- expect(spanC.style.left).toBe('16.8px');
+ it('snaps cell edges to a quarter pixel so a fractional cell width never drifts', () => {
+ const container = document.createElement('div');
+ const cellW = 8.4;
+ renderOverlay(container, makeParams({ lines: ['x'.repeat(78) + '你'], startCol: 0, totalCols: 80, cellW }));
+ const spans = lineSpans(container.children[0]);
+ let col = 0;
+ for (const span of spans) {
+ // Every width is a whole number of quarter pixels, which Blink, WebKit
+ // and Gecko all store exactly, so the browser adds them up exactly too.
+ expect(Number.isInteger(span.width * 4)).toBe(true);
+ expect(Math.abs(span.left - col * cellW)).toBeLessThanOrEqual(0.125);
+ col += span.text === '你' ? 2 : 1;
+ }
+ // The far edge is still on the grid after 80 columns.
+ const last = spans[spans.length - 1];
+ expect(Math.abs(last.left + last.width - 80 * cellW)).toBeLessThanOrEqual(0.125);
});
it('sets span width to cellW', () => {
@@ -67,7 +94,7 @@ describe('renderOverlay', () => {
expect(span.style.width).toBe('9.5px');
});
- it('applies font styles to spans', () => {
+ it('applies font styles once on the line, for its spans to inherit', () => {
const font: FontStyle = {
fontFamily: 'Fira Code',
fontSize: '16px',
@@ -83,12 +110,16 @@ describe('renderOverlay', () => {
// jsdom normalizes hex to rgb()
expect(lineDiv.style.backgroundColor).toBe('rgb(0, 0, 0)');
+ expect(lineDiv.style.fontFamily).toBe('Fira Code');
+ expect(lineDiv.style.fontSize).toBe('16px');
+ expect(lineDiv.style.fontWeight).toBe('bold');
+ expect(lineDiv.style.color).toBe('rgb(255, 0, 0)');
+ expect(lineDiv.style.letterSpacing).toBe('0.5px');
+
+ // Inherited, so a span sets none of them itself.
const span = lineDiv.children[0] as HTMLSpanElement;
- expect(span.style.fontFamily).toBe('Fira Code');
- expect(span.style.fontSize).toBe('16px');
- expect(span.style.fontWeight).toBe('bold');
- expect(span.style.color).toBe('rgb(255, 0, 0)');
- expect(span.style.letterSpacing).toBe('0.5px');
+ expect(span.style.fontFamily).toBe('');
+ expect(span.style.color).toBe('');
});
it('offsets first line by startCol', () => {
@@ -217,47 +248,49 @@ describe('renderOverlay', () => {
const lineDiv = container.children[0] as HTMLDivElement;
const span = lineDiv.children[0] as HTMLSpanElement;
expect(span.style.height).toBe('19px');
- expect(span.style.lineHeight).toBe('19px');
+ // Set on the line and inherited by the span.
+ expect(lineDiv.style.lineHeight).toBe('19px');
});
- it('span top is 0px (no vertical offset / no transform)', () => {
+ it('span has no vertical offset and no transform', () => {
const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: ['a'], cellH: 19 }));
const lineDiv = container.children[0] as HTMLDivElement;
const span = lineDiv.children[0] as HTMLSpanElement;
- expect(span.style.top).toBe('0px');
+ // A flex item starts at the top of its line; nothing shifts it down.
+ expect(span.style.top).toBe('');
+ expect(span.style.marginTop).toBe('');
// No translateY transform — sub-pixel overhang causes artifacts
expect(span.style.transform).toBe('');
});
// ─── Font rendering tests ─────────────────────────────────────
- it('span disables ligatures via font-feature-settings', () => {
+ // These are inherited properties, set once on the line for every span.
+
+ it('line disables ligatures via font-feature-settings', () => {
const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: ['fi'] }));
const lineDiv = container.children[0] as HTMLDivElement;
- const span = lineDiv.children[0] as HTMLSpanElement;
// Check cssText includes the ligature-disabling settings
// jsdom may normalize whitespace; check that both liga and calt are disabled
- expect(span.style.cssText).toContain('font-feature-settings:');
- expect(span.style.cssText).toContain("'liga' 0");
- expect(span.style.cssText).toContain("'calt' 0");
+ expect(lineDiv.style.cssText).toContain('font-feature-settings:');
+ expect(lineDiv.style.cssText).toContain("'liga' 0");
+ expect(lineDiv.style.cssText).toContain("'calt' 0");
});
- it('span has text-align: center for glyph centering', () => {
+ it('line has text-align: center for glyph centering', () => {
const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: ['m'] }));
const lineDiv = container.children[0] as HTMLDivElement;
- const span = lineDiv.children[0] as HTMLSpanElement;
- expect(span.style.textAlign).toBe('center');
+ expect(lineDiv.style.textAlign).toBe('center');
});
- it('span has pointer-events: none', () => {
+ it('line has pointer-events: none', () => {
const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: ['a'] }));
const lineDiv = container.children[0] as HTMLDivElement;
- const span = lineDiv.children[0] as HTMLSpanElement;
- expect(span.style.pointerEvents).toBe('none');
+ expect(lineDiv.style.pointerEvents).toBe('none');
});
// ─── Multi-line cursor positioning ────────────────────────────
@@ -323,20 +356,18 @@ describe('renderOverlay', () => {
const lineDiv = container.children[0] as HTMLDivElement;
expect(lineDiv.children.length).toBe(3);
- const spanA = lineDiv.children[0] as HTMLSpanElement;
- expect(spanA.textContent).toBe('a');
- expect(spanA.style.left).toBe('0px');
- expect(spanA.style.width).toBe('10px'); // 1 cell
+ const [spanA, spanCJK, spanB] = lineSpans(lineDiv);
+ expect(spanA.text).toBe('a');
+ expect(spanA.left).toBe(0);
+ expect(spanA.width).toBe(10); // 1 cell
- const spanCJK = lineDiv.children[1] as HTMLSpanElement;
- expect(spanCJK.textContent).toBe('你');
- expect(spanCJK.style.left).toBe('10px'); // col 1
- expect(spanCJK.style.width).toBe('20px'); // 2 cells
+ expect(spanCJK.text).toBe('你');
+ expect(spanCJK.left).toBe(10); // col 1
+ expect(spanCJK.width).toBe(20); // 2 cells
- const spanB = lineDiv.children[2] as HTMLSpanElement;
- expect(spanB.textContent).toBe('b');
- expect(spanB.style.left).toBe('30px'); // col 3
- expect(spanB.style.width).toBe('10px'); // 1 cell
+ expect(spanB.text).toBe('b');
+ expect(spanB.left).toBe(30); // col 3
+ expect(spanB.width).toBe(10); // 1 cell
});
it('cursor position accounts for CJK width', () => {
@@ -360,13 +391,11 @@ describe('renderOverlay', () => {
renderOverlay(container, makeParams({ lines: ['hi你'], cellW: 8 }));
const lineDiv = container.children[0] as HTMLDivElement;
// h(col 0), i(col 1), 你(col 2, width 2)
- const spanH = lineDiv.children[0] as HTMLSpanElement;
- expect(spanH.style.left).toBe('0px');
- const spanI = lineDiv.children[1] as HTMLSpanElement;
- expect(spanI.style.left).toBe('8px');
- const spanCJK = lineDiv.children[2] as HTMLSpanElement;
- expect(spanCJK.style.left).toBe('16px');
- expect(spanCJK.style.width).toBe('16px');
+ const [spanH, spanI, spanCJK] = lineSpans(lineDiv);
+ expect(spanH.left).toBe(0);
+ expect(spanI.left).toBe(8);
+ expect(spanCJK.left).toBe(16);
+ expect(spanCJK.width).toBe(16);
});
});
@@ -503,3 +532,147 @@ describe('renderOverlay — staying on screen (totalRows)', () => {
expect(container.style.top).toBe(6 * 17 + 'px');
});
});
+
+describe('renderOverlay: rebuilding only the lines that changed', () => {
+ const lineEls = (container: HTMLDivElement) =>
+ Array.from(container.children).filter((el) => el.tagName === 'DIV') as HTMLDivElement[];
+ const text = (line: HTMLDivElement) => Array.from(line.children, (s) => s.textContent).join('');
+
+ it('appending to the last line keeps every earlier line element', () => {
+ const container = document.createElement('div');
+ renderOverlay(container, makeParams({ lines: ['first', 'second', 'th'] }));
+ const [first, second, third] = lineEls(container);
+
+ renderOverlay(container, makeParams({ lines: ['first', 'second', 'thi'] }));
+ const after = lineEls(container);
+ expect(after[0]).toBe(first);
+ expect(after[1]).toBe(second);
+ expect(after[2]).not.toBe(third);
+ expect(after.map(text)).toEqual(['first', 'second', 'thi']);
+ });
+
+ it('keeps the cursor as the last child and moves it with the text', () => {
+ const container = document.createElement('div');
+ const params = { startCol: 0, cellW: 10 };
+ renderOverlay(container, makeParams({ ...params, lines: ['ab'] }));
+ renderOverlay(container, makeParams({ ...params, lines: ['ab', 'c'] }));
+ const children = Array.from(container.children) as HTMLElement[];
+ expect(children.length).toBe(3);
+ const cursor = children[2];
+ expect(cursor.tagName).toBe('SPAN');
+ expect(cursor.style.left).toBe('10px');
+ expect(cursor.style.top).toBe(17 + 'px');
+ expect(container.querySelectorAll(':scope > span').length).toBe(1);
+ });
+
+ it('a wrapped line coming back (backspace) removes the extra line', () => {
+ const container = document.createElement('div');
+ renderOverlay(container, makeParams({ lines: ['abc', 'd'] }));
+ const [first] = lineEls(container);
+ renderOverlay(container, makeParams({ lines: ['abc'] }));
+ expect(lineEls(container)).toEqual([first]);
+ expect(container.children.length).toBe(2); // line + cursor
+ });
+
+ it('removes the cursor when the next render does not show one', () => {
+ const container = document.createElement('div');
+ renderOverlay(container, makeParams({ lines: ['ab'] }));
+ renderOverlay(container, makeParams({ lines: ['ab'], showCursor: false }));
+ expect(container.children.length).toBe(1);
+ expect(container.children[0].tagName).toBe('DIV');
+ });
+
+ it('a font, colour or cell size change rebuilds every line', () => {
+ const container = document.createElement('div');
+ renderOverlay(container, makeParams({ lines: ['one', 'two'] }));
+ const before = lineEls(container);
+
+ renderOverlay(container, makeParams({ lines: ['one', 'two'], font: { ...FONT, color: '#ff0000' } }));
+ const recoloured = lineEls(container);
+ expect(recoloured[0]).not.toBe(before[0]);
+ expect(recoloured[0].style.color).toBe('rgb(255, 0, 0)');
+
+ renderOverlay(container, makeParams({ lines: ['one', 'two'], font: { ...FONT, color: '#ff0000' }, cellW: 12 }));
+ const resized = lineEls(container);
+ expect(resized[1]).not.toBe(recoloured[1]);
+ expect(lineSpans(resized[1])[1].left).toBe(12);
+ });
+
+ it('a Unicode version switch rebuilds lines whose text did not change', () => {
+ const container = document.createElement('div');
+ const unicode = { activeVersion: '6', getStringCellWidth: (s: string) => (s === 'W' ? 1 : 1) };
+ const terminal = { unicode } as any;
+ renderOverlay(container, makeParams({ lines: ['aW'], cellW: 10, terminal }));
+ const before = lineEls(container)[0];
+
+ unicode.activeVersion = '11';
+ unicode.getStringCellWidth = (s: string) => (s === 'W' ? 2 : 1);
+ renderOverlay(container, makeParams({ lines: ['aW'], cellW: 10, terminal }));
+ const after = lineEls(container)[0];
+ expect(after).not.toBe(before);
+ expect(lineSpans(after)[1].width).toBe(20);
+ });
+
+ it('starts over when something else emptied the container', () => {
+ const container = document.createElement('div');
+ renderOverlay(container, makeParams({ lines: ['one', 'two'] }));
+ container.innerHTML = '';
+ renderOverlay(container, makeParams({ lines: ['one', 'two'] }));
+ expect(lineEls(container).map(text)).toEqual(['one', 'two']);
+ expect(container.children.length).toBe(3);
+ });
+
+ it('starts over when something else reordered the lines', () => {
+ const container = document.createElement('div');
+ renderOverlay(container, makeParams({ lines: ['one', 'two'] }));
+ const [one, two] = lineEls(container);
+ container.insertBefore(two, one);
+ renderOverlay(container, makeParams({ lines: ['one', 'two'] }));
+ expect(lineEls(container).map(text)).toEqual(['one', 'two']);
+ expect(container.children.length).toBe(3);
+ });
+
+ it('starts over when something else added to the container', () => {
+ const container = document.createElement('div');
+ renderOverlay(container, makeParams({ lines: ['one'] }));
+ container.appendChild(document.createElement('div'));
+ renderOverlay(container, makeParams({ lines: ['one'] }));
+ expect(container.children.length).toBe(2);
+ expect(text(lineEls(container)[0])).toBe('one');
+ });
+
+ it('a composition growing on the last line leaves the lines it does not reach alone', () => {
+ const container = document.createElement('div');
+ // 'first' (5) + 'abc' pending, composition starts at code point 8.
+ renderOverlay(container, makeParams({ lines: ['first', 'abcx'], compositionStart: 8 }));
+ const [first] = lineEls(container);
+ renderOverlay(container, makeParams({ lines: ['first', 'abcxy'], compositionStart: 8 }));
+ const after = lineEls(container);
+ expect(after[0]).toBe(first);
+ const marked = Array.from(after[1].children).filter((s) => s.hasAttribute('data-zerolag-composition'));
+ expect(marked.map((s) => s.textContent).join('')).toBe('xy');
+ });
+
+ it('a composition that starts on an earlier line marks the whole later line', () => {
+ const container = document.createElement('div');
+ renderOverlay(container, makeParams({ lines: ['abXY', 'ZW'], compositionStart: 2 }));
+ const [first, second] = lineEls(container);
+ const marked = (line: HTMLDivElement) =>
+ Array.from(line.children)
+ .filter((s) => s.hasAttribute('data-zerolag-composition'))
+ .map((s) => s.textContent)
+ .join('');
+ expect(marked(first)).toBe('XY');
+ expect(marked(second)).toBe('ZW');
+ });
+
+ it('two containers keep separate records', () => {
+ const a = document.createElement('div');
+ const b = document.createElement('div');
+ renderOverlay(a, makeParams({ lines: ['same'] }));
+ renderOverlay(b, makeParams({ lines: ['same'] }));
+ expect(lineEls(a)[0]).not.toBe(lineEls(b)[0]);
+ expect(text(lineEls(a)[0])).toBe('same');
+ expect(text(lineEls(b)[0])).toBe('same');
+ });
+});