mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-07 16:09:43 +02:00
fix(terminal): draw IME preview after local-echo text
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.
This commit is contained in:
@@ -58,6 +58,7 @@ export function stringCellWidth(terminal: XtermTerminal | null | undefined, str:
|
||||
export function renderOverlay(container: HTMLDivElement, params: RenderParams): void {
|
||||
const {
|
||||
lines,
|
||||
compositionStart,
|
||||
startCol,
|
||||
totalCols,
|
||||
cellW,
|
||||
@@ -90,12 +91,24 @@ export function renderOverlay(container: HTMLDivElement, params: RenderParams):
|
||||
// `startCol` indents only the line that begins at the prompt marker, so it is
|
||||
// dropped along with that line when the tail is all that fits.
|
||||
const rows = totalRows && totalRows > 0 ? totalRows : terminal?.rows;
|
||||
// Code-point offset of each line in the whole text, so the composition
|
||||
// styling survives the tail slice below.
|
||||
const lineOffsets: number[] = [];
|
||||
{
|
||||
let offset = 0;
|
||||
for (const line of lines) {
|
||||
lineOffsets.push(offset);
|
||||
offset += [...line].length;
|
||||
}
|
||||
}
|
||||
let visibleLines = lines;
|
||||
let firstVisible = 0;
|
||||
let keepsPromptLine = true;
|
||||
let topRow = promptRow;
|
||||
if (rows && rows > 0) {
|
||||
if (lines.length > rows) {
|
||||
visibleLines = lines.slice(lines.length - rows);
|
||||
firstVisible = lines.length - rows;
|
||||
visibleLines = lines.slice(firstVisible);
|
||||
keepsPromptLine = false;
|
||||
topRow = 0;
|
||||
} else if (promptRow + lines.length > rows) {
|
||||
@@ -116,7 +129,21 @@ export function renderOverlay(container: HTMLDivElement, params: RenderParams):
|
||||
const leftPx = indents ? startCol * cellW : 0;
|
||||
const widthPx = indents ? fullWidthPx - leftPx : fullWidthPx;
|
||||
const topPx = i * cellH;
|
||||
const lineEl = makeLine(visibleLines[i], leftPx, topPx, widthPx, cellH, cellW, charTop, charHeight, font, terminal);
|
||||
const lineCompositionFrom =
|
||||
compositionStart === undefined ? undefined : compositionStart - lineOffsets[firstVisible + i];
|
||||
const lineEl = makeLine(
|
||||
visibleLines[i],
|
||||
leftPx,
|
||||
topPx,
|
||||
widthPx,
|
||||
cellH,
|
||||
cellW,
|
||||
charTop,
|
||||
charHeight,
|
||||
font,
|
||||
terminal,
|
||||
lineCompositionFrom
|
||||
);
|
||||
container.appendChild(lineEl);
|
||||
}
|
||||
|
||||
@@ -144,7 +171,10 @@ export function renderOverlay(container: HTMLDivElement, params: RenderParams):
|
||||
* Create a styled line `<div>` with per-character grid positioning.
|
||||
*
|
||||
* Each character gets its own `<span>` positioned by visual column offset.
|
||||
* CJK wide characters occupy 2 cell widths.
|
||||
* CJK wide characters occupy 2 cell widths. Characters at or after
|
||||
* `compositionFrom` (a code-point index into `text`, may be negative) are IME
|
||||
* composition text: underlined, like xterm's own composition view, and marked
|
||||
* `data-zerolag-composition` + `aria-hidden` since they are provisional.
|
||||
*/
|
||||
function makeLine(
|
||||
text: string,
|
||||
@@ -156,7 +186,8 @@ function makeLine(
|
||||
_charTop: number,
|
||||
_charHeight: number,
|
||||
font: FontStyle,
|
||||
terminal?: XtermTerminal | null
|
||||
terminal?: XtermTerminal | null,
|
||||
compositionFrom?: number
|
||||
): HTMLDivElement {
|
||||
const el = document.createElement('div');
|
||||
el.style.cssText = 'position:absolute;pointer-events:none';
|
||||
@@ -172,6 +203,7 @@ function makeLine(
|
||||
|
||||
// CJK wide chars occupy 2 cells — position by visual column offset
|
||||
let colOffset = 0;
|
||||
let index = 0;
|
||||
for (const ch of text) {
|
||||
const cw = charCellWidth(terminal, ch);
|
||||
const span = document.createElement('span');
|
||||
@@ -189,9 +221,15 @@ function makeLine(
|
||||
span.style.fontWeight = font.fontWeight;
|
||||
span.style.color = font.color;
|
||||
if (font.letterSpacing) span.style.letterSpacing = font.letterSpacing;
|
||||
if (compositionFrom !== undefined && index >= compositionFrom) {
|
||||
span.style.textDecoration = 'underline';
|
||||
span.setAttribute('data-zerolag-composition', '');
|
||||
span.setAttribute('aria-hidden', 'true');
|
||||
}
|
||||
span.textContent = ch;
|
||||
el.appendChild(span);
|
||||
colOffset += cw;
|
||||
index++;
|
||||
}
|
||||
|
||||
return el;
|
||||
|
||||
@@ -163,6 +163,12 @@ export interface CellDimensions {
|
||||
/** Parameters for the overlay renderer. */
|
||||
export interface RenderParams {
|
||||
lines: string[];
|
||||
/**
|
||||
* Index (in code points, across all `lines`) where IME composition text
|
||||
* begins. Characters from there on are drawn underlined and marked
|
||||
* `data-zerolag-composition`. Omit when nothing is being composed.
|
||||
*/
|
||||
compositionStart?: number;
|
||||
startCol: number;
|
||||
totalCols: number;
|
||||
cellW: number;
|
||||
|
||||
@@ -67,6 +67,8 @@ export class ZerolagInputAddon implements XtermAddon {
|
||||
private _flushedOffset = 0;
|
||||
private _flushedText = '';
|
||||
private _bufferDetectDone = false;
|
||||
// IME text still being composed: drawn after the pending text, never sent.
|
||||
private _composition = '';
|
||||
|
||||
// Render cache
|
||||
private _lastRenderKey = '';
|
||||
@@ -130,7 +132,7 @@ export class ZerolagInputAddon implements XtermAddon {
|
||||
clearTimeout(this._scrollTimer);
|
||||
this._scrollTimer = null;
|
||||
}
|
||||
} else if (this._pendingText || this._flushedOffset > 0) {
|
||||
} else if (this._hasContent()) {
|
||||
if (this._scrollTimer) clearTimeout(this._scrollTimer);
|
||||
this._scrollTimer = setTimeout(() => {
|
||||
this._scrollTimer = null;
|
||||
@@ -208,6 +210,8 @@ export class ZerolagInputAddon implements XtermAddon {
|
||||
* - `false`: Nothing to remove. The consumer should NOT send backspace.
|
||||
*/
|
||||
removeChar(): 'pending' | 'flushed' | false {
|
||||
// A backspace that reaches the overlay means no composition is open.
|
||||
this._composition = '';
|
||||
if (this._pendingText.length > 0) {
|
||||
this._pendingText = this._pendingText.slice(0, -1);
|
||||
if (this._pendingText.length > 0 || this._flushedOffset > 0) {
|
||||
@@ -252,6 +256,7 @@ export class ZerolagInputAddon implements XtermAddon {
|
||||
*/
|
||||
clear(): void {
|
||||
this._pendingText = '';
|
||||
this._composition = '';
|
||||
this._flushedOffset = 0;
|
||||
this._flushedText = '';
|
||||
this._bufferDetectDone = false;
|
||||
@@ -297,7 +302,7 @@ export class ZerolagInputAddon implements XtermAddon {
|
||||
clearFlushed(): void {
|
||||
this._flushedOffset = 0;
|
||||
this._flushedText = '';
|
||||
if (this._pendingText) {
|
||||
if (this._pendingText || this._composition) {
|
||||
this._render();
|
||||
} else {
|
||||
this._hide();
|
||||
@@ -312,7 +317,7 @@ export class ZerolagInputAddon implements XtermAddon {
|
||||
* that move the prompt.
|
||||
*/
|
||||
rerender(): void {
|
||||
if (this._pendingText || this._flushedOffset > 0) {
|
||||
if (this._hasContent()) {
|
||||
this._lastRenderKey = '';
|
||||
this._render();
|
||||
}
|
||||
@@ -325,7 +330,7 @@ export class ZerolagInputAddon implements XtermAddon {
|
||||
refreshFont(): void {
|
||||
this._cacheFont();
|
||||
this._lastRenderKey = '';
|
||||
if (this._pendingText || this._flushedOffset > 0) this._render();
|
||||
if (this._hasContent()) this._render();
|
||||
}
|
||||
|
||||
// ─── Buffer detection ─────────────────────────────────────────────
|
||||
@@ -391,7 +396,37 @@ export class ZerolagInputAddon implements XtermAddon {
|
||||
this._options.prompt = finder;
|
||||
this._lastPromptPos = null;
|
||||
this._lastRenderKey = '';
|
||||
if (this._pendingText || this._flushedOffset > 0) this._render();
|
||||
if (this._hasContent()) this._render();
|
||||
}
|
||||
|
||||
// ─── IME composition ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Show text an IME is still composing as an underlined tail after the
|
||||
* pending text, wrapped and kept on screen like the rest of the overlay.
|
||||
* Pass `''` to remove it.
|
||||
*
|
||||
* Visual only: the composition is never part of `pendingText`, `hasPending`
|
||||
* or anything a consumer sends. When the IME commits, the consumer adds the
|
||||
* committed text the usual way (`addChar`/`appendText`) and clears the
|
||||
* composition. `clear()` and `removeChar()` drop it too.
|
||||
*/
|
||||
setComposition(text: string): void {
|
||||
// One visual line of provisional text: control characters and line breaks
|
||||
// would break the cell grid.
|
||||
const next = typeof text === 'string' ? text.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, '') : '';
|
||||
if (next === this._composition) return;
|
||||
this._composition = next;
|
||||
if (this._hasContent()) {
|
||||
this._render();
|
||||
} else {
|
||||
this._hide();
|
||||
}
|
||||
}
|
||||
|
||||
/** Text an IME is still composing, drawn after `pendingText` (never sent). */
|
||||
get composition(): string {
|
||||
return this._composition;
|
||||
}
|
||||
|
||||
// ─── Prompt utilities ─────────────────────────────────────────────
|
||||
@@ -443,6 +478,10 @@ export class ZerolagInputAddon implements XtermAddon {
|
||||
|
||||
// ─── Private methods ──────────────────────────────────────────────
|
||||
|
||||
private _hasContent(): boolean {
|
||||
return this._pendingText.length > 0 || this._flushedOffset > 0 || this._composition.length > 0;
|
||||
}
|
||||
|
||||
private _getPromptOffset(): number {
|
||||
const prompt = this._options.prompt ?? DEFAULT_PROMPT;
|
||||
return prompt.offset ?? 2;
|
||||
@@ -505,7 +544,7 @@ export class ZerolagInputAddon implements XtermAddon {
|
||||
|
||||
private _render(): void {
|
||||
if (!this._terminal || !this._overlay) return;
|
||||
if (!this._pendingText && !(this._flushedOffset > 0)) {
|
||||
if (!this._hasContent()) {
|
||||
this._overlay.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
@@ -563,12 +602,16 @@ export class ZerolagInputAddon implements XtermAddon {
|
||||
}
|
||||
}
|
||||
|
||||
// The composition is a styled tail after everything the user has typed.
|
||||
const compositionStart = [...displayText].length;
|
||||
displayText += this._composition;
|
||||
|
||||
// Skip redundant re-renders — include text content to detect
|
||||
// same-length changes (e.g., setFlushed with different text)
|
||||
// `rows` is part of the key: the layout is clamped to the visible rows
|
||||
// (see renderOverlay), so a keyboard opening — which changes rows without
|
||||
// changing the text — must not be skipped as a redundant render.
|
||||
const renderKey = `${displayText}:${startCol}:${activePrompt.row}:${activePrompt.col}:${totalCols}:${this._terminal.rows}:${this._flushedOffset}`;
|
||||
const renderKey = `${displayText}:${compositionStart}:${startCol}:${activePrompt.row}:${activePrompt.col}:${totalCols}:${this._terminal.rows}:${this._flushedOffset}`;
|
||||
if (renderKey === this._lastRenderKey && this._overlay.style.display !== 'none') return;
|
||||
this._lastRenderKey = renderKey;
|
||||
|
||||
@@ -608,6 +651,7 @@ export class ZerolagInputAddon implements XtermAddon {
|
||||
|
||||
renderOverlay(this._overlay, {
|
||||
lines,
|
||||
compositionStart: this._composition ? compositionStart : undefined,
|
||||
startCol,
|
||||
totalCols,
|
||||
cellW,
|
||||
|
||||
Reference in New Issue
Block a user