on main help sign in
refactor(list): simplify focus and highlight interfaces
commit5d6dbc865bbda659517373683c1a7972bbff2d7e authorAyman Bagabas <ayman.bagabas@gmail.com> dateMon 15 Dec 2025, 13:16 (1 year ago) parent53d38c0
3 files changed+69−122
internal/ui/dialog/items.go+39−16 internal/ui/list/item.go+9−11 internal/ui/list/list.go+21−95
internal/ui/dialog/items.go+39−16
@@ -17,7 +17,7 @@ import (
1717// ListItem represents a selectable and searchable item in a dialog list.
1818type ListItem interface {
1919 list.FilterableItem
20− list.FocusStylable
20+ list.Focusable
2121 list.MatchSettable
2222
2323 // ID returns the unique identifier of the item.
@@ -27,8 +27,10 @@ type ListItem interface {
2727// SessionItem wraps a [session.Session] to implement the [ListItem] interface.
2828type SessionItem struct {
2929 session.Session
30− t *styles.Styles
31− m fuzzy.Match
30+ t *styles.Styles
31+ m fuzzy.Match
32+ cache map[int]string
33+ focused bool
3234}
3335
3436var _ ListItem = &SessionItem{}
@@ -45,13 +47,34 @@ func (s *SessionItem) ID() string {
4547
4648// SetMatch sets the fuzzy match for the session item.
4749func (s *SessionItem) SetMatch(m fuzzy.Match) {
50+ s.cache = nil
4851 s.m = m
4952}
5053
5154// Render returns the string representation of the session item.
5255func (s *SessionItem) Render(width int) string {
56+ if s.cache == nil {
57+ s.cache = make(map[int]string)
58+ }
59+
60+ cached, ok := s.cache[width]
61+ if ok {
62+ return cached
63+ }
64+
65+ style := s.t.Dialog.NormalItem
66+ if s.focused {
67+ style = s.t.Dialog.SelectedItem
68+ }
69+
70+ width -= style.GetHorizontalFrameSize()
5371 age := humanize.Time(time.Unix(s.Session.UpdatedAt, 0))
54− age = s.t.Subtle.Render(age)
72+ if s.focused {
73+ age = s.t.Base.Render(age)
74+ } else {
75+ age = s.t.Subtle.Render(age)
76+ }
77+
5578 age = " " + age
5679 ageLen := lipgloss.Width(age)
5780 title := s.Session.Title
@@ -59,12 +82,10 @@ func (s *SessionItem) Render(width int) string {
5982 title = ansi.Truncate(title, max(0, width-ageLen), "…")
6083 right := lipgloss.NewStyle().AlignHorizontal(lipgloss.Right).Width(width - titleLen).Render(age)
6184
85+ content := title
6286 if matches := len(s.m.MatchedIndexes); matches > 0 {
6387 var lastPos int
6488 parts := make([]string, 0)
65− // TODO: Use [ansi.Style].Underline true/false to underline only the
66− // matched parts instead of using [lipgloss.StyleRanges] since it can
67− // be cheaper with less allocations.
6889 ranges := matchedRanges(s.m.MatchedIndexes)
6990 for _, rng := range ranges {
7091 start, stop := bytePosToVisibleCharPos(title, rng)
@@ -86,19 +107,21 @@ func (s *SessionItem) Render(width int) string {
86107 if lastPos < len(title) {
87108 parts = append(parts, title[lastPos:])
88109 }
89− return strings.Join(parts, "") + right
110+
111+ content = strings.Join(parts, "")
90112 }
91− return title + right
92−}
93113
94−// FocusStyle returns the style to be applied when the item is focused.
95−func (s *SessionItem) FocusStyle() lipgloss.Style {
96− return s.t.Dialog.SelectedItem
114+ content = style.Render(content + right)
115+ s.cache[width] = content
116+ return content
97117}
98118
99−// BlurStyle returns the style to be applied when the item is blurred.
100−func (s *SessionItem) BlurStyle() lipgloss.Style {
101− return s.t.Dialog.NormalItem
119+// SetFocused sets the focus state of the session item.
120+func (s *SessionItem) SetFocused(focused bool) {
121+ if s.focused != focused {
122+ s.cache = nil
123+ }
124+ s.focused = focused
102125}
103126
104127// sessionItems takes a slice of [session.Session]s and convert them to a slice
internal/ui/list/item.go+9−11
@@ -1,7 +1,6 @@
11package list
22
33import (
4− "charm.land/lipgloss/v2"
54 "github.com/charmbracelet/x/ansi"
65)
76
@@ -12,18 +11,17 @@ type Item interface {
1211 Render(width int) string
1312}
1413
15−// FocusStylable represents an item that can be styled based on focus state.
16−type FocusStylable interface {
17− // FocusStyle returns the style to apply when the item is focused.
18− FocusStyle() lipgloss.Style
19− // BlurStyle returns the style to apply when the item is unfocused.
20− BlurStyle() lipgloss.Style
14+// Focusable represents an item that can be aware of focus state changes.
15+type Focusable interface {
16+ // SetFocused sets the focus state of the item.
17+ SetFocused(focused bool)
2118}
2219
23−// HighlightStylable represents an item that can be styled for highlighted regions.
24−type HighlightStylable interface {
25− // HighlightStyle returns the style to apply for highlighted regions.
26− HighlightStyle() lipgloss.Style
20+// Highlightable represents an item that can highlight a portion of its content.
21+type Highlightable interface {
22+ // Highlight highlights the content from the given start to end positions.
23+ // Use -1 for no highlight.
24+ Highlight(startLine, startCol, endLine, endCol int)
2725}
2826
2927// MouseClickable represents an item that can handle mouse click events.
internal/ui/list/list.go+21−95
@@ -1,10 +1,8 @@
11package list
22
33import (
4− "image"
54 "strings"
65
7− "charm.land/lipgloss/v2"
86 "github.com/charmbracelet/x/ansi"
97)
108
@@ -35,9 +33,6 @@ type List struct {
3533 mouseDragY int // Current Y in item
3634 lastHighlighted map[int]bool // Track which items were highlighted in last update
3735
38− // Rendered content and cache
39− renderedItems map[int]renderedItem
40−
4136 // offsetIdx is the index of the first visible item in the viewport.
4237 offsetIdx int
4338 // offsetLine is the number of lines of the item at offsetIdx that are
@@ -56,7 +51,6 @@ type renderedItem struct {
5651func NewList(items ...Item) *List {
5752 l := new(List)
5853 l.items = items
59− l.renderedItems = make(map[int]renderedItem)
6054 l.selectedIdx = -1
6155 l.mouseDownItem = -1
6256 l.mouseDragItem = -1
@@ -66,9 +60,6 @@ func NewList(items ...Item) *List {
6660
6761// SetSize sets the size of the list viewport.
6862func (l *List) SetSize(width, height int) {
69− if width != l.width {
70− l.renderedItems = make(map[int]renderedItem)
71− }
7263 l.width = width
7364 l.height = height
7465 // l.normalizeOffsets()
@@ -96,16 +87,16 @@ func (l *List) Len() int {
9687
9788// getItem renders (if needed) and returns the item at the given index.
9889func (l *List) getItem(idx int) renderedItem {
99− return l.renderItem(idx, false)
100−}
90+ if idx < 0 || idx >= len(l.items) {
91+ return renderedItem{}
92+ }
10193
102−// applyHighlight applies highlighting to the given rendered item.
103−func (l *List) applyHighlight(idx int, ri *renderedItem) {
104− // Apply highlight if item supports it
105− if highlightable, ok := l.items[idx].(HighlightStylable); ok {
94+ item := l.items[idx]
95+ if hi, ok := item.(Highlightable); ok {
96+ // Apply highlight
10697 startItemIdx, startLine, startCol, endItemIdx, endLine, endCol := l.getHighlightRange()
98+ sLine, sCol, eLine, eCol := -1, -1, -1, -1
10799 if idx >= startItemIdx && idx <= endItemIdx {
108− var sLine, sCol, eLine, eCol int
109100 if idx == startItemIdx && idx == endItemIdx {
110101 // Single item selection
111102 sLine = startLine
@@ -116,8 +107,8 @@ func (l *List) applyHighlight(idx int, ri *renderedItem) {
116107 // First item - from start position to end of item
117108 sLine = startLine
118109 sCol = startCol
119− eLine = ri.height - 1
120− eCol = 9999 // 9999 = end of line
110+ eLine = -1
111+ eCol = -1
121112 } else if idx == endItemIdx {
122113 // Last item - from start of item to end position
123114 sLine = 0
@@ -128,82 +119,29 @@ func (l *List) applyHighlight(idx int, ri *renderedItem) {
128119 // Middle item - fully highlighted
129120 sLine = 0
130121 sCol = 0
131− eLine = ri.height - 1
132− eCol = 9999
122+ eLine = -1
123+ eCol = -1
133124 }
134−
135− // Apply offset for styling frame
136− contentArea := image.Rect(0, 0, l.width, ri.height)
137−
138− hiStyle := highlightable.HighlightStyle()
139− rendered := Highlight(ri.content, contentArea, sLine, sCol, eLine, eCol, ToHighlighter(hiStyle))
140− ri.content = rendered
141125 }
142− }
143−}
144126
145−// renderItem renders (if needed) and returns the item at the given index. If
146−// process is true, it applies focus and highlight styling.
147−func (l *List) renderItem(idx int, process bool) renderedItem {
148− if idx < 0 || idx >= len(l.items) {
149− return renderedItem{}
127+ hi.Highlight(sLine, sCol, eLine, eCol)
150128 }
151129
152− var style lipgloss.Style
153− focusable, isFocusable := l.items[idx].(FocusStylable)
154− if isFocusable {
155− style = focusable.BlurStyle()
156− if l.focused && idx == l.selectedIdx {
157− style = focusable.FocusStyle()
158− }
130+ if focusable, isFocusable := item.(Focusable); isFocusable {
131+ focusable.SetFocused(l.focused && idx == l.selectedIdx)
159132 }
160133
161− ri, ok := l.renderedItems[idx]
162− if !ok {
163− item := l.items[idx]
164− rendered := item.Render(l.width - style.GetHorizontalFrameSize())
165− rendered = strings.TrimRight(rendered, "\n")
166− height := countLines(rendered)
167−
168− ri = renderedItem{
169− content: rendered,
170− height: height,
171− }
172−
173− l.renderedItems[idx] = ri
174− }
175−
176− if !process {
177− // Simply return cached rendered item with frame size applied
178− if vfs := style.GetVerticalFrameSize(); vfs > 0 {
179− ri.height += vfs
180− }
181− return ri
182− }
183−
184− // We apply highlighting before focus styling so that focus styling
185− // overrides highlight styles.
186− if l.mouseDownItem >= 0 {
187− l.applyHighlight(idx, &ri)
188− }
189−
190− if isFocusable {
191− // Apply focus/blur styling if needed
192− rendered := style.Render(ri.content)
193− height := countLines(rendered)
194− ri.content = rendered
195− ri.height = height
134+ rendered := item.Render(l.width)
135+ rendered = strings.TrimRight(rendered, "\n")
136+ height := countLines(rendered)
137+ ri := renderedItem{
138+ content: rendered,
139+ height: height,
196140 }
197141
198142 return ri
199143}
200144
201−// invalidateItem invalidates the cached rendered content of the item at the
202−// given index.
203−func (l *List) invalidateItem(idx int) {
204− delete(l.renderedItems, idx)
205−}
206−
207145// ScrollToIndex scrolls the list to the given item index.
208146func (l *List) ScrollToIndex(index int) {
209147 if index < 0 {
@@ -330,7 +268,7 @@ func (l *List) Render() string {
330268 linesNeeded := l.height
331269
332270 for linesNeeded > 0 && currentIdx < len(l.items) {
333− item := l.renderItem(currentIdx, true)
271+ item := l.getItem(currentIdx)
334272 itemLines := strings.Split(item.content, "\n")
335273 itemHeight := len(itemLines)
336274
@@ -372,13 +310,6 @@ func (l *List) Render() string {
372310func (l *List) PrependItems(items ...Item) {
373311 l.items = append(items, l.items...)
374312
375− // Shift cache
376− newCache := make(map[int]renderedItem)
377− for idx, val := range l.renderedItems {
378− newCache[idx+len(items)] = val
379− }
380− l.renderedItems = newCache
381−
382313 // Keep view position relative to the content that was visible
383314 l.offsetIdx += len(items)
384315
@@ -397,9 +328,6 @@ func (l *List) SetItems(items ...Item) {
397328// rendered item cache.
398329func (l *List) setItems(evict bool, items ...Item) {
399330 l.items = items
400− if evict {
401− l.renderedItems = make(map[int]renderedItem)
402− }
403331 l.selectedIdx = min(l.selectedIdx, len(l.items)-1)
404332 l.offsetIdx = min(l.offsetIdx, len(l.items)-1)
405333 l.offsetLine = 0
@@ -580,8 +508,6 @@ func (l *List) HandleMouseDown(x, y int) bool {
580508
581509 if clickable, ok := l.items[itemIdx].(MouseClickable); ok {
582510 clickable.HandleMouseClick(ansi.MouseButton1, x, itemY)
583− l.items[itemIdx] = clickable.(Item)
584− l.invalidateItem(itemIdx)
585511 }
586512
587513 return true