charmbracelet / charmbracelet/bubbles
v2: textinput Cursor() X ignores scroll offset when input overflows width
- 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
Assessment
This issue has not been assessed yet.