feat: word-aware line wrapping + span slicing + visual rows
18e4aba0f2dfdbd1098e555e2f0d1c60dcebab17
humdrum <me@humdrum.me> · 2026-06-28 10:33
parent 9d3c7f3c
2 files changed
internal/editor/wrap.go +109 −0
@@ -0,0 +1,109 @@
+package editor
+
+// segment is one visual row of a logical line: a contiguous rune slice plus the
+// rune offset (start column) within the logical line where it begins.
+type segment struct {
+ text string
+ start int
+}
+
+// wrapLine word-wraps a logical line into visual segments at the given width.
+// It breaks just after the last space that fits; a word longer than width is
+// hard-broken at width. The segments partition the line's runes contiguously
+// (concatenating their text reproduces the line exactly), so the cursor maps to
+// exactly one segment. A width <= 0 or a line that fits returns one segment; an
+// empty line returns one empty segment.
+func wrapLine(line string, width int) []segment {
+ r := []rune(line)
+ if width <= 0 || len(r) <= width {
+ return []segment{{text: line, start: 0}}
+ }
+ var segs []segment
+ start := 0
+ for start < len(r) {
+ if start+width >= len(r) {
+ segs = append(segs, segment{text: string(r[start:]), start: start})
+ break
+ }
+ end := start + width
+ brk := -1
+ for i := end - 1; i > start; i-- {
+ if r[i] == ' ' {
+ brk = i + 1 // keep the space as this segment's trailing rune
+ break
+ }
+ }
+ if brk <= start {
+ brk = end // long word: hard break
+ }
+ segs = append(segs, segment{text: string(r[start:brk]), start: start})
+ start = brk
+ }
+ if len(segs) == 0 {
+ segs = append(segs, segment{text: "", start: 0})
+ }
+ return segs
+}
+
+// sliceSpans returns the spans covering rune range [start, end) of a line whose
+// spans concatenate to the full line. Boundary spans are split; styles are
+// preserved.
+func sliceSpans(spans []Span, start, end int) []Span {
+ var out []Span
+ pos := 0
+ for _, sp := range spans {
+ r := []rune(sp.Text)
+ spStart, spEnd := pos, pos+len(r)
+ pos = spEnd
+ lo := max(spStart, start)
+ hi := min(spEnd, end)
+ if lo < hi {
+ out = append(out, Span{Text: string(r[lo-spStart : hi-spStart]), Style: sp.Style})
+ }
+ }
+ return out
+}
+
+// vrow is a rendered visual row: which logical row it came from, the rune start
+// offset and rune count within that line, and the styled spans for the segment.
+type vrow struct {
+ logRow int
+ start int
+ runes int
+ spans []Span
+}
+
+// buildVisual wraps every logical line into visual rows, slicing each line's
+// styled spans to the segment ranges.
+func (e *Editor) buildVisual() []vrow {
+ all := ScanLines(e.Lines, e.theme)
+ var rows []vrow
+ for li := range e.Lines {
+ for _, s := range wrapLine(e.Lines[li], e.Width) {
+ n := len([]rune(s.text))
+ rows = append(rows, vrow{
+ logRow: li,
+ start: s.start,
+ runes: n,
+ spans: sliceSpans(all[li], s.start, s.start+n),
+ })
+ }
+ }
+ return rows
+}
+
+// cursorVIndex returns the index of the visual row holding the cursor. A cursor
+// at the end of a line (or on an empty line) maps to that line's last segment.
+func cursorVIndex(rows []vrow, cur Position) int {
+ last := -1
+ for i, vr := range rows {
+ if vr.logRow != cur.Row {
+ continue
+ }
+ last = i
+ if cur.Col >= vr.start && cur.Col < vr.start+vr.runes {
+ return i
+ }
+ }
+ return last
+}
internal/editor/wrap_test.go +110 −0
@@ -0,0 +1,110 @@
+package editor
+
+import (
+ "testing"
+
+ "glint/internal/theme"
+
+ "github.com/charmbracelet/lipgloss"
+)
+
+func segText(segs []segment) string {
+ s := ""
+ for _, sg := range segs {
+ s += sg.text
+ }
+ return s
+}
+
+func TestWrapLineFitsReturnsOneSegment(t *testing.T) {
+ segs := wrapLine("short line", 80)
+ if len(segs) != 1 || segs[0].text != "short line" || segs[0].start != 0 {
+ t.Fatalf("segs = %#v", segs)
+ }
+}
+
+func TestWrapLineEmpty(t *testing.T) {
+ segs := wrapLine("", 80)
+ if len(segs) != 1 || segs[0].text != "" || segs[0].start != 0 {
+ t.Fatalf("empty line segs = %#v", segs)
+ }
+}
+
+func TestWrapLineWordBreakPartition(t *testing.T) {
+ line := "aaaa bbbb cccc"
+ segs := wrapLine(line, 9)
+ if segText(segs) != line {
+ t.Errorf("partition broken: %q != %q", segText(segs), line)
+ }
+ if len(segs) != 2 {
+ t.Fatalf("want 2 segments, got %d: %#v", len(segs), segs)
+ }
+ if segs[0].text != "aaaa " || segs[0].start != 0 {
+ t.Errorf("seg0 = %#v", segs[0])
+ }
+ if segs[1].text != "bbbb cccc" || segs[1].start != 5 {
+ t.Errorf("seg1 = %#v", segs[1])
+ }
+}
+
+func TestWrapLineHardBreaksLongWord(t *testing.T) {
+ segs := wrapLine("abcdefghij", 4)
+ if segText(segs) != "abcdefghij" {
+ t.Errorf("partition broken: %q", segText(segs))
+ }
+ want := []segment{{"abcd", 0}, {"efgh", 4}, {"ij", 8}}
+ if len(segs) != len(want) {
+ t.Fatalf("got %d segments, want %d", len(segs), len(want))
+ }
+ for i := range want {
+ if segs[i] != want[i] {
+ t.Errorf("seg[%d] = %#v, want %#v", i, segs[i], want[i])
+ }
+ }
+}
+
+func TestWrapLineMultibytePartition(t *testing.T) {
+ line := "héllo wörld café"
+ segs := wrapLine(line, 7)
+ if segText(segs) != line {
+ t.Errorf("multibyte partition broken: %q != %q", segText(segs), line)
+ }
+}
+
+func TestSliceSpansAcrossBoundary(t *testing.T) {
+ red := lipgloss.NewStyle().Foreground(lipgloss.Color("#ff0000"))
+ blue := lipgloss.NewStyle().Foreground(lipgloss.Color("#0000ff"))
+ spans := []Span{{Text: "abc", Style: red}, {Text: "def", Style: blue}}
+ out := sliceSpans(spans, 2, 5) // "c" (red) + "de" (blue)
+ got := ""
+ for _, sp := range out {
+ got += sp.Text
+ }
+ if got != "cde" {
+ t.Errorf("sliced text = %q, want cde", got)
+ }
+ if len(out) != 2 || out[0].Style.GetForeground() != lipgloss.Color("#ff0000") ||
+ out[1].Style.GetForeground() != lipgloss.Color("#0000ff") {
+ t.Errorf("styles not preserved: %#v", out)
+ }
+}
+
+func TestBuildVisualAndCursorIndex(t *testing.T) {
+ e := New()
+ e.SetTheme(theme.FlexokiDark())
+ e.Lines = []string{"aaaa bbbb cccc", "x"}
+ e.Width = 9
+ rows := e.buildVisual()
+ // line 0 wraps into 2 visual rows, line 1 is 1 → 3 total.
+ if len(rows) != 3 {
+ t.Fatalf("visual rows = %d, want 3", len(rows))
+ }
+ // cursor at end of seg2 of line 0 maps to the second visual row.
+ if got := cursorVIndex(rows, Position{Row: 0, Col: 10}); got != 1 {
+ t.Errorf("cursorVIndex = %d, want 1", got)
+ }
+ // cursor on line 1 maps to row index 2.
+ if got := cursorVIndex(rows, Position{Row: 1, Col: 0}); got != 2 {
+ t.Errorf("cursorVIndex(line1) = %d, want 2", got)
+ }
+}