feat(tui): hardware cursor positioning for IME support

- Add Focusable interface for components that need hardware cursor positioning
- Add CURSOR_MARKER (APC escape sequence) for marking cursor position in render output
- Editor and Input components implement Focusable and emit marker when focused
- TUI extracts cursor position from rendered output and positions hardware cursor
- Track hardwareCursorRow separately from cursorRow for differential rendering
- visibleWidth() and extractAnsiCode() now handle APC sequences
- Update overlay-test.ts example to demonstrate Focusable usage
- Add documentation for Focusable interface in docs/tui.md

Closes #719, closes #525
This commit is contained in:
Mario Zechner 2026-01-16 04:30:07 +01:00
parent d9464383ec
commit 07fad1362c
8 changed files with 193 additions and 17 deletions

View file

@ -39,6 +39,30 @@ export interface Component {
invalidate(): void;
}
/**
* Interface for components that can receive focus and display a hardware cursor.
* When focused, the component should emit CURSOR_MARKER at the cursor position
* in its render output. TUI will find this marker and position the hardware
* cursor there for proper IME candidate window positioning.
*/
export interface Focusable {
/** Set by TUI when focus changes. Component should emit CURSOR_MARKER when true. */
focused: boolean;
}
/** Type guard to check if a component implements Focusable */
export function isFocusable(component: Component | null): component is Component & Focusable {
return component !== null && "focused" in component;
}
/**
* Cursor position marker - APC (Application Program Command) sequence.
* This is a zero-width escape sequence that terminals ignore.
* Components emit this at the cursor position when focused.
* TUI finds and strips this marker, then positions the hardware cursor there.
*/
export const CURSOR_MARKER = "\x1b_pi:c\x07";
export { visibleWidth };
/**
@ -180,7 +204,8 @@ export class TUI extends Container {
/** Global callback for debug key (Shift+Ctrl+D). Called before input is forwarded to focused component. */
public onDebug?: () => void;
private renderRequested = false;
private cursorRow = 0; // Track where cursor is (0-indexed, relative to our first line)
private cursorRow = 0; // Logical cursor row (end of rendered content)
private hardwareCursorRow = 0; // Actual terminal cursor row (may differ due to IME positioning)
private inputBuffer = ""; // Buffer for parsing terminal responses
private cellSizeQueryPending = false;
@ -198,7 +223,17 @@ export class TUI extends Container {
}
setFocus(component: Component | null): void {
// Clear focused flag on old component
if (isFocusable(this.focusedComponent)) {
this.focusedComponent.focused = false;
}
this.focusedComponent = component;
// Set focused flag on new component
if (isFocusable(component)) {
component.focused = true;
}
}
/**
@ -317,7 +352,7 @@ export class TUI extends Container {
// Move cursor to the end of the content to prevent overwriting/artifacts on exit
if (this.previousLines.length > 0) {
const targetRow = this.previousLines.length; // Line after the last content
const lineDiff = targetRow - this.cursorRow;
const lineDiff = targetRow - this.hardwareCursorRow;
if (lineDiff > 0) {
this.terminal.write(`\x1b[${lineDiff}B`);
} else if (lineDiff < 0) {
@ -335,6 +370,7 @@ export class TUI extends Container {
this.previousLines = [];
this.previousWidth = -1; // -1 triggers widthChanged, forcing a full clear
this.cursorRow = 0;
this.hardwareCursorRow = 0;
}
if (this.renderRequested) return;
this.renderRequested = true;
@ -700,6 +736,29 @@ export class TUI extends Container {
return sliceByColumn(result, 0, totalWidth, true);
}
/**
* Find and extract cursor position from rendered lines.
* Searches for CURSOR_MARKER, calculates its position, and strips it from the output.
* @returns Cursor position { row, col } or null if no marker found
*/
private extractCursorPosition(lines: string[]): { row: number; col: number } | null {
for (let row = 0; row < lines.length; row++) {
const line = lines[row];
const markerIndex = line.indexOf(CURSOR_MARKER);
if (markerIndex !== -1) {
// Calculate visual column (width of text before marker)
const beforeMarker = line.slice(0, markerIndex);
const col = visibleWidth(beforeMarker);
// Strip marker from the line
lines[row] = line.slice(0, markerIndex) + line.slice(markerIndex + CURSOR_MARKER.length);
return { row, col };
}
}
return null;
}
private doRender(): void {
const width = this.terminal.columns;
const height = this.terminal.rows;
@ -712,6 +771,9 @@ export class TUI extends Container {
newLines = this.compositeOverlays(newLines, width, height);
}
// Extract cursor position before applying line resets (marker must be found first)
const cursorPos = this.extractCursorPosition(newLines);
newLines = this.applyLineResets(newLines);
// Width changed - need full re-render
@ -728,6 +790,8 @@ export class TUI extends Container {
this.terminal.write(buffer);
// After rendering N lines, cursor is at end of last line (clamp to 0 for empty)
this.cursorRow = Math.max(0, newLines.length - 1);
this.hardwareCursorRow = this.cursorRow;
this.positionHardwareCursor(cursorPos, newLines.length);
this.previousLines = newLines;
this.previousWidth = width;
return;
@ -744,6 +808,8 @@ export class TUI extends Container {
buffer += "\x1b[?2026l"; // End synchronized output
this.terminal.write(buffer);
this.cursorRow = Math.max(0, newLines.length - 1);
this.hardwareCursorRow = this.cursorRow;
this.positionHardwareCursor(cursorPos, newLines.length);
this.previousLines = newLines;
this.previousWidth = width;
return;
@ -765,8 +831,9 @@ export class TUI extends Container {
}
}
// No changes
// No changes - but still need to update hardware cursor position if it moved
if (firstChanged === -1) {
this.positionHardwareCursor(cursorPos, newLines.length);
return;
}
@ -776,7 +843,7 @@ export class TUI extends Container {
let buffer = "\x1b[?2026h";
// Move to end of new content (clamp to 0 for empty content)
const targetRow = Math.max(0, newLines.length - 1);
const lineDiff = targetRow - this.cursorRow;
const lineDiff = targetRow - this.hardwareCursorRow;
if (lineDiff > 0) buffer += `\x1b[${lineDiff}B`;
else if (lineDiff < 0) buffer += `\x1b[${-lineDiff}A`;
buffer += "\r";
@ -789,7 +856,9 @@ export class TUI extends Container {
buffer += "\x1b[?2026l";
this.terminal.write(buffer);
this.cursorRow = targetRow;
this.hardwareCursorRow = targetRow;
}
this.positionHardwareCursor(cursorPos, newLines.length);
this.previousLines = newLines;
this.previousWidth = width;
return;
@ -811,6 +880,8 @@ export class TUI extends Container {
buffer += "\x1b[?2026l"; // End synchronized output
this.terminal.write(buffer);
this.cursorRow = Math.max(0, newLines.length - 1);
this.hardwareCursorRow = this.cursorRow;
this.positionHardwareCursor(cursorPos, newLines.length);
this.previousLines = newLines;
this.previousWidth = width;
return;
@ -820,8 +891,8 @@ export class TUI extends Container {
// Build buffer with all updates wrapped in synchronized output
let buffer = "\x1b[?2026h"; // Begin synchronized output
// Move cursor to first changed line
const lineDiff = firstChanged - this.cursorRow;
// Move cursor to first changed line (use hardwareCursorRow for actual position)
const lineDiff = firstChanged - this.hardwareCursorRow;
if (lineDiff > 0) {
buffer += `\x1b[${lineDiff}B`; // Move down
} else if (lineDiff < 0) {
@ -895,8 +966,46 @@ export class TUI extends Container {
// Track cursor position for next render
this.cursorRow = finalCursorRow;
this.hardwareCursorRow = finalCursorRow;
// Position hardware cursor for IME
this.positionHardwareCursor(cursorPos, newLines.length);
this.previousLines = newLines;
this.previousWidth = width;
}
/**
* Position the hardware cursor for IME candidate window.
* @param cursorPos The cursor position extracted from rendered output, or null
* @param totalLines Total number of rendered lines
*/
private positionHardwareCursor(cursorPos: { row: number; col: number } | null, totalLines: number): void {
if (!cursorPos || totalLines <= 0) {
this.terminal.hideCursor();
return;
}
// Clamp cursor position to valid range
const targetRow = Math.max(0, Math.min(cursorPos.row, totalLines - 1));
const targetCol = Math.max(0, cursorPos.col);
// Move cursor from current position to target
const rowDelta = targetRow - this.hardwareCursorRow;
let buffer = "";
if (rowDelta > 0) {
buffer += `\x1b[${rowDelta}B`; // Move down
} else if (rowDelta < 0) {
buffer += `\x1b[${-rowDelta}A`; // Move up
}
// Move to absolute column (1-indexed)
buffer += `\x1b[${targetCol + 1}G`;
if (buffer) {
this.terminal.write(buffer);
}
this.hardwareCursorRow = targetRow;
this.terminal.showCursor();
}
}