Skip to main content

gpui_cn/select/
item.rs

1//! The rows of a select menu: items, group labels, and separators, and
2//! the filter a search query applies to them.
3
4use std::sync::Arc;
5
6use gpui_kit::{AnyElement, App, IntoElement as _, SharedString, Window};
7
8use crate::Icon;
9
10/// Shared and thread-safe, so a search can build rows on a background
11/// thread and send them back.
12type LeadingBuilder = Arc<dyn Fn(&mut Window, &mut App) -> AnyElement + Send + Sync>;
13
14/// What goes before an item's label.
15#[derive(Clone)]
16enum Leading {
17    /// An icon, in the row's text color. Boxed: an icon carries a whole
18    /// style refinement, and a row is cloned as it renders.
19    Icon(Box<Icon>),
20    /// Any element, built each frame.
21    Element(LeadingBuilder),
22}
23
24/// The value of a [`SelectItem`]: the item's identity in the menu.
25///
26/// The key names the row's element id, so it is stable across frames and
27/// unique in the menu. Strings and integers are values already; an
28/// application's own enum or record implements this with its id.
29///
30/// ```
31/// use gpui_cn::SelectValue;
32/// use gpui_kit::SharedString;
33///
34/// #[derive(Clone, PartialEq)]
35/// enum Effort {
36///     Low,
37///     High,
38/// }
39///
40/// impl SelectValue for Effort {
41///     fn key(&self) -> SharedString {
42///         match self {
43///             Self::Low => "low".into(),
44///             Self::High => "high".into(),
45///         }
46///     }
47/// }
48/// ```
49pub trait SelectValue: Clone + PartialEq + 'static {
50    /// The stable name of the value's row.
51    fn key(&self) -> SharedString;
52}
53
54impl SelectValue for SharedString {
55    fn key(&self) -> SharedString {
56        self.clone()
57    }
58}
59
60impl SelectValue for String {
61    fn key(&self) -> SharedString {
62        self.clone().into()
63    }
64}
65
66impl SelectValue for &'static str {
67    fn key(&self) -> SharedString {
68        (*self).into()
69    }
70}
71
72macro_rules! integer_values {
73    ($($int:ty),*) => {
74        $(
75            impl SelectValue for $int {
76                fn key(&self) -> SharedString {
77                    self.to_string().into()
78                }
79            }
80        )*
81    };
82}
83
84integer_values!(usize, u32, u64, i32, i64);
85
86/// One choice in a [`Select`](crate::Select) menu.
87///
88/// The value is the item's identity: it keys the row's element id, the
89/// selection, and the events. The label is what the row and the trigger
90/// show. An item can carry a description under its label, an icon or any
91/// element before it, extra words the search matches, and a disabled
92/// state.
93///
94/// ```
95/// use gpui_cn::{SelectItem, gpui_kit::assets::IconName};
96///
97/// let _ = SelectItem::new("finder", "Finder").icon(IconName::Folder);
98/// let _ = SelectItem::new("never", "Never ask for approval")
99///     .description("Blocked actions fail instead of requesting approval");
100/// let _ = SelectItem::new("de", "Deutsch").keywords(["german"]);
101/// let _ = SelectItem::new(42, "Item 42");
102/// ```
103#[derive(Clone)]
104pub struct SelectItem<V> {
105    value: V,
106    label: SharedString,
107    description: Option<SharedString>,
108    leading: Option<Leading>,
109    /// Words the query is looked for in besides the label.
110    keywords: Vec<SharedString>,
111    disabled: bool,
112}
113
114impl<V: SelectValue> std::fmt::Debug for SelectItem<V> {
115    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
116        f.debug_struct("SelectItem")
117            .field("key", &self.value.key())
118            .field("label", &self.label)
119            .field("description", &self.description)
120            .field("disabled", &self.disabled)
121            .finish_non_exhaustive()
122    }
123}
124
125impl<V: SelectValue> SelectItem<V> {
126    /// An item with a stable value and the label it shows.
127    pub fn new(value: V, label: impl Into<SharedString>) -> Self {
128        let label = label.into();
129        Self {
130            value,
131            keywords: Vec::new(),
132            label,
133            description: None,
134            leading: None,
135            disabled: false,
136        }
137    }
138
139    /// A second line under the label, in the muted color.
140    pub fn description(mut self, description: impl Into<SharedString>) -> Self {
141        self.description = Some(description.into());
142        self
143    }
144
145    /// An icon before the label, at the icon size, in the row's text
146    /// color. The trigger shows the selected item's icon too.
147    pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
148        self.leading = Some(Leading::Icon(Box::new(icon.into())));
149        self
150    }
151
152    /// Any element before the label, built each frame: a color swatch, an
153    /// avatar, an application icon. It replaces the icon in the row; the
154    /// trigger shows it too. The builder is `Send + Sync` so rows can come
155    /// from a search on another thread.
156    pub fn leading<E: gpui_kit::IntoElement>(
157        mut self,
158        build: impl Fn(&mut Window, &mut App) -> E + Send + Sync + 'static,
159    ) -> Self {
160        self.leading = Some(Leading::Element(Arc::new(move |window, cx| {
161            build(window, cx).into_any_element()
162        })));
163        self
164    }
165
166    /// Words the search matches besides the label, such as a language's
167    /// English name beside its own.
168    pub fn keywords(mut self, keywords: impl IntoIterator<Item = impl Into<SharedString>>) -> Self {
169        for keyword in keywords {
170            self.keywords.push(keyword.into());
171        }
172        self
173    }
174
175    /// Whether the item can be chosen. A disabled item shows in the muted
176    /// color and the keyboard skips it.
177    pub fn disabled(mut self, disabled: bool) -> Self {
178        self.disabled = disabled;
179        self
180    }
181
182    /// The item's identity.
183    pub fn value(&self) -> &V {
184        &self.value
185    }
186
187    /// The stable name of the item's row: its value's key.
188    pub fn key(&self) -> SharedString {
189        self.value.key()
190    }
191
192    /// The text the row and the trigger show.
193    pub fn label(&self) -> &SharedString {
194        &self.label
195    }
196
197    /// The line under the label, if any.
198    pub fn description_text(&self) -> Option<&SharedString> {
199        self.description.as_ref()
200    }
201
202    /// The icon before the label, if the leading element is one.
203    pub fn icon_source(&self) -> Option<&Icon> {
204        match &self.leading {
205            Some(Leading::Icon(icon)) => Some(icon),
206            _ => None,
207        }
208    }
209
210    /// Whether the item can be chosen.
211    pub fn is_disabled(&self) -> bool {
212        self.disabled
213    }
214
215    /// The element before the label: the custom one, else the icon.
216    pub(crate) fn render_leading(&self, window: &mut Window, cx: &mut App) -> Option<AnyElement> {
217        match self.leading.as_ref()? {
218            Leading::Icon(icon) => Some((**icon).clone().into_any_element()),
219            Leading::Element(build) => Some(build(window, cx)),
220        }
221    }
222
223    /// Whether the query matches the label or a keyword, as a fuzzy
224    /// subsequence ignoring case. An empty query matches everything.
225    pub fn matches(&self, query: &str) -> bool {
226        self.match_score(query).is_some()
227    }
228
229    /// How well the query matches: the label's score, or half the best
230    /// keyword's score, since a keyword is a weaker hit. `None` for no
231    /// match, and 0 for an empty query.
232    pub fn match_score(&self, query: &str) -> Option<u32> {
233        let label = crate::fuzzy::score_of(query, &self.label);
234        let keyword = self
235            .keywords
236            .iter()
237            .filter_map(|keyword| crate::fuzzy::score_of(query, keyword))
238            .max()
239            .map(|score| score / 2);
240        label.max(keyword)
241    }
242}
243
244/// A row of a select menu.
245#[derive(Clone, Debug)]
246pub enum SelectEntry<V: SelectValue> {
247    /// A choice.
248    Item(SelectItem<V>),
249    /// A heading over the items that follow it, in the muted color.
250    Label(SharedString),
251    /// A hairline between two groups.
252    Separator,
253}
254
255impl<V: SelectValue> SelectEntry<V> {
256    /// A heading over the items that follow it.
257    pub fn label(text: impl Into<SharedString>) -> Self {
258        Self::Label(text.into())
259    }
260
261    /// The item, if this row is one.
262    pub fn item(&self) -> Option<&SelectItem<V>> {
263        match self {
264            Self::Item(item) => Some(item),
265            _ => None,
266        }
267    }
268}
269
270impl<V: SelectValue> From<SelectItem<V>> for SelectEntry<V> {
271    fn from(item: SelectItem<V>) -> Self {
272        Self::Item(item)
273    }
274}
275
276/// The rows that show for a query: every item that matches, each label
277/// that still heads an item, and each separator that still sits between
278/// two rows. Returns indices into `entries`.
279pub(crate) fn visible_entries<V: SelectValue>(
280    entries: &[SelectEntry<V>],
281    query: &str,
282) -> Vec<usize> {
283    let query = query.trim();
284    let mut visible = Vec::with_capacity(entries.len());
285    let mut pending_label: Option<usize> = None;
286    let mut pending_separator: Option<usize> = None;
287    let mut group: Vec<(usize, u32)> = Vec::new();
288    // A group's matches go in best first, ties in the order given, under
289    // its label, with a separator only between two groups that show.
290    let flush = |group: &mut Vec<(usize, u32)>,
291                 visible: &mut Vec<usize>,
292                 pending_separator: &mut Option<usize>,
293                 pending_label: &mut Option<usize>| {
294        if group.is_empty() {
295            return;
296        }
297        if let Some(separator) = pending_separator.take()
298            && !visible.is_empty()
299        {
300            visible.push(separator);
301        }
302        if let Some(label) = pending_label.take() {
303            visible.push(label);
304        }
305        group.sort_by_key(|(_, score)| std::cmp::Reverse(*score));
306        visible.extend(group.drain(..).map(|(index, _)| index));
307    };
308    for (index, entry) in entries.iter().enumerate() {
309        match entry {
310            SelectEntry::Item(item) => {
311                if let Some(score) = item.match_score(query) {
312                    group.push((index, score));
313                }
314            }
315            SelectEntry::Label(_) => {
316                flush(
317                    &mut group,
318                    &mut visible,
319                    &mut pending_separator,
320                    &mut pending_label,
321                );
322                pending_label = Some(index);
323            }
324            SelectEntry::Separator => {
325                flush(
326                    &mut group,
327                    &mut visible,
328                    &mut pending_separator,
329                    &mut pending_label,
330                );
331                pending_label = None;
332                pending_separator = Some(index);
333            }
334        }
335    }
336    flush(
337        &mut group,
338        &mut visible,
339        &mut pending_separator,
340        &mut pending_label,
341    );
342    visible
343}
344
345#[cfg(test)]
346mod tests {
347    use super::*;
348
349    fn entries() -> Vec<SelectEntry<&'static str>> {
350        vec![
351            SelectEntry::label("Type"),
352            SelectItem::new("all", "All chats").into(),
353            SelectItem::new("local", "Local").into(),
354            SelectEntry::Separator,
355            SelectEntry::label("Sort by"),
356            SelectItem::new("updated", "Updated").into(),
357            SelectItem::new("created", "Created")
358                .keywords(["time"])
359                .into(),
360        ]
361    }
362
363    #[test]
364    fn matching_is_fuzzy_ignores_case_and_reads_keywords() {
365        let item = SelectItem::new("de", "Deutsch").keywords(["German"]);
366        assert!(item.matches(""));
367        assert!(item.matches("  "));
368        assert!(item.matches("deu"));
369        assert!(item.matches("GERM"));
370        assert!(item.matches("dtsh"));
371        assert!(item.matches("grmn"));
372        assert!(!item.matches("french"));
373    }
374
375    #[test]
376    fn a_query_ranks_the_rows_of_a_group_best_first() {
377        let entries: Vec<SelectEntry<&'static str>> = vec![
378            SelectEntry::label("Language"),
379            SelectItem::new("de", "Deutsch").keywords(["German"]).into(),
380            SelectItem::new("fr", "French").into(),
381            SelectItem::new("en", "English").into(),
382            SelectEntry::Separator,
383            SelectEntry::label("Other"),
384            SelectItem::new("x", "Zen").into(),
385            SelectItem::new("y", "Enigma").into(),
386        ];
387        let labels = |query: &str| -> Vec<String> {
388            visible_entries(&entries, query)
389                .into_iter()
390                .map(|index| match &entries[index] {
391                    SelectEntry::Item(item) => item.label().to_string(),
392                    SelectEntry::Label(text) => format!("# {text}"),
393                    SelectEntry::Separator => "--".to_string(),
394                })
395                .collect()
396        };
397        assert_eq!(
398            labels("en"),
399            [
400                "# Language",
401                "English",
402                "French",
403                "Deutsch",
404                "--",
405                "# Other",
406                "Enigma",
407                "Zen"
408            ],
409            "label hits beat the keyword hit, groups stay"
410        );
411        assert_eq!(
412            labels("").len(),
413            entries.len(),
414            "an empty query keeps the order"
415        );
416        assert_eq!(
417            visible_entries(&entries, ""),
418            (0..entries.len()).collect::<Vec<_>>()
419        );
420    }
421
422    #[test]
423    fn an_empty_query_shows_every_row() {
424        assert_eq!(visible_entries(&entries(), ""), vec![0, 1, 2, 3, 4, 5, 6]);
425    }
426
427    #[test]
428    fn a_query_keeps_only_the_labels_and_separators_that_still_frame_an_item() {
429        let entries = entries();
430        assert_eq!(visible_entries(&entries, "local"), vec![0, 2]);
431        assert_eq!(visible_entries(&entries, "time"), vec![4, 6]);
432        assert_eq!(visible_entries(&entries, "ated"), vec![4, 5, 6]);
433        assert_eq!(
434            visible_entries(&entries, "l"),
435            vec![0, 2, 1],
436            "Local starts with the letter, so it ranks above All chats"
437        );
438        assert_eq!(
439            visible_entries(&entries, " LOC "),
440            vec![0, 2],
441            "case and space"
442        );
443        assert_eq!(visible_entries(&entries, "zzz"), Vec::<usize>::new());
444    }
445
446    #[test]
447    fn a_separator_never_leads_or_trails() {
448        let entries: Vec<SelectEntry<&'static str>> = vec![
449            SelectEntry::Separator,
450            SelectItem::new("a", "A").into(),
451            SelectEntry::Separator,
452            SelectEntry::Separator,
453            SelectItem::new("b", "B").into(),
454            SelectEntry::Separator,
455        ];
456        assert_eq!(visible_entries(&entries, ""), vec![1, 3, 4]);
457    }
458
459    #[test]
460    fn rows_can_cross_threads() {
461        fn assert_send<T: Send>(_: &T) {}
462        let entries: Vec<SelectEntry<usize>> = vec![
463            SelectItem::new(1, "One")
464                .leading(|_, _| gpui_kit::div())
465                .into(),
466            SelectEntry::Separator,
467        ];
468        assert_send(&entries);
469    }
470
471    #[test]
472    fn readers_read_back() {
473        let item = SelectItem::new("v", "Label")
474            .description("More")
475            .disabled(true);
476        assert_eq!(*item.value(), "v");
477        assert_eq!(item.key(), "v");
478        assert_eq!(item.label(), "Label");
479        assert_eq!(item.description_text().map(|d| d.as_ref()), Some("More"));
480        assert!(item.is_disabled());
481        assert!(item.icon_source().is_none());
482        assert!(SelectEntry::from(item.clone()).item().is_some());
483        assert!(SelectEntry::<&str>::Separator.item().is_none());
484        assert!(format!("{item:?}").contains("Label"));
485        assert_eq!(7usize.key(), "7");
486        assert_eq!(String::from("s").key(), "s");
487    }
488}