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    /// Whether `other` filters and measures exactly like this item, so a
135    /// palette can keep its rows and row sizes when a host re-render rebuilds
136    /// an unchanged model.
137    ///
138    /// An item with a custom child never counts as unchanged, even when it is
139    /// a clone sharing the same closure: the child can read state outside the
140    /// item, so only laying it out again tells whether its height changed.
141    pub(crate) fn same_layout(&self, other: &Self) -> bool {
142        self.content.is_none()
143            && other.content.is_none()
144            && self.label == other.label
145            && self.keywords == other.keywords
146            && match (&self.icon, &other.icon) {
147                (Some(icon), Some(other)) => icon.same_layout(other),
148                (None, None) => true,
149                _ => false,
150            }
151            && self.checked == other.checked
152            && self.disabled == other.disabled
153            && match (&self.action, &other.action) {
154                (Some(action), Some(other)) => action.partial_eq(other.as_ref()),
155                (None, None) => true,
156                _ => false,
157            }
158    }
159}
160
161impl Default for CommandItem {
162    fn default() -> Self {
163        Self::new()
164    }
165}
166
167pub(crate) type CommandItemContent = dyn Fn(&mut Window, &mut App) -> AnyElement;
168
169impl Disableable for CommandItem {
170    fn disabled(mut self, disabled: bool) -> Self {
171        self.disabled = disabled;
172        self
173    }
174}
175
176/// A titled section of [`CommandItem`]s.
177///
178/// The heading is hidden while every item in the group is filtered out.
179pub struct CommandGroup {
180    heading: Option<SharedString>,
181    pub(crate) items: Vec<CommandItem>,
182}
183
184impl Clone for CommandGroup {
185    fn clone(&self) -> Self {
186        Self {
187            heading: self.heading.clone(),
188            items: self.items.clone(),
189        }
190    }
191}
192
193impl CommandGroup {
194    /// Create a new group without a label.
195    pub fn new() -> Self {
196        Self {
197            heading: None,
198            items: Vec::new(),
199        }
200    }
201
202    /// Set the label displayed above the group's items.
203    pub fn label(mut self, label: impl Into<SharedString>) -> Self {
204        self.heading = Some(label.into());
205        self
206    }
207
208    /// Add an item to the group.
209    pub fn item(mut self, item: CommandItem) -> Self {
210        self.items.push(item);
211        self
212    }
213
214    /// Add multiple items to the group.
215    pub fn items(mut self, items: impl IntoIterator<Item = CommandItem>) -> Self {
216        self.items.extend(items);
217        self
218    }
219
220    /// The heading of the group, when it has one.
221    pub fn heading(&self) -> Option<&SharedString> {
222        self.heading.as_ref()
223    }
224
225    fn same_layout(&self, other: &Self) -> bool {
226        self.heading == other.heading
227            && self.items.len() == other.items.len()
228            && self
229                .items
230                .iter()
231                .zip(&other.items)
232                .all(|(item, other)| item.same_layout(other))
233    }
234}
235
236/// A top-level entry in a [`crate::command::Command`].
237pub enum CommandEntry {
238    /// A single ungrouped item.
239    Item(CommandItem),
240    /// A titled group of items.
241    Group(CommandGroup),
242    /// A divider between groups.
243    ///
244    /// A separator that ends up leading, trailing, or next to another
245    /// separator once the query has filtered the list is not rendered.
246    Separator,
247}
248
249impl CommandEntry {
250    /// Whether `other` produces the same rows and row sizes as this entry for
251    /// any query. See [`CommandItem::same_layout`].
252    pub(crate) fn same_layout(&self, other: &Self) -> bool {
253        match (self, other) {
254            (Self::Item(item), Self::Item(other)) => item.same_layout(other),
255            (Self::Group(group), Self::Group(other)) => group.same_layout(other),
256            (Self::Separator, Self::Separator) => true,
257            _ => false,
258        }
259    }
260}
261
262impl Clone for CommandEntry {
263    fn clone(&self) -> Self {
264        match self {
265            Self::Item(item) => Self::Item(item.clone()),
266            Self::Group(group) => Self::Group(group.clone()),
267            Self::Separator => Self::Separator,
268        }
269    }
270}
271
272impl From<CommandItem> for CommandEntry {
273    fn from(item: CommandItem) -> Self {
274        Self::Item(item)
275    }
276}
277
278impl From<CommandGroup> for CommandEntry {
279    fn from(group: CommandGroup) -> Self {
280        Self::Group(group)
281    }
282}
283
284#[cfg(test)]
285mod tests {
286    use std::{cell::Cell, rc::Rc};
287
288    use gpui::{TestAppContext, actions, div};
289
290    use super::*;
291
292    actions!(command_item_test, [CloneAction]);
293
294    #[gpui::test]
295    fn cloned_entries_keep_actions_and_lazy_children_usable(cx: &mut TestAppContext) {
296        let action_count = Rc::new(Cell::new(0));
297        let child_count = Rc::new(Cell::new(0));
298        let action_count_for_handler = action_count.clone();
299        cx.update(|cx| {
300            cx.on_action(move |_: &CloneAction, _| {
301                action_count_for_handler.set(action_count_for_handler.get() + 1);
302            });
303        });
304
305        let child_count_for_builder = child_count.clone();
306        let entry = CommandEntry::Group(
307            CommandGroup::new().label("Group").item(
308                CommandItem::new()
309                    .label("cloneable")
310                    .action(Box::new(CloneAction))
311                    .child(move |_, _| {
312                        child_count_for_builder.set(child_count_for_builder.get() + 1);
313                        div()
314                    }),
315            ),
316        );
317        let cloned = entry.clone();
318        let CommandEntry::Group(group) = cloned else {
319            panic!("the cloned entry should remain a group");
320        };
321        let cloned_item = group.items.into_iter().next().unwrap();
322
323        let cx = cx.add_empty_window();
324        cx.update(|window, cx| {
325            let child = cloned_item.content.as_ref().unwrap().clone();
326            _ = child(window, cx);
327            window.dispatch_action(cloned_item.action.as_ref().unwrap().boxed_clone(), cx);
328        });
329
330        assert_eq!(child_count.get(), 1);
331        assert_eq!(action_count.get(), 1);
332    }
333
334    #[test]
335    fn label_is_optional_for_custom_content() {
336        assert_eq!(CommandItem::new().label_text(), None);
337        assert_eq!(
338            CommandItem::new().label("Calendar").label_text(),
339            Some(&"Calendar".into())
340        );
341    }
342
343    #[test]
344    fn matches_label_and_keywords() {
345        let item = CommandItem::new()
346            .label("Profile")
347            .keywords(["account", "user"]);
348
349        assert!(item.matches(""));
350        assert!(item.matches("PRO"));
351        assert!(item.matches("Account"));
352        assert!(!item.matches("billing"));
353    }
354}