mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-05 15:09:42 +02:00
With local echo on, committed text sits in the LocalEchoOverlay and does not reach the PTY before Enter, so the PTY cursor that places the preview span stays at the prompt start. The span's z-index 6 only counts inside .xterm-helpers (its own z-index 5 stacking context), and the overlay is a z-index 7 layer whose first line is opaque from the prompt column, so every composition after the first one in a prompt was drawn under the overlay. - xterm-zerolag-input: add setComposition(text) and a composition getter. The overlay draws the composition as an underlined, aria-hidden tail after its pending text, through the same wrapping and grow-upward layout. It is never part of pendingText, hasPending or anything sent; clear() and removeChar() drop it, and rerender()/refreshFont() keep it. - terminal-ui.js: while local echo shows typed text (on, and not handed back to PTY echo by a nav key), render and clear the preview through setComposition. The helper span stays for local echo off, and as the fallback when the overlay cannot place the text (no prompt found). - Browser test against real xterm 6, the overlay bundled from its source and styles.css: a second composition after pending text is the topmost element after that text, and the commit lands in the overlay once. Unit tests for setComposition in the package and for the routing in the structure test. - CLAUDE.md and architecture-invariants: state the preview's effective layer.
293 lines
13 KiB
TypeScript
293 lines
13 KiB
TypeScript
/**
|
|
* The iOS IME preview controller against a REAL xterm 6 instance.
|
|
*
|
|
* The controller's logic is unit-tested in test/mobile-ime-preview.test.ts
|
|
* with a stand-in for xterm. What only real xterm proves is the event ORDER:
|
|
* `terminal.open()` registers xterm's keydown listener in the capture phase on
|
|
* the helper textarea, and CompositionHelper.keydown finalizes a composition
|
|
* there and emits the commit through onData synchronously. The controller must
|
|
* observe that keydown first (capture phase on `terminal.element`), and must
|
|
* finalize on exactly the keys xterm does.
|
|
*
|
|
* No server: a blank page loads the vendored xterm bundle and the controller.
|
|
* Browser-driven, so it is excluded from `npm run test:ci` like the other
|
|
* Playwright suites. Run locally:
|
|
* npm run test:browser -- test/mobile-ime-preview.browser.test.ts
|
|
*/
|
|
|
|
import { readFileSync } from 'node:fs';
|
|
import { resolve } from 'node:path';
|
|
import { build } from 'esbuild';
|
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
import { chromium, type Browser, type Page } from 'playwright';
|
|
|
|
const root = resolve(import.meta.dirname, '..');
|
|
|
|
type Step =
|
|
| ['start']
|
|
| ['update', string]
|
|
| ['end', string]
|
|
| ['key', number, string, boolean]
|
|
| ['wait', number]
|
|
| ['consume', string];
|
|
|
|
describe('mobile IME preview with real xterm', () => {
|
|
let browser: Browser;
|
|
let page: Page;
|
|
|
|
beforeAll(async () => {
|
|
browser = await chromium.launch({ headless: true });
|
|
page = await browser.newPage();
|
|
await page.setContent('<div id="t" style="width:600px;height:300px"></div>');
|
|
await page.addScriptTag({ path: resolve(root, 'node_modules/@xterm/xterm/lib/xterm.js') });
|
|
await page.addScriptTag({ path: resolve(root, 'src/web/public/mobile-ime-preview.js') });
|
|
}, 60000);
|
|
|
|
afterAll(async () => {
|
|
if (browser) await browser.close();
|
|
});
|
|
|
|
async function drive(steps: Step[]) {
|
|
return page.evaluate(async (steps: Step[]) => {
|
|
const w = window as any;
|
|
const host = document.getElementById('t') as HTMLElement;
|
|
host.innerHTML = '';
|
|
const term = new w.Terminal();
|
|
term.open(host);
|
|
const textarea = term.textarea as HTMLTextAreaElement;
|
|
const renders: Array<{ text: string; phase: string }> = [];
|
|
const onData: Array<{ data: string; consumed: boolean }> = [];
|
|
const controller = w.MobileImePreview.create({
|
|
textarea,
|
|
keydownTarget: term.element,
|
|
render: (r: { text: string; phase: string }) => renders.push(r),
|
|
clear: () => {},
|
|
});
|
|
term.onData((data: string) => onData.push({ data, consumed: controller.consumeTerminalData(data) }));
|
|
textarea.focus();
|
|
const tick = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
for (const step of steps) {
|
|
if (step[0] === 'start') textarea.dispatchEvent(new CompositionEvent('compositionstart', { data: '' }));
|
|
if (step[0] === 'update') {
|
|
textarea.value = step[1];
|
|
textarea.dispatchEvent(new CompositionEvent('compositionupdate', { data: step[1] }));
|
|
}
|
|
if (step[0] === 'end') textarea.dispatchEvent(new CompositionEvent('compositionend', { data: step[1] }));
|
|
if (step[0] === 'key') {
|
|
const [, keyCode, key, isComposing] = step;
|
|
const event = new KeyboardEvent('keydown', { key, isComposing, bubbles: true, cancelable: true });
|
|
Object.defineProperty(event, 'keyCode', { get: () => keyCode });
|
|
textarea.dispatchEvent(event);
|
|
}
|
|
if (step[0] === 'wait') await tick(step[1]);
|
|
if (step[0] === 'consume') onData.push({ data: step[1], consumed: controller.consumeTerminalData(step[1]) });
|
|
}
|
|
await tick(20);
|
|
const { composing, awaitingCommit, committed, latest } = controller.state;
|
|
const result = { onData, state: { composing, awaitingCommit, committed, latest }, lastRender: renders.at(-1) };
|
|
controller.destroy();
|
|
term.dispose();
|
|
return result;
|
|
}, steps);
|
|
}
|
|
|
|
it('Enter mid-composition: xterm emits the commit and the controller takes it as committed', async () => {
|
|
// compositionupdate's textarea end offset is recorded by xterm on a 0 ms timer.
|
|
const result = await drive([['start'], ['update', '確定'], ['wait', 10], ['key', 13, 'Enter', false]]);
|
|
expect(result.onData).toEqual([
|
|
{ data: '確定', consumed: true },
|
|
{ data: '\r', consumed: false },
|
|
]);
|
|
expect(result.state).toMatchObject({ awaitingCommit: false, committed: true, latest: '確定' });
|
|
expect(result.lastRender).toEqual({ text: '確定', phase: 'committed' });
|
|
});
|
|
|
|
it('keyCode 229 with isComposing false: xterm keeps composing, so the preview keeps following', async () => {
|
|
const result = await drive([
|
|
['start'],
|
|
['update', 'か'],
|
|
['wait', 10],
|
|
['key', 229, 'k', false],
|
|
['update', 'かな'],
|
|
]);
|
|
expect(result.onData).toEqual([]);
|
|
expect(result.state).toMatchObject({ composing: true, awaitingCommit: false, latest: 'かな' });
|
|
expect(result.lastRender).toEqual({ text: 'かな', phase: 'provisional' });
|
|
});
|
|
|
|
it('a deleted composition stops waiting after 2 s, so a later paste is not taken as its commit', async () => {
|
|
const result = await drive([
|
|
['start'],
|
|
['update', 'abc'],
|
|
['update', ''],
|
|
['end', ''],
|
|
['wait', 2100],
|
|
['consume', 'pasted'],
|
|
]);
|
|
expect(result.onData).toEqual([{ data: 'pasted', consumed: false }]);
|
|
expect(result.state).toMatchObject({ awaitingCommit: false, committed: false });
|
|
});
|
|
});
|
|
|
|
/**
|
|
* The preview with local echo ON, the default for Claude sessions on phones.
|
|
* Committed text then sits in the LocalEchoOverlay (a z-index 7 layer in
|
|
* `.xterm-screen`) and never reaches the PTY before Enter, so the PTY cursor,
|
|
* which is where the helper span sits, stays at the prompt start: under the
|
|
* overlay's own opaque text. So a composition that follows text already in the
|
|
* overlay must be drawn by the overlay itself, after that text.
|
|
*
|
|
* Loads the real pieces: xterm 6, the overlay bundled from its package source
|
|
* exactly as scripts/postinstall.js bundles it (plus the same LocalEchoOverlay
|
|
* alias), styles.css, mobile-ime-preview.js, and terminal-ui.js's own
|
|
* `_initMobileImePreview` on a bare CodemanApp prototype.
|
|
*/
|
|
describe('mobile IME preview over the local echo overlay', () => {
|
|
let browser: Browser;
|
|
let page: Page;
|
|
|
|
beforeAll(async () => {
|
|
const bundled = await build({
|
|
entryPoints: [resolve(root, 'packages/xterm-zerolag-input/src/zerolag-input-addon.ts')],
|
|
bundle: true,
|
|
format: 'iife',
|
|
globalName: 'XtermZerolagInput',
|
|
write: false,
|
|
logLevel: 'silent',
|
|
});
|
|
const overlayBundle =
|
|
bundled.outputFiles[0].text +
|
|
'\nwindow.ZerolagInputAddon=XtermZerolagInput.ZerolagInputAddon;' +
|
|
'window.LocalEchoOverlay=class extends XtermZerolagInput.ZerolagInputAddon{' +
|
|
'constructor(terminal){super({prompt:{type:"character",char:"\\u276f",offset:2}});this.activate(terminal);}};\n';
|
|
|
|
browser = await chromium.launch({ headless: true });
|
|
page = await browser.newPage({ viewport: { width: 800, height: 400 }, deviceScaleFactor: 1 });
|
|
await page.setContent(
|
|
'<html class="touch-device"><body><div id="t" style="width:600px;height:240px"></div></body></html>'
|
|
);
|
|
await page.addStyleTag({ path: resolve(root, 'node_modules/@xterm/xterm/css/xterm.css') });
|
|
await page.addStyleTag({ content: readFileSync(resolve(root, 'src/web/public/styles.css'), 'utf8') });
|
|
await page.addScriptTag({ path: resolve(root, 'node_modules/@xterm/xterm/lib/xterm.js') });
|
|
await page.addScriptTag({ content: overlayBundle });
|
|
await page.addScriptTag({ path: resolve(root, 'src/web/public/mobile-ime-preview.js') });
|
|
await page.addScriptTag({ content: 'window.CodemanApp = class CodemanApp {};' });
|
|
await page.addScriptTag({ path: resolve(root, 'src/web/public/terminal-ui.js') });
|
|
}, 60000);
|
|
|
|
afterAll(async () => {
|
|
if (browser) await browser.close();
|
|
});
|
|
|
|
/**
|
|
* Types `pending` into the overlay (as the printable/paste branch does), then
|
|
* composes `composing` and reports what is PAINTED at the cell right after
|
|
* the pending text and at the PTY cursor. Painted = topmost by hit-testing
|
|
* with pointer-events forced on, since the overlay and the preview are
|
|
* pointer-events:none.
|
|
*/
|
|
async function composeAfter(pending: string, composing: string, commit: boolean) {
|
|
return page.evaluate(
|
|
async ({ pending, composing, commit }) => {
|
|
const w = window as any;
|
|
const host = document.getElementById('t') as HTMLElement;
|
|
host.innerHTML = '';
|
|
const term = new w.Terminal({
|
|
cols: 40,
|
|
rows: 8,
|
|
fontSize: 14,
|
|
fontFamily: 'monospace',
|
|
allowProposedApi: true,
|
|
});
|
|
term.open(host);
|
|
await new Promise<void>((r) => term.write('\u276f ', () => r()));
|
|
const app = new w.CodemanApp();
|
|
app.terminal = term;
|
|
app._localEchoEnabled = true;
|
|
app._localEchoOverlay = new w.LocalEchoOverlay(term);
|
|
w.MobileImePreview.isIosWebKitTouch = () => true;
|
|
app._initMobileImePreview();
|
|
|
|
// The helper textarea and span follow the PTY cursor (col 2, row 0), as
|
|
// _syncMobileHelperTextareaToCursor places them.
|
|
const screen = term.element.querySelector('.xterm-screen') as HTMLElement;
|
|
const dims = term._core._renderService.dimensions.css.cell;
|
|
term.element.style.setProperty('--xterm-helper-left', 2 * dims.width + 'px');
|
|
term.element.style.setProperty('--xterm-helper-top', '0px');
|
|
|
|
if (pending) app._localEchoOverlay.appendText(pending);
|
|
const textarea = term.textarea as HTMLTextAreaElement;
|
|
textarea.focus();
|
|
textarea.dispatchEvent(new CompositionEvent('compositionstart', { data: '' }));
|
|
textarea.value = composing;
|
|
textarea.dispatchEvent(new CompositionEvent('compositionupdate', { data: composing }));
|
|
await new Promise((r) => requestAnimationFrame(() => setTimeout(r, 20)));
|
|
|
|
const force = document.createElement('style');
|
|
force.textContent = '.xterm * { pointer-events: auto !important; }';
|
|
document.head.appendChild(force);
|
|
const rect = screen.getBoundingClientRect();
|
|
const widthOf = (s: string) => term._core.unicodeService.getStringCellWidth(s);
|
|
const paintedAt = (col: number) => {
|
|
const el = document.elementFromPoint(
|
|
rect.left + (col + 0.5) * dims.width,
|
|
rect.top + 0.5 * dims.height
|
|
) as HTMLElement | null;
|
|
return {
|
|
text: el?.textContent ?? null,
|
|
composition: !!el?.closest?.('[data-zerolag-composition]'),
|
|
preview: !!el?.closest?.('.codeman-ime-preview'),
|
|
};
|
|
};
|
|
const afterPending = paintedAt(2 + widthOf(pending));
|
|
force.remove();
|
|
|
|
let afterCommit = null;
|
|
if (commit) {
|
|
textarea.dispatchEvent(new CompositionEvent('compositionend', { data: composing }));
|
|
// What the printable/paste branch of terminal-ui.js's onData does.
|
|
if (app._consumeMobileImeTerminalData(composing)) {
|
|
app._localEchoOverlay.appendText(composing);
|
|
app._transferMobileImeCommitToLocalEcho();
|
|
}
|
|
await new Promise((r) => requestAnimationFrame(() => setTimeout(r, 20)));
|
|
afterCommit = {
|
|
pendingText: app._localEchoOverlay.pendingText,
|
|
compositionSpans: term.element.querySelectorAll('[data-zerolag-composition]').length,
|
|
overlayText: app._localEchoOverlay._overlay?.textContent,
|
|
};
|
|
}
|
|
const result = {
|
|
afterPending,
|
|
pendingText: app._localEchoOverlay.pendingText,
|
|
afterCommit,
|
|
};
|
|
app._destroyMobileImePreview();
|
|
app._localEchoOverlay.dispose();
|
|
term.dispose();
|
|
return result;
|
|
},
|
|
{ pending, composing, commit }
|
|
);
|
|
}
|
|
|
|
it('first composition on an empty prompt: the overlay draws it at the prompt', async () => {
|
|
const result = await composeAfter('', '今日は', false);
|
|
expect(result.afterPending).toEqual({ text: '今', composition: true, preview: false });
|
|
expect(result.pendingText).toBe('');
|
|
});
|
|
|
|
it('a second composition is painted after the text already in the overlay, not under it', async () => {
|
|
const result = await composeAfter('今日は', '天気', false);
|
|
expect(result.afterPending.text).toBe('天');
|
|
expect(result.afterPending.composition).toBe(true);
|
|
// Provisional text is never taken into the overlay's pending (unsent) text.
|
|
expect(result.pendingText).toBe('今日は');
|
|
});
|
|
|
|
it('the commit lands once in the overlay and the composition tail is gone', async () => {
|
|
const result = await composeAfter('今日は', '天気', true);
|
|
expect(result.afterCommit).toEqual({ pendingText: '今日は天気', compositionSpans: 0, overlayText: '今日は天気' });
|
|
});
|
|
});
|