gpui_component/command/
item.rs1use std::rc::Rc;
2
3use gpui::{Action, AnyElement, App, IntoElement, SharedString, Window};
4
5use crate::{Disableable, Icon};
6
7pub struct CommandItem {
10 label: Option<SharedString>,
11 keywords: Vec<SharedString>,
12 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 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 pub fn label(mut self, label: impl Into<SharedString>) -> Self {
51 self.label = Some(label.into());
52 self
53 }
54
55 pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
57 self.icon = Some(Box::new(icon.into()));
58 self
59 }
60
61 pub fn action(mut self, action: Box<dyn Action>) -> Self {
65 self.action = Some(action);
66 self
67 }
68
69 pub fn checked(mut self, checked: bool) -> Self {
75 self.checked = checked;
76 self
77 }
78
79 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 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 pub(crate) fn is_disabled(&self) -> bool {
108 self.disabled
109 }
110
111 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
150pub 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 pub fn new() -> Self {
170 Self {
171 heading: None,
172 items: Vec::new(),
173 }
174 }
175
176 pub fn label(mut self, label: impl Into<SharedString>) -> Self {
178 self.heading = Some(label.into());
179 self
180 }
181
182 pub fn item(mut self, item: CommandItem) -> Self {
184 self.items.push(item);
185 self
186 }
187
188 pub fn items(mut self, items: impl IntoIterator<Item = CommandItem>) -> Self {
190 self.items.extend(items);
191 self
192 }
193
194 pub fn heading(&self) -> Option<&SharedString> {
196 self.heading.as_ref()
197 }
198}
199
200pub enum CommandEntry {
202 Item(CommandItem),
204 Group(CommandGroup),
206 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}