fix: restore CJK wide character support in ZerolagInput overlay (#30)

Add charCellWidth/stringCellWidth helpers for Unicode-aware width detection,
fix makeLine to use for...of iteration with visual column positioning, and
fix line splitting in _render to use visual column widths instead of string
length. CJK/fullwidth characters now correctly occupy 2 cell widths.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
arkon
2026-03-04 13:12:30 +01:00
co-authored by Claude Opus 4.6
parent c7cd4f9e17
commit fc92d8a8a4
6 changed files with 1842 additions and 1580 deletions
@@ -1,4 +1,50 @@
import type { RenderParams, FontStyle } from './types.js'; import type { RenderParams, FontStyle, XtermTerminal } from './types.js';
// ─── CJK / fullwidth character width detection ───────────────────────
/**
* Get visual cell width of a single character.
* CJK wide characters occupy 2 cells, others occupy 1.
* Prefers the terminal's Unicode addon when available.
*/
export function charCellWidth(terminal: XtermTerminal | null | undefined, ch: string): number {
if (terminal?.unicode?.getStringCellWidth) {
return terminal.unicode.getStringCellWidth(ch);
}
// Fallback: detect CJK wide characters by Unicode range
const code = ch.codePointAt(0);
if (
code !== undefined &&
code >= 0x1100 &&
(code <= 0x115f || // Hangul Jamo
(code >= 0x2e80 && code <= 0x303e) || // CJK Radicals, Kangxi, Ideographic
(code >= 0x3040 && code <= 0x33bf) || // Hiragana, Katakana, Bopomofo, CJK Compat
(code >= 0x3400 && code <= 0x4dbf) || // CJK Unified Ext A
(code >= 0x4e00 && code <= 0xa4cf) || // CJK Unified, Yi
(code >= 0xa960 && code <= 0xa97c) || // Hangul Jamo Extended-A
(code >= 0xac00 && code <= 0xd7a3) || // Hangul Syllables
(code >= 0xf900 && code <= 0xfaff) || // CJK Compat Ideographs
(code >= 0xfe30 && code <= 0xfe6f) || // CJK Compat Forms
(code >= 0xff01 && code <= 0xff60) || // Fullwidth Forms
(code >= 0xffe0 && code <= 0xffe6) || // Fullwidth Signs
(code >= 0x1f000 && code <= 0x1fbff) || // Mahjong, Domino, Emoji
(code >= 0x20000 && code <= 0x2ffff) || // CJK Unified Ext B-F
(code >= 0x30000 && code <= 0x3ffff)) // CJK Unified Ext G+
)
return 2;
return 1;
}
/**
* Get visual cell width of a string (sum of all character widths).
*/
export function stringCellWidth(terminal: XtermTerminal | null | undefined, str: string): number {
let w = 0;
for (const ch of str) w += charCellWidth(terminal, ch);
return w;
}
// ─── Overlay rendering ────────────────────────────────────────────────
/** /**
* Render the overlay content into the container element. * Render the overlay content into the container element.
@@ -6,100 +52,114 @@ import type { RenderParams, FontStyle } from './types.js';
* Creates per-character `<span>` elements positioned on an exact grid * Creates per-character `<span>` elements positioned on an exact grid
* matching xterm.js's canvas renderer. This avoids sub-pixel drift that * matching xterm.js's canvas renderer. This avoids sub-pixel drift that
* occurs with normal DOM text flow. * occurs with normal DOM text flow.
*
* CJK wide characters are rendered with double-width spans.
*/ */
export function renderOverlay(container: HTMLDivElement, params: RenderParams): void { export function renderOverlay(container: HTMLDivElement, params: RenderParams): void {
const { lines, startCol, totalCols, cellW, cellH, charTop, charHeight, promptRow, font, showCursor, cursorColor } = params; const {
lines,
startCol,
totalCols,
cellW,
cellH,
charTop,
charHeight,
promptRow,
font,
showCursor,
cursorColor,
terminal,
} = params;
// Position container at prompt row. // Position container at prompt row.
container.style.left = '0px'; container.style.left = '0px';
container.style.top = (promptRow * cellH) + 'px'; container.style.top = promptRow * cellH + 'px';
// Clear and rebuild (typically 1-3 line divs, negligible cost) // Clear and rebuild (typically 1-3 line divs, negligible cost)
container.innerHTML = ''; container.innerHTML = '';
const fullWidthPx = totalCols * cellW; const fullWidthPx = totalCols * cellW;
for (let i = 0; i < lines.length; i++) { for (let i = 0; i < lines.length; i++) {
const leftPx = i === 0 ? startCol * cellW : 0; const leftPx = i === 0 ? startCol * cellW : 0;
const widthPx = i === 0 ? (fullWidthPx - leftPx) : fullWidthPx; const widthPx = i === 0 ? fullWidthPx - leftPx : fullWidthPx;
const topPx = i * cellH; const topPx = i * cellH;
const lineEl = makeLine(lines[i], leftPx, topPx, widthPx, cellH, cellW, charTop, charHeight, font); const lineEl = makeLine(lines[i], leftPx, topPx, widthPx, cellH, cellW, charTop, charHeight, font, terminal);
container.appendChild(lineEl); container.appendChild(lineEl);
}
// Block cursor at end of last line (use visual width for CJK support)
if (showCursor) {
const lastLine = lines[lines.length - 1];
const lastLineLeft = lines.length === 1 ? startCol : 0;
const cursorCol = lastLineLeft + stringCellWidth(terminal, lastLine);
if (cursorCol < totalCols) {
const cursor = document.createElement('span');
cursor.style.cssText = 'position:absolute;display:inline-block';
cursor.style.left = cursorCol * cellW + 'px';
cursor.style.top = (lines.length - 1) * cellH + 'px';
cursor.style.width = cellW + 'px';
cursor.style.height = cellH + 'px';
cursor.style.backgroundColor = cursorColor;
container.appendChild(cursor);
} }
}
// Block cursor at end of last line container.style.display = '';
if (showCursor) {
const lastLine = lines[lines.length - 1];
const lastLineLeft = lines.length === 1 ? startCol : 0;
const cursorCol = lastLineLeft + lastLine.length;
if (cursorCol < totalCols) {
const cursor = document.createElement('span');
cursor.style.cssText = 'position:absolute;display:inline-block';
cursor.style.left = (cursorCol * cellW) + 'px';
cursor.style.top = ((lines.length - 1) * cellH) + 'px';
cursor.style.width = cellW + 'px';
cursor.style.height = cellH + 'px';
cursor.style.backgroundColor = cursorColor;
container.appendChild(cursor);
}
}
container.style.display = '';
} }
/** /**
* Create a styled line `<div>` with per-character grid positioning. * Create a styled line `<div>` with per-character grid positioning.
* *
* Each character gets its own `<span>` placed at `i * cellW` pixels. * Each character gets its own `<span>` positioned by visual column offset.
* This matches xterm's canvas renderer where each glyph occupies exactly * CJK wide characters occupy 2 cell widths.
* one cell width, regardless of the actual glyph metrics.
*/ */
function makeLine( function makeLine(
text: string, text: string,
leftPx: number, leftPx: number,
topPx: number, topPx: number,
widthPx: number, widthPx: number,
cellH: number, cellH: number,
cellW: number, cellW: number,
charTop: number, _charTop: number,
charHeight: number, _charHeight: number,
font: FontStyle, font: FontStyle,
terminal?: XtermTerminal | null
): HTMLDivElement { ): HTMLDivElement {
const el = document.createElement('div'); const el = document.createElement('div');
el.style.cssText = 'position:absolute;pointer-events:none'; el.style.cssText = 'position:absolute;pointer-events:none';
el.style.backgroundColor = font.backgroundColor; el.style.backgroundColor = font.backgroundColor;
el.style.left = leftPx + 'px'; el.style.left = leftPx + 'px';
el.style.top = topPx + 'px'; el.style.top = topPx + 'px';
el.style.width = widthPx + 'px'; el.style.width = widthPx + 'px';
// Extend background 1px past cell boundary to cover the compositing // Extend background 1px past cell boundary to cover the compositing
// seam between the overlay layer (z-index:7) and the canvas layer below. // seam between the overlay layer (z-index:7) and the canvas layer below.
// The extra 1px lands in the next row's charTop gap (empty area before // The extra 1px lands in the next row's charTop gap (empty area before
// text rendering starts), so no canvas content is obscured. // text rendering starts), so no canvas content is obscured.
el.style.height = (cellH + 1) + 'px'; el.style.height = cellH + 1 + 'px';
// Spans fill the full cell height with matching lineHeight for natural // CJK wide chars occupy 2 cells — position by visual column offset
// CSS vertical centering. No transform — any sub-pixel overhang past let colOffset = 0;
// the line div causes visible anti-aliasing artifacts at the boundary. for (const ch of text) {
// The ≤0.5px difference from canvas ceil() rounding is imperceptible. const cw = charCellWidth(terminal, ch);
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.textContent = ch;
el.appendChild(span);
colOffset += cw;
}
for (let i = 0; i < text.length; i++) { return el;
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 = (i * cellW) + 'px';
span.style.top = '0px';
span.style.width = 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.textContent = text[i];
el.appendChild(span);
}
return el;
} }
+114 -105
View File
@@ -5,28 +5,35 @@
* Consumers pass their real Terminal instance — we only use these properties. * Consumers pass their real Terminal instance — we only use these properties.
*/ */
export interface XtermTerminal { export interface XtermTerminal {
readonly element: HTMLElement | undefined; readonly element: HTMLElement | undefined;
readonly cols: number; readonly cols: number;
readonly rows: number; readonly rows: number;
readonly options: { readonly options: {
fontFamily?: string; fontFamily?: string;
fontSize?: number; fontSize?: number;
fontWeight?: string | number; fontWeight?: string | number;
theme?: { theme?: {
background?: string; background?: string;
foreground?: string; foreground?: string;
cursor?: string; cursor?: string;
};
}; };
readonly buffer: { };
readonly active: { readonly buffer: {
readonly viewportY: number; readonly active: {
readonly baseY: number; readonly viewportY: number;
getLine(y: number): { readonly baseY: number;
translateToString(trimRight?: boolean): string; getLine(y: number):
} | undefined; | {
}; translateToString(trimRight?: boolean): string;
}
| undefined;
}; };
};
/** Unicode addon (e.g. Unicode11Addon) for CJK wide character width */
readonly unicode?: {
getStringCellWidth(str: string): number;
activeVersion?: string;
};
} }
/** /**
@@ -35,18 +42,18 @@ export interface XtermTerminal {
* The consumer calls `terminal.loadAddon(addon)` which invokes `activate()`. * The consumer calls `terminal.loadAddon(addon)` which invokes `activate()`.
*/ */
export interface XtermAddon { export interface XtermAddon {
activate(terminal: XtermTerminal): void; activate(terminal: XtermTerminal): void;
dispose(): void; dispose(): void;
} }
/** /**
* Position of the prompt in the terminal viewport. * Position of the prompt in the terminal viewport.
*/ */
export interface PromptPosition { export interface PromptPosition {
/** Viewport-relative row (0 = top of viewport) */ /** Viewport-relative row (0 = top of viewport) */
row: number; row: number;
/** Column of the prompt marker character */ /** Column of the prompt marker character */
col: number; col: number;
} }
/** /**
@@ -60,112 +67,114 @@ export interface PromptPosition {
* - `custom`: Full escape hatch — provide your own finder function * - `custom`: Full escape hatch — provide your own finder function
*/ */
export type PromptFinder = export type PromptFinder =
| { type: 'character'; char: string; offset?: number } | { type: 'character'; char: string; offset?: number }
| { type: 'regex'; pattern: RegExp; offset?: number } | { type: 'regex'; pattern: RegExp; offset?: number }
| { type: 'custom'; find: (terminal: XtermTerminal) => PromptPosition | null; offset?: number }; | { type: 'custom'; find: (terminal: XtermTerminal) => PromptPosition | null; offset?: number };
/** /**
* Configuration options for ZerolagInputAddon. * Configuration options for ZerolagInputAddon.
*/ */
export interface ZerolagInputOptions { export interface ZerolagInputOptions {
/** /**
* How to find the prompt in the terminal buffer. * How to find the prompt in the terminal buffer.
* *
* The `offset` controls how many characters after the prompt marker * The `offset` controls how many characters after the prompt marker
* the user input begins (e.g., `"> "` = offset 2). * the user input begins (e.g., `"> "` = offset 2).
* *
* @default { type: 'character', char: '>', offset: 2 } * @default { type: 'character', char: '>', offset: 2 }
*/ */
prompt?: PromptFinder; prompt?: PromptFinder;
/** /**
* Z-index for the overlay element. * Z-index for the overlay element.
* @default 7 * @default 7
*/ */
zIndex?: number; zIndex?: number;
/** /**
* Background color for the overlay. * Background color for the overlay.
* Set to `'transparent'` to disable the opaque background. * Set to `'transparent'` to disable the opaque background.
* @default Read from terminal.options.theme.background * @default Read from terminal.options.theme.background
*/ */
backgroundColor?: string; backgroundColor?: string;
/** /**
* Foreground color for overlay text. * Foreground color for overlay text.
* @default Read from terminal.options.theme.foreground * @default Read from terminal.options.theme.foreground
*/ */
foregroundColor?: string; foregroundColor?: string;
/** /**
* Whether to show a block cursor at the end of the overlay text. * Whether to show a block cursor at the end of the overlay text.
* @default true * @default true
*/ */
showCursor?: boolean; showCursor?: boolean;
/** /**
* Cursor color (block cursor at end of text). * Cursor color (block cursor at end of text).
* @default Read from terminal.options.theme.cursor * @default Read from terminal.options.theme.cursor
*/ */
cursorColor?: string; cursorColor?: string;
/** /**
* Scroll debounce time in ms for re-rendering when user scrolls * Scroll debounce time in ms for re-rendering when user scrolls
* back to the bottom of the terminal. * back to the bottom of the terminal.
* @default 50 * @default 50
*/ */
scrollDebounceMs?: number; scrollDebounceMs?: number;
} }
/** /**
* Read-only state snapshot of the overlay. * Read-only state snapshot of the overlay.
*/ */
export interface ZerolagInputState { export interface ZerolagInputState {
/** Characters typed but not yet acknowledged by the server */ /** Characters typed but not yet acknowledged by the server */
pendingText: string; pendingText: string;
/** Number of characters flushed to PTY but echo not yet received */ /** Number of characters flushed to PTY but echo not yet received */
flushedLength: number; flushedLength: number;
/** Text content of the flushed portion */ /** Text content of the flushed portion */
flushedText: string; flushedText: string;
/** Whether the overlay is currently visible */ /** Whether the overlay is currently visible */
visible: boolean; visible: boolean;
/** Last detected prompt position, if any */ /** Last detected prompt position, if any */
promptPosition: PromptPosition | null; promptPosition: PromptPosition | null;
} }
/** Cell dimensions in CSS pixels. */ /** Cell dimensions in CSS pixels. */
export interface CellDimensions { export interface CellDimensions {
width: number; width: number;
height: number; height: number;
/** Vertical offset (px) from cell top to where characters render. */ /** Vertical offset (px) from cell top to where characters render. */
charTop: number; charTop: number;
/** Height of the character rendering area (px). */ /** Height of the character rendering area (px). */
charHeight: number; charHeight: number;
} }
/** Parameters for the overlay renderer. */ /** Parameters for the overlay renderer. */
export interface RenderParams { export interface RenderParams {
lines: string[]; lines: string[];
startCol: number; startCol: number;
totalCols: number; totalCols: number;
cellW: number; cellW: number;
cellH: number; cellH: number;
/** Vertical offset (px) from cell top to character rendering area. */ /** Vertical offset (px) from cell top to character rendering area. */
charTop: number; charTop: number;
/** Height of the character rendering area (px). */ /** Height of the character rendering area (px). */
charHeight: number; charHeight: number;
promptRow: number; promptRow: number;
font: FontStyle; font: FontStyle;
showCursor: boolean; showCursor: boolean;
cursorColor: string; cursorColor: string;
/** Terminal instance for CJK wide character width detection */
terminal?: XtermTerminal | null;
} }
/** Cached font style properties for overlay rendering. */ /** Cached font style properties for overlay rendering. */
export interface FontStyle { export interface FontStyle {
fontFamily: string; fontFamily: string;
fontSize: string; fontSize: string;
fontWeight: string; fontWeight: string;
color: string; color: string;
backgroundColor: string; backgroundColor: string;
letterSpacing: string; letterSpacing: string;
} }
File diff suppressed because it is too large Load Diff
@@ -1,300 +1,420 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { renderOverlay } from '../src/overlay-renderer.js'; import { renderOverlay, charCellWidth, stringCellWidth } from '../src/overlay-renderer.js';
import type { RenderParams, FontStyle } from '../src/types.js'; import type { RenderParams, FontStyle } from '../src/types.js';
const FONT: FontStyle = { const FONT: FontStyle = {
fontFamily: 'monospace', fontFamily: 'monospace',
fontSize: '14px', fontSize: '14px',
fontWeight: 'normal', fontWeight: 'normal',
color: '#eeeeee', color: '#eeeeee',
backgroundColor: '#0d0d0d', backgroundColor: '#0d0d0d',
letterSpacing: '', letterSpacing: '',
}; };
function makeParams(overrides: Partial<RenderParams> = {}): RenderParams { function makeParams(overrides: Partial<RenderParams> = {}): RenderParams {
return { return {
lines: ['hello'], lines: ['hello'],
startCol: 2, startCol: 2,
totalCols: 80, totalCols: 80,
cellW: 8.4, cellW: 8.4,
cellH: 17, cellH: 17,
charTop: 2, charTop: 2,
charHeight: 14, charHeight: 14,
promptRow: 10, promptRow: 10,
font: FONT, font: FONT,
showCursor: true, showCursor: true,
cursorColor: '#e0e0e0', cursorColor: '#e0e0e0',
...overrides, ...overrides,
}; };
} }
describe('renderOverlay', () => { describe('renderOverlay', () => {
it('positions container at prompt row', () => { it('positions container at prompt row', () => {
const container = document.createElement('div'); const container = document.createElement('div');
renderOverlay(container, makeParams({ promptRow: 5 })); renderOverlay(container, makeParams({ promptRow: 5 }));
expect(container.style.top).toBe((5 * 17) + 'px'); expect(container.style.top).toBe(5 * 17 + 'px');
expect(container.style.left).toBe('0px'); expect(container.style.left).toBe('0px');
}); });
it('creates per-character spans in a line div', () => { it('creates per-character spans in a line div', () => {
const container = document.createElement('div'); const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: ['abc'] })); renderOverlay(container, makeParams({ lines: ['abc'] }));
// Line div + cursor span // Line div + cursor span
expect(container.children.length).toBe(2); expect(container.children.length).toBe(2);
const lineDiv = container.children[0] as HTMLDivElement; const lineDiv = container.children[0] as HTMLDivElement;
expect(lineDiv.children.length).toBe(3); // a, b, c expect(lineDiv.children.length).toBe(3); // a, b, c
const spanA = lineDiv.children[0] as HTMLSpanElement; const spanA = lineDiv.children[0] as HTMLSpanElement;
expect(spanA.textContent).toBe('a'); expect(spanA.textContent).toBe('a');
expect(spanA.style.left).toBe('0px'); expect(spanA.style.left).toBe('0px');
const spanB = lineDiv.children[1] as HTMLSpanElement; const spanB = lineDiv.children[1] as HTMLSpanElement;
expect(spanB.textContent).toBe('b'); expect(spanB.textContent).toBe('b');
expect(spanB.style.left).toBe('8.4px'); expect(spanB.style.left).toBe('8.4px');
const spanC = lineDiv.children[2] as HTMLSpanElement; const spanC = lineDiv.children[2] as HTMLSpanElement;
expect(spanC.textContent).toBe('c'); expect(spanC.textContent).toBe('c');
expect(spanC.style.left).toBe('16.8px'); expect(spanC.style.left).toBe('16.8px');
}); });
it('sets span width to cellW', () => { it('sets span width to cellW', () => {
const container = document.createElement('div'); const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: ['x'], cellW: 9.5 })); renderOverlay(container, makeParams({ lines: ['x'], cellW: 9.5 }));
const lineDiv = container.children[0] as HTMLDivElement; const lineDiv = container.children[0] as HTMLDivElement;
const span = lineDiv.children[0] as HTMLSpanElement; const span = lineDiv.children[0] as HTMLSpanElement;
expect(span.style.width).toBe('9.5px'); expect(span.style.width).toBe('9.5px');
}); });
it('applies font styles to spans', () => { it('applies font styles to spans', () => {
const font: FontStyle = { const font: FontStyle = {
fontFamily: 'Fira Code', fontFamily: 'Fira Code',
fontSize: '16px', fontSize: '16px',
fontWeight: 'bold', fontWeight: 'bold',
color: '#ff0000', color: '#ff0000',
backgroundColor: '#000000', backgroundColor: '#000000',
letterSpacing: '0.5px', letterSpacing: '0.5px',
}; };
const container = document.createElement('div'); const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: ['A'], font })); renderOverlay(container, makeParams({ lines: ['A'], font }));
const lineDiv = container.children[0] as HTMLDivElement; const lineDiv = container.children[0] as HTMLDivElement;
// jsdom normalizes hex to rgb() // jsdom normalizes hex to rgb()
expect(lineDiv.style.backgroundColor).toBe('rgb(0, 0, 0)'); expect(lineDiv.style.backgroundColor).toBe('rgb(0, 0, 0)');
const span = lineDiv.children[0] as HTMLSpanElement; const span = lineDiv.children[0] as HTMLSpanElement;
expect(span.style.fontFamily).toBe('Fira Code'); expect(span.style.fontFamily).toBe('Fira Code');
expect(span.style.fontSize).toBe('16px'); expect(span.style.fontSize).toBe('16px');
expect(span.style.fontWeight).toBe('bold'); expect(span.style.fontWeight).toBe('bold');
expect(span.style.color).toBe('rgb(255, 0, 0)'); expect(span.style.color).toBe('rgb(255, 0, 0)');
expect(span.style.letterSpacing).toBe('0.5px'); expect(span.style.letterSpacing).toBe('0.5px');
}); });
it('offsets first line by startCol', () => { it('offsets first line by startCol', () => {
const container = document.createElement('div'); const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: ['hi'], startCol: 5, cellW: 10 })); renderOverlay(container, makeParams({ lines: ['hi'], startCol: 5, cellW: 10 }));
const lineDiv = container.children[0] as HTMLDivElement; const lineDiv = container.children[0] as HTMLDivElement;
// First line left = startCol * cellW // First line left = startCol * cellW
expect(lineDiv.style.left).toBe('50px'); expect(lineDiv.style.left).toBe('50px');
}); });
it('renders cursor at end of text', () => { it('renders cursor at end of text', () => {
const container = document.createElement('div'); const container = document.createElement('div');
renderOverlay(container, makeParams({ renderOverlay(
lines: ['ab'], container,
startCol: 3, makeParams({
cellW: 10, lines: ['ab'],
cellH: 20, startCol: 3,
showCursor: true, cellW: 10,
cursorColor: '#ff00ff', cellH: 20,
})); showCursor: true,
cursorColor: '#ff00ff',
})
);
// Last child is cursor (after line div) // Last child is cursor (after line div)
const cursor = container.children[container.children.length - 1] as HTMLSpanElement; const cursor = container.children[container.children.length - 1] as HTMLSpanElement;
// cursorCol = startCol(3) + text.length(2) = 5 // cursorCol = startCol(3) + text.length(2) = 5
expect(cursor.style.left).toBe('50px'); expect(cursor.style.left).toBe('50px');
expect(cursor.style.width).toBe('10px'); expect(cursor.style.width).toBe('10px');
expect(cursor.style.height).toBe('20px'); expect(cursor.style.height).toBe('20px');
// jsdom normalizes hex to rgb() // jsdom normalizes hex to rgb()
expect(cursor.style.backgroundColor).toBe('rgb(255, 0, 255)'); expect(cursor.style.backgroundColor).toBe('rgb(255, 0, 255)');
}); });
it('does not render cursor when showCursor is false', () => { it('does not render cursor when showCursor is false', () => {
const container = document.createElement('div'); const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: ['ab'], showCursor: false })); renderOverlay(container, makeParams({ lines: ['ab'], showCursor: false }));
// Only line div, no cursor // Only line div, no cursor
expect(container.children.length).toBe(1); expect(container.children.length).toBe(1);
}); });
it('renders multi-line text', () => { it('renders multi-line text', () => {
const container = document.createElement('div'); const container = document.createElement('div');
renderOverlay(container, makeParams({ renderOverlay(
lines: ['first', 'second'], container,
startCol: 5, makeParams({
cellW: 10, lines: ['first', 'second'],
cellH: 20, startCol: 5,
})); cellW: 10,
cellH: 20,
})
);
// 2 line divs + cursor // 2 line divs + cursor
expect(container.children.length).toBe(3); expect(container.children.length).toBe(3);
const line1 = container.children[0] as HTMLDivElement; const line1 = container.children[0] as HTMLDivElement;
expect(line1.style.left).toBe('50px'); // startCol * cellW expect(line1.style.left).toBe('50px'); // startCol * cellW
expect(line1.style.top).toBe('0px'); expect(line1.style.top).toBe('0px');
expect(line1.children.length).toBe(5); // 'first' expect(line1.children.length).toBe(5); // 'first'
const line2 = container.children[1] as HTMLDivElement; const line2 = container.children[1] as HTMLDivElement;
expect(line2.style.left).toBe('0px'); // wrapped lines start at col 0 expect(line2.style.left).toBe('0px'); // wrapped lines start at col 0
expect(line2.style.top).toBe('20px'); // second row expect(line2.style.top).toBe('20px'); // second row
expect(line2.children.length).toBe(6); // 'second' expect(line2.children.length).toBe(6); // 'second'
}); });
it('clears previous content on re-render', () => { it('clears previous content on re-render', () => {
const container = document.createElement('div'); const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: ['abc'] })); renderOverlay(container, makeParams({ lines: ['abc'] }));
expect(container.children.length).toBe(2); // line + cursor expect(container.children.length).toBe(2); // line + cursor
renderOverlay(container, makeParams({ lines: ['xy'] })); renderOverlay(container, makeParams({ lines: ['xy'] }));
expect(container.children.length).toBe(2); // line + cursor (rebuilt) expect(container.children.length).toBe(2); // line + cursor (rebuilt)
const lineDiv = container.children[0] as HTMLDivElement; const lineDiv = container.children[0] as HTMLDivElement;
expect(lineDiv.children.length).toBe(2); // x, y expect(lineDiv.children.length).toBe(2); // x, y
}); });
it('shows container (display not none)', () => { it('shows container (display not none)', () => {
const container = document.createElement('div'); const container = document.createElement('div');
container.style.display = 'none'; container.style.display = 'none';
renderOverlay(container, makeParams()); renderOverlay(container, makeParams());
expect(container.style.display).toBe(''); expect(container.style.display).toBe('');
}); });
// ─── Anti-flicker / compositing seam tests ──────────────────── // ─── Anti-flicker / compositing seam tests ────────────────────
it('line div height extends 1px past cellH to cover compositing seam', () => { it('line div height extends 1px past cellH to cover compositing seam', () => {
const container = document.createElement('div'); const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: ['abc'], cellH: 19 })); renderOverlay(container, makeParams({ lines: ['abc'], cellH: 19 }));
const lineDiv = container.children[0] as HTMLDivElement; const lineDiv = container.children[0] as HTMLDivElement;
// cellH + 1 = 20px — the extra 1px covers the compositing seam // cellH + 1 = 20px — the extra 1px covers the compositing seam
expect(lineDiv.style.height).toBe('20px'); expect(lineDiv.style.height).toBe('20px');
}); });
it('line div height is cellH+1 for various cell heights', () => { it('line div height is cellH+1 for various cell heights', () => {
for (const cellH of [15, 17, 19, 22]) { for (const cellH of [15, 17, 19, 22]) {
const container = document.createElement('div'); const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: ['x'], cellH })); renderOverlay(container, makeParams({ lines: ['x'], cellH }));
const lineDiv = container.children[0] as HTMLDivElement; const lineDiv = container.children[0] as HTMLDivElement;
expect(lineDiv.style.height).toBe((cellH + 1) + 'px'); expect(lineDiv.style.height).toBe(cellH + 1 + 'px');
} }
}); });
it('multi-line overlay has cellH+1 height on each line div', () => { it('multi-line overlay has cellH+1 height on each line div', () => {
const container = document.createElement('div'); const container = document.createElement('div');
renderOverlay(container, makeParams({ renderOverlay(
lines: ['first', 'second'], container,
cellH: 19, makeParams({
})); lines: ['first', 'second'],
const line1 = container.children[0] as HTMLDivElement; cellH: 19,
const line2 = container.children[1] as HTMLDivElement; })
expect(line1.style.height).toBe('20px'); );
expect(line2.style.height).toBe('20px'); const line1 = container.children[0] as HTMLDivElement;
}); const line2 = container.children[1] as HTMLDivElement;
expect(line1.style.height).toBe('20px');
expect(line2.style.height).toBe('20px');
});
// ─── Span vertical centering tests ──────────────────────────── // ─── Span vertical centering tests ────────────────────────────
it('span uses full cellH for height and lineHeight (CSS centering)', () => { it('span uses full cellH for height and lineHeight (CSS centering)', () => {
const container = document.createElement('div'); const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: ['a'], cellH: 19 })); renderOverlay(container, makeParams({ lines: ['a'], cellH: 19 }));
const lineDiv = container.children[0] as HTMLDivElement; const lineDiv = container.children[0] as HTMLDivElement;
const span = lineDiv.children[0] as HTMLSpanElement; const span = lineDiv.children[0] as HTMLSpanElement;
expect(span.style.height).toBe('19px'); expect(span.style.height).toBe('19px');
expect(span.style.lineHeight).toBe('19px'); expect(span.style.lineHeight).toBe('19px');
}); });
it('span top is 0px (no vertical offset / no transform)', () => { it('span top is 0px (no vertical offset / no transform)', () => {
const container = document.createElement('div'); const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: ['a'], cellH: 19 })); renderOverlay(container, makeParams({ lines: ['a'], cellH: 19 }));
const lineDiv = container.children[0] as HTMLDivElement; const lineDiv = container.children[0] as HTMLDivElement;
const span = lineDiv.children[0] as HTMLSpanElement; const span = lineDiv.children[0] as HTMLSpanElement;
expect(span.style.top).toBe('0px'); expect(span.style.top).toBe('0px');
// No translateY transform — sub-pixel overhang causes artifacts // No translateY transform — sub-pixel overhang causes artifacts
expect(span.style.transform).toBe(''); expect(span.style.transform).toBe('');
}); });
// ─── Font rendering tests ───────────────────────────────────── // ─── Font rendering tests ─────────────────────────────────────
it('span disables ligatures via font-feature-settings', () => { it('span disables ligatures via font-feature-settings', () => {
const container = document.createElement('div'); const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: ['fi'] })); renderOverlay(container, makeParams({ lines: ['fi'] }));
const lineDiv = container.children[0] as HTMLDivElement; const lineDiv = container.children[0] as HTMLDivElement;
const span = lineDiv.children[0] as HTMLSpanElement; const span = lineDiv.children[0] as HTMLSpanElement;
// Check cssText includes the ligature-disabling settings // Check cssText includes the ligature-disabling settings
// jsdom may normalize whitespace; check that both liga and calt are disabled // 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('font-feature-settings:');
expect(span.style.cssText).toContain("'liga' 0"); expect(span.style.cssText).toContain("'liga' 0");
expect(span.style.cssText).toContain("'calt' 0"); expect(span.style.cssText).toContain("'calt' 0");
}); });
it('span has text-align: center for glyph centering', () => { it('span has text-align: center for glyph centering', () => {
const container = document.createElement('div'); const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: ['m'] })); renderOverlay(container, makeParams({ lines: ['m'] }));
const lineDiv = container.children[0] as HTMLDivElement; const lineDiv = container.children[0] as HTMLDivElement;
const span = lineDiv.children[0] as HTMLSpanElement; const span = lineDiv.children[0] as HTMLSpanElement;
expect(span.style.textAlign).toBe('center'); expect(span.style.textAlign).toBe('center');
}); });
it('span has pointer-events: none', () => { it('span has pointer-events: none', () => {
const container = document.createElement('div'); const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: ['a'] })); renderOverlay(container, makeParams({ lines: ['a'] }));
const lineDiv = container.children[0] as HTMLDivElement; const lineDiv = container.children[0] as HTMLDivElement;
const span = lineDiv.children[0] as HTMLSpanElement; const span = lineDiv.children[0] as HTMLSpanElement;
expect(span.style.pointerEvents).toBe('none'); expect(span.style.pointerEvents).toBe('none');
}); });
// ─── Multi-line cursor positioning ──────────────────────────── // ─── Multi-line cursor positioning ────────────────────────────
it('cursor on wrapped line uses col 0 as base', () => { it('cursor on wrapped line uses col 0 as base', () => {
const container = document.createElement('div'); const container = document.createElement('div');
renderOverlay(container, makeParams({ renderOverlay(
lines: ['first', 'ab'], container,
startCol: 5, makeParams({
cellW: 10, lines: ['first', 'ab'],
cellH: 20, startCol: 5,
showCursor: true, cellW: 10,
})); cellH: 20,
// Cursor at end of second line: col = 0 + 2 = 2 showCursor: true,
const cursor = container.children[container.children.length - 1] as HTMLSpanElement; })
expect(cursor.style.left).toBe('20px'); // 2 * 10 );
expect(cursor.style.top).toBe('20px'); // row 1 * cellH // Cursor at end of second line: col = 0 + 2 = 2
}); const cursor = container.children[container.children.length - 1] as HTMLSpanElement;
expect(cursor.style.left).toBe('20px'); // 2 * 10
expect(cursor.style.top).toBe('20px'); // row 1 * cellH
});
// ─── charTop/charHeight passed through ──────────────────────── // ─── charTop/charHeight passed through ────────────────────────
it('accepts charTop and charHeight params without error', () => { it('accepts charTop and charHeight params without error', () => {
const container = document.createElement('div'); const container = document.createElement('div');
expect(() => renderOverlay(container, makeParams({ expect(() =>
lines: ['test'], renderOverlay(
charTop: 2, container,
charHeight: 14, makeParams({
}))).not.toThrow(); lines: ['test'],
expect(container.children.length).toBeGreaterThan(0); charTop: 2,
}); charHeight: 14,
})
)
).not.toThrow();
expect(container.children.length).toBeGreaterThan(0);
});
// ─── Line div positioning regression ────────────────────────── // ─── Line div positioning regression ──────────────────────────
it('line div background color matches font.backgroundColor', () => { it('line div background color matches font.backgroundColor', () => {
const container = document.createElement('div'); const container = document.createElement('div');
const font: FontStyle = { ...FONT, backgroundColor: '#1a1a1a' }; const font: FontStyle = { ...FONT, backgroundColor: '#1a1a1a' };
renderOverlay(container, makeParams({ lines: ['x'], font })); renderOverlay(container, makeParams({ lines: ['x'], font }));
const lineDiv = container.children[0] as HTMLDivElement; const lineDiv = container.children[0] as HTMLDivElement;
// jsdom normalizes hex to rgb() // jsdom normalizes hex to rgb()
expect(lineDiv.style.backgroundColor).toBe('rgb(26, 26, 26)'); expect(lineDiv.style.backgroundColor).toBe('rgb(26, 26, 26)');
}); });
it('empty line produces line div with no spans', () => { it('empty line produces line div with no spans', () => {
const container = document.createElement('div'); const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: [''] })); renderOverlay(container, makeParams({ lines: [''] }));
const lineDiv = container.children[0] as HTMLDivElement; const lineDiv = container.children[0] as HTMLDivElement;
expect(lineDiv.children.length).toBe(0); expect(lineDiv.children.length).toBe(0);
}); });
// ─── CJK wide character support ───────────────────────────────
it('CJK characters get double-width spans', () => {
const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: ['a你b'], cellW: 10 }));
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 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
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
});
it('cursor position accounts for CJK width', () => {
const container = document.createElement('div');
renderOverlay(
container,
makeParams({
lines: ['你好'],
startCol: 2,
cellW: 10,
showCursor: true,
})
);
// 你(2) + 好(2) = 4 visual cols, cursor at startCol(2) + 4 = 6
const cursor = container.children[container.children.length - 1] as HTMLSpanElement;
expect(cursor.style.left).toBe('60px');
});
it('mixed ASCII and CJK characters position correctly', () => {
const container = document.createElement('div');
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');
});
});
describe('charCellWidth', () => {
it('returns 1 for ASCII characters', () => {
expect(charCellWidth(null, 'a')).toBe(1);
expect(charCellWidth(null, '!')).toBe(1);
expect(charCellWidth(null, ' ')).toBe(1);
});
it('returns 2 for CJK ideographs', () => {
expect(charCellWidth(null, '你')).toBe(2);
expect(charCellWidth(null, '好')).toBe(2);
expect(charCellWidth(null, '中')).toBe(2);
});
it('returns 2 for Japanese hiragana', () => {
expect(charCellWidth(null, 'こ')).toBe(2);
expect(charCellWidth(null, 'ん')).toBe(2);
});
it('returns 2 for Korean syllables', () => {
expect(charCellWidth(null, '안')).toBe(2);
expect(charCellWidth(null, '녕')).toBe(2);
});
it('returns 2 for fullwidth forms', () => {
expect(charCellWidth(null, '\uff01')).toBe(2); // !
expect(charCellWidth(null, '\uff21')).toBe(2); // A
});
it('uses terminal unicode addon when available', () => {
const mockTerminal = {
unicode: { getStringCellWidth: (s: string) => (s === 'W' ? 2 : 1) },
} as any;
expect(charCellWidth(mockTerminal, 'W')).toBe(2);
expect(charCellWidth(mockTerminal, 'n')).toBe(1);
});
});
describe('stringCellWidth', () => {
it('sums individual character widths', () => {
expect(stringCellWidth(null, 'abc')).toBe(3);
expect(stringCellWidth(null, '你好')).toBe(4);
expect(stringCellWidth(null, 'a你b')).toBe(4);
});
it('returns 0 for empty string', () => {
expect(stringCellWidth(null, '')).toBe(0);
});
}); });
File diff suppressed because it is too large Load Diff
+10 -10
View File
@@ -20,7 +20,7 @@
<script defer src="vendor/xterm-addon-fit.min.js"></script> <script defer src="vendor/xterm-addon-fit.min.js"></script>
<script defer src="vendor/xterm-addon-webgl.min.js"></script> <script defer src="vendor/xterm-addon-webgl.min.js"></script>
<script defer src="vendor/xterm-addon-unicode11.min.js"></script> <script defer src="vendor/xterm-addon-unicode11.min.js"></script>
<script defer src="vendor/xterm-zerolag-input.js?v=0.3.0"></script> <script defer src="vendor/xterm-zerolag-input.js?v=0.3.2"></script>
<!-- Synchronous mobile detection — runs before first paint to prevent panel flash --> <!-- Synchronous mobile detection — runs before first paint to prevent panel flash -->
<script>if(window.innerWidth<768||(('ontouchstart' in window||navigator.maxTouchPoints>0)&&window.innerWidth<1024))document.documentElement.classList.add('mobile-init');</script> <script>if(window.innerWidth<768||(('ontouchstart' in window||navigator.maxTouchPoints>0)&&window.innerWidth<1024))document.documentElement.classList.add('mobile-init');</script>
<!-- Inline critical CSS for instant skeleton paint (before styles.css loads) --> <!-- Inline critical CSS for instant skeleton paint (before styles.css loads) -->
@@ -1674,14 +1674,14 @@
<!-- Lines drawn dynamically --> <!-- Lines drawn dynamically -->
</svg> </svg>
<script defer src="constants.js?v=0.3.0"></script> <script defer src="constants.js?v=0.3.2"></script>
<script defer src="mobile-handlers.js?v=0.3.0"></script> <script defer src="mobile-handlers.js?v=0.3.2"></script>
<script defer src="voice-input.js?v=0.3.0"></script> <script defer src="voice-input.js?v=0.3.2"></script>
<script defer src="notification-manager.js?v=0.3.0"></script> <script defer src="notification-manager.js?v=0.3.2"></script>
<script defer src="keyboard-accessory.js?v=0.3.0"></script> <script defer src="keyboard-accessory.js?v=0.3.2"></script>
<script defer src="app.js?v=0.3.0"></script> <script defer src="app.js?v=0.3.2"></script>
<script defer src="ralph-wizard.js?v=0.3.0"></script> <script defer src="ralph-wizard.js?v=0.3.2"></script>
<script defer src="api-client.js?v=0.3.0"></script> <script defer src="api-client.js?v=0.3.2"></script>
<script defer src="subagent-windows.js?v=0.3.0"></script> <script defer src="subagent-windows.js?v=0.3.2"></script>
</body> </body>
</html> </html>