Skip to main content

gpui_component/searchable_list/
vec.rs

1use gpui::{App, SharedString, Task, Window};
2
3use crate::IndexPath;
4
5use super::delegate::{SearchableListDelegate, SearchableListItem};
6
7// MARK: Primitive impls
8
9impl SearchableListItem for String {
10    type Value = Self;
11
12    fn title(&self) -> SharedString {
13        SharedString::from(self.clone())
14    }
15
16    fn value(&self) -> &Self::Value {
17        self
18    }
19}
20
21impl SearchableListItem for SharedString {
22    type Value = Self;
23
24    fn title(&self) -> SharedString {
25        self.clone()
26    }
27
28    fn value(&self) -> &Self::Value {
29        self
30    }
31}
32
33impl SearchableListItem for &'static str {
34    type Value = Self;
35
36    fn title(&self) -> SharedString {
37        SharedString::from(*self)
38    }
39
40    fn value(&self) -> &Self::Value {
41        self
42    }
43}
44
45// MARK: Vec delegate
46
47impl<T: SearchableListItem + 'static> SearchableListDelegate for Vec<T> {
48    type Item = T;
49
50    fn items_count(&self, _: usize) -> usize {
51        self.len()
52    }
53
54    fn item(&self, ix: IndexPath) -> Option<&Self::Item> {
55        self.as_slice().get(ix.row)
56    }
57
58    fn position<V>(&self, value: &V) -> Option<IndexPath>
59    where
60        Self::Item: SearchableListItem<Value = V>,
61        V: PartialEq,
62    {
63        self.iter()
64            .position(|v| v.value() == value)
65            .map(|ix| IndexPath::default().row(ix))
66    }
67}
68
69// MARK: SearchableVec
70
71/// A vector of items that supports incremental filtering.
72///
73/// On each `perform_search` call the matched view is rebuilt by filtering the
74/// full `items` list. Use this as a delegate when all data is already in memory.
75#[derive(Debug, Clone)]
76pub struct SearchableVec<T> {
77    items: Vec<T>,
78    matched: Vec<Matched>,
79}
80
81/// An entry of the filtered view, pointing into `SearchableVec::items`.
82#[derive(Debug, Clone)]
83struct Matched {
84    ix: usize,
85    /// The matched rows of a group, or `None` to keep every row.
86    rows: Option<Vec<usize>>,
87}
88
89impl Matched {
90    fn all(ix: usize) -> Self {
91        Self { ix, rows: None }
92    }
93
94    /// The number of rows shown, out of `all` rows of the item.
95    fn len(&self, all: usize) -> usize {
96        self.rows.as_ref().map_or(all, Vec::len)
97    }
98
99    fn row(&self, row: usize) -> Option<usize> {
100        match &self.rows {
101            Some(rows) => rows.get(row).copied(),
102            None => Some(row),
103        }
104    }
105}
106
107impl<T> SearchableVec<T> {
108    fn from_items(items: Vec<T>) -> Self {
109        Self {
110            matched: (0..items.len()).map(Matched::all).collect(),
111            items,
112        }
113    }
114
115    fn matched_item(&self, ix: usize) -> Option<(&Matched, &T)> {
116        let matched = self.matched.get(ix)?;
117        Some((matched, self.items.get(matched.ix)?))
118    }
119
120    fn matched_items(&self) -> impl Iterator<Item = (&Matched, &T)> {
121        self.matched
122            .iter()
123            .filter_map(|matched| Some((matched, self.items.get(matched.ix)?)))
124    }
125}
126
127impl<T: Clone> SearchableVec<T> {
128    /// Create a new `SearchableVec` from an initial list of items.
129    pub fn new(items: impl Into<Vec<T>>) -> Self {
130        Self::from_items(items.into())
131    }
132
133    /// Append an item to both the master list and the current filtered view.
134    pub fn push(&mut self, item: T) {
135        self.matched.push(Matched::all(self.items.len()));
136        self.items.push(item);
137    }
138}
139
140impl<T: SearchableListItem> From<Vec<T>> for SearchableVec<T> {
141    fn from(items: Vec<T>) -> Self {
142        Self::from_items(items)
143    }
144}
145
146impl<I: SearchableListItem + 'static> SearchableListDelegate for SearchableVec<I> {
147    type Item = I;
148
149    fn items_count(&self, _: usize) -> usize {
150        self.matched.len()
151    }
152
153    fn item(&self, ix: IndexPath) -> Option<&Self::Item> {
154        self.matched_item(ix.row).map(|(_, item)| item)
155    }
156
157    fn position<V>(&self, value: &V) -> Option<IndexPath>
158    where
159        Self::Item: SearchableListItem<Value = V>,
160        V: PartialEq,
161    {
162        self.matched_items()
163            .position(|(_, v)| v.value() == value)
164            .map(|ix| IndexPath::default().row(ix))
165    }
166
167    fn perform_search(&mut self, query: &str, _: &mut Window, _: &mut App) -> Task<()> {
168        self.matched = self
169            .items
170            .iter()
171            .enumerate()
172            .filter(|(_, item)| item.matches(query))
173            .map(|(ix, _)| Matched::all(ix))
174            .collect();
175
176        Task::ready(())
177    }
178}
179
180// MARK: SearchableGroup
181
182/// A named group of items used for sectioned lists.
183#[derive(Debug, Clone)]
184pub struct SearchableGroup<I: SearchableListItem> {
185    pub title: SharedString,
186    pub items: Vec<I>,
187}
188
189impl<I: SearchableListItem> SearchableGroup<I> {
190    /// Create an empty group with the given section title.
191    pub fn new(title: impl Into<SharedString>) -> Self {
192        Self {
193            title: title.into(),
194            items: vec![],
195        }
196    }
197
198    /// Append a single item to this group.
199    pub fn item(mut self, item: I) -> Self {
200        self.items.push(item);
201        self
202    }
203
204    /// Append multiple items to this group.
205    pub fn items(mut self, items: impl IntoIterator<Item = I>) -> Self {
206        self.items.extend(items);
207        self
208    }
209
210    /// The rows matching `query`, or `None` when neither the title nor any
211    /// row matches and the whole group is hidden.
212    fn matched_rows(&self, query: &str) -> Option<Vec<usize>> {
213        let rows: Vec<usize> = self
214            .items
215            .iter()
216            .enumerate()
217            .filter(|(_, item)| item.matches(query))
218            .map(|(ix, _)| ix)
219            .collect();
220
221        (!rows.is_empty() || self.title.to_lowercase().contains(&query.to_lowercase()))
222            .then_some(rows)
223    }
224}
225
226impl<I: SearchableListItem + 'static> SearchableListDelegate for SearchableVec<SearchableGroup<I>> {
227    type Item = I;
228
229    fn sections_count(&self, _: &App) -> usize {
230        self.matched.len()
231    }
232
233    fn items_count(&self, section: usize) -> usize {
234        self.matched_item(section)
235            .map_or(0, |(matched, group)| matched.len(group.items.len()))
236    }
237
238    fn section(&self, section: usize) -> Option<gpui::AnyElement> {
239        use gpui::IntoElement as _;
240
241        let (_, group) = self.matched_item(section)?;
242        Some(group.title.clone().into_any_element())
243    }
244
245    fn item(&self, ix: IndexPath) -> Option<&Self::Item> {
246        let (matched, group) = self.matched_item(ix.section)?;
247
248        group.items.get(matched.row(ix.row)?)
249    }
250
251    fn position<V>(&self, value: &V) -> Option<IndexPath>
252    where
253        Self::Item: SearchableListItem<Value = V>,
254        V: PartialEq,
255    {
256        for (ix, (matched, group)) in self.matched_items().enumerate() {
257            for row_ix in 0..matched.len(group.items.len()) {
258                let item = matched.row(row_ix).and_then(|row| group.items.get(row));
259                if item.is_some_and(|item| item.value() == value) {
260                    return Some(IndexPath::default().section(ix).row(row_ix));
261                }
262            }
263        }
264
265        None
266    }
267
268    fn perform_search(&mut self, query: &str, _: &mut Window, _: &mut App) -> Task<()> {
269        self.matched = self
270            .items
271            .iter()
272            .enumerate()
273            .filter_map(|(ix, group)| {
274                let rows = group.matched_rows(query)?;
275                Some(Matched {
276                    ix,
277                    rows: Some(rows),
278                })
279            })
280            .collect();
281
282        Task::ready(())
283    }
284}
285
286#[cfg(test)]
287mod tests {
288    use gpui::TestAppContext;
289
290    use super::*;
291
292    #[gpui::test]
293    fn test_searchable_vec_maps_matched_rows_to_items(cx: &mut TestAppContext) {
294        let cx = cx.add_empty_window();
295        cx.update(|window, cx| {
296            let mut items = SearchableVec::new(vec!["Rust", "Go", "Ruby"]);
297            _ = items.perform_search("ru", window, cx);
298            assert_eq!(items.items_count(0), 2);
299            assert_eq!(items.item(IndexPath::new(1)), Some(&"Ruby"));
300            assert_eq!(items.position(&"Ruby"), Some(IndexPath::new(1)));
301            assert_eq!(items.position(&"Go"), None);
302
303            items.push("Rune");
304            assert_eq!(items.item(IndexPath::new(2)), Some(&"Rune"));
305
306            _ = items.perform_search("", window, cx);
307            assert_eq!(items.items_count(0), 4);
308        });
309    }
310
311    #[gpui::test]
312    fn test_searchable_group_keeps_matched_rows(cx: &mut TestAppContext) {
313        let cx = cx.add_empty_window();
314        cx.update(|window, cx| {
315            let mut groups = SearchableVec::new(vec![
316                SearchableGroup::new("Fruits").items(["Apple", "Banana"]),
317                SearchableGroup::new("Berries").items(["Blueberry", "Cranberry"]),
318                SearchableGroup::new("Nuts").items(["Almond"]),
319            ]);
320
321            _ = groups.perform_search("cran", window, cx);
322            assert_eq!(groups.sections_count(cx), 1);
323            assert_eq!(groups.items_count(0), 1);
324            assert_eq!(groups.item(IndexPath::new(0)), Some(&"Cranberry"));
325            assert_eq!(groups.position(&"Cranberry"), Some(IndexPath::new(0)));
326            assert_eq!(groups.position(&"Blueberry"), None);
327
328            // A title match keeps the section, with only its matching rows.
329            _ = groups.perform_search("nuts", window, cx);
330            assert_eq!(groups.sections_count(cx), 1);
331            assert_eq!(groups.items_count(0), 0);
332
333            _ = groups.perform_search("b", window, cx);
334            assert_eq!(groups.sections_count(cx), 2);
335            assert_eq!(groups.items_count(1), 2);
336            assert_eq!(
337                groups.position(&"Cranberry"),
338                Some(IndexPath::new(1).section(1))
339            );
340        });
341    }
342}