fix(ui): implement Highlightable interface for message items

Ayman Bagabas created

Change summary

internal/ui/chat/messages.go  | 11 +++++-
internal/ui/list/highlight.go | 58 +++++++++++++++++++++++++++++++-----
internal/ui/list/item.go      |  8 +++-
internal/ui/model/chat.go     |  2 
4 files changed, 64 insertions(+), 15 deletions(-)

Detailed changes

internal/ui/chat/messages.go 🔗

@@ -77,6 +77,8 @@ type highlightableMessageItem struct {
 	highlighter list.Highlighter
 }
 
+var _ list.Highlightable = (*highlightableMessageItem)(nil)
+
 // isHighlighted returns true if the item has a highlight range set.
 func (h *highlightableMessageItem) isHighlighted() bool {
 	return h.startLine != -1 || h.endLine != -1
@@ -91,8 +93,8 @@ func (h *highlightableMessageItem) renderHighlighted(content string, width, heig
 	return list.Highlight(content, area, h.startLine, h.startCol, h.endLine, h.endCol, h.highlighter)
 }
 
-// Highlight implements MessageItem.
-func (h *highlightableMessageItem) Highlight(startLine int, startCol int, endLine int, endCol int) {
+// SetHighlight implements [MessageItem].
+func (h *highlightableMessageItem) SetHighlight(startLine int, startCol int, endLine int, endCol int) {
 	// Adjust columns for the style's left inset (border + padding) since we
 	// highlight the content only.
 	offset := messageLeftPaddingTotal
@@ -106,6 +108,11 @@ func (h *highlightableMessageItem) Highlight(startLine int, startCol int, endLin
 	}
 }
 
+// Highlight implements [MessageItem].
+func (h *highlightableMessageItem) Highlight() (startLine int, startCol int, endLine int, endCol int) {
+	return h.startLine, h.startCol, h.endLine, h.endCol
+}
+
 func defaultHighlighter(sty *styles.Styles) *highlightableMessageItem {
 	return &highlightableMessageItem{
 		startLine:   -1,

internal/ui/list/highlight.go 🔗

@@ -2,25 +2,60 @@ package list
 
 import (
 	"image"
+	"strings"
 
 	"charm.land/lipgloss/v2"
 	uv "github.com/charmbracelet/ultraviolet"
 )
 
 // DefaultHighlighter is the default highlighter function that applies inverse style.
-var DefaultHighlighter Highlighter = func(s uv.Style) uv.Style {
-	s.Attrs |= uv.AttrReverse
-	return s
+var DefaultHighlighter Highlighter = func(x, y int, c *uv.Cell) *uv.Cell {
+	if c == nil {
+		return c
+	}
+	c.Style.Attrs |= uv.AttrReverse
+	return c
 }
 
 // Highlighter represents a function that defines how to highlight text.
-type Highlighter func(uv.Style) uv.Style
+type Highlighter func(x, y int, c *uv.Cell) *uv.Cell
+
+// HighlightContent returns the content with highlighted regions based on the specified parameters.
+func HighlightContent(content string, area image.Rectangle, startLine, startCol, endLine, endCol int) string {
+	var sb strings.Builder
+	pos := image.Pt(-1, -1)
+	HighlightBuffer(content, area, startLine, startCol, endLine, endCol, func(x, y int, c *uv.Cell) *uv.Cell {
+		pos.X = x
+		if pos.Y == -1 {
+			pos.Y = y
+		} else if y > pos.Y {
+			sb.WriteString(strings.Repeat("\n", y-pos.Y))
+			pos.Y = y
+		}
+		sb.WriteString(c.Content)
+		return c
+	})
+	if sb.Len() > 0 {
+		sb.WriteString("\n")
+	}
+	return sb.String()
+}
 
 // Highlight highlights a region of text within the given content and region.
 func Highlight(content string, area image.Rectangle, startLine, startCol, endLine, endCol int, highlighter Highlighter) string {
-	if startLine < 0 || startCol < 0 {
+	buf := HighlightBuffer(content, area, startLine, startCol, endLine, endCol, highlighter)
+	if buf == nil {
 		return content
 	}
+	return buf.Render()
+}
+
+// HighlightBuffer highlights a region of text within the given content and
+// region, returning a [uv.ScreenBuffer].
+func HighlightBuffer(content string, area image.Rectangle, startLine, startCol, endLine, endCol int, highlighter Highlighter) *uv.ScreenBuffer {
+	if startLine < 0 || startCol < 0 {
+		return nil
+	}
 
 	if highlighter == nil {
 		highlighter = DefaultHighlighter
@@ -87,17 +122,22 @@ func Highlight(content string, area image.Rectangle, startLine, startCol, endLin
 				continue
 			}
 			cell := line.At(x)
-			cell.Style = highlighter(cell.Style)
+			if cell != nil {
+				line.Set(x, highlighter(x, y, cell))
+			}
 		}
 	}
 
-	return buf.Render()
+	return &buf
 }
 
 // ToHighlighter converts a [lipgloss.Style] to a [Highlighter].
 func ToHighlighter(lgStyle lipgloss.Style) Highlighter {
-	return func(uv.Style) uv.Style {
-		return ToStyle(lgStyle)
+	return func(_ int, _ int, c *uv.Cell) *uv.Cell {
+		if c != nil {
+			c.Style = ToStyle(lgStyle)
+		}
+		return c
 	}
 }
 

internal/ui/list/item.go 🔗

@@ -21,9 +21,11 @@ type Focusable interface {
 
 // Highlightable represents an item that can highlight a portion of its content.
 type Highlightable interface {
-	// Highlight highlights the content from the given start to end positions.
-	// Use -1 for no highlight.
-	Highlight(startLine, startCol, endLine, endCol int)
+	// SetHighlight highlights the content from the given start to end
+	// positions. Use -1 for no highlight.
+	SetHighlight(startLine, startCol, endLine, endCol int)
+	// Highlight returns the current highlight positions within the item.
+	Highlight() (startLine, startCol, endLine, endCol int)
 }
 
 // MouseClickable represents an item that can handle mouse click events.

internal/ui/model/chat.go 🔗

@@ -515,7 +515,7 @@ func (m *Chat) applyHighlightRange(idx, selectedIdx int, item list.Item) list.It
 			}
 		}
 
-		hi.Highlight(sLine, sCol, eLine, eCol)
+		hi.SetHighlight(sLine, sCol, eLine, eCol)
 		return hi.(list.Item)
 	}