Compare commits

...
Author SHA1 Message Date
Codeman maintainer 1082724db9 perf(zerolag): rebuild only the changed overlay line, one paint layer per line
The local echo overlay rebuilt every character of the prompt on each
keystroke, one absolutely positioned span per character, so a keystroke
cost O(prompt length) in DOM work, style, layout and paint. Every
positioned span was also its own paint layer, so the browser re-layerized
and repainted thousands of them per frame. In headless Chromium with a 6x
CPU slowdown, a ~4,100-character prompt ran at a median 813 ms per frame,
and ~300 characters already missed 60 fps (54 ms).

renderOverlay() now keeps a per-container record of the line elements it
drew and rebuilds only lines whose text, position or composition range
changed. It starts over when the font, colour, cell size or Unicode
version changes, or when something else emptied or edited the container.

makeLine() lays the spans out in a flex row instead of positioning each
one, and sets the shared font styles once on the line. Cell edges are
snapped to a quarter pixel, which Blink, WebKit and Gecko all store
exactly, so the widths add up to the grid without drift and every edge
stays within 1/8 px of col * cellW. Same prompt: 17 ms per frame.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 06:07:34 +01:00
9 changed files with 425 additions and 122 deletions

No files matched your search

@@ -0,0 +1,6 @@
---
"aicodeman": patch
"xterm-zerolag-input": patch
---
**Typing stays smooth in a long prompt.** The local echo overlay (the instant preview of what you type before the session echoes it back) used to throw away and rebuild every character of the prompt on each keystroke, so the longer the prompt, the slower each key. On a phone-class CPU it dropped below 60 fps at about 300 characters, and at about 4,000 characters a single keystroke took most of a second to appear. It now rebuilds only the line that changed, and each line is one paint layer instead of one layer per character, so a keystroke costs the same however long the prompt is. What you see is unchanged: every character still sits on its terminal cell, within 1/8 px. This ships in `xterm-zerolag-input` as well: the README's rendering section describes the new layout.
+1 -1
View File
@@ -1227,7 +1227,7 @@ Full details: [`docs/archive/code-structure-findings.md`](docs/archive/code-stru
[![npm](https://img.shields.io/npm/v/xterm-zerolag-input?style=flat-square&color=22c55e)](https://www.npmjs.com/package/xterm-zerolag-input)
Instant keystroke feedback overlay for xterm.js. Eliminates perceived input latency over high-RTT connections by rendering typed characters immediately as a pixel-perfect DOM overlay. Zero dependencies, 6.1 kB gzipped, configurable prompt detection, CJK/emoji wide-character support, full state machine with 238 tests.
Instant keystroke feedback overlay for xterm.js. Eliminates perceived input latency over high-RTT connections by rendering typed characters immediately as a pixel-perfect DOM overlay. Zero dependencies, 5.7 kB minified and gzipped, configurable prompt detection, CJK/emoji wide-character support, full state machine with 276 tests. Typing cost does not grow with prompt length.
```bash
npm install xterm-zerolag-input
+1 -1
View File
@@ -1221,7 +1221,7 @@ npm test # 运行测试(与 CI 相同;浏览器/移动端
[![npm](https://img.shields.io/npm/v/xterm-zerolag-input?style=flat-square&color=22c55e)](https://www.npmjs.com/package/xterm-zerolag-input)
为 xterm.js 提供即时按键反馈的叠加层。通过把输入的字符立即渲染为像素级精准的 DOM 叠加层,消除高 RTT 连接下的感知输入延迟。零依赖、gzip 后 6.1 kB、可配置的提示符检测、CJK/emoji 宽字符支持、带 238 个测试的完整状态机。
为 xterm.js 提供即时按键反馈的叠加层。通过把输入的字符立即渲染为像素级精准的 DOM 叠加层,消除高 RTT 连接下的感知输入延迟。零依赖、压缩并 gzip 后 5.7 kB、可配置的提示符检测、CJK/emoji 宽字符支持、带 276 个测试的完整状态机。输入开销不随提示词长度增长。
```bash
npm install xterm-zerolag-input
+21 -12
View File
@@ -8,8 +8,8 @@
<a href="https://www.npmjs.com/package/xterm-zerolag-input"><img src="https://img.shields.io/npm/v/xterm-zerolag-input?style=flat-square&color=22c55e" alt="npm"></a>
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-1e3a5f?style=flat-square" alt="MIT"></a>
<img src="https://img.shields.io/badge/Dependencies-0-22c55e?style=flat-square" alt="Zero dependencies">
<img src="https://img.shields.io/badge/Size-6.1%20kB%20gzip-22c55e?style=flat-square" alt="6.1 kB gzipped">
<img src="https://img.shields.io/badge/Tests-227-22c55e?style=flat-square" alt="175 tests">
<img src="https://img.shields.io/badge/Size-5.7%20kB%20gzip-22c55e?style=flat-square" alt="5.7 kB minified and gzipped">
<img src="https://img.shields.io/badge/Tests-276-22c55e?style=flat-square" alt="276 tests">
<img src="https://img.shields.io/badge/xterm.js-v5%20%7C%20v7+-3b82f6?style=flat-square" alt="xterm.js v5 and v7+">
</p>
</p>
@@ -60,12 +60,13 @@ Since 0.2.0 the package ships **two addons for two kinds of TUIs**:
| | |
|---|---|
| **Survives full-screen TUIs** | Ink, blessed, and friends repaint the whole screen constantly. The overlay is a separate DOM layer they cannot reach, so it does not get clobbered. |
| **Pixel-matched to the canvas** | Each character is its own absolutely-positioned `<span>` at exact cell coordinates, so it does not drift out of the grid like normal DOM text flow. |
| **Pixel-matched to the canvas** | Each character is its own `<span>`, exactly as wide as its cells and snapped to the grid, so it does not drift out of the grid like normal DOM text flow. |
| **Cheap per keystroke** | Only the line that changed is rebuilt, and each line is one paint layer, so a keystroke costs the same in a 4,000-character prompt as in a short one. |
| **Wide characters included** | CJK, fullwidth forms and emoji render double-width and position by visual column, using the terminal's Unicode addon when one is loaded. |
| **Backspace that actually works** | A three-layer cascade (unsent, in-flight, already on screen) tells you exactly what to forward to the PTY, so editing works through any mix of typed, flushed and tab-completed text. |
| **You keep control of input** | The addon never hooks `onData` for you. You decide what gets echoed and what gets forwarded, which is what makes char-at-a-time, buffered, and multi-session tab switching all possible. |
| **Small and self-contained** | 6.1 kB gzipped, zero runtime dependencies, dual CJS/ESM with full type declarations. |
| **Proven under load** | Extracted from [Codeman](https://getcodeman.com), hardened over thousands of hours of real remote and mobile usage, 175 tests over every state transition. |
| **Small and self-contained** | 5.7 kB minified and gzipped, zero runtime dependencies, dual CJS/ESM with full type declarations. |
| **Proven under load** | Extracted from [Codeman](https://getcodeman.com), hardened over thousands of hours of real remote and mobile usage, 276 tests over every state transition. |
Built for anything that puts a terminal behind a network hop: SSH web clients, cloud IDEs, mobile terminals, Kubernetes and container consoles, remote agent dashboards, browser-based dev environments.
@@ -128,7 +129,7 @@ This library answers all three:
- a **DOM overlay** on its own z-index layer, which Ink cannot touch
- **bottom-up buffer scanning** for the prompt instead of trusting the cursor
- **one absolutely-positioned `<span>` per character** at exact cell-grid coordinates
- **one `<span>` per character**, sized to its cells and snapped to the cell grid
---
@@ -508,15 +509,23 @@ div.xterm-screen (position: relative)
### Per-character grid alignment
Each character is an absolutely-positioned `<span>`:
Each line is an absolutely-positioned `<div>` at its row, and each character is a `<span>` in a flex row inside it, as wide as the cells it covers:
```
left = visualColumn * cellWidth (CSS pixels)
top = lineIndex * cellHeight (CSS pixels)
width = cellWidth * charCellWidth (1 cell, or 2 for wide characters)
line top = lineIndex * cellHeight (CSS pixels)
edge(col) = round(col * cellWidth * 4) / 4 (snapped to a quarter pixel)
span width = edge(col + charCellWidth) - edge(col) (1 cell, or 2 for wide characters)
```
Positioning by visual column instead of letting the browser lay out text is what removes sub-pixel drift.
A span starts where the widths before it add up to. Normal DOM text drifts because glyph advances that the layout engine cannot store exactly carry their rounding into every later cell. A quarter pixel is stored exactly by Blink and WebKit (1/64 px layout units) and Gecko (1/60 px app units), so the snapped widths always add up to the snapped edge: no drift at any column, and every edge stays within 1/8 px of `col * cellWidth`.
The spans are deliberately not absolutely positioned. A positioned element gets its own paint layer, and with one per character the browser had to re-layerize and repaint thousands of them on every keystroke of a long prompt. The font, colour, line height, centring and ligature settings are set once per line and inherited.
### Rendering cost
Each render compares every line with what the previous render left in the overlay and rebuilds only the lines whose text or position changed. Typing a character changes only the last line, so a keystroke costs one line of DOM work, layout and paint, however long the prompt. A font, colour, cell size or Unicode version change rebuilds everything, and so does anything else emptying the overlay element.
Measured in headless Chromium with a 6x CPU slowdown standing in for a phone, typing into a prompt of about 4,100 characters: 0.4.x took a median 813 ms per frame (513 ms of script, style and layout per keystroke), this version 17 ms (8.6 ms). At about 300 characters, 0.4.x already missed 60 fps (54 ms frames).
### Font matching
@@ -543,7 +552,7 @@ The overlay hides while the cursor row is scrolled out of the viewport and re-re
## Known limitations
- **Canvas and WebGL font mismatch**: minor sub-pixel differences are still possible. Per-character absolute positioning keeps them small.
- **Canvas and WebGL font mismatch**: minor sub-pixel differences are still possible. Per-character cells snapped to a quarter pixel keep them under 1/8 px.
- **Grapheme clusters**: widths are summed per code point, so ZWJ emoji sequences (for example 👨‍👩‍👧) and combining marks can be over-counted. Single-code-point emoji and CJK are correct.
- **Password prompts**: the overlay will happily show characters the server is not echoing. Call `clear()` when you detect a no-echo prompt.
- **Prompt characters in output**: if your prompt marker also appears in command output, detection can latch onto the wrong line. Use a regex or a custom finder.
@@ -46,14 +46,61 @@ export function stringCellWidth(terminal: XtermTerminal | null | undefined, str:
// ─── Overlay rendering ────────────────────────────────────────────────
/**
* Cell edges are snapped to this fraction of a CSS pixel.
*
* The glyph spans sit in normal flow, so each one starts where the widths
* before it add up to, and a width the layout engine cannot store exactly
* would carry its rounding error into every later cell (the drift that plain
* DOM text shows against the canvas grid). A quarter pixel is exact in
* Blink and WebKit (1/64 px layout units) and in Gecko (1/60 px app units),
* so the widths always add up to the snapped edge: no drift at any column,
* and every edge is within 1/8 px of `col * cellW`.
*/
const EDGE_SNAP = 4;
function cellEdge(col: number, cellW: number): number {
return Math.round(col * cellW * EDGE_SNAP) / EDGE_SNAP;
}
/** What the last render left in a container, so the next one rebuilds only the lines that changed. */
interface RenderedLines {
/** Everything a line's markup depends on besides its own text and position. */
styleKey: string;
lines: HTMLDivElement[];
lineKeys: string[];
cursor: HTMLSpanElement | null;
}
const rendered = new WeakMap<HTMLDivElement, RenderedLines>();
/**
* False once anything else has emptied or edited the container (the addon's
* hide path clears it): the children must be exactly the recorded lines, in
* order, then the cursor.
*/
function holdsOnlyRendered(container: HTMLDivElement, state: RenderedLines): boolean {
const children = container.children;
if (children.length !== state.lines.length + (state.cursor ? 1 : 0)) return false;
for (let i = 0; i < state.lines.length; i++) {
if (children[i] !== state.lines[i]) return false;
}
return !state.cursor || children[state.lines.length] === state.cursor;
}
/**
* Render the overlay content into the container element.
*
* Creates per-character `<span>` elements positioned on an exact grid
* matching xterm.js's canvas renderer. This avoids sub-pixel drift that
* occurs with normal DOM text flow.
* Each line is a `<div>` holding one `<span>` per character on the cell grid
* of xterm.js's canvas renderer (see makeLine). CJK wide characters get
* double-width spans.
*
* CJK wide characters are rendered with double-width spans.
* Only lines whose content or position changed since the previous render
* into the same container are rebuilt. Typing a character changes just the
* last line, so a keystroke costs one line of DOM work however long the
* prompt is; rebuilding everything made each keystroke O(prompt length) in
* DOM work, layout and paint, which a long prompt on a phone paid for in
* dropped frames.
*/
export function renderOverlay(container: HTMLDivElement, params: RenderParams): void {
const {
@@ -94,11 +141,14 @@ export function renderOverlay(container: HTMLDivElement, params: RenderParams):
// Code-point offset of each line in the whole text, so the composition
// styling survives the tail slice below.
const lineOffsets: number[] = [];
const lineLengths: number[] = [];
{
let offset = 0;
for (const line of lines) {
const length = [...line].length;
lineOffsets.push(offset);
offset += [...line].length;
lineLengths.push(length);
offset += length;
}
}
let visibleLines = lines;
@@ -120,8 +170,29 @@ export function renderOverlay(container: HTMLDivElement, params: RenderParams):
container.style.left = '0px';
container.style.top = topRow * cellH + 'px';
// Clear and rebuild (typically 1-3 line divs, negligible cost)
container.innerHTML = '';
// Reuse the previous render's lines when nothing they depend on but their
// own text and position changed; otherwise start from an empty container.
const styleKey = [
cellW,
cellH,
font.fontFamily,
font.fontSize,
font.fontWeight,
font.color,
font.backgroundColor,
font.letterSpacing,
terminal?.unicode?.activeVersion ?? '',
].join('\n');
let state = rendered.get(container);
if (!state || state.styleKey !== styleKey || !holdsOnlyRendered(container, state)) {
container.innerHTML = '';
state = { styleKey, lines: [], lineKeys: [], cursor: null };
rendered.set(container, state);
}
// Taken out while the lines change, so it is appended last again below.
state.cursor?.remove();
state.cursor = null;
const fullWidthPx = totalCols * cellW;
for (let i = 0; i < visibleLines.length; i++) {
@@ -129,8 +200,16 @@ export function renderOverlay(container: HTMLDivElement, params: RenderParams):
const leftPx = indents ? startCol * cellW : 0;
const widthPx = indents ? fullWidthPx - leftPx : fullWidthPx;
const topPx = i * cellH;
const lineCompositionFrom =
compositionStart === undefined ? undefined : compositionStart - lineOffsets[firstVisible + i];
// Clamped to the line, so a line the composition does not reach keeps
// the same key (and its element) while the composition moves along.
let lineCompositionFrom: number | undefined;
if (compositionStart !== undefined) {
const from = compositionStart - lineOffsets[firstVisible + i];
if (from < lineLengths[firstVisible + i]) lineCompositionFrom = Math.max(0, from);
}
// Text goes last, so no character in it can make two different lines collide.
const key = `${leftPx}|${topPx}|${widthPx}|${lineCompositionFrom ?? ''}|${visibleLines[i]}`;
if (state.lineKeys[i] === key) continue;
const lineEl = makeLine(
visibleLines[i],
leftPx,
@@ -144,8 +223,15 @@ export function renderOverlay(container: HTMLDivElement, params: RenderParams):
terminal,
lineCompositionFrom
);
container.appendChild(lineEl);
const previous = state.lines[i];
if (previous) container.replaceChild(lineEl, previous);
else container.appendChild(lineEl);
state.lines[i] = lineEl;
state.lineKeys[i] = key;
}
for (let i = state.lines.length - 1; i >= visibleLines.length; i--) state.lines[i].remove();
state.lines.length = visibleLines.length;
state.lineKeys.length = visibleLines.length;
// Block cursor at end of last line (use visual width for CJK support)
if (showCursor) {
@@ -161,6 +247,7 @@ export function renderOverlay(container: HTMLDivElement, params: RenderParams):
cursor.style.height = cellH + 'px';
cursor.style.backgroundColor = cursorColor;
container.appendChild(cursor);
state.cursor = cursor;
}
}
@@ -168,13 +255,20 @@ export function renderOverlay(container: HTMLDivElement, params: RenderParams):
}
/**
* Create a styled line `<div>` with per-character grid positioning.
* Create a styled line `<div>` with one `<span>` per character on the cell grid.
*
* Each character gets its own `<span>` 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++;
}
@@ -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> = {}): 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', () => {
@@ -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', () => {
@@ -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;
}
@@ -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');
});
});