#1044·bubbles

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

Author: joestumpCreated Aug 26, 2026Updated Aug 26, 2026
Labelsenhancement

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 using Claude Code.