Skip to content

Commit b9e45ad

Browse files
committed
fix(cli): sync terminal cursor with input caret for CJK IME
1 parent d490200 commit b9e45ad

2 files changed

Lines changed: 328 additions & 11 deletions

File tree

cli/src/components/__tests__/multiline-input.test.tsx

Lines changed: 194 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,21 @@
1-
import { describe, test, expect } from 'bun:test'
1+
import { beforeAll, describe, expect, test } from 'bun:test'
2+
import { createTestRenderer } from '@opentui/core/testing'
3+
import { createRoot, flushSync } from '@opentui/react'
4+
import React from 'react'
25

36
import {
47
getKeypadPrintableSequence,
58
isKeypadEnter,
69
} from '../../utils/keypad-keys'
10+
import { initializeThemeStore } from '../../hooks/use-theme'
11+
import {
12+
calculateMultilineInputCursorPosition,
13+
MultilineInput,
14+
} from '../multiline-input'
15+
16+
beforeAll(() => {
17+
initializeThemeStore()
18+
})
719

820
/**
921
* Tests for tab character cursor rendering in MultilineInput component.
@@ -242,6 +254,187 @@ describe('MultilineInput - tab character handling', () => {
242254
})
243255
})
244256

257+
describe('MultilineInput - hardware cursor coordinates', () => {
258+
const viewportX = 10
259+
const viewportY = 20
260+
const lineInfo = (lineStartCols: number[]) => ({ lineStartCols })
261+
262+
test('places the ASCII caret using terminal cell columns', () => {
263+
expect(
264+
calculateMultilineInputCursorPosition({
265+
text: 'hello',
266+
cursorPosition: 2,
267+
lineInfo: lineInfo([0]),
268+
viewportX,
269+
viewportY,
270+
verticalScrollPosition: 0,
271+
}),
272+
).toEqual({ x: 13, y: 21 })
273+
})
274+
275+
test('advances two terminal cells after a CJK wide character', () => {
276+
expect(
277+
calculateMultilineInputCursorPosition({
278+
text: '你a',
279+
cursorPosition: 1,
280+
lineInfo: lineInfo([0]),
281+
viewportX,
282+
viewportY,
283+
verticalScrollPosition: 0,
284+
}),
285+
).toEqual({ x: 13, y: 21 })
286+
})
287+
288+
test('handles mixed ASCII and CJK widths', () => {
289+
expect(
290+
calculateMultilineInputCursorPosition({
291+
text: 'a你b',
292+
cursorPosition: 2,
293+
lineInfo: lineInfo([0]),
294+
viewportX,
295+
viewportY,
296+
verticalScrollPosition: 0,
297+
}),
298+
).toEqual({ x: 14, y: 21 })
299+
})
300+
301+
test('uses the existing four-cell tab expansion', () => {
302+
expect(
303+
calculateMultilineInputCursorPosition({
304+
text: '\ta',
305+
cursorPosition: 1,
306+
lineInfo: lineInfo([0]),
307+
viewportX,
308+
viewportY,
309+
verticalScrollPosition: 0,
310+
}),
311+
).toEqual({ x: 15, y: 21 })
312+
})
313+
314+
test('uses cumulative visual-line offsets for wrapped text', () => {
315+
expect(
316+
calculateMultilineInputCursorPosition({
317+
text: 'abcdefghij',
318+
cursorPosition: 7,
319+
lineInfo: lineInfo([0, 5]),
320+
viewportX,
321+
viewportY,
322+
verticalScrollPosition: 0,
323+
}),
324+
).toEqual({ x: 13, y: 22 })
325+
})
326+
327+
test('applies the viewport offset and vertical scroll position', () => {
328+
expect(
329+
calculateMultilineInputCursorPosition({
330+
text: 'a\nb\nc',
331+
cursorPosition: 4,
332+
lineInfo: lineInfo([0, 2, 4]),
333+
viewportX,
334+
viewportY,
335+
verticalScrollPosition: 1,
336+
}),
337+
).toEqual({ x: 11, y: 22 })
338+
})
339+
})
340+
341+
describe('MultilineInput - hardware cursor lifecycle', () => {
342+
test('moves the renderer cursor two cells after a CJK character', async () => {
343+
const setup = await createTestRenderer({ width: 30, height: 8 })
344+
const root = createRoot(setup.renderer)
345+
346+
try {
347+
flushSync(() =>
348+
root.render(
349+
<MultilineInput
350+
value="你a"
351+
onChange={() => {}}
352+
onSubmit={() => {}}
353+
onPaste={() => {}}
354+
cursorPosition={1}
355+
maxHeight={3}
356+
shouldBlinkCursor={false}
357+
focused
358+
/>,
359+
),
360+
)
361+
await setup.renderOnce()
362+
363+
expect(setup.renderer.getCursorState()).toMatchObject({
364+
x: 4,
365+
y: 1,
366+
visible: true,
367+
})
368+
} finally {
369+
flushSync(() => root.unmount())
370+
setup.renderer.destroy()
371+
}
372+
})
373+
374+
test('places the renderer cursor on the wrapped visual row', async () => {
375+
const setup = await createTestRenderer({ width: 12, height: 8 })
376+
const root = createRoot(setup.renderer)
377+
const props = {
378+
value: 'one two three',
379+
onChange: () => {},
380+
onSubmit: () => {},
381+
onPaste: () => {},
382+
cursorPosition: 13,
383+
maxHeight: 5,
384+
shouldBlinkCursor: false,
385+
focused: true,
386+
}
387+
388+
try {
389+
flushSync(() => root.render(<MultilineInput {...props} />))
390+
await setup.renderOnce()
391+
392+
// The wrapped second line is scrolled into the top viewport row.
393+
expect(setup.renderer.getCursorState()).toMatchObject({
394+
x: 7,
395+
y: 1,
396+
visible: true,
397+
})
398+
} finally {
399+
flushSync(() => root.unmount())
400+
setup.renderer.destroy()
401+
}
402+
})
403+
404+
test('hides the hardware cursor when unfocused and on unmount', async () => {
405+
const setup = await createTestRenderer({ width: 30, height: 8 })
406+
const root = createRoot(setup.renderer)
407+
const props = {
408+
value: 'input',
409+
onChange: () => {},
410+
onSubmit: () => {},
411+
onPaste: () => {},
412+
cursorPosition: 2,
413+
maxHeight: 3,
414+
shouldBlinkCursor: false,
415+
}
416+
417+
try {
418+
flushSync(() => root.render(<MultilineInput {...props} focused />))
419+
await setup.renderOnce()
420+
expect(setup.renderer.getCursorState().visible).toBe(true)
421+
422+
flushSync(() => root.render(<MultilineInput {...props} focused={false} />))
423+
await setup.renderOnce()
424+
expect(setup.renderer.getCursorState().visible).toBe(false)
425+
426+
flushSync(() => root.render(<MultilineInput {...props} focused />))
427+
await setup.renderOnce()
428+
expect(setup.renderer.getCursorState().visible).toBe(true)
429+
430+
flushSync(() => root.unmount())
431+
expect(setup.renderer.getCursorState().visible).toBe(false)
432+
} finally {
433+
setup.renderer.destroy()
434+
}
435+
})
436+
})
437+
245438
/**
246439
* Tests for Chinese/IME character input handling in MultilineInput component.
247440
*

0 commit comments

Comments
 (0)