mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-21 10:00:39 +00:00
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:
parent
d9464383ec
commit
07fad1362c
8 changed files with 193 additions and 17 deletions
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue