on main help sign in
refactor(list): simplify focus and highlight interfaces
5d6dbc8Ayman Bagabas
internal/ui/dialog/items.go+39−16
@@ -17,7 +17,7 @@ import (
17// ListItem represents a selectable and searchable item in a dialog list.
17// ListItem represents a selectable and searchable item in a dialog list.
18type ListItem interface {
18type ListItem interface {
19 list.FilterableItem
19 list.FilterableItem
20− list.FocusStylable
20+ list.Focusable
21 list.MatchSettable
21 list.MatchSettable
22
22
23 // ID returns the unique identifier of the item.
23 // ID returns the unique identifier of the item.
@@ -27,8 +27,10 @@ type ListItem interface {
27// SessionItem wraps a [session.Session] to implement the [ListItem] interface.
27// SessionItem wraps a [session.Session] to implement the [ListItem] interface.
28type SessionItem struct {
28type SessionItem struct {
29 session.Session
29 session.Session
30− t *styles.Styles
30+ t *styles.Styles
31− m fuzzy.Match
31+ m fuzzy.Match
32+ cache map[int]string
33+ focused bool
32}
34}
33
35
34var _ ListItem = &SessionItem{}
36var _ ListItem = &SessionItem{}
@@ -45,13 +47,34 @@ func (s *SessionItem) ID() string {
45
47
46// SetMatch sets the fuzzy match for the session item.
48// SetMatch sets the fuzzy match for the session item.
47func (s *SessionItem) SetMatch(m fuzzy.Match) {
49func (s *SessionItem) SetMatch(m fuzzy.Match) {
50+ s.cache = nil
48 s.m = m
51 s.m = m
49}
52}
50
53
51// Render returns the string representation of the session item.
54// Render returns the string representation of the session item.
52func (s *SessionItem) Render(width int) string {
55func (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()
53 age := humanize.Time(time.Unix(s.Session.UpdatedAt, 0))
71 age := humanize.Time(time.Unix(s.Session.UpdatedAt, 0))
72+ if s.focused {
73+ age = s.t.Base.Render(age)
74+ } else {
54− age = s.t.Subtle.Render(age)
75+ age = s.t.Subtle.Render(age)
76+ }
77+
55 age = " " + age
78 age = " " + age
56 ageLen := lipgloss.Width(age)
79 ageLen := lipgloss.Width(age)
57 title := s.Session.Title
80 title := s.Session.Title
@@ -59,12 +82,10 @@ func (s *SessionItem) Render(width int) string {
59 title = ansi.Truncate(title, max(0, width-ageLen), "…")
82 title = ansi.Truncate(title, max(0, width-ageLen), "…")
60 right := lipgloss.NewStyle().AlignHorizontal(lipgloss.Right).Width(width - titleLen).Render(age)
83 right := lipgloss.NewStyle().AlignHorizontal(lipgloss.Right).Width(width - titleLen).Render(age)
61
84
85+ content := title
62 if matches := len(s.m.MatchedIndexes); matches > 0 {
86 if matches := len(s.m.MatchedIndexes); matches > 0 {
63 var lastPos int
87 var lastPos int
64 parts := make([]string, 0)
88 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.
68 ranges := matchedRanges(s.m.MatchedIndexes)
89 ranges := matchedRanges(s.m.MatchedIndexes)
69 for _, rng := range ranges {
90 for _, rng := range ranges {
70 start, stop := bytePosToVisibleCharPos(title, rng)
91 start, stop := bytePosToVisibleCharPos(title, rng)
@@ -86,19 +107,21 @@ func (s *SessionItem) Render(width int) string {
86 if lastPos < len(title) {
107 if lastPos < len(title) {
87 parts = append(parts, title[lastPos:])
108 parts = append(parts, title[lastPos:])
88 }
109 }
110+
89− return strings.Join(parts, "") + right
111+ content = strings.Join(parts, "")
90 }
112 }
91− return title + right
92−}
93
113
94−// FocusStyle returns the style to be applied when the item is focused.
114+ content = style.Render(content + right)
95−func (s *SessionItem) FocusStyle() lipgloss.Style {
115+ s.cache[width] = content
96− return s.t.Dialog.SelectedItem
116+ return content
97}
117}
98
118
99−// BlurStyle returns the style to be applied when the item is blurred.
119+// SetFocused sets the focus state of the session item.
100−func (s *SessionItem) BlurStyle() lipgloss.Style {
120+func (s *SessionItem) SetFocused(focused bool) {
101− return s.t.Dialog.NormalItem
121+ if s.focused != focused {
122+ s.cache = nil
123+ }
124+ s.focused = focused
102}
125}
103
126
104// sessionItems takes a slice of [session.Session]s and convert them to a slice
127// 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 @@
1package list
1package list
2
2
3import (
3import (
4− "charm.land/lipgloss/v2"
5 "github.com/charmbracelet/x/ansi"
4 "github.com/charmbracelet/x/ansi"
6)
5)
7
6
@@ -12,18 +11,17 @@ type Item interface {
12 Render(width int) string
11 Render(width int) string
13}
12}
14
13
15−// FocusStylable represents an item that can be styled based on focus state.
14+// Focusable represents an item that can be aware of focus state changes.
16−type FocusStylable interface {
15+type Focusable interface {
17− // FocusStyle returns the style to apply when the item is focused.
16+ // SetFocused sets the focus state of the item.
18− FocusStyle() lipgloss.Style
17+ SetFocused(focused bool)
19− // BlurStyle returns the style to apply when the item is unfocused.
20− BlurStyle() lipgloss.Style
21}
18}
22
19
23−// HighlightStylable represents an item that can be styled for highlighted regions.
20+// Highlightable represents an item that can highlight a portion of its content.
24−type HighlightStylable interface {
21+type Highlightable interface {
25− // HighlightStyle returns the style to apply for highlighted regions.
22+ // Highlight highlights the content from the given start to end positions.
26− HighlightStyle() lipgloss.Style
23+ // Use -1 for no highlight.
24+ Highlight(startLine, startCol, endLine, endCol int)
27}
25}
28
26
29// MouseClickable represents an item that can handle mouse click events.
27// MouseClickable represents an item that can handle mouse click events.
internal/ui/list/list.go+21−95
@@ -1,10 +1,8 @@
1package list
1package list
2
2
3import (
3import (
4− "image"
5 "strings"
4 "strings"
6
5
7− "charm.land/lipgloss/v2"
8 "github.com/charmbracelet/x/ansi"
6 "github.com/charmbracelet/x/ansi"
9)
7)
10
8
@@ -35,9 +33,6 @@ type List struct {
35 mouseDragY int // Current Y in item
33 mouseDragY int // Current Y in item
36 lastHighlighted map[int]bool // Track which items were highlighted in last update
34 lastHighlighted map[int]bool // Track which items were highlighted in last update
37
35
38− // Rendered content and cache
39− renderedItems map[int]renderedItem
40−
41 // offsetIdx is the index of the first visible item in the viewport.
36 // offsetIdx is the index of the first visible item in the viewport.
42 offsetIdx int
37 offsetIdx int
43 // offsetLine is the number of lines of the item at offsetIdx that are
38 // offsetLine is the number of lines of the item at offsetIdx that are
@@ -56,7 +51,6 @@ type renderedItem struct {
56func NewList(items ...Item) *List {
51func NewList(items ...Item) *List {
57 l := new(List)
52 l := new(List)
58 l.items = items
53 l.items = items
59− l.renderedItems = make(map[int]renderedItem)
60 l.selectedIdx = -1
54 l.selectedIdx = -1
61 l.mouseDownItem = -1
55 l.mouseDownItem = -1
62 l.mouseDragItem = -1
56 l.mouseDragItem = -1
@@ -66,9 +60,6 @@ func NewList(items ...Item) *List {
66
60
67// SetSize sets the size of the list viewport.
61// SetSize sets the size of the list viewport.
68func (l *List) SetSize(width, height int) {
62func (l *List) SetSize(width, height int) {
69− if width != l.width {
70− l.renderedItems = make(map[int]renderedItem)
71− }
72 l.width = width
63 l.width = width
73 l.height = height
64 l.height = height
74 // l.normalizeOffsets()
65 // l.normalizeOffsets()
@@ -96,16 +87,16 @@ func (l *List) Len() int {
96
87
97// getItem renders (if needed) and returns the item at the given index.
88// getItem renders (if needed) and returns the item at the given index.
98func (l *List) getItem(idx int) renderedItem {
89func (l *List) getItem(idx int) renderedItem {
99− return l.renderItem(idx, false)
90+ if idx < 0 || idx >= len(l.items) {
91+ return renderedItem{}
100−}
92+ }
101
93
102−// applyHighlight applies highlighting to the given rendered item.
94+ item := l.items[idx]
103−func (l *List) applyHighlight(idx int, ri *renderedItem) {
95+ if hi, ok := item.(Highlightable); ok {
104− // Apply highlight if item supports it
96+ // Apply highlight
105− if highlightable, ok := l.items[idx].(HighlightStylable); ok {
106 startItemIdx, startLine, startCol, endItemIdx, endLine, endCol := l.getHighlightRange()
97 startItemIdx, startLine, startCol, endItemIdx, endLine, endCol := l.getHighlightRange()
98+ sLine, sCol, eLine, eCol := -1, -1, -1, -1
107 if idx >= startItemIdx && idx <= endItemIdx {
99 if idx >= startItemIdx && idx <= endItemIdx {
108− var sLine, sCol, eLine, eCol int
109 if idx == startItemIdx && idx == endItemIdx {
100 if idx == startItemIdx && idx == endItemIdx {
110 // Single item selection
101 // Single item selection
111 sLine = startLine
102 sLine = startLine
@@ -116,8 +107,8 @@ func (l *List) applyHighlight(idx int, ri *renderedItem) {
116 // First item - from start position to end of item
107 // First item - from start position to end of item
117 sLine = startLine
108 sLine = startLine
118 sCol = startCol
109 sCol = startCol
119− eLine = ri.height - 1
110+ eLine = -1
120− eCol = 9999 // 9999 = end of line
111+ eCol = -1
121 } else if idx == endItemIdx {
112 } else if idx == endItemIdx {
122 // Last item - from start of item to end position
113 // Last item - from start of item to end position
123 sLine = 0
114 sLine = 0
@@ -128,82 +119,29 @@ func (l *List) applyHighlight(idx int, ri *renderedItem) {
128 // Middle item - fully highlighted
119 // Middle item - fully highlighted
129 sLine = 0
120 sLine = 0
130 sCol = 0
121 sCol = 0
131− eLine = ri.height - 1
122+ eLine = -1
132− eCol = 9999
123+ eCol = -1
133 }
124 }
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
141 }
125 }
142− }
143−}
144
126
145−// renderItem renders (if needed) and returns the item at the given index. If
127+ hi.Highlight(sLine, sCol, eLine, eCol)
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{}
150 }
128 }
151
129
152− var style lipgloss.Style
153− focusable, isFocusable := l.items[idx].(FocusStylable)
130+ if focusable, isFocusable := item.(Focusable); isFocusable {
154− if isFocusable {
155− style = focusable.BlurStyle()
156− if l.focused && idx == l.selectedIdx {
131+ focusable.SetFocused(l.focused && idx == l.selectedIdx)
157− style = focusable.FocusStyle()
158− }
159 }
132 }
160
133
161− ri, ok := l.renderedItems[idx]
162− if !ok {
163− item := l.items[idx]
164− rendered := item.Render(l.width - style.GetHorizontalFrameSize())
134+ rendered := item.Render(l.width)
165− rendered = strings.TrimRight(rendered, "\n")
135+ rendered = strings.TrimRight(rendered, "\n")
166− height := countLines(rendered)
136+ height := countLines(rendered)
167−
168− ri = renderedItem{
137+ ri := renderedItem{
169− content: rendered,
138+ content: rendered,
170− height: height,
139+ 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
196 }
140 }
197
141
198 return ri
142 return ri
199}
143}
200
144
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−
207// ScrollToIndex scrolls the list to the given item index.
145// ScrollToIndex scrolls the list to the given item index.
208func (l *List) ScrollToIndex(index int) {
146func (l *List) ScrollToIndex(index int) {
209 if index < 0 {
147 if index < 0 {
@@ -330,7 +268,7 @@ func (l *List) Render() string {
330 linesNeeded := l.height
268 linesNeeded := l.height
331
269
332 for linesNeeded > 0 && currentIdx < len(l.items) {
270 for linesNeeded > 0 && currentIdx < len(l.items) {
333− item := l.renderItem(currentIdx, true)
271+ item := l.getItem(currentIdx)
334 itemLines := strings.Split(item.content, "\n")
272 itemLines := strings.Split(item.content, "\n")
335 itemHeight := len(itemLines)
273 itemHeight := len(itemLines)
336
274
@@ -372,13 +310,6 @@ func (l *List) Render() string {
372func (l *List) PrependItems(items ...Item) {
310func (l *List) PrependItems(items ...Item) {
373 l.items = append(items, l.items...)
311 l.items = append(items, l.items...)
374
312
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−
382 // Keep view position relative to the content that was visible
313 // Keep view position relative to the content that was visible
383 l.offsetIdx += len(items)
314 l.offsetIdx += len(items)
384
315
@@ -397,9 +328,6 @@ func (l *List) SetItems(items ...Item) {
397// rendered item cache.
328// rendered item cache.
398func (l *List) setItems(evict bool, items ...Item) {
329func (l *List) setItems(evict bool, items ...Item) {
399 l.items = items
330 l.items = items
400− if evict {
401− l.renderedItems = make(map[int]renderedItem)
402− }
403 l.selectedIdx = min(l.selectedIdx, len(l.items)-1)
331 l.selectedIdx = min(l.selectedIdx, len(l.items)-1)
404 l.offsetIdx = min(l.offsetIdx, len(l.items)-1)
332 l.offsetIdx = min(l.offsetIdx, len(l.items)-1)
405 l.offsetLine = 0
333 l.offsetLine = 0
@@ -580,8 +508,6 @@ func (l *List) HandleMouseDown(x, y int) bool {
580
508
581 if clickable, ok := l.items[itemIdx].(MouseClickable); ok {
509 if clickable, ok := l.items[itemIdx].(MouseClickable); ok {
582 clickable.HandleMouseClick(ansi.MouseButton1, x, itemY)
510 clickable.HandleMouseClick(ansi.MouseButton1, x, itemY)
583− l.items[itemIdx] = clickable.(Item)
584− l.invalidateItem(itemIdx)
585 }
511 }
586
512
587 return true
513 return true