Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 145 additions & 0 deletions cli/src/components/__tests__/multiline-input-caret.test.tsx
Original file line number Diff line number Diff line change
@@ -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) => (
<MultilineInput
value={value}
cursorPosition={cursorPosition}
onChange={() => {}}
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)
})
})
162 changes: 6 additions & 156 deletions cli/src/components/__tests__/multiline-input.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/

/**
Expand Down Expand Up @@ -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)
*/
Expand All @@ -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
Expand Down
Loading
Loading