From 5d6dbc865bbda659517373683c1a7972bbff2d7e Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Mon, 15 Dec 2025 13:16:29 -0500 Subject: [PATCH] refactor(list): simplify focus and highlight interfaces --- 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(-) diff --git a/internal/ui/dialog/items.go b/internal/ui/dialog/items.go index eb0bfc727d3322f0e928d36069b9d483fc130df5..3cdc010f9f225d2acbe1cb129c010806a9531987 100644 --- a/internal/ui/dialog/items.go +++ b/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 diff --git a/internal/ui/list/item.go b/internal/ui/list/item.go index 48d53b75d057d40f76bf2b16ce2060601c1222f5..a544b85b37dedf889cdc1ecb6ae77388040907f2 100644 --- a/internal/ui/list/item.go +++ b/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. diff --git a/internal/ui/list/list.go b/internal/ui/list/list.go index 92585c208f16d91d5c8c3e9f7d8f5f28c4721f9c..b758208fd86e055480c030d3791bc337556bed8b 100644 --- a/internal/ui/list/list.go +++ b/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