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
}