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