highlight.go

  1package list
  2
  3import (
  4	"image"
  5	"strings"
  6
  7	"charm.land/lipgloss/v2"
  8	"github.com/charmbracelet/crush/internal/stringext"
  9	uv "github.com/charmbracelet/ultraviolet"
 10)
 11
 12// DefaultHighlighter is the default highlighter function that applies inverse style.
 13var DefaultHighlighter Highlighter = func(x, y int, c *uv.Cell) *uv.Cell {
 14	if c == nil {
 15		return c
 16	}
 17	c.Style.Attrs |= uv.AttrReverse
 18	return c
 19}
 20
 21// Highlighter represents a function that defines how to highlight text.
 22type Highlighter func(x, y int, c *uv.Cell) *uv.Cell
 23
 24// HighlightContent returns the content with highlighted regions based on the specified parameters.
 25func HighlightContent(content string, area image.Rectangle, startLine, startCol, endLine, endCol int) string {
 26	var sb strings.Builder
 27	pos := image.Pt(-1, -1)
 28	HighlightBuffer(content, area, startLine, startCol, endLine, endCol, func(x, y int, c *uv.Cell) *uv.Cell {
 29		pos.X = x
 30		if pos.Y == -1 {
 31			pos.Y = y
 32		} else if y > pos.Y {
 33			sb.WriteString(strings.Repeat("\n", y-pos.Y))
 34			pos.Y = y
 35		}
 36		sb.WriteString(c.Content)
 37		return c
 38	})
 39	if sb.Len() > 0 {
 40		sb.WriteString("\n")
 41	}
 42	return sb.String()
 43}
 44
 45// Highlight highlights a region of text within the given content and region.
 46func Highlight(content string, area image.Rectangle, startLine, startCol, endLine, endCol int, highlighter Highlighter) string {
 47	buf := HighlightBuffer(content, area, startLine, startCol, endLine, endCol, highlighter)
 48	if buf == nil {
 49		return content
 50	}
 51	return buf.Render()
 52}
 53
 54// HighlightBuffer highlights a region of text within the given content and
 55// region, returning a [uv.ScreenBuffer].
 56func HighlightBuffer(content string, area image.Rectangle, startLine, startCol, endLine, endCol int, highlighter Highlighter) *uv.ScreenBuffer {
 57	content = stringext.NormalizeSpace(content)
 58
 59	if startLine < 0 || startCol < 0 {
 60		return nil
 61	}
 62
 63	if highlighter == nil {
 64		highlighter = DefaultHighlighter
 65	}
 66
 67	width, height := area.Dx(), area.Dy()
 68	buf := uv.NewScreenBuffer(width, height)
 69	styled := uv.NewStyledString(content)
 70	styled.Draw(&buf, area)
 71
 72	// Treat -1 as "end of content"
 73	if endLine < 0 {
 74		endLine = height - 1
 75	}
 76	if endCol < 0 {
 77		endCol = width
 78	}
 79
 80	for y := startLine; y <= endLine && y < height; y++ {
 81		if y >= buf.Height() {
 82			break
 83		}
 84
 85		line := buf.Line(y)
 86
 87		// Determine column range for this line
 88		colStart := 0
 89		if y == startLine {
 90			colStart = min(startCol, len(line))
 91		}
 92
 93		colEnd := len(line)
 94		if y == endLine {
 95			colEnd = min(endCol, len(line))
 96		}
 97
 98		// Track last non-empty position as we go
 99		lastContentX := -1
100
101		// Single pass: check content and track last non-empty position
102		for x := colStart; x < colEnd; x++ {
103			cell := line.At(x)
104			if cell == nil {
105				continue
106			}
107
108			// Update last content position if non-empty
109			if cell.Content != "" && cell.Content != " " {
110				lastContentX = x
111			}
112		}
113
114		// Only apply highlight up to last content position
115		highlightEnd := colEnd
116		if lastContentX >= 0 {
117			highlightEnd = lastContentX + 1
118		} else if lastContentX == -1 {
119			highlightEnd = colStart // No content on this line
120		}
121
122		// Apply highlight style only to cells with content
123		for x := colStart; x < highlightEnd; x++ {
124			if !image.Pt(x, y).In(area) {
125				continue
126			}
127			cell := line.At(x)
128			if cell != nil {
129				highlighter(x, y, cell)
130			}
131		}
132	}
133
134	return &buf
135}
136
137// ToHighlighter converts a [lipgloss.Style] to a [Highlighter].
138func ToHighlighter(lgStyle lipgloss.Style) Highlighter {
139	return func(_ int, _ int, c *uv.Cell) *uv.Cell {
140		if c != nil {
141			c.Style = ToStyle(lgStyle)
142		}
143		return c
144	}
145}
146
147// ToStyle converts an inline [lipgloss.Style] to a [uv.Style].
148func ToStyle(lgStyle lipgloss.Style) uv.Style {
149	var uvStyle uv.Style
150
151	// Colors are already color.Color
152	uvStyle.Fg = lgStyle.GetForeground()
153	uvStyle.Bg = lgStyle.GetBackground()
154
155	// Build attributes using bitwise OR
156	var attrs uint8
157
158	if lgStyle.GetBold() {
159		attrs |= uv.AttrBold
160	}
161
162	if lgStyle.GetItalic() {
163		attrs |= uv.AttrItalic
164	}
165
166	if lgStyle.GetUnderline() {
167		uvStyle.Underline = uv.UnderlineSingle
168	}
169
170	if lgStyle.GetStrikethrough() {
171		attrs |= uv.AttrStrikethrough
172	}
173
174	if lgStyle.GetFaint() {
175		attrs |= uv.AttrFaint
176	}
177
178	if lgStyle.GetBlink() {
179		attrs |= uv.AttrBlink
180	}
181
182	if lgStyle.GetReverse() {
183		attrs |= uv.AttrReverse
184	}
185
186	uvStyle.Attrs = attrs
187
188	return uvStyle
189}
190
191// AdjustArea adjusts the given area rectangle by subtracting margins, borders,
192// and padding from the style.
193func AdjustArea(area image.Rectangle, style lipgloss.Style) image.Rectangle {
194	topMargin, rightMargin, bottomMargin, leftMargin := style.GetMargin()
195	topBorder, rightBorder, bottomBorder, leftBorder := style.GetBorderTopSize(),
196		style.GetBorderRightSize(),
197		style.GetBorderBottomSize(),
198		style.GetBorderLeftSize()
199	topPadding, rightPadding, bottomPadding, leftPadding := style.GetPadding()
200
201	return image.Rectangle{
202		Min: image.Point{
203			X: area.Min.X + leftMargin + leftBorder + leftPadding,
204			Y: area.Min.Y + topMargin + topBorder + topPadding,
205		},
206		Max: image.Point{
207			X: area.Max.X - (rightMargin + rightBorder + rightPadding),
208			Y: area.Max.Y - (bottomMargin + bottomBorder + bottomPadding),
209		},
210	}
211}