1package viewport
2
3import (
4 "github.com/charmbracelet/lipgloss/v2"
5 "github.com/charmbracelet/x/ansi"
6 "github.com/rivo/uniseg"
7)
8
9// parseMatches converts the given matches into highlight ranges.
10//
11// Assumptions:
12// - matches are measured in bytes, e.g. what [regex.FindAllStringIndex] would return
13// - matches were made against the given content
14// - matches are in order
15// - matches do not overlap
16// - content is line terminated with \n only
17//
18// We'll then convert the ranges into [highlightInfo]s, which hold the starting
19// line and the grapheme positions.
20func parseMatches(
21 content string,
22 matches [][]int,
23) []highlightInfo {
24 if len(matches) == 0 {
25 return nil
26 }
27
28 line := 0
29 graphemePos := 0
30 previousLinesOffset := 0
31 bytePos := 0
32
33 highlights := make([]highlightInfo, 0, len(matches))
34 gr := uniseg.NewGraphemes(ansi.Strip(content))
35
36 for _, match := range matches {
37 byteStart, byteEnd := match[0], match[1]
38
39 // hilight for this match:
40 hi := highlightInfo{
41 lines: map[int][2]int{},
42 }
43
44 // find the beginning of this byte range, setup current line and
45 // grapheme position.
46 for byteStart > bytePos {
47 if !gr.Next() {
48 break
49 }
50 if content[bytePos] == '\n' {
51 previousLinesOffset = graphemePos + 1
52 line++
53 }
54 graphemePos += max(1, gr.Width())
55 bytePos += len(gr.Str())
56 }
57
58 hi.lineStart = line
59 hi.lineEnd = line
60
61 graphemeStart := graphemePos
62
63 // loop until we find the end
64 for byteEnd > bytePos {
65 if !gr.Next() {
66 break
67 }
68
69 // if it ends with a new line, add the range, increase line, and continue
70 if content[bytePos] == '\n' {
71 colstart := max(0, graphemeStart-previousLinesOffset)
72 colend := max(graphemePos-previousLinesOffset+1, colstart) // +1 its \n itself
73
74 if colend > colstart {
75 hi.lines[line] = [2]int{colstart, colend}
76 hi.lineEnd = line
77 }
78
79 previousLinesOffset = graphemePos + 1
80 line++
81 }
82
83 graphemePos += max(1, gr.Width())
84 bytePos += len(gr.Str())
85 }
86
87 // we found it!, add highlight and continue
88 if bytePos == byteEnd {
89 colstart := max(0, graphemeStart-previousLinesOffset)
90 colend := max(graphemePos-previousLinesOffset, colstart)
91
92 if colend > colstart {
93 hi.lines[line] = [2]int{colstart, colend}
94 hi.lineEnd = line
95 }
96 }
97
98 highlights = append(highlights, hi)
99 }
100
101 return highlights
102}
103
104type highlightInfo struct {
105 // in which line this highlight starts and ends
106 lineStart, lineEnd int
107
108 // the grapheme highlight ranges for each of these lines
109 lines map[int][2]int
110}
111
112// coords returns the line x column of this highlight.
113func (hi highlightInfo) coords() (int, int, int) {
114 for i := hi.lineStart; i <= hi.lineEnd; i++ {
115 hl, ok := hi.lines[i]
116 if !ok {
117 continue
118 }
119 return i, hl[0], hl[1]
120 }
121 return hi.lineStart, 0, 0
122}
123
124func makeHighlightRanges(
125 highlights []highlightInfo,
126 line int,
127 style lipgloss.Style,
128) []lipgloss.Range {
129 result := []lipgloss.Range{}
130 for _, hi := range highlights {
131 lihi, ok := hi.lines[line]
132 if !ok {
133 continue
134 }
135 if lihi == [2]int{} {
136 continue
137 }
138 result = append(result, lipgloss.NewRange(lihi[0], lihi[1], style))
139 }
140 return result
141}