mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 18:39:33 +01:00
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>
239 lines
8.8 KiB
TypeScript
239 lines
8.8 KiB
TypeScript
import { describe, it, expect, afterEach } from 'vitest';
|
||
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
|
||
// after the pending text. Visual only, never part of what a consumer sends.
|
||
|
||
const CELL_W = 10;
|
||
|
||
let cleanups: (() => void)[] = [];
|
||
|
||
afterEach(() => {
|
||
for (const fn of cleanups) fn();
|
||
cleanups = [];
|
||
});
|
||
|
||
function setup(opts: { lines?: string[]; cols?: number; rows?: number } = {}) {
|
||
const mock = createMockTerminal({
|
||
buffer: { lines: opts.lines ?? ['$ '] },
|
||
cols: opts.cols,
|
||
rows: opts.rows,
|
||
cellWidth: CELL_W,
|
||
cellHeight: 20,
|
||
});
|
||
const addon = new ZerolagInputAddon({ prompt: { type: 'character', char: '$', offset: 2 } });
|
||
mock.terminal.loadAddon(addon);
|
||
cleanups.push(() => {
|
||
addon.dispose();
|
||
mock.cleanup();
|
||
});
|
||
const overlay = mock.terminal.element.querySelector('.xterm-screen')!.lastElementChild as HTMLDivElement;
|
||
return { addon, mock, overlay };
|
||
}
|
||
|
||
/** Line divs of the overlay (the block cursor is a bare span, not a div). */
|
||
function lineDivs(overlay: HTMLDivElement): HTMLDivElement[] {
|
||
return Array.from(overlay.children).filter((el) => el.tagName === 'DIV') as HTMLDivElement[];
|
||
}
|
||
|
||
function lineText(line: HTMLDivElement): string {
|
||
return Array.from(line.children)
|
||
.map((s) => s.textContent)
|
||
.join('');
|
||
}
|
||
|
||
function compositionText(overlay: HTMLDivElement): string {
|
||
return Array.from(overlay.querySelectorAll('[data-zerolag-composition]'))
|
||
.map((s) => s.textContent)
|
||
.join('');
|
||
}
|
||
|
||
describe('setComposition', () => {
|
||
it('renders the composition after pendingText, underlined and aria-hidden', () => {
|
||
const { addon, overlay } = setup();
|
||
addon.appendText('abc');
|
||
addon.setComposition('xy');
|
||
|
||
const [line] = lineDivs(overlay);
|
||
expect(lineText(line)).toBe('abcxy');
|
||
const spans = Array.from(line.children) as HTMLSpanElement[];
|
||
for (const span of spans.slice(0, 3)) {
|
||
expect(span.hasAttribute('data-zerolag-composition')).toBe(false);
|
||
expect(span.style.textDecoration).toBe('');
|
||
}
|
||
for (const span of spans.slice(3)) {
|
||
expect(span.hasAttribute('data-zerolag-composition')).toBe(true);
|
||
expect(span.getAttribute('aria-hidden')).toBe('true');
|
||
expect(span.style.textDecoration).toBe('underline');
|
||
}
|
||
// Grid positions continue straight on from the pending text.
|
||
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('');
|
||
});
|
||
|
||
it('places a wide composition by cell width after wide pending text', () => {
|
||
const { addon, overlay } = setup();
|
||
addon.appendText('今日は');
|
||
addon.setComposition('天気');
|
||
const spans = Array.from(lineDivs(overlay)[0].children) as HTMLSpanElement[];
|
||
expect(spans.map((s) => s.textContent).join('')).toBe('今日は天気');
|
||
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', () => {
|
||
const { addon } = setup();
|
||
addon.appendText('abc');
|
||
addon.setFlushed(2, 'zz');
|
||
addon.setComposition('xy');
|
||
expect(addon.pendingText).toBe('abc');
|
||
expect(addon.getFlushed()).toEqual({ count: 2, text: 'zz' });
|
||
expect(addon.composition).toBe('xy');
|
||
expect(addon.state.pendingText).toBe('abc');
|
||
expect(addon.state.flushedText).toBe('zz');
|
||
});
|
||
|
||
it('shows on an empty prompt without making anything pending', () => {
|
||
const { addon, overlay } = setup();
|
||
addon.setComposition('かな');
|
||
expect(addon.pendingText).toBe('');
|
||
expect(addon.hasPending).toBe(false);
|
||
expect(addon.state.visible).toBe(true);
|
||
expect(compositionText(overlay)).toBe('かな');
|
||
});
|
||
|
||
it('wraps with the pending text: the tail continues onto the next line', () => {
|
||
// 12 cols, prompt at col 0 + offset 2 = 10 cells on the first line.
|
||
const { addon, overlay } = setup({ cols: 12 });
|
||
addon.appendText('abcdefgh');
|
||
addon.setComposition('WXYZ');
|
||
const lines = lineDivs(overlay);
|
||
expect(lines.map(lineText)).toEqual(['abcdefghWX', 'YZ']);
|
||
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(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', () => {
|
||
// 2 visible rows, 3 lines of text: the first line is dropped.
|
||
const { addon, overlay } = setup({ cols: 6, rows: 2 });
|
||
addon.appendText('abcdefghij');
|
||
addon.setComposition('XYZ');
|
||
const lines = lineDivs(overlay);
|
||
expect(lines.map(lineText)).toEqual(['efghij', 'XYZ']);
|
||
expect(compositionText(overlay)).toBe('XYZ');
|
||
const first = Array.from(lines[0].children);
|
||
expect(first.some((s) => s.hasAttribute('data-zerolag-composition'))).toBe(false);
|
||
});
|
||
|
||
it("setComposition('') removes the tail and keeps the pending text", () => {
|
||
const { addon, overlay } = setup();
|
||
addon.appendText('abc');
|
||
addon.setComposition('xy');
|
||
addon.setComposition('');
|
||
expect(lineText(lineDivs(overlay)[0])).toBe('abc');
|
||
expect(compositionText(overlay)).toBe('');
|
||
expect(addon.pendingText).toBe('abc');
|
||
});
|
||
|
||
it("setComposition('') on an otherwise empty overlay hides it", () => {
|
||
const { addon, overlay } = setup();
|
||
addon.setComposition('xy');
|
||
addon.setComposition('');
|
||
expect(overlay.style.display).toBe('none');
|
||
expect(overlay.innerHTML).toBe('');
|
||
});
|
||
|
||
it('clear() (Enter, Ctrl+C) drops the composition with everything else', () => {
|
||
const { addon, overlay } = setup();
|
||
addon.appendText('abc');
|
||
addon.setComposition('xy');
|
||
addon.clear();
|
||
expect(addon.composition).toBe('');
|
||
expect(overlay.style.display).toBe('none');
|
||
addon.addChar('q');
|
||
expect(lineText(lineDivs(overlay)[0])).toBe('q');
|
||
});
|
||
|
||
it('removeChar() drops the composition and removes a pending char, not a composed one', () => {
|
||
const { addon, overlay } = setup();
|
||
addon.appendText('abc');
|
||
addon.setComposition('xy');
|
||
expect(addon.removeChar()).toBe('pending');
|
||
expect(addon.pendingText).toBe('ab');
|
||
expect(addon.composition).toBe('');
|
||
expect(lineText(lineDivs(overlay)[0])).toBe('ab');
|
||
});
|
||
|
||
it('removeChar() with nothing to remove still takes a composition-only overlay off screen', () => {
|
||
const { addon, overlay } = setup();
|
||
addon.setComposition('ka');
|
||
expect(compositionText(overlay)).toBe('ka');
|
||
expect(addon.removeChar()).toBe(false);
|
||
expect(addon.composition).toBe('');
|
||
expect(compositionText(overlay)).toBe('');
|
||
expect(overlay.style.display).toBe('none');
|
||
expect(addon.state.visible).toBe(false);
|
||
});
|
||
|
||
it('removeChar() repaints flushed text without the dropped composition', () => {
|
||
const { addon, overlay } = setup();
|
||
addon.setFlushed(3, 'abc');
|
||
addon.setComposition('xy');
|
||
expect(addon.removeChar()).toBe('flushed');
|
||
expect(compositionText(overlay)).toBe('');
|
||
expect(lineText(lineDivs(overlay)[0])).toBe('ab');
|
||
});
|
||
|
||
it('text appended while composing lands before the tail', () => {
|
||
const { addon, overlay } = setup();
|
||
addon.appendText('ab');
|
||
addon.setComposition('xy');
|
||
addon.addChar('c');
|
||
expect(lineText(lineDivs(overlay)[0])).toBe('abcxy');
|
||
expect(compositionText(overlay)).toBe('xy');
|
||
});
|
||
|
||
it('rerender() and refreshFont() keep the composition', () => {
|
||
const { addon, overlay } = setup();
|
||
addon.appendText('abc');
|
||
addon.setComposition('xy');
|
||
addon.rerender();
|
||
expect(compositionText(overlay)).toBe('xy');
|
||
addon.refreshFont();
|
||
expect(compositionText(overlay)).toBe('xy');
|
||
expect(lineText(lineDivs(overlay)[0])).toBe('abcxy');
|
||
});
|
||
|
||
it('re-renders when only the composition changes', () => {
|
||
const { addon, overlay } = setup();
|
||
addon.appendText('abc');
|
||
addon.setComposition('x');
|
||
addon.setComposition('xy');
|
||
expect(lineText(lineDivs(overlay)[0])).toBe('abcxy');
|
||
});
|
||
|
||
it('strips control characters and line breaks from the composition', () => {
|
||
const { addon, overlay } = setup();
|
||
addon.setComposition('a\nb\u0007c
');
|
||
expect(addon.composition).toBe('abc');
|
||
expect(compositionText(overlay)).toBe('abc');
|
||
});
|
||
|
||
it('draws the block cursor after the composition', () => {
|
||
const { addon, overlay } = setup();
|
||
addon.appendText('ab');
|
||
addon.setComposition('xy');
|
||
const cursor = Array.from(overlay.children).find((el) => el.tagName === 'SPAN') as HTMLSpanElement;
|
||
// prompt col 0 + offset 2 + 4 cells
|
||
expect(cursor.style.left).toBe(6 * CELL_W + 'px');
|
||
});
|
||
});
|