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

@ -1,7 +1,7 @@
import type { AutocompleteProvider, CombinedAutocompleteProvider } from "../autocomplete.js";
import { getEditorKeybindings } from "../keybindings.js";
import { matchesKey } from "../keys.js";
import type { Component, TUI } from "../tui.js";
import { type Component, CURSOR_MARKER, type Focusable, type TUI } from "../tui.js";
import { getSegmenter, isPunctuationChar, isWhitespaceChar, visibleWidth } from "../utils.js";
import { SelectList, type SelectListTheme } from "./select-list.js";
@ -204,13 +204,16 @@ export interface EditorTheme {
selectList: SelectListTheme;
}
export class Editor implements Component {
export class Editor implements Component, Focusable {
private state: EditorState = {
lines: [""],
cursorLine: 0,
cursorCol: 0,
};
/** Focusable interface - set by TUI when focus changes */
focused: boolean = false;
protected tui: TUI;
private theme: EditorTheme;
@ -365,6 +368,9 @@ export class Editor implements Component {
}
// Render each visible layout line
// Emit hardware cursor marker only when focused and not showing autocomplete
const emitCursorMarker = this.focused && !this.isAutocompleting;
for (const layoutLine of visibleLines) {
let displayText = layoutLine.text;
let lineVisibleWidth = visibleWidth(layoutLine.text);
@ -374,6 +380,9 @@ export class Editor implements Component {
const before = displayText.slice(0, layoutLine.cursorPos);
const after = displayText.slice(layoutLine.cursorPos);
// Hardware cursor marker (zero-width, emitted before fake cursor for IME positioning)
const marker = emitCursorMarker ? CURSOR_MARKER : "";
if (after.length > 0) {
// Cursor is on a character (grapheme) - replace it with highlighted version
// Get the first grapheme from 'after'
@ -381,14 +390,14 @@ export class Editor implements Component {
const firstGrapheme = afterGraphemes[0]?.segment || "";
const restAfter = after.slice(firstGrapheme.length);
const cursor = `\x1b[7m${firstGrapheme}\x1b[0m`;
displayText = before + cursor + restAfter;
displayText = before + marker + cursor + restAfter;
// lineVisibleWidth stays the same - we're replacing, not adding
} else {
// Cursor is at the end - check if we have room for the space
if (lineVisibleWidth < width) {
// We have room - add highlighted space
const cursor = "\x1b[7m \x1b[0m";
displayText = before + cursor;
displayText = before + marker + cursor;
// lineVisibleWidth increases by 1 - we're adding a space
lineVisibleWidth = lineVisibleWidth + 1;
} else {
@ -403,7 +412,7 @@ export class Editor implements Component {
.slice(0, -1)
.map((g) => g.segment)
.join("");
displayText = beforeWithoutLast + cursor;
displayText = beforeWithoutLast + marker + cursor;
}
// lineVisibleWidth stays the same
}

View file

@ -1,5 +1,5 @@
import { getEditorKeybindings } from "../keybindings.js";
import type { Component } from "../tui.js";
import { type Component, CURSOR_MARKER, type Focusable } from "../tui.js";
import { getSegmenter, isPunctuationChar, isWhitespaceChar, visibleWidth } from "../utils.js";
const segmenter = getSegmenter();
@ -7,12 +7,15 @@ const segmenter = getSegmenter();
/**
* Input component - single-line text input with horizontal scrolling
*/
export class Input implements Component {
export class Input implements Component, Focusable {
private value: string = "";
private cursor: number = 0; // Cursor position in the value
public onSubmit?: (value: string) => void;
public onEscape?: () => void;
/** Focusable interface - set by TUI when focus changes */
focused: boolean = false;
// Bracketed paste mode buffering
private pasteBuffer: string = "";
private isInPaste: boolean = false;
@ -325,9 +328,12 @@ export class Input implements Component {
const atCursor = visibleText[cursorDisplay] || " "; // Character at cursor, or space if at end
const afterCursor = visibleText.slice(cursorDisplay + 1);
// Hardware cursor marker (zero-width, emitted before fake cursor for IME positioning)
const marker = this.focused ? CURSOR_MARKER : "";
// Use inverse video to show cursor
const cursorChar = `\x1b[7m${atCursor}\x1b[27m`; // ESC[7m = reverse video, ESC[27m = normal
const textWithCursor = beforeCursor + cursorChar + afterCursor;
const textWithCursor = beforeCursor + marker + cursorChar + afterCursor;
// Calculate visual width
const visualLength = visibleWidth(textWithCursor);

View file

@ -75,6 +75,9 @@ export {
export {
type Component,
Container,
CURSOR_MARKER,
type Focusable,
isFocusable,
type OverlayAnchor,
type OverlayHandle,
type OverlayMargin,

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();
}
}

View file

@ -112,6 +112,8 @@ export function visibleWidth(str: string): number {
clean = clean.replace(/\x1b\[[0-9;]*[mGKHJ]/g, "");
// Strip OSC 8 hyperlinks: \x1b]8;;URL\x07 and \x1b]8;;\x07
clean = clean.replace(/\x1b\]8;;[^\x07]*\x07/g, "");
// Strip APC sequences: \x1b_...\x07 or \x1b_...\x1b\\ (used for cursor marker)
clean = clean.replace(/\x1b_[^\x07\x1b]*(?:\x07|\x1b\\)/g, "");
}
// Calculate width
@ -160,6 +162,18 @@ export function extractAnsiCode(str: string, pos: number): { code: string; lengt
return null;
}
// APC sequence: ESC _ ... BEL or ESC _ ... ST (ESC \)
// Used for cursor marker and application-specific commands
if (next === "_") {
let j = pos + 2;
while (j < str.length) {
if (str[j] === "\x07") return { code: str.substring(pos, j + 1), length: j + 1 - pos };
if (str[j] === "\x1b" && str[j + 1] === "\\") return { code: str.substring(pos, j + 2), length: j + 2 - pos };
j++;
}
return null;
}
return null;
}