mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 08:29:42 +02:00
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:
@@ -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,13 +52,28 @@ 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 = '';
|
||||||
@@ -20,22 +81,22 @@ export function renderOverlay(container: HTMLDivElement, params: RenderParams):
|
|||||||
|
|
||||||
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
|
// Block cursor at end of last line (use visual width for CJK support)
|
||||||
if (showCursor) {
|
if (showCursor) {
|
||||||
const lastLine = lines[lines.length - 1];
|
const lastLine = lines[lines.length - 1];
|
||||||
const lastLineLeft = lines.length === 1 ? startCol : 0;
|
const lastLineLeft = lines.length === 1 ? startCol : 0;
|
||||||
const cursorCol = lastLineLeft + lastLine.length;
|
const cursorCol = lastLineLeft + stringCellWidth(terminal, lastLine);
|
||||||
if (cursorCol < totalCols) {
|
if (cursorCol < totalCols) {
|
||||||
const cursor = document.createElement('span');
|
const cursor = document.createElement('span');
|
||||||
cursor.style.cssText = 'position:absolute;display:inline-block';
|
cursor.style.cssText = 'position:absolute;display:inline-block';
|
||||||
cursor.style.left = (cursorCol * cellW) + 'px';
|
cursor.style.left = cursorCol * cellW + 'px';
|
||||||
cursor.style.top = ((lines.length - 1) * cellH) + 'px';
|
cursor.style.top = (lines.length - 1) * cellH + 'px';
|
||||||
cursor.style.width = cellW + 'px';
|
cursor.style.width = cellW + 'px';
|
||||||
cursor.style.height = cellH + 'px';
|
cursor.style.height = cellH + 'px';
|
||||||
cursor.style.backgroundColor = cursorColor;
|
cursor.style.backgroundColor = cursorColor;
|
||||||
@@ -49,9 +110,8 @@ export function renderOverlay(container: HTMLDivElement, params: RenderParams):
|
|||||||
/**
|
/**
|
||||||
* 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,
|
||||||
@@ -60,9 +120,10 @@ function makeLine(
|
|||||||
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';
|
||||||
@@ -74,22 +135,20 @@ function makeLine(
|
|||||||
// 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);
|
||||||
|
|
||||||
for (let i = 0; i < text.length; i++) {
|
|
||||||
const span = document.createElement('span');
|
const span = document.createElement('span');
|
||||||
// No ligatures — canvas renders each glyph independently.
|
// No ligatures — canvas renders each glyph independently.
|
||||||
span.style.cssText =
|
span.style.cssText =
|
||||||
'position:absolute;display:inline-block;text-align:center;pointer-events:none;' +
|
'position:absolute;display:inline-block;text-align:center;pointer-events:none;' +
|
||||||
"font-feature-settings:'liga' 0,'calt' 0";
|
"font-feature-settings:'liga' 0,'calt' 0";
|
||||||
span.style.left = (i * cellW) + 'px';
|
span.style.left = colOffset * cellW + 'px';
|
||||||
span.style.top = '0px';
|
span.style.top = '0px';
|
||||||
span.style.width = cellW + 'px';
|
span.style.width = cw * cellW + 'px';
|
||||||
span.style.height = cellH + 'px';
|
span.style.height = cellH + 'px';
|
||||||
span.style.lineHeight = cellH + 'px';
|
span.style.lineHeight = cellH + 'px';
|
||||||
span.style.fontFamily = font.fontFamily;
|
span.style.fontFamily = font.fontFamily;
|
||||||
@@ -97,8 +156,9 @@ function makeLine(
|
|||||||
span.style.fontWeight = font.fontWeight;
|
span.style.fontWeight = font.fontWeight;
|
||||||
span.style.color = font.color;
|
span.style.color = font.color;
|
||||||
if (font.letterSpacing) span.style.letterSpacing = font.letterSpacing;
|
if (font.letterSpacing) span.style.letterSpacing = font.letterSpacing;
|
||||||
span.textContent = text[i];
|
span.textContent = ch;
|
||||||
el.appendChild(span);
|
el.appendChild(span);
|
||||||
|
colOffset += cw;
|
||||||
}
|
}
|
||||||
|
|
||||||
return el;
|
return el;
|
||||||
|
|||||||
@@ -22,11 +22,18 @@ export interface XtermTerminal {
|
|||||||
readonly active: {
|
readonly active: {
|
||||||
readonly viewportY: number;
|
readonly viewportY: number;
|
||||||
readonly baseY: number;
|
readonly baseY: number;
|
||||||
getLine(y: number): {
|
getLine(y: number):
|
||||||
|
| {
|
||||||
translateToString(trimRight?: boolean): string;
|
translateToString(trimRight?: boolean): string;
|
||||||
} | undefined;
|
}
|
||||||
|
| undefined;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
/** Unicode addon (e.g. Unicode11Addon) for CJK wide character width */
|
||||||
|
readonly unicode?: {
|
||||||
|
getStringCellWidth(str: string): number;
|
||||||
|
activeVersion?: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -158,6 +165,8 @@ export interface RenderParams {
|
|||||||
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. */
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import type {
|
|||||||
} from './types.js';
|
} from './types.js';
|
||||||
import { getCellDimensions } from './cell-dimensions.js';
|
import { getCellDimensions } from './cell-dimensions.js';
|
||||||
import { findPrompt, readTextAfterPrompt } from './prompt-finder.js';
|
import { findPrompt, readTextAfterPrompt } from './prompt-finder.js';
|
||||||
import { renderOverlay } from './overlay-renderer.js';
|
import { renderOverlay, charCellWidth } from './overlay-renderer.js';
|
||||||
|
|
||||||
const DEFAULT_PROMPT: PromptFinder = { type: 'character', char: '>', offset: 2 };
|
const DEFAULT_PROMPT: PromptFinder = { type: 'character', char: '>', offset: 2 };
|
||||||
const DEFAULT_Z_INDEX = 7;
|
const DEFAULT_Z_INDEX = 7;
|
||||||
@@ -59,9 +59,8 @@ const DEFAULT_CURSOR = '#e0e0e0';
|
|||||||
export class ZerolagInputAddon implements XtermAddon {
|
export class ZerolagInputAddon implements XtermAddon {
|
||||||
private _terminal: XtermTerminal | null = null;
|
private _terminal: XtermTerminal | null = null;
|
||||||
private _overlay: HTMLDivElement | null = null;
|
private _overlay: HTMLDivElement | null = null;
|
||||||
private _options: Required<
|
private _options: Required<Pick<ZerolagInputOptions, 'zIndex' | 'showCursor' | 'scrollDebounceMs'>> &
|
||||||
Pick<ZerolagInputOptions, 'zIndex' | 'showCursor' | 'scrollDebounceMs'>
|
ZerolagInputOptions;
|
||||||
> & ZerolagInputOptions;
|
|
||||||
|
|
||||||
// Text state
|
// Text state
|
||||||
private _pendingText = '';
|
private _pendingText = '';
|
||||||
@@ -110,8 +109,7 @@ export class ZerolagInputAddon implements XtermAddon {
|
|||||||
|
|
||||||
// Create overlay container
|
// Create overlay container
|
||||||
this._overlay = document.createElement('div');
|
this._overlay = document.createElement('div');
|
||||||
this._overlay.style.cssText =
|
this._overlay.style.cssText = `position:absolute;z-index:${this._options.zIndex};pointer-events:none;display:none`;
|
||||||
`position:absolute;z-index:${this._options.zIndex};pointer-events:none;display:none`;
|
|
||||||
|
|
||||||
// Insert into xterm DOM
|
// Insert into xterm DOM
|
||||||
const screen = terminal.element?.querySelector('.xterm-screen');
|
const screen = terminal.element?.querySelector('.xterm-screen');
|
||||||
@@ -140,7 +138,9 @@ export class ZerolagInputAddon implements XtermAddon {
|
|||||||
this._render();
|
this._render();
|
||||||
}, this._options.scrollDebounceMs);
|
}, this._options.scrollDebounceMs);
|
||||||
}
|
}
|
||||||
} catch { /* ignore */ }
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const viewport = terminal.element?.querySelector('.xterm-viewport');
|
const viewport = terminal.element?.querySelector('.xterm-viewport');
|
||||||
@@ -466,7 +466,9 @@ export class ZerolagInputAddon implements XtermAddon {
|
|||||||
this._bufferDetectDone = true;
|
this._bufferDetectDone = true;
|
||||||
return afterPrompt;
|
return afterPrompt;
|
||||||
}
|
}
|
||||||
} catch { /* ignore */ }
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -478,14 +480,8 @@ export class ZerolagInputAddon implements XtermAddon {
|
|||||||
this._font.fontFamily = t.options.fontFamily || 'monospace';
|
this._font.fontFamily = t.options.fontFamily || 'monospace';
|
||||||
this._font.fontSize = (t.options.fontSize || 14) + 'px';
|
this._font.fontSize = (t.options.fontSize || 14) + 'px';
|
||||||
this._font.fontWeight = String(t.options.fontWeight || 'normal');
|
this._font.fontWeight = String(t.options.fontWeight || 'normal');
|
||||||
this._font.backgroundColor =
|
this._font.backgroundColor = this._options.backgroundColor ?? t.options.theme?.background ?? DEFAULT_BG;
|
||||||
this._options.backgroundColor ??
|
this._font.color = this._options.foregroundColor ?? t.options.theme?.foreground ?? DEFAULT_FG;
|
||||||
t.options.theme?.background ??
|
|
||||||
DEFAULT_BG;
|
|
||||||
this._font.color =
|
|
||||||
this._options.foregroundColor ??
|
|
||||||
t.options.theme?.foreground ??
|
|
||||||
DEFAULT_FG;
|
|
||||||
this._font.letterSpacing = '';
|
this._font.letterSpacing = '';
|
||||||
|
|
||||||
// Prefer computed styles from rendered rows (matches actual rendering)
|
// Prefer computed styles from rendered rows (matches actual rendering)
|
||||||
@@ -573,21 +569,39 @@ export class ZerolagInputAddon implements XtermAddon {
|
|||||||
if (renderKey === this._lastRenderKey && this._overlay.style.display !== 'none') return;
|
if (renderKey === this._lastRenderKey && this._overlay.style.display !== 'none') return;
|
||||||
this._lastRenderKey = renderKey;
|
this._lastRenderKey = renderKey;
|
||||||
|
|
||||||
// Split text into visual lines matching terminal character-wrap
|
// Split into visual lines by column width (CJK wide chars = 2 cols)
|
||||||
const firstLineCols = Math.max(1, totalCols - startCol);
|
const firstLineCols = Math.max(1, totalCols - startCol);
|
||||||
|
const chars = [...displayText]; // proper Unicode iteration
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
let remaining = displayText;
|
let ci = 0;
|
||||||
lines.push(remaining.slice(0, firstLineCols));
|
// First line: remaining columns after prompt
|
||||||
remaining = remaining.slice(firstLineCols);
|
{
|
||||||
while (remaining.length > 0) {
|
let lineStr = '';
|
||||||
lines.push(remaining.slice(0, totalCols));
|
let lineCols = 0;
|
||||||
remaining = remaining.slice(totalCols);
|
while (ci < chars.length) {
|
||||||
|
const cw = charCellWidth(this._terminal, chars[ci]);
|
||||||
|
if (lineCols + cw > firstLineCols) break;
|
||||||
|
lineStr += chars[ci];
|
||||||
|
lineCols += cw;
|
||||||
|
ci++;
|
||||||
|
}
|
||||||
|
lines.push(lineStr);
|
||||||
|
}
|
||||||
|
// Subsequent lines: full terminal width
|
||||||
|
while (ci < chars.length) {
|
||||||
|
let lineStr = '';
|
||||||
|
let lineCols = 0;
|
||||||
|
while (ci < chars.length) {
|
||||||
|
const cw = charCellWidth(this._terminal, chars[ci]);
|
||||||
|
if (lineCols + cw > totalCols) break;
|
||||||
|
lineStr += chars[ci];
|
||||||
|
lineCols += cw;
|
||||||
|
ci++;
|
||||||
|
}
|
||||||
|
lines.push(lineStr);
|
||||||
}
|
}
|
||||||
|
|
||||||
const cursorColor =
|
const cursorColor = this._options.cursorColor ?? this._terminal.options.theme?.cursor ?? DEFAULT_CURSOR;
|
||||||
this._options.cursorColor ??
|
|
||||||
this._terminal.options.theme?.cursor ??
|
|
||||||
DEFAULT_CURSOR;
|
|
||||||
|
|
||||||
renderOverlay(this._overlay, {
|
renderOverlay(this._overlay, {
|
||||||
lines,
|
lines,
|
||||||
@@ -601,6 +615,7 @@ export class ZerolagInputAddon implements XtermAddon {
|
|||||||
font: this._font,
|
font: this._font,
|
||||||
showCursor: this._options.showCursor,
|
showCursor: this._options.showCursor,
|
||||||
cursorColor,
|
cursorColor,
|
||||||
|
terminal: this._terminal,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// Hide on render error but preserve pendingText —
|
// Hide on render error but preserve pendingText —
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
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 = {
|
||||||
@@ -32,7 +32,7 @@ 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');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -101,14 +101,17 @@ describe('renderOverlay', () => {
|
|||||||
|
|
||||||
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(
|
||||||
|
container,
|
||||||
|
makeParams({
|
||||||
lines: ['ab'],
|
lines: ['ab'],
|
||||||
startCol: 3,
|
startCol: 3,
|
||||||
cellW: 10,
|
cellW: 10,
|
||||||
cellH: 20,
|
cellH: 20,
|
||||||
showCursor: true,
|
showCursor: true,
|
||||||
cursorColor: '#ff00ff',
|
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;
|
||||||
@@ -129,12 +132,15 @@ describe('renderOverlay', () => {
|
|||||||
|
|
||||||
it('renders multi-line text', () => {
|
it('renders multi-line text', () => {
|
||||||
const container = document.createElement('div');
|
const container = document.createElement('div');
|
||||||
renderOverlay(container, makeParams({
|
renderOverlay(
|
||||||
|
container,
|
||||||
|
makeParams({
|
||||||
lines: ['first', 'second'],
|
lines: ['first', 'second'],
|
||||||
startCol: 5,
|
startCol: 5,
|
||||||
cellW: 10,
|
cellW: 10,
|
||||||
cellH: 20,
|
cellH: 20,
|
||||||
}));
|
})
|
||||||
|
);
|
||||||
|
|
||||||
// 2 line divs + cursor
|
// 2 line divs + cursor
|
||||||
expect(container.children.length).toBe(3);
|
expect(container.children.length).toBe(3);
|
||||||
@@ -184,16 +190,19 @@ describe('renderOverlay', () => {
|
|||||||
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(
|
||||||
|
container,
|
||||||
|
makeParams({
|
||||||
lines: ['first', 'second'],
|
lines: ['first', 'second'],
|
||||||
cellH: 19,
|
cellH: 19,
|
||||||
}));
|
})
|
||||||
|
);
|
||||||
const line1 = container.children[0] as HTMLDivElement;
|
const line1 = container.children[0] as HTMLDivElement;
|
||||||
const line2 = container.children[1] as HTMLDivElement;
|
const line2 = container.children[1] as HTMLDivElement;
|
||||||
expect(line1.style.height).toBe('20px');
|
expect(line1.style.height).toBe('20px');
|
||||||
@@ -230,7 +239,7 @@ describe('renderOverlay', () => {
|
|||||||
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");
|
||||||
});
|
});
|
||||||
@@ -255,13 +264,16 @@ describe('renderOverlay', () => {
|
|||||||
|
|
||||||
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(
|
||||||
|
container,
|
||||||
|
makeParams({
|
||||||
lines: ['first', 'ab'],
|
lines: ['first', 'ab'],
|
||||||
startCol: 5,
|
startCol: 5,
|
||||||
cellW: 10,
|
cellW: 10,
|
||||||
cellH: 20,
|
cellH: 20,
|
||||||
showCursor: true,
|
showCursor: true,
|
||||||
}));
|
})
|
||||||
|
);
|
||||||
// Cursor at end of second line: col = 0 + 2 = 2
|
// Cursor at end of second line: col = 0 + 2 = 2
|
||||||
const cursor = container.children[container.children.length - 1] as HTMLSpanElement;
|
const cursor = container.children[container.children.length - 1] as HTMLSpanElement;
|
||||||
expect(cursor.style.left).toBe('20px'); // 2 * 10
|
expect(cursor.style.left).toBe('20px'); // 2 * 10
|
||||||
@@ -272,11 +284,16 @@ describe('renderOverlay', () => {
|
|||||||
|
|
||||||
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(() =>
|
||||||
|
renderOverlay(
|
||||||
|
container,
|
||||||
|
makeParams({
|
||||||
lines: ['test'],
|
lines: ['test'],
|
||||||
charTop: 2,
|
charTop: 2,
|
||||||
charHeight: 14,
|
charHeight: 14,
|
||||||
}))).not.toThrow();
|
})
|
||||||
|
)
|
||||||
|
).not.toThrow();
|
||||||
expect(container.children.length).toBeGreaterThan(0);
|
expect(container.children.length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -297,4 +314,107 @@ describe('renderOverlay', () => {
|
|||||||
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -376,7 +376,10 @@ describe('ZerolagInputAddon', () => {
|
|||||||
prompt: { type: 'character', char: '\u276f', offset: 2 },
|
prompt: { type: 'character', char: '\u276f', offset: 2 },
|
||||||
});
|
});
|
||||||
mock.terminal.loadAddon(addon);
|
mock.terminal.loadAddon(addon);
|
||||||
cleanups.push(() => { addon.dispose(); mock.cleanup(); });
|
cleanups.push(() => {
|
||||||
|
addon.dispose();
|
||||||
|
mock.cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
const text = addon.readPromptText();
|
const text = addon.readPromptText();
|
||||||
expect(text).toBe('hello');
|
expect(text).toBe('hello');
|
||||||
@@ -536,7 +539,10 @@ describe('ZerolagInputAddon', () => {
|
|||||||
prompt: { type: 'character', char: '$', offset: 2 },
|
prompt: { type: 'character', char: '$', offset: 2 },
|
||||||
});
|
});
|
||||||
mock.terminal.loadAddon(addon);
|
mock.terminal.loadAddon(addon);
|
||||||
cleanups.push(() => { addon.dispose(); mock.cleanup(); });
|
cleanups.push(() => {
|
||||||
|
addon.dispose();
|
||||||
|
mock.cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
// Switch to regex
|
// Switch to regex
|
||||||
addon.setPrompt({ type: 'regex', pattern: /\$/, offset: 2 });
|
addon.setPrompt({ type: 'regex', pattern: /\$/, offset: 2 });
|
||||||
@@ -647,6 +653,58 @@ describe('ZerolagInputAddon', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('CJK wide character support', () => {
|
||||||
|
it('addChar works with CJK characters', () => {
|
||||||
|
const { addon } = tracked();
|
||||||
|
addon.addChar('你');
|
||||||
|
addon.addChar('好');
|
||||||
|
expect(addon.pendingText).toBe('你好');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('appendText works with CJK characters', () => {
|
||||||
|
const { addon } = tracked();
|
||||||
|
addon.appendText('こんにちは');
|
||||||
|
expect(addon.pendingText).toBe('こんにちは');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removeChar removes CJK characters correctly', () => {
|
||||||
|
const { addon } = tracked();
|
||||||
|
addon.addChar('你');
|
||||||
|
addon.addChar('好');
|
||||||
|
addon.removeChar();
|
||||||
|
expect(addon.pendingText).toBe('你');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mixed ASCII and CJK renders without error', () => {
|
||||||
|
const { addon } = tracked();
|
||||||
|
addon.addChar('h');
|
||||||
|
addon.addChar('i');
|
||||||
|
addon.addChar('你');
|
||||||
|
addon.addChar('好');
|
||||||
|
expect(addon.pendingText).toBe('hi你好');
|
||||||
|
expect(addon.hasPending).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('CJK line wrapping accounts for double-width', () => {
|
||||||
|
// With 10 cols and startCol=2, first line has 8 available cols
|
||||||
|
// Each CJK char takes 2 cols, so 4 CJK chars fill the first line
|
||||||
|
const { addon } = tracked(['$ '], '$');
|
||||||
|
// Type 5 CJK chars — should overflow first line
|
||||||
|
for (const ch of '你好世界啊') {
|
||||||
|
addon.addChar(ch);
|
||||||
|
}
|
||||||
|
expect(addon.pendingText).toBe('你好世界啊');
|
||||||
|
expect(addon.hasPending).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Korean text renders without error', () => {
|
||||||
|
const { addon } = tracked();
|
||||||
|
addon.appendText('안녕하세요');
|
||||||
|
expect(addon.pendingText).toBe('안녕하세요');
|
||||||
|
expect(addon.hasPending).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('addChar implicit buffer detection', () => {
|
describe('addChar implicit buffer detection', () => {
|
||||||
it('first keystroke detects existing buffer text as flushed', () => {
|
it('first keystroke detects existing buffer text as flushed', () => {
|
||||||
const { addon } = tracked(['$ existing']);
|
const { addon } = tracked(['$ existing']);
|
||||||
|
|||||||
+10
-10
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user