▍ humdrum codex / glint v1.0.2

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)
+	}
+}