charmbracelet / charmbracelet/bubbles
textarea: expose the cursor as a byte offset into Value()
- Dominant language
- Go
- Stars
- 8.9k
- Forks
- 457
- Avg merge
- 1d 18h
- Merged PRs (30d)
- 5
Description
**Is your feature request related to a problem? Please describe.**
`Line()` and `Column()` locate the cursor in the value's grid of rows and runes. That is the wrong coordinate space for a caller that wants to treat `Value()` as a flat string — slicing around the cursor, scanning backwards for a token boundary, splicing a completion in.
Converting grid coordinates to a byte index means re-deriving the join `Value()` already performed, and every caller writes the same loop:
```go
off := 0
for i, row := range /* rows the caller cannot reach */ {
if i == m.Line() { ... }
off += len(string(row)) + 1
}
```
`m.value` is unexported, so callers cannot even write that loop against the real rows — they have to re-split `Value()` on `"\n"` and hope the split matches.
**Describe the solution you'd like**
The pair that closes the round trip:
```go
func (m Model) ByteOffset() int
func (m *Model) SetCursorByteOffset(off int)
```
`ByteOffset` always lands on a UTF-8 boundary. `SetCursorByteOffset` clamps a negative offset to the start and a past-the-end offset to the end, and snaps an offset landing inside a multi-byte rune forward to the next real position — so round-tripping an offset produced by `ByteOffset` is exact. It also repositions the viewport, like every other cursor mover, so restoring a saved offset after an edit cannot leave the cursor scrolled out of sight.
**Describe alternatives you've considered**
- **Callers computing it from `Value()`.** The status quo. It duplicates the join, and the mid-rune case is quiet and surprising to get wrong: slicing a row at the requested byte yields a partial UTF-8 sequence that decodes to U+FFFD and counts as a whole rune, so asking for byte 2 of `"世界"` lands the cursor at byte 6 — past the rune the caller was pointing into, not before it.
- **Exposing the rows instead** (`func (m Model) Rows() [][]rune`). Larger surface, still leaves every caller writing the offset loop.
- **Rune offsets rather than byte offsets.** Byte offsets are what `Value()` slicing, `regexp`, and `strings.Index` all speak.
**Additional context**
Implementation and tests in #1031. No new dependencies; two new methods, no change to existing behaviour.
🤖 Posted on behalf of `@joestump` by [`claude-opus-5`](https://openrouter.ai/anthropic/claude-opus-5) using [Claude Code](https://claude.com/claude-code).
Contributor guide
Research direction
Start with the implementation and tests referenced in issue #1031, then trace the textarea model's existing cursor-movement and viewport behavior. Done means adding ByteOffset and SetCursorByteOffset with UTF-8 boundary handling, clamping, exact round-tripping, viewport repositioning, and tests for the stated cases without changing existing behavior.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- go
- Domain
- cli
- Issue type
- Feature
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 68/100