refactor(list): simplify focus and highlight interfaces

Ayman Bagabas created

Change summary

internal/ui/dialog/items.go |  55 +++++++++++++-----
internal/ui/list/item.go    |  20 +++---
internal/ui/list/list.go    | 116 +++++++-------------------------------
3 files changed, 69 insertions(+), 122 deletions(-)

Detailed changes

internal/ui/dialog/items.go 🔗

@@ -17,7 +17,7 @@ import (
 // ListItem represents a selectable and searchable item in a dialog list.
 type ListItem interface {
 	list.FilterableItem
-	list.FocusStylable
+	list.Focusable
 	list.MatchSettable
 
 	// ID returns the unique identifier of the item.
@@ -27,8 +27,10 @@ type ListItem interface {
 // SessionItem wraps a [session.Session] to implement the [ListItem] interface.
 type SessionItem struct {
 	session.Session
-	t *styles.Styles
-	m fuzzy.Match
+	t       *styles.Styles
+	m       fuzzy.Match
+	cache   map[int]string
+	focused bool
 }
 
 var _ ListItem = &SessionItem{}
@@ -45,13 +47,34 @@ func (s *SessionItem) ID() string {
 
 // SetMatch sets the fuzzy match for the session item.
 func (s *SessionItem) SetMatch(m fuzzy.Match) {
+	s.cache = nil
 	s.m = m
 }
 
 // Render returns the string representation of the session item.
 func (s *SessionItem) Render(width int) string {
+	if s.cache == nil {
+		s.cache = make(map[int]string)
+	}
+
+	cached, ok := s.cache[width]
+	if ok {
+		return cached
+	}
+
+	style := s.t.Dialog.NormalItem
+	if s.focused {
+		style = s.t.Dialog.SelectedItem
+	}
+
+	width -= style.GetHorizontalFrameSize()
 	age := humanize.Time(time.Unix(s.Session.UpdatedAt, 0))
-	age = s.t.Subtle.Render(age)
+	if s.focused {
+		age = s.t.Base.Render(age)
+	} else {
+		age = s.t.Subtle.Render(age)
+	}
+
 	age = " " + age
 	ageLen := lipgloss.Width(age)
 	title := s.Session.Title
@@ -59,12 +82,10 @@ func (s *SessionItem) Render(width int) string {
 	title = ansi.Truncate(title, max(0, width-ageLen), "…")
 	right := lipgloss.NewStyle().AlignHorizontal(lipgloss.Right).Width(width - titleLen).Render(age)
 
+	content := title
 	if matches := len(s.m.MatchedIndexes); matches > 0 {
 		var lastPos int
 		parts := make([]string, 0)
-		// TODO: Use [ansi.Style].Underline true/false to underline only the
-		// matched parts instead of using [lipgloss.StyleRanges] since it can
-		// be cheaper with less allocations.
 		ranges := matchedRanges(s.m.MatchedIndexes)
 		for _, rng := range ranges {
 			start, stop := bytePosToVisibleCharPos(title, rng)
@@ -86,19 +107,21 @@ func (s *SessionItem) Render(width int) string {
 		if lastPos < len(title) {
 			parts = append(parts, title[lastPos:])
 		}
-		return strings.Join(parts, "") + right
+
+		content = strings.Join(parts, "")
 	}
-	return title + right
-}
 
-// FocusStyle returns the style to be applied when the item is focused.
-func (s *SessionItem) FocusStyle() lipgloss.Style {
-	return s.t.Dialog.SelectedItem
+	content = style.Render(content + right)
+	s.cache[width] = content
+	return content
 }
 
-// BlurStyle returns the style to be applied when the item is blurred.
-func (s *SessionItem) BlurStyle() lipgloss.Style {
-	return s.t.Dialog.NormalItem
+// SetFocused sets the focus state of the session item.
+func (s *SessionItem) SetFocused(focused bool) {
+	if s.focused != focused {
+		s.cache = nil
+	}
+	s.focused = focused
 }
 
 // sessionItems takes a slice of [session.Session]s and convert them to a slice

internal/ui/list/item.go 🔗

@@ -1,7 +1,6 @@
 package list
 
 import (
-	"charm.land/lipgloss/v2"
 	"github.com/charmbracelet/x/ansi"
 )
 
@@ -12,18 +11,17 @@ type Item interface {
 	Render(width int) string
 }
 
-// FocusStylable represents an item that can be styled based on focus state.
-type FocusStylable interface {
-	// FocusStyle returns the style to apply when the item is focused.
-	FocusStyle() lipgloss.Style
-	// BlurStyle returns the style to apply when the item is unfocused.
-	BlurStyle() lipgloss.Style
+// Focusable represents an item that can be aware of focus state changes.
+type Focusable interface {
+	// SetFocused sets the focus state of the item.
+	SetFocused(focused bool)
 }
 
-// HighlightStylable represents an item that can be styled for highlighted regions.
-type HighlightStylable interface {
-	// HighlightStyle returns the style to apply for highlighted regions.
-	HighlightStyle() lipgloss.Style
+// 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)
 }
 
 // MouseClickable represents an item that can handle mouse click events.

internal/ui/list/list.go 🔗

@@ -1,10 +1,8 @@
 package list
 
 import (
-	"image"
 	"strings"
 
-	"charm.land/lipgloss/v2"
 	"github.com/charmbracelet/x/ansi"
 )
 
@@ -35,9 +33,6 @@ type List struct {
 	mouseDragY      int          // Current Y in item
 	lastHighlighted map[int]bool // Track which items were highlighted in last update
 
-	// Rendered content and cache
-	renderedItems map[int]renderedItem
-
 	// offsetIdx is the index of the first visible item in the viewport.
 	offsetIdx int
 	// offsetLine is the number of lines of the item at offsetIdx that are
@@ -56,7 +51,6 @@ type renderedItem struct {
 func NewList(items ...Item) *List {
 	l := new(List)
 	l.items = items
-	l.renderedItems = make(map[int]renderedItem)
 	l.selectedIdx = -1
 	l.mouseDownItem = -1
 	l.mouseDragItem = -1
@@ -66,9 +60,6 @@ func NewList(items ...Item) *List {
 
 // SetSize sets the size of the list viewport.
 func (l *List) SetSize(width, height int) {
-	if width != l.width {
-		l.renderedItems = make(map[int]renderedItem)
-	}
 	l.width = width
 	l.height = height
 	// l.normalizeOffsets()
@@ -96,16 +87,16 @@ func (l *List) Len() int {
 
 // getItem renders (if needed) and returns the item at the given index.
 func (l *List) getItem(idx int) renderedItem {
-	return l.renderItem(idx, false)
-}
+	if idx < 0 || idx >= len(l.items) {
+		return renderedItem{}
+	}
 
-// applyHighlight applies highlighting to the given rendered item.
-func (l *List) applyHighlight(idx int, ri *renderedItem) {
-	// Apply highlight if item supports it
-	if highlightable, ok := l.items[idx].(HighlightStylable); ok {
+	item := l.items[idx]
+	if hi, ok := item.(Highlightable); ok {
+		// Apply highlight
 		startItemIdx, startLine, startCol, endItemIdx, endLine, endCol := l.getHighlightRange()
+		sLine, sCol, eLine, eCol := -1, -1, -1, -1
 		if idx >= startItemIdx && idx <= endItemIdx {
-			var sLine, sCol, eLine, eCol int
 			if idx == startItemIdx && idx == endItemIdx {
 				// Single item selection
 				sLine = startLine
@@ -116,8 +107,8 @@ func (l *List) applyHighlight(idx int, ri *renderedItem) {
 				// First item - from start position to end of item
 				sLine = startLine
 				sCol = startCol
-				eLine = ri.height - 1
-				eCol = 9999 // 9999 = end of line
+				eLine = -1
+				eCol = -1
 			} else if idx == endItemIdx {
 				// Last item - from start of item to end position
 				sLine = 0
@@ -128,82 +119,29 @@ func (l *List) applyHighlight(idx int, ri *renderedItem) {
 				// Middle item - fully highlighted
 				sLine = 0
 				sCol = 0
-				eLine = ri.height - 1
-				eCol = 9999
+				eLine = -1
+				eCol = -1
 			}
-
-			// Apply offset for styling frame
-			contentArea := image.Rect(0, 0, l.width, ri.height)
-
-			hiStyle := highlightable.HighlightStyle()
-			rendered := Highlight(ri.content, contentArea, sLine, sCol, eLine, eCol, ToHighlighter(hiStyle))
-			ri.content = rendered
 		}
-	}
-}
 
-// renderItem renders (if needed) and returns the item at the given index. If
-// process is true, it applies focus and highlight styling.
-func (l *List) renderItem(idx int, process bool) renderedItem {
-	if idx < 0 || idx >= len(l.items) {
-		return renderedItem{}
+		hi.Highlight(sLine, sCol, eLine, eCol)
 	}
 
-	var style lipgloss.Style
-	focusable, isFocusable := l.items[idx].(FocusStylable)
-	if isFocusable {
-		style = focusable.BlurStyle()
-		if l.focused && idx == l.selectedIdx {
-			style = focusable.FocusStyle()
-		}
+	if focusable, isFocusable := item.(Focusable); isFocusable {
+		focusable.SetFocused(l.focused && idx == l.selectedIdx)
 	}
 
-	ri, ok := l.renderedItems[idx]
-	if !ok {
-		item := l.items[idx]
-		rendered := item.Render(l.width - style.GetHorizontalFrameSize())
-		rendered = strings.TrimRight(rendered, "\n")
-		height := countLines(rendered)
-
-		ri = renderedItem{
-			content: rendered,
-			height:  height,
-		}
-
-		l.renderedItems[idx] = ri
-	}
-
-	if !process {
-		// Simply return cached rendered item with frame size applied
-		if vfs := style.GetVerticalFrameSize(); vfs > 0 {
-			ri.height += vfs
-		}
-		return ri
-	}
-
-	// We apply highlighting before focus styling so that focus styling
-	// overrides highlight styles.
-	if l.mouseDownItem >= 0 {
-		l.applyHighlight(idx, &ri)
-	}
-
-	if isFocusable {
-		// Apply focus/blur styling if needed
-		rendered := style.Render(ri.content)
-		height := countLines(rendered)
-		ri.content = rendered
-		ri.height = height
+	rendered := item.Render(l.width)
+	rendered = strings.TrimRight(rendered, "\n")
+	height := countLines(rendered)
+	ri := renderedItem{
+		content: rendered,
+		height:  height,
 	}
 
 	return ri
 }
 
-// invalidateItem invalidates the cached rendered content of the item at the
-// given index.
-func (l *List) invalidateItem(idx int) {
-	delete(l.renderedItems, idx)
-}
-
 // ScrollToIndex scrolls the list to the given item index.
 func (l *List) ScrollToIndex(index int) {
 	if index < 0 {
@@ -330,7 +268,7 @@ func (l *List) Render() string {
 	linesNeeded := l.height
 
 	for linesNeeded > 0 && currentIdx < len(l.items) {
-		item := l.renderItem(currentIdx, true)
+		item := l.getItem(currentIdx)
 		itemLines := strings.Split(item.content, "\n")
 		itemHeight := len(itemLines)
 
@@ -372,13 +310,6 @@ func (l *List) Render() string {
 func (l *List) PrependItems(items ...Item) {
 	l.items = append(items, l.items...)
 
-	// Shift cache
-	newCache := make(map[int]renderedItem)
-	for idx, val := range l.renderedItems {
-		newCache[idx+len(items)] = val
-	}
-	l.renderedItems = newCache
-
 	// Keep view position relative to the content that was visible
 	l.offsetIdx += len(items)
 
@@ -397,9 +328,6 @@ func (l *List) SetItems(items ...Item) {
 // rendered item cache.
 func (l *List) setItems(evict bool, items ...Item) {
 	l.items = items
-	if evict {
-		l.renderedItems = make(map[int]renderedItem)
-	}
 	l.selectedIdx = min(l.selectedIdx, len(l.items)-1)
 	l.offsetIdx = min(l.offsetIdx, len(l.items)-1)
 	l.offsetLine = 0
@@ -580,8 +508,6 @@ func (l *List) HandleMouseDown(x, y int) bool {
 
 	if clickable, ok := l.items[itemIdx].(MouseClickable); ok {
 		clickable.HandleMouseClick(ansi.MouseButton1, x, itemY)
-		l.items[itemIdx] = clickable.(Item)
-		l.invalidateItem(itemIdx)
 	}
 
 	return true