diff --git a/cli/src/components/__tests__/multiline-input-caret.test.tsx b/cli/src/components/__tests__/multiline-input-caret.test.tsx new file mode 100644 index 0000000000..79c254d857 --- /dev/null +++ b/cli/src/components/__tests__/multiline-input-caret.test.tsx @@ -0,0 +1,145 @@ +import { afterEach, beforeAll, describe, expect, test } from 'bun:test' +import { createTestRenderer } from '@opentui/core/testing' +import { createRoot, flushSync } from '@opentui/react' +import React from 'react' + +import { initializeThemeStore } from '../../hooks/use-theme' +import { MultilineInput } from '../multiline-input' + +let cleanupRenderer: (() => void) | undefined + +beforeAll(() => { + initializeThemeStore() +}) + +afterEach(() => { + cleanupRenderer?.() + cleanupRenderer = undefined +}) + +const input = (cursorPosition: number, value: string, focused: boolean) => ( + {}} + onSubmit={() => {}} + onPaste={() => {}} + focused={focused} + shouldBlinkCursor={false} + /> +) + +/** + * Mounts the real input and reports where the *terminal's* cursor ended up: + * that position, not anything drawn by the component, is what an IME anchors + * its candidate window to (#1128). + */ +const mountInput = async ({ + value, + cursorPosition, + focused = true, +}: { + value: string + cursorPosition: number + focused?: boolean +}) => { + const setup = await createTestRenderer({ width: 60, height: 12 }) + const root = createRoot(setup.renderer) + cleanupRenderer = () => { + flushSync(() => root.unmount()) + setup.renderer.destroy() + } + + const paint = async (position: number) => { + flushSync(() => { + root.render(input(position, value, focused)) + }) + // The component publishes the caret from a frame callback, so the state for + // this render lands on the frame *after* the commit — give it two. + await setup.renderOnce() + await setup.renderOnce() + return setup.renderer.getCursorState() + } + + return { + caretAt: paint, + cursor: () => setup.renderer.getCursorState(), + frame: () => setup.captureCharFrame(), + first: await paint(cursorPosition), + } +} + +describe('MultilineInput - the terminal cursor sits on the caret', () => { + test('draws no caret of its own', async () => { + const field = await mountInput({ value: 'hello', cursorPosition: 5 }) + + // A drawn glyph would sit next to the real cursor and show two carets. + expect(field.frame()).not.toContain('▍') + expect(field.frame()).toContain('hello') + }) + + test('shows the terminal cursor while focused', async () => { + const field = await mountInput({ value: 'hello', cursorPosition: 0 }) + + expect(field.first.visible).toBe(true) + }) + + test('hides the terminal cursor when the input is not focused', async () => { + const field = await mountInput({ + value: 'hello', + cursorPosition: 3, + focused: false, + }) + + expect(field.first.visible).toBe(false) + }) + + test('advances one column per character', async () => { + const field = await mountInput({ value: 'hello', cursorPosition: 0 }) + + const third = await field.caretAt(3) + + expect(third.x - field.first.x).toBe(3) + expect(third.y).toBe(field.first.y) + }) + + test('counts a CJK glyph as two columns', async () => { + const field = await mountInput({ value: '你好', cursorPosition: 0 }) + + const afterFirst = await field.caretAt(1) + const afterSecond = await field.caretAt(2) + + // 你 is two cells wide, so the caret must move two columns, not one. + expect(afterFirst.x - field.first.x).toBe(2) + expect(afterSecond.x - afterFirst.x).toBe(2) + }) + + test('expands a tab to four columns', async () => { + const field = await mountInput({ value: '\tx', cursorPosition: 0 }) + + const afterTab = await field.caretAt(1) + + expect(afterTab.x - field.first.x).toBe(4) + }) + + test('keeps the caret on the line it is on', async () => { + const field = await mountInput({ value: 'one\ntwo', cursorPosition: 4 }) + + const laterOnSameLine = await field.caretAt(6) + + expect(field.first.visible).toBe(true) + expect(laterOnSameLine.y).toBe(field.first.y) + expect(laterOnSameLine.x - field.first.x).toBe(2) + }) + + test('hides the caret when its line is scrolled out of view', async () => { + // The first line sits above the visible box, so there is no cell to use. + const field = await mountInput({ value: 'one\ntwo', cursorPosition: 0 }) + + expect(field.first.visible).toBe(false) + + const visibleLine = await field.caretAt(4) + + expect(visibleLine.visible).toBe(true) + }) +}) diff --git a/cli/src/components/__tests__/multiline-input.test.tsx b/cli/src/components/__tests__/multiline-input.test.tsx index 7fcf7eaa17..d55e8b08fd 100644 --- a/cli/src/components/__tests__/multiline-input.test.tsx +++ b/cli/src/components/__tests__/multiline-input.test.tsx @@ -6,13 +6,12 @@ import { } from '../../utils/keypad-keys' /** - * Tests for tab character cursor rendering in MultilineInput component. + * Tab expansion for the caret position in MultilineInput. * - * The shouldHighlight logic determines whether to show a highlighted character - * or the cursor symbol (▍) at the cursor position. - * - * Additionally, tabs are expanded to spaces (TAB_WIDTH=4) for proper rendering, - * so the cursor appears at the correct visual position. + * Tabs are expanded to spaces (TAB_WIDTH=4) so the caret lands on the right + * visual column. Since #1128 the component no longer draws its own caret: the + * real terminal cursor is moved to that column instead, which is what the + * rendering tests in multiline-input-caret.test.tsx assert. */ /** @@ -45,28 +44,9 @@ function isPrintableCharacterKey(key: { name?: string }): boolean { */ const CONTROL_CHAR_REGEX = /[\u0000-\u0008\u000b-\u000c\u000e-\u001f\u007f]/ -describe('MultilineInput - tab character handling', () => { +describe('MultilineInput - tab expansion for the caret', () => { const TAB_WIDTH = 4 - /** - * Helper function that mimics the shouldHighlight logic from MultilineInput. - * This tests the core fix: tabs should NOT be highlighted (like newlines). - */ - function shouldHighlightChar( - showCursor: boolean, - isPlaceholder: boolean, - cursorPosition: number, - displayValue: string, - ): boolean { - return ( - showCursor && - !isPlaceholder && - cursorPosition < displayValue.length && - displayValue[cursorPosition] !== '\n' && - displayValue[cursorPosition] !== '\t' // This is the fix being tested - ) - } - /** * Calculate cursor position in expanded string (tabs -> spaces) */ @@ -81,136 +61,6 @@ describe('MultilineInput - tab character handling', () => { return renderPos } - test('does NOT highlight when cursor is on a tab character', () => { - const value = 'hello\tworld' - const cursorPosition = 5 // Position of the tab - - const shouldHighlight = shouldHighlightChar( - true, - false, - cursorPosition, - value, - ) - - // Tab characters should not be highlighted (should show cursor symbol instead) - expect(shouldHighlight).toBe(false) - }) - - test('does NOT highlight when cursor is on a newline character', () => { - const value = 'line1\nline2' - const cursorPosition = 5 // Position of the newline - - const shouldHighlight = shouldHighlightChar( - true, - false, - cursorPosition, - value, - ) - - // Newlines should not be highlighted (existing behavior) - expect(shouldHighlight).toBe(false) - }) - - test('DOES highlight when cursor is on a regular character', () => { - const value = 'hello' - const cursorPosition = 1 // Position of 'e' - - const shouldHighlight = shouldHighlightChar( - true, - false, - cursorPosition, - value, - ) - - // Regular characters should be highlighted - expect(shouldHighlight).toBe(true) - }) - - test('does NOT highlight when not focused (showCursor=false)', () => { - const value = 'hello\tworld' - const cursorPosition = 5 - - const shouldHighlight = shouldHighlightChar( - false, - false, - cursorPosition, - value, - ) - - expect(shouldHighlight).toBe(false) - }) - - test('does NOT highlight when showing placeholder', () => { - const value = '' - const cursorPosition = 0 - - const shouldHighlight = shouldHighlightChar( - true, - true, - cursorPosition, - value, - ) - - expect(shouldHighlight).toBe(false) - }) - - test('does NOT highlight when cursor is at end of string', () => { - const value = 'hello' - const cursorPosition = 5 // Beyond last character - - const shouldHighlight = shouldHighlightChar( - true, - false, - cursorPosition, - value, - ) - - expect(shouldHighlight).toBe(false) - }) - - test('handles multiple tabs - does NOT highlight tab at position 2', () => { - const value = '\t\t\tindented' - const cursorPosition = 2 // Third tab - - const shouldHighlight = shouldHighlightChar( - true, - false, - cursorPosition, - value, - ) - - expect(shouldHighlight).toBe(false) - }) - - test('handles tab at end of string', () => { - const value = 'text\t' - const cursorPosition = 4 // Position of trailing tab - - const shouldHighlight = shouldHighlightChar( - true, - false, - cursorPosition, - value, - ) - - expect(shouldHighlight).toBe(false) - }) - - test('handles space character - DOES highlight (spaces are visible)', () => { - const value = 'hello world' - const cursorPosition = 5 // Position of space - - const shouldHighlight = shouldHighlightChar( - true, - false, - cursorPosition, - value, - ) - - // Spaces should be highlighted (they are visible characters) - expect(shouldHighlight).toBe(true) - }) - test('expands single tab to 4 spaces for rendering', () => { const value = 'hello\tworld' const cursorPosition = 6 // After the tab diff --git a/cli/src/components/input-cursor.tsx b/cli/src/components/input-cursor.tsx deleted file mode 100644 index 229b572935..0000000000 --- a/cli/src/components/input-cursor.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { TextAttributes } from '@opentui/core' -import React, { useEffect, useRef, useState } from 'react' - -interface InputCursorProps { - visible: boolean - focused: boolean - shouldBlink?: boolean - char?: string - color?: string - blinkDelay?: number - blinkInterval?: number - bold?: boolean -} - -export const InputCursor: React.FC = ({ - visible, - focused, - shouldBlink = true, - char = '▍', - color, - blinkDelay = 500, - blinkInterval = 500, // Faster blinking - bold = true, -}) => { - // false = normal/visible, true = invisible - const [isInvisible, setIsInvisible] = useState(false) - const blinkIntervalRef = useRef(null) - - // Handle blinking (toggle visible/invisible) when idle - useEffect(() => { - // Clear any existing interval - if (blinkIntervalRef.current) { - clearInterval(blinkIntervalRef.current) - blinkIntervalRef.current = null - } - - // Reset cursor to visible - setIsInvisible(false) - - // Only blink if shouldBlink is enabled, focused, and visible - if (!shouldBlink || !focused || !visible) return - - // Set up idle detection - const idleTimer = setTimeout(() => { - // Start blinking interval (toggle between visible and invisible) - blinkIntervalRef.current = setInterval(() => { - setIsInvisible((prev) => !prev) - }, blinkInterval) - }, blinkDelay) - - return () => { - clearTimeout(idleTimer) - if (blinkIntervalRef.current) { - clearInterval(blinkIntervalRef.current) - blinkIntervalRef.current = null - } - } - }, [visible, focused, shouldBlink, blinkDelay, blinkInterval]) - - if (!visible || !focused) { - return null - } - - // When invisible, return a space to maintain layout - if (isInvisible) { - return - } - - return ( - - {char} - - ) -} \ No newline at end of file diff --git a/cli/src/components/multiline-input.tsx b/cli/src/components/multiline-input.tsx index c5db1c51fd..b4f713dbb3 100644 --- a/cli/src/components/multiline-input.tsx +++ b/cli/src/components/multiline-input.tsx @@ -1,8 +1,4 @@ -import { - decodePasteBytes, - stripAnsiSequences, - TextAttributes, -} from '@opentui/core' +import { decodePasteBytes, stripAnsiSequences } from '@opentui/core' import { useAppContext, useKeyboard, useRenderer } from '@opentui/react' import { forwardRef, @@ -10,12 +6,11 @@ import { useEffect, useImperativeHandle, useRef, - useState, } from 'react' -import { InputCursor } from './input-cursor' import { useTheme } from '../hooks/use-theme' import { useChatStore } from '../state/chat-store' +import { caretCell } from '../utils/ime-caret' import { getKeypadPrintableSequence, isKeypadEnter, @@ -25,7 +20,6 @@ import { isLinefeedActingAsEnter, markReturnKeySeenForKey, } from '../utils/terminal-enter-detection' -import { supportsTruecolor } from '../utils/theme-system' import { calculateNewCursorPosition } from '../utils/word-wrap-utils' import type { InputValue } from '../types/store' @@ -91,7 +85,6 @@ function findNextWordBoundary(text: string, cursor: number): number { return pos } -export const CURSOR_CHAR = '▍' const CONTROL_CHAR_REGEX = /[\u0000-\u0008\u000b-\u000c\u000e-\u001f\u007f]/ const TAB_WIDTH = 4 @@ -226,7 +219,6 @@ export const MultilineInput = forwardRef< const effectiveShouldBlinkCursor = shouldBlinkCursor ?? hookBlinkValue const scrollBoxRef = useRef(null) - const [lastActivity, setLastActivity] = useState(Date.now()) const stickyColumnRef = useRef(null) @@ -262,11 +254,6 @@ export const MultilineInput = forwardRef< [cursorPosition], ) - // Update last activity on value or cursor changes - useEffect(() => { - setLastActivity(Date.now()) - }, [value, cursorPosition]) - const textRef = useRef(null) const lineInfo = textRef.current @@ -506,7 +493,6 @@ export const MultilineInput = forwardRef< const isPlaceholder = value.length === 0 && placeholder.length > 0 const displayValue = isPlaceholder ? placeholder : value - const showCursor = focused // Replace tabs with spaces for proper rendering const displayValueForRendering = displayValue.replace( @@ -520,32 +506,99 @@ export const MultilineInput = forwardRef< renderCursorPosition += displayValue[i] === '\t' ? TAB_WIDTH : 1 } - const { beforeCursor, afterCursor, activeChar, shouldHighlight } = (() => { - if (!showCursor) { - return { - beforeCursor: '', - afterCursor: '', - activeChar: ' ', - shouldHighlight: false, - } - } + // Whether the caret sits *on* a character rather than past the end of a line. + // Vertical navigation uses it to keep the column; the caret itself is drawn by + // the terminal now (see the renderAfter hook below), not by this component. + const caretOverCharacter = + !isPlaceholder && + renderCursorPosition < displayValueForRendering.length && + displayValue[cursorPosition] !== '\n' && + displayValue[cursorPosition] !== '\t' + + // Terminals anchor an IME candidate window to their *real* cursor (Windows + // Terminal via ConPTY, macOS, ibus/fcitx), and this input used to draw its own + // caret instead of moving it — so Chinese/Japanese/Korean popups landed + // wherever the last frame happened to write text (#1128). Publishing the + // caret's cell here keeps the real cursor on the caret instead. + const caretStateRef = useRef({ + focused, + blinking: Boolean(effectiveShouldBlinkCursor), + caretIndex: renderCursorPosition, + text: displayValueForRendering, + }) + caretStateRef.current = { + focused, + blinking: Boolean(effectiveShouldBlinkCursor), + caretIndex: renderCursorPosition, + text: displayValueForRendering, + } - const beforeCursor = displayValueForRendering.slice(0, renderCursorPosition) - const afterCursor = displayValueForRendering.slice(renderCursorPosition) - const activeChar = afterCursor.charAt(0) || ' ' - const shouldHighlight = - !isPlaceholder && - renderCursorPosition < displayValueForRendering.length && - displayValue[cursorPosition] !== '\n' && - displayValue[cursorPosition] !== '\t' + const publishedBlinkingRef = useRef(null) - return { - beforeCursor, - afterCursor, - activeChar, - shouldHighlight, + // Runs every frame, so a scroll or a resize moves the real cursor too, without + // waiting for a React re-render (and without a drawn caret that could go + // stale). `renderAfter` is not usable here: the text renderable overrides + // `render()` without calling it. + const publishCarets = useCallback(() => { + const text = textRef.current + const scrollBox = scrollBoxRef.current + const { + focused: inputFocused, + blinking, + caretIndex, + text: renderedText, + } = caretStateRef.current + if (!text || !inputFocused || !scrollBox) { + renderer.setCursorPosition(0, 0, false) + return } - })() + // Read the wrap info at frame time: a value captured during render is one + // layout behind, which would put every caret on the first line. + const lineStarts = + ( + text as unknown as { + textBufferView?: { lineInfo?: { lineStartCols?: number[] } } + } + ).textBufferView?.lineInfo?.lineStartCols ?? [] + const viewport = ( + scrollBox as { viewport?: { y?: number; height?: number } } + ).viewport + const viewportRows = Math.round(Number(viewport?.height ?? 0)) + // The first callback of a frame still sees the previous layout, before the + // input box has any height. Leaving the cursor alone beats flickering it off + // for a frame. + if (viewportRows <= 0) return + const cell = caretCell({ + text: renderedText, + caretIndex, + lineStarts, + originRow: Math.round(text.screenY), + originCol: Math.round(text.screenX), + viewportTop: Math.round(Number(viewport?.y ?? 0)), + viewportRows, + }) + if (!cell) { + // Scrolled out of view: no cursor is better than one parked outside. + renderer.setCursorPosition(0, 0, false) + return + } + // Keep the "no blinking" preference meaningful now that the caret is real, + // but only on change: re-sending the style every frame would re-emit the + // terminal sequence for no reason. + if (publishedBlinkingRef.current !== blinking) { + publishedBlinkingRef.current = blinking + renderer.setCursorStyle({ blinking }) + } + renderer.setCursorPosition(cell.col, cell.row, true) + }, [renderer]) + + useEffect(() => { + const onFrame = async () => publishCarets() + renderer.setFrameCallback(onFrame) + return () => { + renderer.removeFrameCallback(onFrame) + } + }, [renderer, publishCarets]) // --- Keyboard Handler Helpers --- @@ -978,13 +1031,13 @@ export const MultilineInput = forwardRef< // Up arrow (no modifiers) if (key.name === 'up' && !key.ctrl && !key.meta && !key.option) { preventKeyDefault(key) - const desiredIndex = getOrSetStickyColumn(lineStarts, !shouldHighlight) + const desiredIndex = getOrSetStickyColumn(lineStarts, !caretOverCharacter) onChange({ text: value, cursorPosition: calculateNewCursorPosition({ cursorPosition, lineStarts, - cursorIsChar: !shouldHighlight, + cursorIsChar: !caretOverCharacter, direction: 'up', desiredIndex, }), @@ -996,13 +1049,13 @@ export const MultilineInput = forwardRef< // Down arrow (no modifiers) if (key.name === 'down' && !key.ctrl && !key.meta && !key.option) { preventKeyDefault(key) - const desiredIndex = getOrSetStickyColumn(lineStarts, !shouldHighlight) + const desiredIndex = getOrSetStickyColumn(lineStarts, !caretOverCharacter) onChange({ text: value, cursorPosition: calculateNewCursorPosition({ cursorPosition, lineStarts, - cursorIsChar: !shouldHighlight, + cursorIsChar: !caretOverCharacter, direction: 'down', desiredIndex, }), @@ -1013,7 +1066,14 @@ export const MultilineInput = forwardRef< return false }, - [value, cursorPosition, onChange, moveCursor, shouldHighlight, getOrSetStickyColumn], + [ + value, + cursorPosition, + onChange, + moveCursor, + caretOverCharacter, + getOrSetStickyColumn, + ], ) // Handle character input (regular chars, tab, and IME/multi-byte input) @@ -1157,9 +1217,6 @@ export const MultilineInput = forwardRef< ? theme.inputFocusedFg : theme.inputFg - // Use theme's info color for selection highlight background - const highlightBg = theme.info - return ( - {showCursor ? ( - <> - {beforeCursor} - {shouldHighlight ? ( - - {activeChar === ' ' ? '\u00a0' : activeChar} - - ) : ( - - )} - {shouldHighlight - ? afterCursor.length > 0 - ? afterCursor.slice(1) - : '' - : afterCursor} - {layoutMetrics.gutterEnabled ? '\n' : ''} - - ) : ( - <> - {displayValueForRendering} - {layoutMetrics.gutterEnabled ? '\n' : ''} - - )} + {displayValueForRendering} + {layoutMetrics.gutterEnabled ? '\n' : ''} ) diff --git a/cli/src/hooks/use-clipboard.ts b/cli/src/hooks/use-clipboard.ts index 37680bc760..fde8c61047 100644 --- a/cli/src/hooks/use-clipboard.ts +++ b/cli/src/hooks/use-clipboard.ts @@ -1,7 +1,6 @@ import { useRenderer } from '@opentui/react' import { useEffect, useRef, useState } from 'react' -import { CURSOR_CHAR } from '../components/multiline-input' import { copyTextToClipboard, registerClipboardRenderer, @@ -54,9 +53,7 @@ export const useClipboard = () => { ? selectionObj : null - // Filter out cursor character from selected text - const cleanedText = - rawText?.replace(new RegExp(CURSOR_CHAR, 'g'), '') ?? null + const cleanedText = rawText if (!cleanedText || cleanedText.trim().length === 0) { pendingSelectionRef.current = null diff --git a/cli/src/utils/__tests__/ime-caret.test.ts b/cli/src/utils/__tests__/ime-caret.test.ts new file mode 100644 index 0000000000..8043d54e23 --- /dev/null +++ b/cli/src/utils/__tests__/ime-caret.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, test } from 'bun:test' + +import { caretCell } from '../ime-caret' + +const base = { + lineStarts: [0], + originRow: 3, + originCol: 2, + viewportTop: 3, + viewportRows: 4, +} + +describe('caretCell', () => { + test('puts the caret after the text on a single line', () => { + expect(caretCell({ ...base, text: 'hello', caretIndex: 5 })).toEqual({ + col: 8, + row: 4, + }) + }) + + test('moves with the caret inside a line', () => { + expect(caretCell({ ...base, text: 'hello', caretIndex: 0 })).toEqual({ + col: 3, + row: 4, + }) + expect(caretCell({ ...base, text: 'hello', caretIndex: 2 })).toEqual({ + col: 5, + row: 4, + }) + }) + + test('counts a CJK glyph as two cells, not one', () => { + // 你 = 2 cells: the caret after it sits on column 5, not 4. + expect(caretCell({ ...base, text: '你好', caretIndex: 1 })).toEqual({ + col: 5, + row: 4, + }) + expect(caretCell({ ...base, text: '你好', caretIndex: 2 })).toEqual({ + col: 7, + row: 4, + }) + }) + + test('mixes CJK and ASCII correctly', () => { + expect(caretCell({ ...base, text: 'a你b', caretIndex: 3 })).toEqual({ + col: 7, + row: 4, + }) + }) + + test('follows the visual line the caret is on', () => { + const multiline = { + ...base, + lineStarts: [0, 6, 11], + text: 'first\nsecond\nthird', + } + expect(caretCell({ ...multiline, caretIndex: 0 })?.row).toBe(4) + expect(caretCell({ ...multiline, caretIndex: 6 })?.row).toBe(5) + expect(caretCell({ ...multiline, caretIndex: 11 })?.row).toBe(6) + // Column restarts at the visual line start. + expect(caretCell({ ...multiline, caretIndex: 13 })).toEqual({ + col: 4, + row: 6, + }) + }) + + test('returns null when the caret row is scrolled out of view', () => { + expect( + caretCell({ + ...base, + text: 'first\nsecond\nthird', + lineStarts: [0, 6, 11], + caretIndex: 0, + originRow: 1, // first line is above the visible area + }), + ).toBeNull() + expect( + caretCell({ + ...base, + text: 'a\nb', + lineStarts: [0, 2], + caretIndex: 2, + viewportRows: 1, // only the first line is visible + }), + ).toBeNull() + }) + + test('handles the first row of the viewport', () => { + expect( + caretCell({ + ...base, + text: 'a\nb', + lineStarts: [0, 2], + caretIndex: 2, + viewportRows: 2, + }), + ).toEqual({ col: 3, row: 5 }) + }) + + test('clamps a caret index past the end of the text', () => { + expect(caretCell({ ...base, text: 'hi', caretIndex: 99 })).toEqual( + caretCell({ ...base, text: 'hi', caretIndex: 2 }), + ) + }) + + test('falls back to a single line when the buffer has no line info', () => { + expect( + caretCell({ ...base, text: 'hi', caretIndex: 1, lineStarts: [] }), + ).toEqual({ col: 4, row: 4 }) + }) +}) diff --git a/cli/src/utils/ime-caret.ts b/cli/src/utils/ime-caret.ts new file mode 100644 index 0000000000..2bb443dcc4 --- /dev/null +++ b/cli/src/utils/ime-caret.ts @@ -0,0 +1,72 @@ +/** + * Where the terminal's *hardware* cursor has to sit so an IME puts its + * candidate window on the caret. + * + * Terminals anchor the IME composition/candidate window to the real cursor + * position (Windows Terminal via ConPTY, macOS via AXBoundsForRange, Linux via + * ibus/fcitx cursor tracking). This input draws its own caret glyph and never + * moves the real cursor, so the popup ends up wherever the last frame happened + * to write text. See issue #1128. + * + * Returned coordinates are 1-based, which is what `renderer.setCursorPosition` + * expects: the framework's own edit-buffer renderable adds 1 for the same + * reason. + */ +import stringWidth from 'string-width' + +export type CaretCell = { + /** 1-based screen column. */ + col: number + /** 1-based screen row. */ + row: number +} + +export type CaretCellInput = { + /** Rendered text, with tabs already expanded to spaces. */ + text: string + /** Caret offset into `text`, as a string index. */ + caretIndex: number + /** First character index of each visual (wrapped) line, from the text buffer. */ + lineStarts: readonly number[] + /** 0-based screen row of the first row of `text`. */ + originRow: number + /** 0-based screen column of the left edge of `text`. */ + originCol: number + /** 0-based screen row of the top of the visible input area. */ + viewportTop: number + /** How many rows of the input area are visible. */ + viewportRows: number +} + +/** + * Cell for the caret, or `null` when it is scrolled out of view — a real cursor + * parked outside the input box would be worse than no cursor at all. + */ +export function caretCell(input: CaretCellInput): CaretCell | null { + const { + text, + lineStarts, + originRow, + originCol, + viewportTop, + viewportRows, + } = input + const caretIndex = Math.max(0, Math.min(input.caretIndex, text.length)) + + const starts = lineStarts.length > 0 ? lineStarts : [0] + let lineIndex = 0 + for (let i = 0; i < starts.length; i++) { + if (starts[i] > caretIndex) break + lineIndex = i + } + const lineStart = starts[lineIndex] ?? 0 + + // CJK glyphs occupy two cells, so the column must be measured in cells and + // never in string indices. + const colInLine = stringWidth(text.slice(lineStart, caretIndex)) + const row = originRow + lineIndex + + if (row < viewportTop || row >= viewportTop + viewportRows) return null + + return { col: originCol + colInLine + 1, row: row + 1 } +}