charmbracelet / charmbracelet/bubbles
textinput: Cursor() returns incorrect X position for wide (CJK) characters
- Dominant language
- Go
- Stars
- 8.9k
- Forks
- 457
- Avg merge
- 1d 18h
- Merged PRs (30d)
- 5
Description
**Describe the bug**
`textinput.Model.Cursor()` computes the cursor X offset using `m.Position()`, which returns a rune index. For wide characters (CJK, fullwidth, etc.) that occupy 2 terminal columns, this results in the cursor being placed at the wrong screen position.
In contrast, `textarea.Model.Cursor()` correctly uses `lineInfo.CharOffset`, which is computed via `uniseg.StringWidth` (display width).
```go
// textinput.go — current (buggy)
func (m Model) Cursor() *tea.Cursor {
// ...
xOffset := m.Position() + promptWidth // m.Position() = rune index, not display width
// ...
}
// textarea.go — correct
func (m Model) Cursor() *tea.Cursor {
// ...
lineInfo := m.LineInfo()
xOffset := lineInfo.CharOffset + ... // CharOffset = uniseg.StringWidth(...)
// ...
}
```
For example, with the value "あいう" (3 CJK runes, 6 display columns):
- `m.Position()` returns `3` (rune count)
- Correct cursor X should be `6` (display columns)
- The cursor is placed 3 columns too far to the left
This bug was introduced in v2 with the real cursor API. v1 only had virtual cursors (rendered inline in `View()`), which are not affected.
**Setup**
- OS: macOS
- Shell: zsh
- Terminal Emulator: WezTerm
- Locale: en_US.UTF-8
**To Reproduce**
1. Create a `textinput.Model` with `SetVirtualCursor(false)`
2. Type CJK characters (e.g. Japanese hiragana "あいう")
3. Observe that the terminal cursor position does not match the end of the typed text
**Source Code**
```go
package main
import (
"fmt"
"os"
"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2"
)
type model struct {
ti textinput.Model
}
func initialModel() model {
ti := textinput.New()
ti.Placeholder = "Type CJK text here"
ti.SetVirtualCursor(false)
ti.Focus()
ti.SetWidth(40)
return model{ti: ti}
}
func (m model) Init() tea.Cmd { return nil }
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyPressMsg:
if msg.String() == "ctrl+c" {
return m, tea.Quit
}
}
var cmd tea.Cmd
m.ti, cmd = m.ti.Update(msg)
return m, cmd
}
func (m model) View() tea.View {
var v tea.View
v.SetContent(fmt.Sprintf("Input: %s\n\nPress Ctrl+C to quit.", m.ti.View()))
if c := m.ti.Cursor(); c != nil {
promptW := len([]rune(m.ti.Prompt))
c.X += len("Input: ") - promptW
v.Cursor = c
}
return v
}
func main() {
p := tea.NewProgram(initialModel())
if _, err := p.Run(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
```
**Expected behavior**
The cursor X position returned by `Cursor()` should reflect the display width (terminal columns) of the text before the cursor, not the rune count. The fix would be to use display-width-aware calculation, similar to what `textarea.Cursor()` already does:
```go
// Suggested fix
xOffset := uniseg.StringWidth(string(m.value[m.offset:m.pos])) + promptWidth
```
**Additional context**
- `textarea.Cursor()` handles this correctly using `lineInfo.CharOffset` (computed with `uniseg.StringWidth`)
- The virtual cursor mode (`SetVirtualCursor(true)`) is not affected since it renders the cursor inline in `View()`
- Only the real cursor mode (`SetVirtualCursor(false)`) is affected
- Bubbles version: v2.0.0 (latest as of writing, also present on master at `363089b`)
Contributor guide
Research direction
Read textinput.go's Cursor method and compare its offset calculation with textarea.go's Cursor method and CharOffset handling. Reproduce the real-cursor case with the CJK example from the issue, then verify that Cursor() reports terminal-column width rather than rune count while virtual-cursor behavior remains unchanged.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- go
- Domain
- cli
- Issue type
- Bug
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 78/100