Skip to content

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

@708u

Description

@708u

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() 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

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])) + 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)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions