selector.go

  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}