@@ -0,0 +1,260 @@
1+package list
2+
3+import (
4+ "regexp"
5+ "sort"
6+ "strings"
7+
8+ "github.com/charmbracelet/bubbles/v2/key"
9+ "github.com/charmbracelet/bubbles/v2/textinput"
10+ tea "github.com/charmbracelet/bubbletea/v2"
11+ "github.com/charmbracelet/crush/internal/tui/components/core/layout"
12+ "github.com/charmbracelet/crush/internal/tui/styles"
13+ "github.com/charmbracelet/lipgloss/v2"
14+ "github.com/sahilm/fuzzy"
15+)
16+
17+type FilterableGroupList[T FilterableItem] interface {
18+ GroupedList[T]
19+ Cursor() *tea.Cursor
20+ SetInputWidth(int)
21+ SetInputPlaceholder(string)
22+}
23+type filterableGroupList[T FilterableItem] struct {
24+ *groupedList[T]
25+ *filterableOptions
26+ width, height int
27+ groups []Group[T]
28+ // stores all available items
29+ input textinput.Model
30+ inputWidth int
31+ query string
32+}
33+
34+func NewFilterableGroupedList[T FilterableItem](items []Group[T], opts ...filterableListOption) FilterableGroupList[T] {
35+ t := styles.CurrentTheme()
36+
37+ f := &filterableGroupList[T]{
38+ filterableOptions: &filterableOptions{
39+ inputStyle: t.S().Base,
40+ placeholder: "Type to filter",
41+ },
42+ }
43+ for _, opt := range opts {
44+ opt(f.filterableOptions)
45+ }
46+ f.groupedList = NewGroupedList(items, f.listOptions...).(*groupedList[T])
47+
48+ f.updateKeyMaps()
49+
50+ if f.inputHidden {
51+ return f
52+ }
53+
54+ ti := textinput.New()
55+ ti.Placeholder = f.placeholder
56+ ti.SetVirtualCursor(false)
57+ ti.Focus()
58+ ti.SetStyles(t.S().TextInput)
59+ f.input = ti
60+ return f
61+}
62+
63+func (f *filterableGroupList[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
64+ switch msg := msg.(type) {
65+ case tea.KeyPressMsg:
66+ switch {
67+ // handle movements
68+ case key.Matches(msg, f.keyMap.Down),
69+ key.Matches(msg, f.keyMap.Up),
70+ key.Matches(msg, f.keyMap.DownOneItem),
71+ key.Matches(msg, f.keyMap.UpOneItem),
72+ key.Matches(msg, f.keyMap.HalfPageDown),
73+ key.Matches(msg, f.keyMap.HalfPageUp),
74+ key.Matches(msg, f.keyMap.PageDown),
75+ key.Matches(msg, f.keyMap.PageUp),
76+ key.Matches(msg, f.keyMap.End),
77+ key.Matches(msg, f.keyMap.Home):
78+ u, cmd := f.groupedList.Update(msg)
79+ f.groupedList = u.(*groupedList[T])
80+ return f, cmd
81+ default:
82+ if !f.inputHidden {
83+ var cmds []tea.Cmd
84+ var cmd tea.Cmd
85+ f.input, cmd = f.input.Update(msg)
86+ cmds = append(cmds, cmd)
87+
88+ if f.query != f.input.Value() {
89+ cmd = f.Filter(f.input.Value())
90+ cmds = append(cmds, cmd)
91+ }
92+ f.query = f.input.Value()
93+ return f, tea.Batch(cmds...)
94+ }
95+ }
96+ }
97+ u, cmd := f.groupedList.Update(msg)
98+ f.groupedList = u.(*groupedList[T])
99+ return f, cmd
100+}
101+
102+func (f *filterableGroupList[T]) View() string {
103+ if f.inputHidden {
104+ return f.groupedList.View()
105+ }
106+
107+ return lipgloss.JoinVertical(
108+ lipgloss.Left,
109+ f.inputStyle.Render(f.input.View()),
110+ f.groupedList.View(),
111+ )
112+}
113+
114+// removes bindings that are used for search
115+func (f *filterableGroupList[T]) updateKeyMaps() {
116+ alphanumeric := regexp.MustCompile("^[a-zA-Z0-9]*$")
117+
118+ removeLettersAndNumbers := func(bindings []string) []string {
119+ var keep []string
120+ for _, b := range bindings {
121+ if len(b) != 1 {
122+ keep = append(keep, b)
123+ continue
124+ }
125+ if b == " " {
126+ continue
127+ }
128+ m := alphanumeric.MatchString(b)
129+ if !m {
130+ keep = append(keep, b)
131+ }
132+ }
133+ return keep
134+ }
135+
136+ updateBinding := func(binding key.Binding) key.Binding {
137+ newKeys := removeLettersAndNumbers(binding.Keys())
138+ if len(newKeys) == 0 {
139+ binding.SetEnabled(false)
140+ return binding
141+ }
142+ binding.SetKeys(newKeys...)
143+ return binding
144+ }
145+
146+ f.keyMap.Down = updateBinding(f.keyMap.Down)
147+ f.keyMap.Up = updateBinding(f.keyMap.Up)
148+ f.keyMap.DownOneItem = updateBinding(f.keyMap.DownOneItem)
149+ f.keyMap.UpOneItem = updateBinding(f.keyMap.UpOneItem)
150+ f.keyMap.HalfPageDown = updateBinding(f.keyMap.HalfPageDown)
151+ f.keyMap.HalfPageUp = updateBinding(f.keyMap.HalfPageUp)
152+ f.keyMap.PageDown = updateBinding(f.keyMap.PageDown)
153+ f.keyMap.PageUp = updateBinding(f.keyMap.PageUp)
154+ f.keyMap.End = updateBinding(f.keyMap.End)
155+ f.keyMap.Home = updateBinding(f.keyMap.Home)
156+}
157+
158+func (m *filterableGroupList[T]) GetSize() (int, int) {
159+ return m.width, m.height
160+}
161+
162+func (f *filterableGroupList[T]) SetSize(w, h int) tea.Cmd {
163+ f.width = w
164+ f.height = h
165+ if f.inputHidden {
166+ return f.groupedList.SetSize(w, h)
167+ }
168+ if f.inputWidth == 0 {
169+ f.input.SetWidth(w)
170+ } else {
171+ f.input.SetWidth(f.inputWidth)
172+ }
173+ return f.groupedList.SetSize(w, h-(f.inputHeight()))
174+}
175+
176+func (f *filterableGroupList[T]) inputHeight() int {
177+ return lipgloss.Height(f.inputStyle.Render(f.input.View()))
178+}
179+
180+func (f *filterableGroupList[T]) Filter(query string) tea.Cmd {
181+ var cmds []tea.Cmd
182+ for _, item := range f.items {
183+ if i, ok := any(item).(layout.Focusable); ok {
184+ cmds = append(cmds, i.Blur())
185+ }
186+ if i, ok := any(item).(HasMatchIndexes); ok {
187+ i.MatchIndexes(make([]int, 0))
188+ }
189+ }
190+
191+ f.selectedItem = ""
192+ if query == "" {
193+ return f.groupedList.SetGroups(f.groups)
194+ }
195+
196+ var newGroups []Group[T]
197+ for _, g := range f.groups {
198+ words := make([]string, len(g.Items))
199+ for i, item := range g.Items {
200+ words[i] = strings.ToLower(item.FilterValue())
201+ }
202+
203+ matches := fuzzy.Find(query, words)
204+
205+ sort.SliceStable(matches, func(i, j int) bool {
206+ return matches[i].Score > matches[j].Score
207+ })
208+
209+ var matchedItems []T
210+ for _, match := range matches {
211+ item := g.Items[match.Index]
212+ if i, ok := any(item).(HasMatchIndexes); ok {
213+ i.MatchIndexes(match.MatchedIndexes)
214+ }
215+ matchedItems = append(matchedItems, item)
216+ }
217+ if len(matchedItems) > 0 {
218+ newGroups = append(newGroups, Group[T]{
219+ Section: g.Section,
220+ Items: matchedItems,
221+ })
222+ }
223+ }
224+ cmds = append(cmds, f.groupedList.SetGroups(newGroups))
225+ return tea.Batch(cmds...)
226+}
227+
228+func (f *filterableGroupList[T]) SetGroups(groups []Group[T]) tea.Cmd {
229+ f.groups = groups
230+ return f.groupedList.SetGroups(groups)
231+}
232+
233+func (f *filterableGroupList[T]) Cursor() *tea.Cursor {
234+ if f.inputHidden {
235+ return nil
236+ }
237+ return f.input.Cursor()
238+}
239+
240+func (f *filterableGroupList[T]) Blur() tea.Cmd {
241+ f.input.Blur()
242+ return f.groupedList.Blur()
243+}
244+
245+func (f *filterableGroupList[T]) Focus() tea.Cmd {
246+ f.input.Focus()
247+ return f.groupedList.Focus()
248+}
249+
250+func (f *filterableGroupList[T]) IsFocused() bool {
251+ return f.groupedList.IsFocused()
252+}
253+
254+func (f *filterableGroupList[T]) SetInputWidth(w int) {
255+ f.inputWidth = w
256+}
257+
258+func (f *filterableGroupList[T]) SetInputPlaceholder(ph string) {
259+ f.placeholder = ph
260+}