okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

nekomimi.petfix macos terminal.app rendering4e62e9e

main
7.7 KiB259 linesraw
1package tui
2
3import (
4	"math"
5	"slices"
6	"strings"
7
8	"github.com/charmbracelet/x/ansi"
9	"github.com/rivo/uniseg"
10)
11
12// Row marks are zero-width APC strings that widths and ansi.Strip skip.
13// They tell a copy what a row is beyond its text. AppModel.View removes them
14// before Bubble Tea renders; they must never reach the terminal.
15const (
16	// markChrome labels the transcript rather than being part of it: who
17	// speaks, a turn's signoff, tool glances.
18	markChrome = "\x1b_albedo:chrome\x1b\\"
19	// markWrap continues the row above, which broke at a space, and
20	// markSplit continues it mid-word.
21	markWrap  = "\x1b_albedo:wrap\x1b\\"
22	markSplit = "\x1b_albedo:split\x1b\\"
23)
24
25// chrome marks every row of s.
26func chrome(s string) string {
27	return markChrome + strings.ReplaceAll(s, "\n", "\n"+markChrome)
28}
29
30// wrapJoiner is what goes between a row and the row above it continues.
31func wrapJoiner(line string) (string, bool) {
32	if strings.Contains(line, markWrap) {
33		return " ", true
34	}
35	return "", strings.Contains(line, markSplit)
36}
37
38// markChunks marks the rows line wrapped into as continuing the one before,
39// and gives them the line's chrome.
40func markChunks(line string, chunks []string) []string {
41	carried := ""
42	if strings.Contains(line, markChrome) {
43		carried = markChrome
44	}
45	for i := 1; i < len(chunks); i++ {
46		mark := markSplit
47		if strings.HasSuffix(ansi.Strip(chunks[i-1]), " ") {
48			mark = markWrap
49		}
50		chunks[i] = carried + mark + chunks[i]
51	}
52	return chunks
53}
54
55// markWraps marks the rows of wrapped, markdown rendered to a width, that
56// continue the row above. flat is the same markdown unwrapped: a run of
57// wrapped rows that spells one of its lines, in order, is that line. A row
58// that spells none, like a table's, stays a line of its own.
59func markWraps(wrapped, flat string) string {
60	rows := strings.Split(wrapped, "\n")
61	var lines []string
62	for line := range strings.SplitSeq(flat, "\n") {
63		lines = append(lines, rowText(line))
64	}
65	// rest is what the line being spelled has left, after a space when spaced
66	next, rest, spaced := 0, "", false
67	for i, row := range rows {
68		text := rowText(row)
69		if cont := strings.TrimLeft(text, " "); rest != "" && cont != "" && strings.HasPrefix(rest, cont) {
70			mark := markSplit
71			if spaced {
72				mark = markWrap
73			}
74			rows[i] = mark + row
75			rest, spaced = spell(rest, cont)
76			continue
77		}
78		rest = ""
79		// rows that spell nothing hold the search back, so it looks ahead
80		// past the flat lines they stand for
81		for k := next; k < min(len(lines), next+16); k++ {
82			if lines[k] == text || text != "" && strings.HasPrefix(lines[k], text) {
83				next = k + 1
84				rest, spaced = spell(lines[k], text)
85				break
86			}
87		}
88	}
89	return strings.Join(rows, "\n")
90}
91
92// spell is what line has left after text, and whether spaces came first.
93func spell(line, text string) (string, bool) {
94	after := line[len(text):]
95	rest := strings.TrimLeft(after, " ")
96	return rest, len(rest) < len(after)
97}
98
99// rowText is what a row says: its plain text without quote bars or padding.
100func rowText(line string) string {
101	_, text, _ := selectedRange(line, 0, math.MaxInt, quoteColumns(line))
102	return strings.TrimRight(text, " ")
103}
104
105type Point struct {
106	Row int
107	Col int
108}
109type Selection struct {
110	Anchor Point
111	Head   Point
112	// Gutter is the decoration at the start of every row, which neither
113	// the highlight nor the copied text includes.
114	Gutter int
115}
116
117func (s Selection) IsEmpty() bool { return s.Anchor == s.Head }
118func (s Selection) Normalized() (Point, Point) {
119	if s.Anchor.Row < s.Head.Row || (s.Anchor.Row == s.Head.Row && s.Anchor.Col <= s.Head.Col) {
120		return s.Anchor, s.Head
121	}
122	return s.Head, s.Anchor
123}
124
125// selectedRange splits line around the columns from to to. Columns in skip
126// are left out of the selection.
127func selectedRange(line string, from, to int, skip map[int]bool) (before, selected, after string) {
128	plain := ansi.Strip(line)
129	var chosen, prefix, suffix strings.Builder
130	col := 0
131	for len(plain) > 0 {
132		cluster, rest, _, _ := uniseg.FirstGraphemeClusterInString(plain, -1)
133		width := ansi.StringWidth(cluster)
134		switch {
135		case col+width > from && col < to:
136			if !skip[col] {
137				chosen.WriteString(cluster)
138			}
139		case col < from:
140			prefix.WriteString(cluster)
141		default:
142			suffix.WriteString(cluster)
143		}
144		plain = rest
145		col += width
146	}
147	return prefix.String(), chosen.String(), suffix.String()
148}
149
150// selBounds is the column range of sel on one row of lines.
151func selBounds(line string, sel Selection, start, end Point, row int) (int, int) {
152	from, to := sel.Gutter, ansi.StringWidth(line)
153	if row == start.Row {
154		from = max(from, start.Col)
155	}
156	if row == end.Row {
157		to = end.Col
158	}
159	return from, to
160}
161
162// SelectedText is the text under sel: what the rows say, without what the
163// transcript draws around it. Code block frames and quote bars are left out,
164// and so are chrome rows unless nothing else is selected; wrapped rows join
165// back into their lines.
166func SelectedText(lines []string, sel Selection) string {
167	if sel.IsEmpty() || len(lines) == 0 {
168		return ""
169	}
170	start, end := sel.Normalized()
171	type piece struct {
172		text          string
173		joiner        string
174		chrome, joins bool
175	}
176	var picked []piece
177	onlyChrome := true
178	for row := max(0, start.Row); row <= end.Row && row < len(lines); row++ {
179		line := lines[row]
180		if strings.Contains(line, codeFence(fenceOpen)) {
181			continue
182		}
183		// a code block's closing frame also spaces the block from what
184		// follows, so it copies as a blank row
185		if strings.Contains(line, codeFence(fenceClose)) {
186			picked = append(picked, piece{})
187			continue
188		}
189		from, to := selBounds(line, sel, start, end, row)
190		_, text, _ := selectedRange(line, from, to, quoteColumns(line))
191		if act, ok := actionOf(line); ok && act.verb == verbOpen {
192			// the toggle is a control, not part of what the row says
193			text = strings.TrimPrefix(strings.TrimPrefix(text, toggleClosed), toggleOpen)
194		}
195		// rows are padded out to the viewport's width
196		p := piece{text: strings.TrimRight(text, " "), chrome: strings.Contains(line, markChrome)}
197		if row > start.Row {
198			p.joiner, p.joins = wrapJoiner(line)
199		}
200		onlyChrome = onlyChrome && (p.chrome || p.text == "")
201		picked = append(picked, p)
202	}
203	var rows []string
204	// joinable says the last row kept is the row above; dropped says chrome
205	// was left out since the last text, so a blank row after it is spare
206	joinable, dropped := false, false
207	for _, p := range picked {
208		switch {
209		case p.chrome && !onlyChrome:
210			joinable, dropped = false, true
211		case p.joins && joinable:
212			rows[len(rows)-1] += p.joiner + strings.TrimLeft(p.text, " ")
213		case p.text == "" && dropped && len(rows) > 0 && rows[len(rows)-1] == "":
214			joinable = false
215		default:
216			rows = append(rows, p.text)
217			joinable, dropped = true, dropped && p.text == ""
218		}
219	}
220	// and the viewport's height, so blank rows at either end are padding too
221	for len(rows) > 0 && rows[0] == "" {
222		rows = rows[1:]
223	}
224	for len(rows) > 0 && rows[len(rows)-1] == "" {
225		rows = rows[:len(rows)-1]
226	}
227	return strings.Join(rows, "\n")
228}
229
230// quoteColumns are the columns of line under quote bars.
231func quoteColumns(line string) map[int]bool {
232	bar := quoteBar()
233	skip := map[int]bool{}
234	for at := 0; ; at += len(bar) {
235		i := strings.Index(line[at:], bar)
236		if i < 0 {
237			return skip
238		}
239		at += i
240		col := ansi.StringWidth(line[:at])
241		skip[col], skip[col+1] = true, true
242	}
243}
244
245func HighlightSelection(lines []string, sel Selection) []string {
246	if sel.IsEmpty() {
247		return lines
248	}
249	start, end := sel.Normalized()
250	out := slices.Clone(lines)
251	for row := max(0, start.Row); row <= end.Row && row < len(out); row++ {
252		from, to := selBounds(out[row], sel, start, end, row)
253		before, text, after := selectedRange(out[row], from, to, nil)
254		if text != "" {
255			out[row] = before + DefaultStyles.Cursor.Render(text) + after
256		}
257	}
258	return out
259}