highlight.go

  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}