Skip to main content

gpui_component/command/
item.rs

1use std::rc::Rc;
2
3use gpui::{Action, AnyElement, App, IntoElement, SharedString, Window};
4
5use crate::{Disableable, Icon};
6
7/// A single command in a [`crate::command::Command`] palette.
8///
9pub struct CommandItem {
10    label: Option<SharedString>,
11    keywords: Vec<SharedString>,
12    /// Boxed: an [`Icon`] carries a whole `StyleRefinement`, which would make
13    /// every item — and so the palette's item vector — kilobytes wide.
14    pub(crate) icon: Option<Box<Icon>>,
15    pub(crate) action: Option<Box<dyn Action>>,
16    pub(crate) checked: bool,
17    disabled: bool,
18    pub(crate) content: Option<Rc<CommandItemContent>>,
19}
20
21impl Clone for CommandItem {
22    fn clone(&self) -> Self {
23        Self {
24            label: self.label.clone(),
25            keywords: self.keywords.clone(),
26            icon: self.icon.clone(),
27            action: self.action.as_ref().map(|action| action.boxed_clone()),
28            checked: self.checked,
29            disabled: self.disabled,
30            content: self.content.clone(),
31        }
32    }
33}
34
35impl CommandItem {
36    /// Create an empty command item.
37    pub fn new() -> Self {
38        Self {
39            label: None,
40            keywords: Vec::new(),
41            icon: None,
42            action: None,
43            checked: false,
44            disabled: false,
45            content: None,
46        }
47    }
48
49    /// Set the label to display and search.
50    pub fn label(mut self, label: impl Into<SharedString>) -> Self {
51        self.label = Some(label.into());
52        self
53    }
54
55    /// Set the leading icon.
56    pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
57        self.icon = Some(Box::new(icon.into()));
58        self
59    }
60
61    /// Set the Action dispatched when this item is clicked or confirmed.
62    ///
63    /// The Action's active keybinding is also shown by the default row.
64    pub fn action(mut self, action: Box<dyn Action>) -> Self {
65        self.action = Some(action);
66        self
67    }
68
69    /// Mark this item as the chosen one, drawing a check at the right end of
70    /// the row.
71    ///
72    /// A resolved Action binding takes that slot, so an item with one shows no
73    /// check.
74    pub fn checked(mut self, checked: bool) -> Self {
75        self.checked = checked;
76        self
77    }
78
79    /// Add extra terms the search matches against, besides the label.
80    pub fn keywords<I, S>(mut self, keywords: I) -> Self
81    where
82        I: IntoIterator<Item = S>,
83        S: Into<SharedString>,
84    {
85        self.keywords
86            .extend(keywords.into_iter().map(|keyword| keyword.into()));
87        self
88    }
89
90    /// Replace the row content (icon and label) with a lazily built child.
91    ///
92    /// The builder may run more than once for measurement and rendering, so it
93    /// must be side-effect-free. Custom children own their complete visual
94    /// presentation, including any keybinding hint.
95    pub fn child<F, E>(mut self, builder: F) -> Self
96    where
97        F: Fn(&mut Window, &mut App) -> E + 'static,
98        E: IntoElement,
99    {
100        self.content = Some(Rc::new(move |window, cx| {
101            builder(window, cx).into_any_element()
102        }));
103        self
104    }
105
106    /// Whether this item is non-interactive.
107    pub(crate) fn is_disabled(&self) -> bool {
108        self.disabled
109    }
110
111    /// Whether this item matches the search query, ignoring case.
112    ///
113    /// An empty query matches everything.
114    pub(crate) fn matches(&self, query: &str) -> bool {
115        if query.is_empty() {
116            return true;
117        }
118
119        let query = query.to_lowercase();
120
121        self.label
122            .as_ref()
123            .is_some_and(|label| label.to_lowercase().contains(&query))
124            || self
125                .keywords
126                .iter()
127                .any(|keyword| keyword.to_lowercase().contains(&query))
128    }
129
130    pub(crate) fn label_text(&self) -> Option<&SharedString> {
131        self.label.as_ref()
132    }
133}
134
135impl Default for CommandItem {
136    fn default() -> Self {
137        Self::new()
138    }
139}
140
141pub(crate) type CommandItemContent = dyn Fn(&mut Window, &mut App) -> AnyElement;
142
143impl Disableable for CommandItem {
144    fn disabled(mut self, disabled: bool) -> Self {
145        self.disabled = disabled;
146        self
147    }
148}
149
150/// A titled section of [`CommandItem`]s.
151///
152/// The heading is hidden while every item in the group is filtered out.
153pub struct CommandGroup {
154    heading: Option<SharedString>,
155    pub(crate) items: Vec<CommandItem>,
156}
157
158impl Clone for CommandGroup {
159    fn clone(&self) -> Self {
160        Self {
161            heading: self.heading.clone(),
162            items: self.items.clone(),
163        }
164    }
165}
166
167impl CommandGroup {
168    /// Create a new group without a label.
169    pub fn new() -> Self {
170        Self {
171            heading: None,
172            items: Vec::new(),
173        }
174    }
175
176    /// Set the label displayed above the group's items.
177    pub fn label(mut self, label: impl Into<SharedString>) -> Self {
178        self.heading = Some(label.into());
179        self
180    }
181
182    /// Add an item to the group.
183    pub fn item(mut self, item: CommandItem) -> Self {
184        self.items.push(item);
185        self
186    }
187
188    /// Add multiple items to the group.
189    pub fn items(mut self, items: impl IntoIterator<Item = CommandItem>) -> Self {
190        self.items.extend(items);
191        self
192    }
193
194    /// The heading of the group, when it has one.
195    pub fn heading(&self) -> Option<&SharedString> {
196        self.heading.as_ref()
197    }
198}
199
200/// A top-level entry in a [`crate::command::Command`].
201pub enum CommandEntry {
202    /// A single ungrouped item.
203    Item(CommandItem),
204    /// A titled group of items.
205    Group(CommandGroup),
206    /// A divider between groups.
207    ///
208    /// A separator that ends up leading, trailing, or next to another
209    /// separator once the query has filtered the list is not rendered.
210    Separator,
211}
212
213impl Clone for CommandEntry {
214    fn clone(&self) -> Self {
215        match self {
216            Self::Item(item) => Self::Item(item.clone()),
217            Self::Group(group) => Self::Group(group.clone()),
218            Self::Separator => Self::Separator,
219        }
220    }
221}
222
223impl From<CommandItem> for CommandEntry {
224    fn from(item: CommandItem) -> Self {
225        Self::Item(item)
226    }
227}
228
229impl From<CommandGroup> for CommandEntry {
230    fn from(group: CommandGroup) -> Self {
231        Self::Group(group)
232    }
233}
234
235#[cfg(test)]
236mod tests {
237    use std::{cell::Cell, rc::Rc};
238
239    use gpui::{TestAppContext, actions, div};
240
241    use super::*;
242
243    actions!(command_item_test, [CloneAction]);
244
245    #[gpui::test]
246    fn cloned_entries_keep_actions_and_lazy_children_usable(cx: &mut TestAppContext) {
247        let action_count = Rc::new(Cell::new(0));
248        let child_count = Rc::new(Cell::new(0));
249        let action_count_for_handler = action_count.clone();
250        cx.update(|cx| {
251            cx.on_action(move |_: &CloneAction, _| {
252                action_count_for_handler.set(action_count_for_handler.get() + 1);
253            });
254        });
255
256        let child_count_for_builder = child_count.clone();
257        let entry = CommandEntry::Group(
258            CommandGroup::new().label("Group").item(
259                CommandItem::new()
260                    .label("cloneable")
261                    .action(Box::new(CloneAction))
262                    .child(move |_, _| {
263                        child_count_for_builder.set(child_count_for_builder.get() + 1);
264                        div()
265                    }),
266            ),
267        );
268        let cloned = entry.clone();
269        let CommandEntry::Group(group) = cloned else {
270            panic!("the cloned entry should remain a group");
271        };
272        let cloned_item = group.items.into_iter().next().unwrap();
273
274        let cx = cx.add_empty_window();
275        cx.update(|window, cx| {
276            let child = cloned_item.content.as_ref().unwrap().clone();
277            _ = child(window, cx);
278            window.dispatch_action(cloned_item.action.as_ref().unwrap().boxed_clone(), cx);
279        });
280
281        assert_eq!(child_count.get(), 1);
282        assert_eq!(action_count.get(), 1);
283    }
284
285    #[test]
286    fn label_is_optional_for_custom_content() {
287        assert_eq!(CommandItem::new().label_text(), None);
288        assert_eq!(
289            CommandItem::new().label("Calendar").label_text(),
290            Some(&"Calendar".into())
291        );
292    }
293
294    #[test]
295    fn matches_label_and_keywords() {
296        let item = CommandItem::new()
297            .label("Profile")
298            .keywords(["account", "user"]);
299
300        assert!(item.matches(""));
301        assert!(item.matches("PRO"));
302        assert!(item.matches("Account"));
303        assert!(!item.matches("billing"));
304    }
305}