From 7b6d9beceadcd99d59fe7c07b6c2bbd2e09675b6 Mon Sep 17 00:00:00 2001 From: KazenDev Date: Mon, 14 Sep 2026 13:23:28 -0500 Subject: [PATCH] fix(cli): put the real terminal cursor on the caret so IME popups land right MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Terminals anchor an IME candidate window (Windows Terminal via ConPTY, macOS, ibus/fcitx) to their *real* cursor. This input drew its own `▍` glyph and never moved that cursor, so Chinese/Japanese/Korean popups landed wherever the last frame happened to write text, and Persian/RTL inherited the same misplacement. The caret is the terminal's now: `caretCell` (pure, in utils/ime-caret.ts) turns the caret index into a 1-based screen cell — measured in cells, so a CJK glyph counts two columns, and offset by the text renderable's own screen origin so a scrolled input still resolves. The component publishes it every frame through `renderer.setFrameCallback`, which also fixes a misplaced cursor after a scroll or resize without waiting for a re-render. `renderAfter` was the obvious hook and is not usable here: the text renderable overrides `render()` without calling it. The fake caret goes away with it. Leaving both would show two cursors, and the drawn one could only ever go stale. That retires input-cursor.tsx and the `shouldHighlight` branch with its inverted-span highlight. Vertical navigation still needs to know whether the caret sits *on* a character, so that decision survives as `caretOverCharacter`. The clipboard hook stops stripping a cursor character that no longer exists. If the caret's line is scrolled out of view, or the frame still holds the previous layout, the cursor is left alone or hidden rather than parked outside the input box. Verified: 17 new tests across two files (the helper's geometry and the component's published position), 9 obsolete ones removed. The CLI typecheck is unchanged at its 10 pre-existing errors, and the full suite matches baseline (3090 -> 3098 pass, same 20 pre-existing failures). Refs #1128 --- .../__tests__/multiline-input-caret.test.tsx | 145 ++++++++++++++ .../__tests__/multiline-input.test.tsx | 162 +-------------- cli/src/components/input-cursor.tsx | 77 -------- cli/src/components/multiline-input.tsx | 186 ++++++++++-------- cli/src/hooks/use-clipboard.ts | 5 +- cli/src/utils/__tests__/ime-caret.test.ts | 111 +++++++++++ cli/src/utils/ime-caret.ts | 72 +++++++ 7 files changed, 441 insertions(+), 317 deletions(-) create mode 100644 cli/src/components/__tests__/multiline-input-caret.test.tsx delete mode 100644 cli/src/components/input-cursor.tsx create mode 100644 cli/src/utils/__tests__/ime-caret.test.ts create mode 100644 cli/src/utils/ime-caret.ts 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 } +}