▍ humdrum codex / glint v1.0.2
license AGPL-3.0
4.2 KB raw
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
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
}