charmbracelet / charmbracelet/bubbles

v2: textinput Cursor() X ignores scroll offset when input overflows width

Open
#1,001 4 comments 3 reactions 0 assignees View on GitHub
Dominant language
Go
Stars
8.9k
Forks
457
Avg merge
1d 18h
Merged PRs (30d)
5

Description

## Description

`textinput.Model.Cursor()` returns an X coordinate based on `m.Position()` (the absolute cursor position in the full value), but when the input text is wider than the configured width, the textinput scrolls horizontally. `View()` correctly uses `m.pos - m.offset` for the visible cursor column, but `Cursor()` does not account for the scroll offset.

This means the hardware cursor (used when `SetVirtualCursor(false)`) is placed at the wrong column when the input is scrolled.

## Version

`charm.land/bubbles/v2 v2.1.0`

## Affected code

In `textinput.go`, the `Cursor()` method:

```go
func (m Model) Cursor() *tea.Cursor {
if m.useVirtualCursor || !m.Focused() {
return nil
}

w := lipgloss.Width
promptWidth := w(m.promptView())
xOffset := m.Position() + promptWidth // <-- uses absolute position
if m.width > 0 {
xOffset = min(xOffset, m.width+promptWidth)
}
// ...
}
```

Compare with `View()` which correctly adjusts for scroll:

```go
value := m.value[m.offset:m.offsetRight]
pos := max(0, m.pos-m.offset) // <-- uses scroll-adjusted position
```

## Reproduction test

```go
package textinput_test

import (
"strings"
"testing"

"charm.land/bubbles/v2/textinput"
)

func TestCursorXAccountsForScrollOffset(t *testing.T) {
m := textinput.New()
m.Focus()
m.SetVirtualCursor(false)
m.CharLimit = 200
m.SetWidth(20)

text := strings.Repeat("a", 30)
m.SetValue(text)
m.CursorEnd()

m, _ = m.Update(nil)

cur := m.Cursor()
if cur == nil {
t.Fatal("Cursor() returned nil")
}

width := m.Width()
t.Logf("width=%d pos=%d cursor.X=%d", width, m.Position(), cur.X)

if cur.X > width {
t.Errorf("Cursor().X = %d, exceeds viewport width %d; "+
"Cursor() uses Position() (absolute) instead of the "+
"scroll-adjusted visible column", cur.X, width)
}
}
```

**Output:**
```
width=20 pos=30 cursor.X=22
Cursor().X = 22, exceeds viewport width 20
```

## Expected behavior

`Cursor().X` should reflect the visible cursor column within the viewport (i.e., `m.pos - m.offset + promptWidth`), consistent with how `View()` renders the cursor.

## Impact

Any consumer using the hardware cursor (`SetVirtualCursor(false)`) with text longer than the input width will see the terminal cursor placed at the wrong column. We hit this building an SSH-accessible CLI where the hardware cursor is needed to avoid re-render-induced text selection breakage.

## Suggested fix

Replace `m.Position()` with `m.Position() - m.offset` in `Cursor()` (the `offset` field is unexported, so this must be fixed in the package):

```go
xOffset := (m.pos - m.offset) + promptWidth
```

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.