charmbracelet / charmbracelet/bubbles

textarea: expose the cursor as a byte offset into Value()

Open
#1,044 0 comments 0 reactions 0 assignees View on GitHub
enhancement
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.