textinput: Cursor() returns incorrect X position for wide (CJK) characters
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).
// 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()returns3(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
- Create a
textinput.ModelwithSetVirtualCursor(false) - Type CJK characters (e.g. Japanese hiragana "あいう")
- Observe that the terminal cursor position does not match the end of the typed text
Source Code
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:
// Suggested fix
xOffset := uniseg.StringWidth(string(m.value[m.offset:m.pos])) + promptWidthAdditional context
textarea.Cursor()handles this correctly usinglineInfo.CharOffset(computed withuniseg.StringWidth)- The virtual cursor mode (
SetVirtualCursor(true)) is not affected since it renders the cursor inline inView() - Only the real cursor mode (
SetVirtualCursor(false)) is affected - Bubbles version: v2.0.0 (latest as of writing, also present on master at
363089b)
Source: charmbracelet/bubbles