mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-25 02:19:33 +01:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8b81b8478 | ||
|
|
d79e25d5d8 | ||
|
|
b1df5319d5 | ||
|
|
fc92d8a8a4 | ||
|
|
c7cd4f9e17 |
No files matched your search
@@ -1,5 +1,11 @@
|
|||||||
# aicodeman
|
# aicodeman
|
||||||
|
|
||||||
|
## 0.3.3
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- fix: eliminate WebGL re-render flicker during tab switch by keeping renderer active instead of toggling it off/on around large buffer writes
|
||||||
|
|
||||||
## 0.3.2
|
## 0.3.2
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ When user says "COM":
|
|||||||
4. **Sync CLAUDE.md version**: Update the `**Version**` line below to match the new version from `package.json`
|
4. **Sync CLAUDE.md version**: Update the `**Version**` line below to match the new version from `package.json`
|
||||||
5. **Commit and deploy**: `git add -A && git commit -m "chore: version packages" && git push && npm run build && systemctl --user restart codeman-web`
|
5. **Commit and deploy**: `git add -A && git commit -m "chore: version packages" && git push && npm run build && systemctl --user restart codeman-web`
|
||||||
|
|
||||||
**Version**: 0.3.2 (must match `package.json`)
|
**Version**: 0.3.3 (must match `package.json`)
|
||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
@@ -109,7 +109,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
|||||||
| **Infra** | `src/hooks-config.ts`, `src/push-store.ts`, `src/tunnel-manager.ts`, `src/image-watcher.ts`, `src/file-stream-manager.ts` | |
|
| **Infra** | `src/hooks-config.ts`, `src/push-store.ts`, `src/tunnel-manager.ts`, `src/image-watcher.ts`, `src/file-stream-manager.ts` | |
|
||||||
| **Plan** | `src/plan-orchestrator.ts`, `src/prompts/*.ts`, `src/templates/claude-md.ts` | |
|
| **Plan** | `src/plan-orchestrator.ts`, `src/prompts/*.ts`, `src/templates/claude-md.ts` | |
|
||||||
| **Web** | `src/web/server.ts`, `src/web/sse-events.ts`, `src/web/routes/*.ts` (13 modules), `src/web/ports/*.ts`, `src/web/middleware/auth.ts`, `src/web/schemas.ts` | |
|
| **Web** | `src/web/server.ts`, `src/web/sse-events.ts`, `src/web/routes/*.ts` (13 modules), `src/web/ports/*.ts`, `src/web/middleware/auth.ts`, `src/web/schemas.ts` | |
|
||||||
| **Frontend** | `src/web/public/app.js` ★ (~11.7K lines) + 9 JS modules | |
|
| **Frontend** | `src/web/public/app.js` ★ (~11.8K lines) + 10 JS modules (incl. `sw.js` service worker) | |
|
||||||
| **Types** | `src/types/index.ts` → 14 domain files | See `@fileoverview` in index.ts |
|
| **Types** | `src/types/index.ts` → 14 domain files | See `@fileoverview` in index.ts |
|
||||||
|
|
||||||
★ = Large file (>50KB). All files have `@fileoverview` JSDoc — read that before diving in.
|
★ = Large file (>50KB). All files have `@fileoverview` JSDoc — read that before diving in.
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aicodeman",
|
"name": "aicodeman",
|
||||||
"version": "0.3.2",
|
"version": "0.3.3",
|
||||||
"description": "The missing control plane for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
"description": "The missing control plane for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
}
|
||||||
@@ -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
@@ -0,0 +1,843 @@
|
|||||||
|
/**
|
||||||
|
* Comprehensive CJK wide character test plan for PR #30.
|
||||||
|
*
|
||||||
|
* Tests all 7 items from the test plan:
|
||||||
|
* 1. Chinese text input — no character overlap
|
||||||
|
* 2. CJK text renders with correct double-width spacing
|
||||||
|
* 3. Japanese (こんにちは) and Korean (안녕하세요) input
|
||||||
|
* 4. Cursor positions correctly after CJK characters
|
||||||
|
* 5. Long CJK input wraps at correct column boundary
|
||||||
|
* 6. Existing ASCII input is unaffected
|
||||||
|
* 7. Teammate terminal panels render CJK correctly (app.js embedded copy)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, afterEach } from 'vitest';
|
||||||
|
import { renderOverlay, charCellWidth, stringCellWidth } from '../src/overlay-renderer.js';
|
||||||
|
import type { RenderParams, FontStyle } from '../src/types.js';
|
||||||
|
import { createMockTerminal } from './helpers.js';
|
||||||
|
import { ZerolagInputAddon } from '../src/zerolag-input-addon.js';
|
||||||
|
|
||||||
|
// ─── Shared fixtures ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const FONT: FontStyle = {
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 'normal',
|
||||||
|
color: '#eeeeee',
|
||||||
|
backgroundColor: '#0d0d0d',
|
||||||
|
letterSpacing: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
function makeParams(overrides: Partial<RenderParams> = {}): RenderParams {
|
||||||
|
return {
|
||||||
|
lines: ['hello'],
|
||||||
|
startCol: 2,
|
||||||
|
totalCols: 80,
|
||||||
|
cellW: 10,
|
||||||
|
cellH: 17,
|
||||||
|
charTop: 2,
|
||||||
|
charHeight: 14,
|
||||||
|
promptRow: 0,
|
||||||
|
font: FONT,
|
||||||
|
showCursor: true,
|
||||||
|
cursorColor: '#e0e0e0',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extract span data from a rendered line div */
|
||||||
|
function getSpans(lineDiv: HTMLDivElement) {
|
||||||
|
const spans: { text: string; left: number; width: number }[] = [];
|
||||||
|
for (let i = 0; i < lineDiv.children.length; i++) {
|
||||||
|
const span = lineDiv.children[i] as HTMLSpanElement;
|
||||||
|
spans.push({
|
||||||
|
text: span.textContent || '',
|
||||||
|
left: parseFloat(span.style.left),
|
||||||
|
width: parseFloat(span.style.width),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return spans;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Addon setup helpers ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
let cleanups: (() => void)[] = [];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const fn of cleanups) fn();
|
||||||
|
cleanups = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
function tracked(lines: string[] = ['$ '], promptChar = '$') {
|
||||||
|
const mock = createMockTerminal({ buffer: { lines }, cols: 80 });
|
||||||
|
const addon = new ZerolagInputAddon({
|
||||||
|
prompt: { type: 'character', char: promptChar, offset: 2 },
|
||||||
|
});
|
||||||
|
mock.terminal.loadAddon(addon);
|
||||||
|
cleanups.push(() => {
|
||||||
|
addon.dispose();
|
||||||
|
mock.cleanup();
|
||||||
|
});
|
||||||
|
return { addon, mock };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
// TEST 1: Chinese text input — no character overlap
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
describe('Test 1: Chinese text — no character overlap', () => {
|
||||||
|
it('consecutive Chinese characters have contiguous non-overlapping spans', () => {
|
||||||
|
const container = document.createElement('div');
|
||||||
|
const text = '你好世界';
|
||||||
|
renderOverlay(container, makeParams({ lines: [text], cellW: 10 }));
|
||||||
|
|
||||||
|
const lineDiv = container.children[0] as HTMLDivElement;
|
||||||
|
const spans = getSpans(lineDiv);
|
||||||
|
|
||||||
|
expect(spans.length).toBe(4);
|
||||||
|
|
||||||
|
// Each span: left = previous span's (left + width), width = 20px (2 cells)
|
||||||
|
for (let i = 0; i < spans.length; i++) {
|
||||||
|
expect(spans[i].width).toBe(20); // 2 cells * 10px
|
||||||
|
if (i > 0) {
|
||||||
|
const expectedLeft = spans[i - 1].left + spans[i - 1].width;
|
||||||
|
expect(spans[i].left).toBe(expectedLeft);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Chinese sentence (simulated pinyin output) renders without gaps or overlaps', () => {
|
||||||
|
const container = document.createElement('div');
|
||||||
|
const text = '我是一个测试';
|
||||||
|
renderOverlay(container, makeParams({ lines: [text], cellW: 8 }));
|
||||||
|
|
||||||
|
const lineDiv = container.children[0] as HTMLDivElement;
|
||||||
|
const spans = getSpans(lineDiv);
|
||||||
|
|
||||||
|
expect(spans.length).toBe(6);
|
||||||
|
|
||||||
|
// Verify contiguous positioning
|
||||||
|
let expectedLeft = 0;
|
||||||
|
for (const span of spans) {
|
||||||
|
expect(span.left).toBe(expectedLeft);
|
||||||
|
expect(span.width).toBe(16); // 2 * 8px
|
||||||
|
expectedLeft += span.width;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Total visual width should be 6 chars * 2 cells * 8px = 96px
|
||||||
|
expect(expectedLeft).toBe(96);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mixed Chinese + ASCII has no gaps between spans', () => {
|
||||||
|
const container = document.createElement('div');
|
||||||
|
const text = 'hello你好world';
|
||||||
|
renderOverlay(container, makeParams({ lines: [text], cellW: 10 }));
|
||||||
|
|
||||||
|
const lineDiv = container.children[0] as HTMLDivElement;
|
||||||
|
const spans = getSpans(lineDiv);
|
||||||
|
|
||||||
|
// h(10) e(10) l(10) l(10) o(10) 你(20) 好(20) w(10) o(10) r(10) l(10) d(10)
|
||||||
|
expect(spans.length).toBe(12);
|
||||||
|
|
||||||
|
// Verify contiguity: no gaps between any adjacent spans
|
||||||
|
for (let i = 1; i < spans.length; i++) {
|
||||||
|
const prevEnd = spans[i - 1].left + spans[i - 1].width;
|
||||||
|
expect(spans[i].left).toBe(prevEnd);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('addChar with Chinese characters accumulates correctly in addon', () => {
|
||||||
|
const { addon } = tracked();
|
||||||
|
for (const ch of '你好世界') {
|
||||||
|
addon.addChar(ch);
|
||||||
|
}
|
||||||
|
expect(addon.pendingText).toBe('你好世界');
|
||||||
|
expect(addon.hasPending).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
// TEST 2: CJK text renders with correct double-width spacing
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
describe('Test 2: CJK double-width spacing', () => {
|
||||||
|
it('charCellWidth returns 2 for CJK Unified Ideographs (0x4E00-0x9FFF)', () => {
|
||||||
|
// Common Chinese characters
|
||||||
|
const chars = '中文测试你好世界天地人';
|
||||||
|
for (const ch of chars) {
|
||||||
|
expect(charCellWidth(null, ch)).toBe(2);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('charCellWidth returns 2 for CJK Extension A (0x3400-0x4DBF)', () => {
|
||||||
|
expect(charCellWidth(null, '\u3400')).toBe(2); // First Extension A char
|
||||||
|
expect(charCellWidth(null, '\u4DB5')).toBe(2); // One of the last Extension A chars
|
||||||
|
});
|
||||||
|
|
||||||
|
it('charCellWidth returns 2 for CJK Radicals (0x2E80-0x2EFF)', () => {
|
||||||
|
expect(charCellWidth(null, '\u2E80')).toBe(2); // CJK Radical Repeat
|
||||||
|
});
|
||||||
|
|
||||||
|
it('charCellWidth returns 2 for fullwidth ASCII forms (0xFF01-0xFF5E)', () => {
|
||||||
|
expect(charCellWidth(null, '\uFF01')).toBe(2); // !
|
||||||
|
expect(charCellWidth(null, '\uFF21')).toBe(2); // A
|
||||||
|
expect(charCellWidth(null, '\uFF41')).toBe(2); // a
|
||||||
|
expect(charCellWidth(null, '\uFF10')).toBe(2); // 0
|
||||||
|
});
|
||||||
|
|
||||||
|
it('charCellWidth returns 2 for CJK Compatibility Ideographs (0xF900-0xFAFF)', () => {
|
||||||
|
expect(charCellWidth(null, '\uF900')).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stringCellWidth calculates correct visual width for CJK strings', () => {
|
||||||
|
expect(stringCellWidth(null, '你好')).toBe(4); // 2 + 2
|
||||||
|
expect(stringCellWidth(null, '你好世界')).toBe(8); // 4 * 2
|
||||||
|
expect(stringCellWidth(null, 'hi你好')).toBe(6); // 1 + 1 + 2 + 2
|
||||||
|
expect(stringCellWidth(null, '你a好b')).toBe(6); // 2 + 1 + 2 + 1
|
||||||
|
expect(stringCellWidth(null, 'abc你好def')).toBe(10); // 3 + 4 + 3
|
||||||
|
});
|
||||||
|
|
||||||
|
it('CJK span widths are exactly 2 * cellW pixels', () => {
|
||||||
|
const container = document.createElement('div');
|
||||||
|
renderOverlay(container, makeParams({ lines: ['你'], cellW: 8.4 }));
|
||||||
|
const lineDiv = container.children[0] as HTMLDivElement;
|
||||||
|
const span = lineDiv.children[0] as HTMLSpanElement;
|
||||||
|
expect(span.style.width).toBe('16.8px'); // 2 * 8.4
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ASCII span widths remain 1 * cellW pixels', () => {
|
||||||
|
const container = document.createElement('div');
|
||||||
|
renderOverlay(container, makeParams({ lines: ['a'], cellW: 8.4 }));
|
||||||
|
const lineDiv = container.children[0] as HTMLDivElement;
|
||||||
|
const span = lineDiv.children[0] as HTMLSpanElement;
|
||||||
|
expect(span.style.width).toBe('8.4px'); // 1 * 8.4
|
||||||
|
});
|
||||||
|
|
||||||
|
it('terminal unicode addon is preferred over fallback when available', () => {
|
||||||
|
const mockTerminal = {
|
||||||
|
unicode: {
|
||||||
|
getStringCellWidth: (s: string) => {
|
||||||
|
// Custom width: treat 'W' as wide
|
||||||
|
return s === 'W' ? 2 : 1;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as any;
|
||||||
|
expect(charCellWidth(mockTerminal, 'W')).toBe(2);
|
||||||
|
expect(charCellWidth(mockTerminal, 'a')).toBe(1);
|
||||||
|
// Fallback ignores terminal addon result for actual CJK
|
||||||
|
expect(charCellWidth(null, '你')).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
// TEST 3: Japanese (こんにちは) and Korean (안녕하세요) input
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
describe('Test 3: Japanese and Korean input', () => {
|
||||||
|
describe('Japanese', () => {
|
||||||
|
it('charCellWidth returns 2 for Hiragana (0x3040-0x309F)', () => {
|
||||||
|
const hiragana = 'あいうえおかきくけこさしすせそ';
|
||||||
|
for (const ch of hiragana) {
|
||||||
|
expect(charCellWidth(null, ch)).toBe(2);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('charCellWidth returns 2 for Katakana (0x30A0-0x30FF)', () => {
|
||||||
|
const katakana = 'アイウエオカキクケコサシスセソ';
|
||||||
|
for (const ch of katakana) {
|
||||||
|
expect(charCellWidth(null, ch)).toBe(2);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Japanese greeting renders with correct span positions', () => {
|
||||||
|
const container = document.createElement('div');
|
||||||
|
const text = 'こんにちは';
|
||||||
|
renderOverlay(container, makeParams({ lines: [text], cellW: 10 }));
|
||||||
|
|
||||||
|
const lineDiv = container.children[0] as HTMLDivElement;
|
||||||
|
const spans = getSpans(lineDiv);
|
||||||
|
|
||||||
|
expect(spans.length).toBe(5);
|
||||||
|
// こ(0,20) ん(20,20) に(40,20) ち(60,20) は(80,20)
|
||||||
|
expect(spans[0]).toEqual({ text: 'こ', left: 0, width: 20 });
|
||||||
|
expect(spans[1]).toEqual({ text: 'ん', left: 20, width: 20 });
|
||||||
|
expect(spans[2]).toEqual({ text: 'に', left: 40, width: 20 });
|
||||||
|
expect(spans[3]).toEqual({ text: 'ち', left: 60, width: 20 });
|
||||||
|
expect(spans[4]).toEqual({ text: 'は', left: 80, width: 20 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mixed Japanese + ASCII positions correctly', () => {
|
||||||
|
const container = document.createElement('div');
|
||||||
|
const text = 'hello こんにちは';
|
||||||
|
renderOverlay(container, makeParams({ lines: [text], cellW: 10 }));
|
||||||
|
|
||||||
|
const lineDiv = container.children[0] as HTMLDivElement;
|
||||||
|
const spans = getSpans(lineDiv);
|
||||||
|
|
||||||
|
// h(0) e(10) l(20) l(30) o(40) space(50) こ(60) ん(80) に(100) ち(120) は(140)
|
||||||
|
expect(spans.length).toBe(11);
|
||||||
|
expect(spans[5]).toEqual({ text: ' ', left: 50, width: 10 }); // space
|
||||||
|
expect(spans[6]).toEqual({ text: 'こ', left: 60, width: 20 }); // first CJK after ASCII
|
||||||
|
});
|
||||||
|
|
||||||
|
it('addon handles Japanese input via addChar', () => {
|
||||||
|
const { addon } = tracked();
|
||||||
|
for (const ch of 'こんにちは') {
|
||||||
|
addon.addChar(ch);
|
||||||
|
}
|
||||||
|
expect(addon.pendingText).toBe('こんにちは');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('addon handles Japanese input via appendText (paste)', () => {
|
||||||
|
const { addon } = tracked();
|
||||||
|
addon.appendText('こんにちは世界');
|
||||||
|
expect(addon.pendingText).toBe('こんにちは世界');
|
||||||
|
expect(addon.hasPending).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stringCellWidth correct for Japanese greeting', () => {
|
||||||
|
expect(stringCellWidth(null, 'こんにちは')).toBe(10); // 5 * 2
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Korean', () => {
|
||||||
|
it('charCellWidth returns 2 for Hangul Syllables (0xAC00-0xD7A3)', () => {
|
||||||
|
const hangul = '가나다라마바사아자차카타파하';
|
||||||
|
for (const ch of hangul) {
|
||||||
|
expect(charCellWidth(null, ch)).toBe(2);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('charCellWidth returns 2 for Hangul Jamo (0x1100-0x115F)', () => {
|
||||||
|
expect(charCellWidth(null, '\u1100')).toBe(2); // ᄀ
|
||||||
|
expect(charCellWidth(null, '\u1112')).toBe(2); // ᄒ
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Korean greeting renders with correct span positions', () => {
|
||||||
|
const container = document.createElement('div');
|
||||||
|
const text = '안녕하세요';
|
||||||
|
renderOverlay(container, makeParams({ lines: [text], cellW: 10 }));
|
||||||
|
|
||||||
|
const lineDiv = container.children[0] as HTMLDivElement;
|
||||||
|
const spans = getSpans(lineDiv);
|
||||||
|
|
||||||
|
expect(spans.length).toBe(5);
|
||||||
|
expect(spans[0]).toEqual({ text: '안', left: 0, width: 20 });
|
||||||
|
expect(spans[1]).toEqual({ text: '녕', left: 20, width: 20 });
|
||||||
|
expect(spans[2]).toEqual({ text: '하', left: 40, width: 20 });
|
||||||
|
expect(spans[3]).toEqual({ text: '세', left: 60, width: 20 });
|
||||||
|
expect(spans[4]).toEqual({ text: '요', left: 80, width: 20 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('addon handles Korean input', () => {
|
||||||
|
const { addon } = tracked();
|
||||||
|
addon.appendText('안녕하세요');
|
||||||
|
expect(addon.pendingText).toBe('안녕하세요');
|
||||||
|
expect(addon.hasPending).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removeChar removes Korean characters one at a time', () => {
|
||||||
|
const { addon } = tracked();
|
||||||
|
addon.appendText('안녕');
|
||||||
|
addon.removeChar();
|
||||||
|
expect(addon.pendingText).toBe('안');
|
||||||
|
addon.removeChar();
|
||||||
|
expect(addon.pendingText).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stringCellWidth correct for Korean greeting', () => {
|
||||||
|
expect(stringCellWidth(null, '안녕하세요')).toBe(10); // 5 * 2
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mixed CJK scripts', () => {
|
||||||
|
it('Chinese + Japanese + Korean in one string', () => {
|
||||||
|
const text = '你好こんにちは안녕';
|
||||||
|
expect(stringCellWidth(null, text)).toBe(18); // 9 chars * 2 each
|
||||||
|
|
||||||
|
const container = document.createElement('div');
|
||||||
|
renderOverlay(container, makeParams({ lines: [text], cellW: 10 }));
|
||||||
|
const lineDiv = container.children[0] as HTMLDivElement;
|
||||||
|
const spans = getSpans(lineDiv);
|
||||||
|
|
||||||
|
// All 9 CJK chars: contiguous double-width spans
|
||||||
|
expect(spans.length).toBe(9);
|
||||||
|
let expectedLeft = 0;
|
||||||
|
for (const span of spans) {
|
||||||
|
expect(span.left).toBe(expectedLeft);
|
||||||
|
expect(span.width).toBe(20);
|
||||||
|
expectedLeft += 20;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
// TEST 4: Cursor positions correctly after CJK characters
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
describe('Test 4: Cursor positioning after CJK', () => {
|
||||||
|
it('cursor after Chinese text on first line', () => {
|
||||||
|
const container = document.createElement('div');
|
||||||
|
renderOverlay(
|
||||||
|
container,
|
||||||
|
makeParams({
|
||||||
|
lines: ['你好世界'],
|
||||||
|
startCol: 2,
|
||||||
|
cellW: 10,
|
||||||
|
showCursor: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
// cursorCol = startCol(2) + stringCellWidth('你好世界')(8) = 10
|
||||||
|
const cursor = container.children[container.children.length - 1] as HTMLSpanElement;
|
||||||
|
expect(cursor.style.left).toBe('100px'); // 10 * 10px
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cursor after Japanese text on first line', () => {
|
||||||
|
const container = document.createElement('div');
|
||||||
|
renderOverlay(
|
||||||
|
container,
|
||||||
|
makeParams({
|
||||||
|
lines: ['こんにちは'],
|
||||||
|
startCol: 2,
|
||||||
|
cellW: 10,
|
||||||
|
showCursor: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
// cursorCol = 2 + 10 = 12
|
||||||
|
const cursor = container.children[container.children.length - 1] as HTMLSpanElement;
|
||||||
|
expect(cursor.style.left).toBe('120px');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cursor after Korean text on first line', () => {
|
||||||
|
const container = document.createElement('div');
|
||||||
|
renderOverlay(
|
||||||
|
container,
|
||||||
|
makeParams({
|
||||||
|
lines: ['안녕하세요'],
|
||||||
|
startCol: 2,
|
||||||
|
cellW: 10,
|
||||||
|
showCursor: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
// cursorCol = 2 + 10 = 12
|
||||||
|
const cursor = container.children[container.children.length - 1] as HTMLSpanElement;
|
||||||
|
expect(cursor.style.left).toBe('120px');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cursor after mixed ASCII + CJK', () => {
|
||||||
|
const container = document.createElement('div');
|
||||||
|
renderOverlay(
|
||||||
|
container,
|
||||||
|
makeParams({
|
||||||
|
lines: ['hi你好'],
|
||||||
|
startCol: 3,
|
||||||
|
cellW: 10,
|
||||||
|
showCursor: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
// cursorCol = 3 + stringCellWidth('hi你好')(6) = 9
|
||||||
|
const cursor = container.children[container.children.length - 1] as HTMLSpanElement;
|
||||||
|
expect(cursor.style.left).toBe('90px');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cursor on wrapped line after CJK text', () => {
|
||||||
|
const container = document.createElement('div');
|
||||||
|
renderOverlay(
|
||||||
|
container,
|
||||||
|
makeParams({
|
||||||
|
lines: ['你好世界', '再见'],
|
||||||
|
startCol: 2,
|
||||||
|
cellW: 10,
|
||||||
|
cellH: 20,
|
||||||
|
showCursor: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
// Last line is '再见', starts at col 0 (wrapped line), width = 4
|
||||||
|
const cursor = container.children[container.children.length - 1] as HTMLSpanElement;
|
||||||
|
expect(cursor.style.left).toBe('40px'); // 0 + 4 = 4, * 10 = 40
|
||||||
|
expect(cursor.style.top).toBe('20px'); // row 1 * cellH
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cursor hidden when it would exceed totalCols', () => {
|
||||||
|
const container = document.createElement('div');
|
||||||
|
// 4 CJK chars = 8 visual cols, startCol=73, cursorCol = 73 + 8 = 81 > 80
|
||||||
|
renderOverlay(
|
||||||
|
container,
|
||||||
|
makeParams({
|
||||||
|
lines: ['你好世界'],
|
||||||
|
startCol: 73,
|
||||||
|
totalCols: 80,
|
||||||
|
cellW: 10,
|
||||||
|
showCursor: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
// Should be 1 line div, no cursor span (cursor at col 81 >= totalCols 80)
|
||||||
|
// Actually cursor check is cursorCol < totalCols, so at 81 it's hidden
|
||||||
|
const children = container.children;
|
||||||
|
// If cursor is rendered, last child would be a cursor span
|
||||||
|
// With cursorCol 81 >= 80, cursor should NOT be rendered
|
||||||
|
expect(children.length).toBe(1); // only line div
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
// TEST 5: Long CJK input wraps at correct column boundary
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
describe('Test 5: CJK line wrapping at column boundaries', () => {
|
||||||
|
it('CJK chars wrap when they would overflow first line', () => {
|
||||||
|
const container = document.createElement('div');
|
||||||
|
// totalCols=10, startCol=2 → firstLineCols=8
|
||||||
|
// Each CJK char = 2 cols → first line fits 4 chars (8 cols)
|
||||||
|
// 5th char wraps to second line
|
||||||
|
const text = '你好世界啊'; // 5 chars = 10 visual cols
|
||||||
|
renderOverlay(
|
||||||
|
container,
|
||||||
|
makeParams({
|
||||||
|
lines: ['你好世界', '啊'],
|
||||||
|
startCol: 2,
|
||||||
|
totalCols: 10,
|
||||||
|
cellW: 10,
|
||||||
|
cellH: 20,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(container.children.length).toBe(3); // 2 line divs + cursor
|
||||||
|
|
||||||
|
const line1 = container.children[0] as HTMLDivElement;
|
||||||
|
expect(line1.children.length).toBe(4); // 你好世界
|
||||||
|
expect(line1.style.left).toBe('20px'); // startCol * cellW
|
||||||
|
|
||||||
|
const line2 = container.children[1] as HTMLDivElement;
|
||||||
|
expect(line2.children.length).toBe(1); // 啊
|
||||||
|
expect(line2.style.left).toBe('0px'); // wrapped line starts at col 0
|
||||||
|
expect(line2.style.top).toBe('20px'); // second row
|
||||||
|
});
|
||||||
|
|
||||||
|
it('CJK char that would partially overflow stays on next line', () => {
|
||||||
|
// totalCols=9, startCol=2 → firstLineCols=7
|
||||||
|
// CJK chars are 2-wide. 3 chars = 6 cols (fits). 4th char = 8 cols > 7. Wraps.
|
||||||
|
const container = document.createElement('div');
|
||||||
|
renderOverlay(
|
||||||
|
container,
|
||||||
|
makeParams({
|
||||||
|
lines: ['你好世', '界'],
|
||||||
|
startCol: 2,
|
||||||
|
totalCols: 9,
|
||||||
|
cellW: 10,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const line1 = container.children[0] as HTMLDivElement;
|
||||||
|
expect(line1.children.length).toBe(3); // 3 CJK chars fit (6 cols <= 7)
|
||||||
|
const line2 = container.children[1] as HTMLDivElement;
|
||||||
|
expect(line2.children.length).toBe(1); // 界 wraps
|
||||||
|
});
|
||||||
|
|
||||||
|
it('addon _render splits CJK text into visual lines correctly', () => {
|
||||||
|
// Narrow terminal: 10 cols, startCol=2 → firstLineCols=8 → 4 CJK chars
|
||||||
|
const mock = createMockTerminal({
|
||||||
|
buffer: { lines: ['$ '] },
|
||||||
|
cols: 10,
|
||||||
|
});
|
||||||
|
const addon = new ZerolagInputAddon({
|
||||||
|
prompt: { type: 'character', char: '$', offset: 2 },
|
||||||
|
});
|
||||||
|
mock.terminal.loadAddon(addon);
|
||||||
|
cleanups.push(() => {
|
||||||
|
addon.dispose();
|
||||||
|
mock.cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Type 6 CJK chars (12 visual cols)
|
||||||
|
for (const ch of '你好世界再见') {
|
||||||
|
addon.addChar(ch);
|
||||||
|
}
|
||||||
|
expect(addon.pendingText).toBe('你好世界再见');
|
||||||
|
expect(addon.hasPending).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mixed ASCII + CJK wraps correctly', () => {
|
||||||
|
const container = document.createElement('div');
|
||||||
|
// totalCols=10, startCol=2 → firstLineCols=8
|
||||||
|
// 'ab' = 2 cols, '你好' = 4 cols, 'cd' = 2 cols → total 8 cols (fits line 1)
|
||||||
|
// '世' = 2 cols → wraps to line 2
|
||||||
|
renderOverlay(
|
||||||
|
container,
|
||||||
|
makeParams({
|
||||||
|
lines: ['ab你好cd', '世'],
|
||||||
|
startCol: 2,
|
||||||
|
totalCols: 10,
|
||||||
|
cellW: 10,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const line1 = container.children[0] as HTMLDivElement;
|
||||||
|
expect(line1.children.length).toBe(6); // a,b,你,好,c,d
|
||||||
|
const line2 = container.children[1] as HTMLDivElement;
|
||||||
|
expect(line2.children.length).toBe(1); // 世
|
||||||
|
});
|
||||||
|
|
||||||
|
it('odd column count: CJK char does not split across boundary', () => {
|
||||||
|
// totalCols=11, startCol=2 → firstLineCols=9
|
||||||
|
// 4 CJK chars = 8 cols (fits). 5th CJK = 10 cols > 9 (wraps).
|
||||||
|
// 1 empty column remains on first line (CJK can't fit in 1 col).
|
||||||
|
const container = document.createElement('div');
|
||||||
|
renderOverlay(
|
||||||
|
container,
|
||||||
|
makeParams({
|
||||||
|
lines: ['你好世界', '啊'],
|
||||||
|
startCol: 2,
|
||||||
|
totalCols: 11,
|
||||||
|
cellW: 10,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const line1 = container.children[0] as HTMLDivElement;
|
||||||
|
expect(line1.children.length).toBe(4); // 4 chars = 8 cols, 5th would need 10 > 9
|
||||||
|
const line2 = container.children[1] as HTMLDivElement;
|
||||||
|
expect(line2.children.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('CJK fills entire wrapped line', () => {
|
||||||
|
const container = document.createElement('div');
|
||||||
|
// totalCols=6, startCol=0 → firstLineCols=6
|
||||||
|
// 3 CJK chars = 6 cols (fills line 1), next 3 fill line 2
|
||||||
|
renderOverlay(
|
||||||
|
container,
|
||||||
|
makeParams({
|
||||||
|
lines: ['你好世', '界再见'],
|
||||||
|
startCol: 0,
|
||||||
|
totalCols: 6,
|
||||||
|
cellW: 10,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const line1 = container.children[0] as HTMLDivElement;
|
||||||
|
expect(line1.children.length).toBe(3);
|
||||||
|
const line2 = container.children[1] as HTMLDivElement;
|
||||||
|
expect(line2.children.length).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
// TEST 6: Existing ASCII input is unaffected
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
describe('Test 6: ASCII input — no regression', () => {
|
||||||
|
it('ASCII characters still get 1-cell-wide spans', () => {
|
||||||
|
const container = document.createElement('div');
|
||||||
|
renderOverlay(container, makeParams({ lines: ['abcdef'], cellW: 10 }));
|
||||||
|
const lineDiv = container.children[0] as HTMLDivElement;
|
||||||
|
const spans = getSpans(lineDiv);
|
||||||
|
|
||||||
|
for (const span of spans) {
|
||||||
|
expect(span.width).toBe(10); // 1 * cellW
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ASCII span positions are sequential at 1-cell intervals', () => {
|
||||||
|
const container = document.createElement('div');
|
||||||
|
renderOverlay(container, makeParams({ lines: ['xyz'], cellW: 8.4 }));
|
||||||
|
const lineDiv = container.children[0] as HTMLDivElement;
|
||||||
|
const spans = getSpans(lineDiv);
|
||||||
|
|
||||||
|
expect(spans[0].left).toBe(0);
|
||||||
|
expect(spans[1].left).toBeCloseTo(8.4, 5);
|
||||||
|
expect(spans[2].left).toBeCloseTo(16.8, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ASCII cursor positions at correct column', () => {
|
||||||
|
const container = document.createElement('div');
|
||||||
|
renderOverlay(
|
||||||
|
container,
|
||||||
|
makeParams({
|
||||||
|
lines: ['hello'],
|
||||||
|
startCol: 3,
|
||||||
|
cellW: 10,
|
||||||
|
showCursor: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
// cursor at startCol(3) + 5 = 8
|
||||||
|
const cursor = container.children[container.children.length - 1] as HTMLSpanElement;
|
||||||
|
expect(cursor.style.left).toBe('80px');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('charCellWidth returns 1 for all printable ASCII', () => {
|
||||||
|
for (let code = 32; code < 127; code++) {
|
||||||
|
const ch = String.fromCharCode(code);
|
||||||
|
expect(charCellWidth(null, ch)).toBe(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stringCellWidth equals length for pure ASCII', () => {
|
||||||
|
expect(stringCellWidth(null, 'hello world')).toBe(11);
|
||||||
|
expect(stringCellWidth(null, 'test123!@#')).toBe(10);
|
||||||
|
expect(stringCellWidth(null, '')).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ASCII multi-line wrapping is unaffected', () => {
|
||||||
|
const container = document.createElement('div');
|
||||||
|
renderOverlay(
|
||||||
|
container,
|
||||||
|
makeParams({
|
||||||
|
lines: ['abcde', 'fgh'],
|
||||||
|
startCol: 5,
|
||||||
|
totalCols: 10,
|
||||||
|
cellW: 10,
|
||||||
|
cellH: 20,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(container.children.length).toBe(3); // 2 lines + cursor
|
||||||
|
const line1 = container.children[0] as HTMLDivElement;
|
||||||
|
const line2 = container.children[1] as HTMLDivElement;
|
||||||
|
expect(line1.children.length).toBe(5);
|
||||||
|
expect(line2.children.length).toBe(3);
|
||||||
|
expect(line1.style.left).toBe('50px'); // startCol * cellW
|
||||||
|
expect(line2.style.left).toBe('0px');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('addon addChar/removeChar/clear work for ASCII', () => {
|
||||||
|
const { addon } = tracked();
|
||||||
|
addon.addChar('h');
|
||||||
|
addon.addChar('e');
|
||||||
|
addon.addChar('l');
|
||||||
|
addon.addChar('l');
|
||||||
|
addon.addChar('o');
|
||||||
|
expect(addon.pendingText).toBe('hello');
|
||||||
|
|
||||||
|
addon.removeChar();
|
||||||
|
expect(addon.pendingText).toBe('hell');
|
||||||
|
|
||||||
|
addon.clear();
|
||||||
|
expect(addon.pendingText).toBe('');
|
||||||
|
expect(addon.hasPending).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
// TEST 7: Teammate terminal panels render CJK correctly
|
||||||
|
//
|
||||||
|
// Teammate panels share the global xterm-zerolag-input.js vendor bundle
|
||||||
|
// (loaded via <script> in index.html). This is the IIFE build of the
|
||||||
|
// same package source tested in tests 1-6. The build pipeline is:
|
||||||
|
// src/overlay-renderer.ts → tsup → dist/index.global.js → vendor copy
|
||||||
|
//
|
||||||
|
// Since all terminals (main + teammate) use the same LocalEchoOverlay
|
||||||
|
// class from the global scope, CJK correctness is guaranteed by:
|
||||||
|
// (a) The package source handles CJK correctly (tests 1-6 above)
|
||||||
|
// (b) The IIFE build bundles the exact same charCellWidth / makeLine code
|
||||||
|
//
|
||||||
|
// These tests verify the exported module includes CJK-aware functions
|
||||||
|
// and that teammate-style terminal instances work identically.
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
describe('Test 7: Teammate terminal panels render CJK correctly', () => {
|
||||||
|
it('package exports charCellWidth and stringCellWidth', () => {
|
||||||
|
// These are the CJK-aware functions that the IIFE build exposes
|
||||||
|
expect(typeof charCellWidth).toBe('function');
|
||||||
|
expect(typeof stringCellWidth).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ZerolagInputAddon (used by LocalEchoOverlay) handles CJK in teammate terminals', () => {
|
||||||
|
// Simulate a teammate terminal panel: separate terminal instance,
|
||||||
|
// same addon class, different prompt character
|
||||||
|
const mock = createMockTerminal({
|
||||||
|
buffer: { lines: ['❯ '] },
|
||||||
|
cols: 40,
|
||||||
|
});
|
||||||
|
const addon = new ZerolagInputAddon({
|
||||||
|
prompt: { type: 'character', char: '❯', offset: 2 },
|
||||||
|
});
|
||||||
|
mock.terminal.loadAddon(addon);
|
||||||
|
cleanups.push(() => {
|
||||||
|
addon.dispose();
|
||||||
|
mock.cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Type CJK in teammate terminal
|
||||||
|
for (const ch of '你好世界') {
|
||||||
|
addon.addChar(ch);
|
||||||
|
}
|
||||||
|
expect(addon.pendingText).toBe('你好世界');
|
||||||
|
expect(addon.hasPending).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('teammate terminal with narrow width wraps CJK correctly', () => {
|
||||||
|
// Teammate panels are often narrower (sidebar, split view)
|
||||||
|
const mock = createMockTerminal({
|
||||||
|
buffer: { lines: ['❯ '] },
|
||||||
|
cols: 12, // narrow panel
|
||||||
|
});
|
||||||
|
const addon = new ZerolagInputAddon({
|
||||||
|
prompt: { type: 'character', char: '❯', offset: 2 },
|
||||||
|
});
|
||||||
|
mock.terminal.loadAddon(addon);
|
||||||
|
cleanups.push(() => {
|
||||||
|
addon.dispose();
|
||||||
|
mock.cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Type 6 CJK chars (12 visual cols) with startCol=2 → only 10 available
|
||||||
|
// First line: 5 CJK = 10 cols. 6th wraps.
|
||||||
|
for (const ch of '你好世界再见') {
|
||||||
|
addon.addChar(ch);
|
||||||
|
}
|
||||||
|
expect(addon.pendingText).toBe('你好世界再见');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mixed CJK scripts render identically in teammate and main terminals', () => {
|
||||||
|
// Verify that the same input produces the same overlay output
|
||||||
|
// regardless of which terminal instance it's on
|
||||||
|
const text = '你好こんにちは안녕';
|
||||||
|
|
||||||
|
// "Main" terminal
|
||||||
|
const container1 = document.createElement('div');
|
||||||
|
renderOverlay(container1, makeParams({ lines: [text], cellW: 10 }));
|
||||||
|
const line1 = container1.children[0] as HTMLDivElement;
|
||||||
|
const spans1 = getSpans(line1);
|
||||||
|
|
||||||
|
// "Teammate" terminal (same params — same bundle)
|
||||||
|
const container2 = document.createElement('div');
|
||||||
|
renderOverlay(container2, makeParams({ lines: [text], cellW: 10 }));
|
||||||
|
const line2 = container2.children[0] as HTMLDivElement;
|
||||||
|
const spans2 = getSpans(line2);
|
||||||
|
|
||||||
|
// Identical rendering
|
||||||
|
expect(spans1.length).toBe(spans2.length);
|
||||||
|
for (let i = 0; i < spans1.length; i++) {
|
||||||
|
expect(spans1[i]).toEqual(spans2[i]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('CJK rendering correct in overlay with teammate-typical prompt (❯)', () => {
|
||||||
|
const container = document.createElement('div');
|
||||||
|
renderOverlay(
|
||||||
|
container,
|
||||||
|
makeParams({
|
||||||
|
lines: ['こんにちは世界'],
|
||||||
|
startCol: 2, // after ❯ prompt
|
||||||
|
cellW: 10,
|
||||||
|
showCursor: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const lineDiv = container.children[0] as HTMLDivElement;
|
||||||
|
const spans = getSpans(lineDiv);
|
||||||
|
expect(spans.length).toBe(7);
|
||||||
|
|
||||||
|
// All CJK, all double-width, contiguous
|
||||||
|
let expectedLeft = 0;
|
||||||
|
for (const span of spans) {
|
||||||
|
expect(span.left).toBe(expectedLeft);
|
||||||
|
expect(span.width).toBe(20);
|
||||||
|
expectedLeft += 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cursor position: startCol(2) + 14 visual cols = 16
|
||||||
|
const cursor = container.children[container.children.length - 1] as HTMLSpanElement;
|
||||||
|
expect(cursor.style.left).toBe('160px');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
+15
-7
@@ -542,12 +542,14 @@ class CodemanApp {
|
|||||||
const container = document.getElementById('terminalContainer');
|
const container = document.getElementById('terminalContainer');
|
||||||
this.terminal.open(container);
|
this.terminal.open(container);
|
||||||
|
|
||||||
// Activate WebGL renderer for up to 900% faster rendering (fallback to canvas on failure)
|
// Activate WebGL renderer for up to 900% faster rendering (fallback to canvas on failure).
|
||||||
|
// Store reference so we can disable during large buffer loads to prevent GPU stalls.
|
||||||
|
this._webglAddon = null;
|
||||||
if (typeof WebglAddon !== 'undefined') {
|
if (typeof WebglAddon !== 'undefined') {
|
||||||
try {
|
try {
|
||||||
const webglAddon = new WebglAddon.WebglAddon();
|
this._webglAddon = new WebglAddon.WebglAddon();
|
||||||
webglAddon.onContextLoss(() => { webglAddon.dispose(); });
|
this._webglAddon.onContextLoss(() => { this._webglAddon.dispose(); this._webglAddon = null; });
|
||||||
this.terminal.loadAddon(webglAddon);
|
this.terminal.loadAddon(this._webglAddon);
|
||||||
} catch (_e) { /* WebGL2 unavailable — canvas renderer used */ }
|
} catch (_e) { /* WebGL2 unavailable — canvas renderer used */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1349,13 +1351,16 @@ class CodemanApp {
|
|||||||
resolve();
|
resolve();
|
||||||
};
|
};
|
||||||
|
|
||||||
// For small buffers, write directly
|
// For small buffers, write directly — single-frame render is fast enough
|
||||||
if (cleanBuffer.length <= chunkSize) {
|
if (cleanBuffer.length <= chunkSize) {
|
||||||
this.terminal.write(cleanBuffer);
|
this.terminal.write(cleanBuffer);
|
||||||
finish();
|
finish();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Large buffers: write in chunks across animation frames.
|
||||||
|
// Each 32KB chunk keeps per-frame WebGL render work under ~5ms,
|
||||||
|
// avoiding GPU stalls without needing to toggle the renderer.
|
||||||
let offset = 0;
|
let offset = 0;
|
||||||
const writeChunk = () => {
|
const writeChunk = () => {
|
||||||
if (offset >= cleanBuffer.length) {
|
if (offset >= cleanBuffer.length) {
|
||||||
@@ -3436,12 +3441,15 @@ class CodemanApp {
|
|||||||
// belt-and-suspenders guard for any edge cases.
|
// belt-and-suspenders guard for any edge cases.
|
||||||
this._restoringFlushedState = true;
|
this._restoringFlushedState = true;
|
||||||
try {
|
try {
|
||||||
// Instant cache restore — show previous buffer immediately while network fetch runs
|
// Instant cache restore — show previous buffer via chunked write to avoid WebGL GPU stalls.
|
||||||
|
// Direct terminal.write() of large cached buffers (256KB+) can block the main thread
|
||||||
|
// for 5+ seconds while the WebGL renderer processes ReadPixels synchronously.
|
||||||
const cachedBuffer = this.terminalBufferCache.get(sessionId);
|
const cachedBuffer = this.terminalBufferCache.get(sessionId);
|
||||||
if (cachedBuffer) {
|
if (cachedBuffer) {
|
||||||
this.terminal.clear();
|
this.terminal.clear();
|
||||||
this.terminal.reset();
|
this.terminal.reset();
|
||||||
this.terminal.write(cachedBuffer);
|
await this.chunkedTerminalWrite(cachedBuffer);
|
||||||
|
if (selectGen !== this._selectGeneration) { this._restoringFlushedState = false; return; }
|
||||||
this.terminal.scrollToBottom();
|
this.terminal.scrollToBottom();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ const TITLE_FLASH_INTERVAL_MS = 1500; // Title flash rate
|
|||||||
const BROWSER_NOTIF_RATE_LIMIT_MS = 3000; // Rate limit for browser notifications
|
const BROWSER_NOTIF_RATE_LIMIT_MS = 3000; // Rate limit for browser notifications
|
||||||
const AUTO_CLOSE_NOTIFICATION_MS = 8000; // Auto-close browser notifications
|
const AUTO_CLOSE_NOTIFICATION_MS = 8000; // Auto-close browser notifications
|
||||||
const THROTTLE_DELAY_MS = 100; // General UI throttle delay
|
const THROTTLE_DELAY_MS = 100; // General UI throttle delay
|
||||||
const TERMINAL_CHUNK_SIZE = 128 * 1024; // 128KB chunks for terminal data
|
const TERMINAL_CHUNK_SIZE = 32 * 1024; // 32KB chunks for terminal data (smaller to avoid WebGL GPU stalls)
|
||||||
const TERMINAL_TAIL_SIZE = 256 * 1024; // 256KB tail for initial load
|
const TERMINAL_TAIL_SIZE = 256 * 1024; // 256KB tail for initial load
|
||||||
const SYNC_WAIT_TIMEOUT_MS = 50; // Wait timeout for terminal sync
|
const SYNC_WAIT_TIMEOUT_MS = 50; // Wait timeout for terminal sync
|
||||||
const STATS_POLLING_INTERVAL_MS = 2000; // System stats polling
|
const STATS_POLLING_INTERVAL_MS = 2000; // System stats polling
|
||||||
|
|||||||
+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>
|
||||||
@@ -71,6 +71,10 @@ export const IOSKeyboard: React.FC<IOSKeyboardProps> = ({ activeKey, pressAge =
|
|||||||
activeKey !== undefined && key.toLowerCase() === activeKey.toLowerCase();
|
activeKey !== undefined && key.toLowerCase() === activeKey.toLowerCase();
|
||||||
const age = (key: string) => (isActive(key) ? pressAge : 99);
|
const age = (key: string) => (isActive(key) ? pressAge : 99);
|
||||||
|
|
||||||
|
// Backspace/delete key highlight
|
||||||
|
const deleteHighlight = activeKey === '⌫' && pressAge < 4 ? 1 - pressAge / 4 : 0;
|
||||||
|
const deleteBg = deleteHighlight > 0 ? lerpColor(SPECIAL_BG, KEY_BG_ACTIVE, deleteHighlight) : SPECIAL_BG;
|
||||||
|
|
||||||
// Key widths: 10 keys + 9 gaps in ~375px row → each key ~33px
|
// Key widths: 10 keys + 9 gaps in ~375px row → each key ~33px
|
||||||
const letterKeyW = 33;
|
const letterKeyW = 33;
|
||||||
// Row 2 has 9 keys → same key width but centered with side padding
|
// Row 2 has 9 keys → same key width but centered with side padding
|
||||||
@@ -126,7 +130,7 @@ export const IOSKeyboard: React.FC<IOSKeyboardProps> = ({ activeKey, pressAge =
|
|||||||
width: 42,
|
width: 42,
|
||||||
height: KEY_H,
|
height: KEY_H,
|
||||||
borderRadius: 5,
|
borderRadius: 5,
|
||||||
background: SPECIAL_BG,
|
background: deleteBg,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
|||||||
@@ -16,17 +16,17 @@ import { IOSKeyboard } from '../components/IOSKeyboard';
|
|||||||
// ─── Scene timing (frames @ 30fps) ───
|
// ─── Scene timing (frames @ 30fps) ───
|
||||||
const TITLE_DUR = 60;
|
const TITLE_DUR = 60;
|
||||||
const PHONES_DUR = 30;
|
const PHONES_DUR = 30;
|
||||||
const TYPING_DUR = 420;
|
const TYPING_DUR = 468;
|
||||||
const HOLD_DUR = 60;
|
const HOLD_DUR = 60;
|
||||||
const OUTRO_DUR = 45;
|
const OUTRO_DUR = 45;
|
||||||
|
|
||||||
const TITLE_START = 0;
|
const TITLE_START = 0;
|
||||||
const PHONES_START = TITLE_DUR; // 60
|
const PHONES_START = TITLE_DUR; // 60
|
||||||
const TYPING_START = PHONES_START + PHONES_DUR; // 90
|
const TYPING_START = PHONES_START + PHONES_DUR; // 90
|
||||||
const HOLD_START = TYPING_START + TYPING_DUR; // 510
|
const HOLD_START = TYPING_START + TYPING_DUR; // 558
|
||||||
const OUTRO_START = HOLD_START + HOLD_DUR; // 570
|
const OUTRO_START = HOLD_START + HOLD_DUR; // 618
|
||||||
|
|
||||||
export const ZEROLAG_TOTAL_FRAMES = OUTRO_START + OUTRO_DUR; // 615
|
export const ZEROLAG_TOTAL_FRAMES = OUTRO_START + OUTRO_DUR; // 663
|
||||||
|
|
||||||
// ─── iPhone 17 Pro safe area ───
|
// ─── iPhone 17 Pro safe area ───
|
||||||
const SAFE_AREA_TOP = 59; // Below Dynamic Island
|
const SAFE_AREA_TOP = 59; // Below Dynamic Island
|
||||||
@@ -42,25 +42,61 @@ const TERMINAL_TOP = SAFE_AREA_TOP + 52;
|
|||||||
const TERMINAL_LEFT = 14;
|
const TERMINAL_LEFT = 14;
|
||||||
const TERMINAL_FONT = 22; // Large for video readability
|
const TERMINAL_FONT = 22; // Large for video readability
|
||||||
|
|
||||||
// ─── Typing schedule ───
|
// ─── Typing schedule (with typo + backspace correction) ───
|
||||||
const TYPED_TEXT = 'fix the auth bug in the login flow';
|
const CORRECT_TEXT = 'fix the auth bug in the login flow';
|
||||||
const FRAME_GAP = 12; // ~400ms between keystrokes
|
const FRAME_GAP = 12; // ~400ms between keystrokes
|
||||||
|
const TYPO_INDEX = 28; // After "logi", type "m" instead of "n"
|
||||||
|
|
||||||
// Remote connection lag: 600ms-1.2s+ per char (18-36+ frames)
|
// Remote connection lag: 600ms-1.2s+ per char (18-36+ frames)
|
||||||
const LAGGY_DELAYS = [
|
const LAGGY_DELAYS = [
|
||||||
24, 30, 36, 32, 26, 22, 34, 28, 38, 20, 30, 24, 32, 26, 36, 22,
|
24, 30, 36, 32, 26, 22, 34, 28, 38, 20, 30, 24, 32, 26, 36, 22,
|
||||||
30, 24, 32, 28, 34, 26, 30, 22, 28, 36, 24, 30, 32, 26, 34, 28, 24, 30,
|
30, 24, 32, 28, 34, 26, 30, 22, 28, 36, 24, 30, 32, 26, 34, 28, 24, 30,
|
||||||
|
26, 32, 28, 34,
|
||||||
];
|
];
|
||||||
|
|
||||||
type KeyEvent = { frame: number; char: string; lagDelay: number };
|
type KeyAction = { frame: number; action: 'type' | 'backspace'; char: string; lagDelay: number };
|
||||||
|
|
||||||
const TYPING_SCHEDULE: KeyEvent[] = TYPED_TEXT.split('').map((char, i) => ({
|
const buildSchedule = (): KeyAction[] => {
|
||||||
frame: i * FRAME_GAP,
|
const actions: KeyAction[] = [];
|
||||||
char,
|
let idx = 0;
|
||||||
lagDelay: LAGGY_DELAYS[i % LAGGY_DELAYS.length],
|
const lag = (i: number) => LAGGY_DELAYS[i % LAGGY_DELAYS.length];
|
||||||
}));
|
|
||||||
|
|
||||||
const CONFIRM_DELAY = 14;
|
// Type correctly up to typo point: "fix the auth bug in the logi"
|
||||||
|
for (let i = 0; i < TYPO_INDEX; i++) {
|
||||||
|
actions.push({ frame: idx * FRAME_GAP, action: 'type', char: CORRECT_TEXT[i], lagDelay: lag(idx) });
|
||||||
|
idx++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Typo: type "m" instead of "n"
|
||||||
|
actions.push({ frame: idx * FRAME_GAP, action: 'type', char: 'm', lagDelay: lag(idx) });
|
||||||
|
idx++;
|
||||||
|
|
||||||
|
// Backspace to fix it
|
||||||
|
actions.push({ frame: idx * FRAME_GAP, action: 'backspace', char: '⌫', lagDelay: lag(idx) });
|
||||||
|
idx++;
|
||||||
|
|
||||||
|
// Type correct remaining: "n flow"
|
||||||
|
for (let i = TYPO_INDEX; i < CORRECT_TEXT.length; i++) {
|
||||||
|
actions.push({ frame: idx * FRAME_GAP, action: 'type', char: CORRECT_TEXT[i], lagDelay: lag(idx) });
|
||||||
|
idx++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return actions;
|
||||||
|
};
|
||||||
|
|
||||||
|
const TYPING_SCHEDULE = buildSchedule();
|
||||||
|
|
||||||
|
/** Replay actions in order up to current frame, computing the visible text buffer */
|
||||||
|
const computeVisibleText = (frame: number, withLag: boolean): string => {
|
||||||
|
let buffer = '';
|
||||||
|
for (const a of TYPING_SCHEDULE) {
|
||||||
|
const threshold = withLag ? a.frame + a.lagDelay : a.frame;
|
||||||
|
if (frame < threshold) break; // TCP-ordered: stop at first unresolved
|
||||||
|
if (a.action === 'backspace') buffer = buffer.slice(0, -1);
|
||||||
|
else buffer += a.char;
|
||||||
|
}
|
||||||
|
return buffer;
|
||||||
|
};
|
||||||
|
|
||||||
// ─── iOS Status Bar (sits in the safe area, flanking Dynamic Island) ───
|
// ─── iOS Status Bar (sits in the safe area, flanking Dynamic Island) ───
|
||||||
const IOSStatusBar: React.FC = () => (
|
const IOSStatusBar: React.FC = () => (
|
||||||
@@ -295,13 +331,8 @@ const OutroScene: React.FC = () => {
|
|||||||
const TypingDemo: React.FC = () => {
|
const TypingDemo: React.FC = () => {
|
||||||
const frame = useCurrentFrame();
|
const frame = useCurrentFrame();
|
||||||
|
|
||||||
const laggyChars = TYPING_SCHEDULE.filter((ev) => frame >= ev.frame + ev.lagDelay).map((ev) => ev.char);
|
const laggyTyped = computeVisibleText(frame, true);
|
||||||
const laggyTyped = laggyChars.join('');
|
const zerolagTyped = computeVisibleText(frame, false);
|
||||||
|
|
||||||
const zerolagOverlay = TYPING_SCHEDULE.filter((ev) => frame >= ev.frame).map((ev) => ({
|
|
||||||
char: ev.char,
|
|
||||||
confirmed: frame >= ev.frame + CONFIRM_DELAY,
|
|
||||||
}));
|
|
||||||
|
|
||||||
let activeKey: string | undefined;
|
let activeKey: string | undefined;
|
||||||
let pressAge = 99;
|
let pressAge = 99;
|
||||||
@@ -326,12 +357,12 @@ const TypingDemo: React.FC = () => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<MobileCodeman typed={laggyTyped} cursorVisible activeKey={activeKey} pressAge={pressAge} noAnimation />
|
<MobileCodeman typed={zerolagTyped} cursorVisible activeKey={activeKey} pressAge={pressAge} noAnimation />
|
||||||
<PhoneLabel title="Without Zerolag" detail="600ms–1.2s delay" dotColor={colors.accent.red} detailColor={colors.accent.red} />
|
<PhoneLabel title="With Zerolag" detail="0ms delay" dotColor={colors.accent.green} detailColor={colors.accent.green} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<MobileCodeman typed="" overlayChars={zerolagOverlay} cursorVisible activeKey={activeKey} pressAge={pressAge} noAnimation />
|
<MobileCodeman typed={laggyTyped} cursorVisible activeKey={activeKey} pressAge={pressAge} noAnimation />
|
||||||
<PhoneLabel title="With Zerolag" detail="0ms delay" dotColor={colors.accent.green} detailColor={colors.accent.green} />
|
<PhoneLabel title="Without Zerolag" detail="600ms–1.2s delay" dotColor={colors.accent.red} detailColor={colors.accent.red} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</AbsoluteFill>
|
</AbsoluteFill>
|
||||||
@@ -359,11 +390,11 @@ const PanelsEntrance: React.FC = () => {
|
|||||||
<div style={{ display: 'flex', gap: 50, alignItems: 'flex-start' }}>
|
<div style={{ display: 'flex', gap: 50, alignItems: 'flex-start' }}>
|
||||||
<div>
|
<div>
|
||||||
<MobileCodeman typed="" cursorVisible noAnimation />
|
<MobileCodeman typed="" cursorVisible noAnimation />
|
||||||
<PhoneLabel title="Without Zerolag" detail="600ms–1.2s delay" dotColor={colors.accent.red} detailColor={colors.accent.red} />
|
<PhoneLabel title="With Zerolag" detail="0ms delay" dotColor={colors.accent.green} detailColor={colors.accent.green} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<MobileCodeman typed="" cursorVisible noAnimation />
|
<MobileCodeman typed="" cursorVisible noAnimation />
|
||||||
<PhoneLabel title="With Zerolag" detail="0ms delay" dotColor={colors.accent.green} detailColor={colors.accent.green} />
|
<PhoneLabel title="Without Zerolag" detail="600ms–1.2s delay" dotColor={colors.accent.red} detailColor={colors.accent.red} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</AbsoluteFill>
|
</AbsoluteFill>
|
||||||
|
|||||||
Reference in new issue
Block a user