models_list.go

  1package dialog
  2
  3import (
  4	"fmt"
  5	"slices"
  6	"sort"
  7	"strings"
  8
  9	"github.com/charmbracelet/crush/internal/ui/list"
 10	"github.com/charmbracelet/crush/internal/ui/styles"
 11	"github.com/sahilm/fuzzy"
 12)
 13
 14// ModelsList is a list specifically for model items and groups.
 15type ModelsList struct {
 16	*list.List
 17	groups []ModelGroup
 18	query  string
 19	t      *styles.Styles
 20}
 21
 22// NewModelsList creates a new list suitable for model items and groups.
 23func NewModelsList(sty *styles.Styles, groups ...ModelGroup) *ModelsList {
 24	f := &ModelsList{
 25		List:   list.NewList(),
 26		groups: groups,
 27		t:      sty,
 28	}
 29	f.RegisterRenderCallback(list.FocusedRenderCallback(f.List))
 30	return f
 31}
 32
 33// Len returns the number of model items across all groups.
 34func (f *ModelsList) Len() int {
 35	n := 0
 36	for _, g := range f.groups {
 37		n += len(g.Items)
 38	}
 39	return n
 40}
 41
 42// SetGroups sets the model groups and updates the list items.
 43func (f *ModelsList) SetGroups(groups ...ModelGroup) {
 44	f.groups = groups
 45	items := []list.Item{}
 46	for _, g := range f.groups {
 47		items = append(items, &g)
 48		for _, item := range g.Items {
 49			items = append(items, item)
 50		}
 51		// Add a space separator after each provider section
 52		items = append(items, list.NewSpacerItem(1))
 53	}
 54	f.SetItems(items...)
 55}
 56
 57// SetFilter sets the filter query and updates the list items.
 58func (f *ModelsList) SetFilter(q string) {
 59	f.query = q
 60	f.SetItems(f.VisibleItems()...)
 61}
 62
 63// SetSelected sets the selected item index. It overrides the base method to
 64// skip non-model items.
 65func (f *ModelsList) SetSelected(index int) {
 66	if index < 0 || index >= f.Len() {
 67		f.List.SetSelected(index)
 68		return
 69	}
 70
 71	f.List.SetSelected(index)
 72	for {
 73		selectedItem := f.SelectedItem()
 74		if _, ok := selectedItem.(*ModelItem); ok {
 75			return
 76		}
 77		f.List.SetSelected(index + 1)
 78		index++
 79		if index >= f.Len() {
 80			return
 81		}
 82	}
 83}
 84
 85// SetSelectedItem sets the selected item in the list by item ID.
 86func (f *ModelsList) SetSelectedItem(itemID string) {
 87	if itemID == "" {
 88		return
 89	}
 90
 91	// Walk the selectable model items using the same helpers that
 92	// keyboard navigation uses, so we stay in sync with the flat
 93	// list layout.
 94	for ok := f.SelectFirst(); ok; ok = f.SelectNext() {
 95		if mi, is := f.SelectedItem().(*ModelItem); is && mi.ID() == itemID {
 96			return
 97		}
 98	}
 99}
100
101// SelectNext selects the next model item, skipping any non-focusable items
102// like group headers and spacers.
103func (f *ModelsList) SelectNext() (v bool) {
104	v = f.List.SelectNext()
105	for v {
106		selectedItem := f.SelectedItem()
107		if _, ok := selectedItem.(*ModelItem); ok {
108			return v
109		}
110		v = f.List.SelectNext()
111	}
112	return v
113}
114
115// SelectPrev selects the previous model item, skipping any non-focusable items
116// like group headers and spacers.
117func (f *ModelsList) SelectPrev() (v bool) {
118	v = f.List.SelectPrev()
119	for v {
120		selectedItem := f.SelectedItem()
121		if _, ok := selectedItem.(*ModelItem); ok {
122			return v
123		}
124		v = f.List.SelectPrev()
125	}
126	return v
127}
128
129// SelectFirst selects the first model item in the list.
130func (f *ModelsList) SelectFirst() (v bool) {
131	v = f.List.SelectFirst()
132	for v {
133		selectedItem := f.SelectedItem()
134		_, ok := selectedItem.(*ModelItem)
135		if ok {
136			return v
137		}
138		v = f.List.SelectNext()
139	}
140	return v
141}
142
143// SelectLast selects the last model item in the list.
144func (f *ModelsList) SelectLast() (v bool) {
145	v = f.List.SelectLast()
146	for v {
147		selectedItem := f.SelectedItem()
148		if _, ok := selectedItem.(*ModelItem); ok {
149			return v
150		}
151		v = f.List.SelectPrev()
152	}
153	return v
154}
155
156// IsSelectedFirst checks if the selected item is the first model item.
157func (f *ModelsList) IsSelectedFirst() bool {
158	originalIndex := f.Selected()
159	f.SelectFirst()
160	isFirst := f.Selected() == originalIndex
161	f.List.SetSelected(originalIndex)
162	return isFirst
163}
164
165// IsSelectedLast checks if the selected item is the last model item.
166func (f *ModelsList) IsSelectedLast() bool {
167	originalIndex := f.Selected()
168	f.SelectLast()
169	isLast := f.Selected() == originalIndex
170	f.List.SetSelected(originalIndex)
171	return isLast
172}
173
174// VisibleItems returns the visible items after filtering.
175func (f *ModelsList) VisibleItems() []list.Item {
176	query := strings.ToLower(strings.ReplaceAll(f.query, " ", ""))
177
178	if query == "" {
179		// No filter, return all items with group headers
180		items := []list.Item{}
181		for _, g := range f.groups {
182			items = append(items, &g)
183			for _, item := range g.Items {
184				item.SetMatch(fuzzy.Match{})
185				items = append(items, item)
186			}
187			// Add a space separator after each provider section
188			items = append(items, list.NewSpacerItem(1))
189		}
190		return items
191	}
192
193	filterableItems := make([]list.FilterableItem, 0, f.Len())
194	for _, g := range f.groups {
195		for _, item := range g.Items {
196			filterableItems = append(filterableItems, item)
197		}
198	}
199
200	items := []list.Item{}
201	visitedGroups := map[int]bool{}
202
203	// Reconstruct groups with matched items
204	// Find which group this item belongs to
205	for gi, g := range f.groups {
206		addedCount := 0
207		name := strings.ToLower(g.Title) + " "
208
209		names := make([]string, len(filterableItems))
210		for i, item := range filterableItems {
211			ms := item.(*ModelItem)
212			names[i] = fmt.Sprintf("%s%s", name, ms.Filter())
213		}
214
215		matches := fuzzy.Find(query, names)
216
217		// Sort by original index to preserve order within the group
218		sort.SliceStable(matches, func(i, j int) bool {
219			return matches[i].Index < matches[j].Index
220		})
221
222		for _, match := range matches {
223			item := filterableItems[match.Index].(*ModelItem)
224			idxs := []int{}
225			for _, idx := range match.MatchedIndexes {
226				// Adjusts removing provider name highlights
227				if idx < len(name) {
228					continue
229				}
230				idxs = append(idxs, idx-len(name))
231			}
232
233			match.MatchedIndexes = idxs
234			if slices.Contains(g.Items, item) {
235				if !visitedGroups[gi] {
236					// Add section header
237					items = append(items, &g)
238					visitedGroups[gi] = true
239				}
240				// Add the matched item
241				item.SetMatch(match)
242				items = append(items, item)
243				addedCount++
244			}
245		}
246		if addedCount > 0 {
247			// Add a space separator after each provider section
248			items = append(items, list.NewSpacerItem(1))
249		}
250	}
251
252	return items
253}
254
255// Render renders the filterable list.
256func (f *ModelsList) Render() string {
257	f.SetItems(f.VisibleItems()...)
258	return f.List.Render()
259}
260
261type modelGroups []ModelGroup
262
263func (m modelGroups) Len() int {
264	n := 0
265	for _, g := range m {
266		n += len(g.Items)
267	}
268	return n
269}
270
271func (m modelGroups) String(i int) string {
272	count := 0
273	for _, g := range m {
274		if i < count+len(g.Items) {
275			return g.Items[i-count].Filter()
276		}
277		count += len(g.Items)
278	}
279	return ""
280}