1use 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 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
176pub 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 pub fn new() -> Self {
196 Self {
197 heading: None,
198 items: Vec::new(),
199 }
200 }
201
202 pub fn label(mut self, label: impl Into<SharedString>) -> Self {
204 self.heading = Some(label.into());
205 self
206 }
207
208 pub fn item(mut self, item: CommandItem) -> Self {
210 self.items.push(item);
211 self
212 }
213
214 pub fn items(mut self, items: impl IntoIterator<Item = CommandItem>) -> Self {
216 self.items.extend(items);
217 self
218 }
219
220 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
236pub enum CommandEntry {
238 Item(CommandItem),
240 Group(CommandGroup),
242 Separator,
247}
248
249impl CommandEntry {
250 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}