charmbracelet / charmbracelet/bubbles

textinput: Cursor() returns incorrect X position for wide (CJK) characters

Open Beginner friendly
#906 1 comment 0 reactions 0 assignees View on GitHub
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.