1// Package selector provides selector UI components.
2package selector
3
4import (
5 "sync"
6
7 "github.com/charmbracelet/bubbles/v2/key"
8 "github.com/charmbracelet/bubbles/v2/list"
9 tea "github.com/charmbracelet/bubbletea/v2"
10 "github.com/charmbracelet/soft-serve/pkg/ui/common"
11)
12
13// Selector is a list of items that can be selected.
14type Selector struct {
15 *list.Model
16 common common.Common
17 active int
18 filterState list.FilterState
19
20 // XXX: we use a mutex to support concurrent access to the model. This is
21 // needed to implement pagination for the Log component. list.Model does
22 // not support item pagination so we hack it ourselves on top of
23 // list.Model.
24 mtx sync.RWMutex
25}
26
27// IdentifiableItem is an item that can be identified by a string. Implements
28// list.DefaultItem.
29type IdentifiableItem interface {
30 list.DefaultItem
31 ID() string
32}
33
34// ItemDelegate is a wrapper around list.ItemDelegate.
35type ItemDelegate interface {
36 list.ItemDelegate
37}
38
39// SelectMsg is a message that is sent when an item is selected.
40type SelectMsg struct{ IdentifiableItem }
41
42// ActiveMsg is a message that is sent when an item is active but not selected.
43type ActiveMsg struct{ IdentifiableItem }
44
45// New creates a new selector.
46func New(common common.Common, items []IdentifiableItem, delegate ItemDelegate) *Selector {
47 itms := make([]list.Item, len(items))
48 for i, item := range items {
49 itms[i] = item
50 }
51 l := list.New(itms, delegate, common.Width, common.Height)
52 l.Styles.NoItems = common.Styles.NoContent
53 s := &Selector{
54 Model: &l,
55 common: common,
56 }
57 s.SetSize(common.Width, common.Height)
58 return s
59}
60
61// PerPage returns the number of items per page.
62func (s *Selector) PerPage() int {
63 s.mtx.RLock()
64 defer s.mtx.RUnlock()
65 return s.Paginator.PerPage
66}
67
68// SetPage sets the current page.
69func (s *Selector) SetPage(page int) {
70 s.mtx.Lock()
71 defer s.mtx.Unlock()
72 s.Paginator.Page = page
73}
74
75// Page returns the current page.
76func (s *Selector) Page() int {
77 s.mtx.RLock()
78 defer s.mtx.RUnlock()
79 return s.Paginator.Page
80}
81
82// TotalPages returns the total number of pages.
83func (s *Selector) TotalPages() int {
84 s.mtx.RLock()
85 defer s.mtx.RUnlock()
86 return s.Paginator.TotalPages
87}
88
89// SetTotalPages sets the total number of pages given the number of items.
90func (s *Selector) SetTotalPages(items int) int {
91 s.mtx.Lock()
92 defer s.mtx.Unlock()
93 return s.Paginator.SetTotalPages(items)
94}
95
96// SelectedItem returns the currently selected item.
97func (s *Selector) SelectedItem() IdentifiableItem {
98 s.mtx.RLock()
99 defer s.mtx.RUnlock()
100 item := s.Model.SelectedItem()
101 i, ok := item.(IdentifiableItem)
102 if !ok {
103 return nil
104 }
105 return i
106}
107
108// Select selects the item at the given index.
109func (s *Selector) Select(index int) {
110 s.mtx.RLock()
111 defer s.mtx.RUnlock()
112 s.Model.Select(index)
113}
114
115// SetShowTitle sets the show title flag.
116func (s *Selector) SetShowTitle(show bool) {
117 s.mtx.Lock()
118 defer s.mtx.Unlock()
119 s.Model.SetShowTitle(show)
120}
121
122// SetShowHelp sets the show help flag.
123func (s *Selector) SetShowHelp(show bool) {
124 s.mtx.Lock()
125 defer s.mtx.Unlock()
126 s.Model.SetShowHelp(show)
127}
128
129// SetShowStatusBar sets the show status bar flag.
130func (s *Selector) SetShowStatusBar(show bool) {
131 s.mtx.Lock()
132 defer s.mtx.Unlock()
133 s.Model.SetShowStatusBar(show)
134}
135
136// DisableQuitKeybindings disables the quit keybindings.
137func (s *Selector) DisableQuitKeybindings() {
138 s.mtx.Lock()
139 defer s.mtx.Unlock()
140 s.Model.DisableQuitKeybindings()
141}
142
143// SetShowFilter sets the show filter flag.
144func (s *Selector) SetShowFilter(show bool) {
145 s.mtx.Lock()
146 defer s.mtx.Unlock()
147 s.Model.SetShowFilter(show)
148}
149
150// SetShowPagination sets the show pagination flag.
151func (s *Selector) SetShowPagination(show bool) {
152 s.mtx.Lock()
153 defer s.mtx.Unlock()
154 s.Model.SetShowPagination(show)
155}
156
157// SetFilteringEnabled sets the filtering enabled flag.
158func (s *Selector) SetFilteringEnabled(enabled bool) {
159 s.mtx.Lock()
160 defer s.mtx.Unlock()
161 s.Model.SetFilteringEnabled(enabled)
162}
163
164// SetSize implements common.Component.
165func (s *Selector) SetSize(width, height int) {
166 s.mtx.Lock()
167 defer s.mtx.Unlock()
168 s.common.SetSize(width, height)
169 s.Model.SetSize(width, height)
170}
171
172// SetItems sets the items in the selector.
173func (s *Selector) SetItems(items []IdentifiableItem) tea.Cmd {
174 its := make([]list.Item, len(items))
175 for i, item := range items {
176 its[i] = item
177 }
178 s.mtx.Lock()
179 defer s.mtx.Unlock()
180 return s.Model.SetItems(its)
181}
182
183// Index returns the index of the selected item.
184func (s *Selector) Index() int {
185 s.mtx.RLock()
186 defer s.mtx.RUnlock()
187 return s.Model.Index()
188}
189
190// Items returns the items in the selector.
191func (s *Selector) Items() []list.Item {
192 s.mtx.RLock()
193 defer s.mtx.RUnlock()
194 return s.Model.Items()
195}
196
197// VisibleItems returns all the visible items in the selector.
198func (s *Selector) VisibleItems() []list.Item {
199 s.mtx.RLock()
200 defer s.mtx.RUnlock()
201 return s.Model.VisibleItems()
202}
203
204// FilterState returns the filter state.
205func (s *Selector) FilterState() list.FilterState {
206 s.mtx.RLock()
207 defer s.mtx.RUnlock()
208 return s.Model.FilterState()
209}
210
211// CursorUp moves the cursor up.
212func (s *Selector) CursorUp() {
213 s.mtx.Lock()
214 defer s.mtx.Unlock()
215 s.Model.CursorUp()
216}
217
218// CursorDown moves the cursor down.
219func (s *Selector) CursorDown() {
220 s.mtx.Lock()
221 defer s.mtx.Unlock()
222 s.Model.CursorDown()
223}
224
225// Init implements tea.Model.
226func (s *Selector) Init() tea.Cmd {
227 return s.activeCmd
228}
229
230// Update implements tea.Model.
231func (s *Selector) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
232 cmds := make([]tea.Cmd, 0)
233 switch msg := msg.(type) {
234 case tea.MouseClickMsg:
235 m := msg.Mouse()
236 switch m.Button {
237 case tea.MouseWheelUp:
238 s.CursorUp()
239 case tea.MouseWheelDown:
240 s.CursorDown()
241 case tea.MouseLeft:
242 curIdx := s.Index()
243 for i, item := range s.Items() {
244 item, _ := item.(IdentifiableItem)
245 // Check each item to see if it's in bounds.
246 if item != nil && s.common.Zone.Get(item.ID()).InBounds(msg) {
247 if i == curIdx {
248 cmds = append(cmds, s.SelectItemCmd)
249 } else {
250 s.Select(i)
251 }
252 break
253 }
254 }
255 }
256 case tea.KeyPressMsg:
257 filterState := s.FilterState()
258 switch {
259 case key.Matches(msg, s.common.KeyMap.Help):
260 if filterState == list.Filtering {
261 return s, tea.Batch(cmds...)
262 }
263 case key.Matches(msg, s.common.KeyMap.Select):
264 if filterState != list.Filtering {
265 cmds = append(cmds, s.SelectItemCmd)
266 }
267 }
268 case list.FilterMatchesMsg:
269 cmds = append(cmds, s.activeFilterCmd)
270 }
271 m, cmd := s.Model.Update(msg)
272 s.mtx.Lock()
273 s.Model = &m
274 s.mtx.Unlock()
275 if cmd != nil {
276 cmds = append(cmds, cmd)
277 }
278 // Track filter state and update active item when filter state changes.
279 filterState := s.FilterState()
280 if s.filterState != filterState {
281 cmds = append(cmds, s.activeFilterCmd)
282 }
283 s.filterState = filterState
284 // Send ActiveMsg when index change.
285 if s.active != s.Index() {
286 cmds = append(cmds, s.activeCmd)
287 }
288 s.active = s.Index()
289 return s, tea.Batch(cmds...)
290}
291
292// View implements tea.Model.
293func (s *Selector) View() string {
294 return s.Model.View()
295}
296
297// SelectItemCmd is a command that selects the currently active item.
298func (s *Selector) SelectItemCmd() tea.Msg {
299 return SelectMsg{s.SelectedItem()}
300}
301
302func (s *Selector) activeCmd() tea.Msg {
303 item := s.SelectedItem()
304 return ActiveMsg{item}
305}
306
307func (s *Selector) activeFilterCmd() tea.Msg {
308 // Here we use VisibleItems because when list.FilterMatchesMsg is sent,
309 // VisibleItems is the only way to get the list of filtered items. The list
310 // bubble should export something like list.FilterMatchesMsg.Items().
311 items := s.VisibleItems()
312 if len(items) == 0 {
313 return nil
314 }
315 item := items[0]
316 i, ok := item.(IdentifiableItem)
317 if !ok {
318 return nil
319 }
320 return ActiveMsg{i}
321}