package editor import "github.com/charmbracelet/lipgloss" // 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, Prose: sp.Prose, Wavy: sp.Wavy, UnderColor: sp.UnderColor, }) } } 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. Every span is given the theme background // so glyphs sit on the theme's paper rather than the terminal default. func (e *Editor) buildVisual() []vrow { if e.visual != nil { return e.visual } e.buildCount++ var all [][]Span if e.codeFile != "" { all = ScanCode(e.Lines, e.codeFile, e.theme) } else { all = ScanLines(e.Lines, e.theme) } if e.spellActive() { all = e.spellPass(all) } 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: withBackground(sliceSpans(all[li], s.start, s.start+n), e.theme.Background), }) } } e.visual = rows return rows } // invalidate drops the memoized visual model so the next buildVisual rescans. // Called from every mutation of the scan/wrap inputs (Lines, Width, theme, // spell state, codeFile); cursor and scroll changes do not invalidate. func (e *Editor) invalidate() { e.visual = nil } // withBackground stamps the theme background onto every span's style so the // rendered text has no terminal-default gaps between or under glyphs. Spans that // already carry a background (e.g. ==highlight==) keep theirs. func withBackground(spans []Span, bg lipgloss.Color) []Span { for i := range spans { if _, unset := spans[i].Style.GetBackground().(lipgloss.NoColor); unset { spans[i].Style = spans[i].Style.Background(bg) } } return spans } // 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 }