Skip to main content

ui/components/
command.rs

1use std::{cell::Cell, rc::Rc};
2
3use gpui::{
4    AnyElement, Bounds, Context, Entity, KeyDownEvent, Pixels, Render, anchored, canvas, deferred,
5    point,
6};
7
8use crate::utils::fuzzy_subsequence_score;
9use crate::{ListHeader, TextInput, prelude::*};
10
11/// A command-palette item: either a non-selectable group label or a selectable entry.
12#[derive(Clone)]
13pub enum CommandItem {
14    Group(SharedString),
15    Entry {
16        id: SharedString,
17        label: SharedString,
18    },
19}
20
21/// A command palette built on the Combobox input+list pattern with subsequence
22/// fuzzy filtering and keyboard navigation (up/down/enter, clamped at ends).
23///
24/// Stateful view — create with `cx.new(|cx| Command::new(cx, items))`.
25#[derive(RegisterComponent)]
26pub struct Command {
27    items: Vec<CommandItem>,
28    input: Entity<TextInput>,
29    selected: usize,
30    open: bool,
31    last_query: String,
32    trigger_bounds: Rc<Cell<Option<Bounds<Pixels>>>>,
33}
34
35impl Command {
36    pub fn new(cx: &mut Context<Self>, items: Vec<CommandItem>) -> Self {
37        let input = cx.new(|cx| TextInput::new(cx).placeholder("Type a command or search…"));
38        cx.observe(&input, |_, _, cx| cx.notify()).detach();
39        Self {
40            items,
41            input,
42            selected: 0,
43            open: true,
44            last_query: String::new(),
45            trigger_bounds: Rc::new(Cell::new(None)),
46        }
47    }
48
49    fn filtered_indices(&self, cx: &App) -> Vec<usize> {
50        let query = self.input.read(cx).text();
51        let mut matches: Vec<(usize, usize)> = self
52            .items
53            .iter()
54            .enumerate()
55            .filter_map(|(i, item)| match item {
56                CommandItem::Group(_) => None,
57                CommandItem::Entry { label, .. } => {
58                    fuzzy_subsequence_score(query, label).map(|score| (i, score))
59                }
60            })
61            .collect();
62        matches.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
63        matches.into_iter().map(|(i, _)| i).collect()
64    }
65
66    fn selectable_count(&self, cx: &App) -> usize {
67        self.filtered_indices(cx).len()
68    }
69
70    fn clamp_selected(&mut self, cx: &App) {
71        let count = self.selectable_count(cx);
72        if count == 0 {
73            self.selected = 0;
74        } else {
75            self.selected = self.selected.min(count - 1);
76        }
77    }
78
79    fn on_key_down(&mut self, event: &KeyDownEvent, _window: &mut Window, cx: &mut Context<Self>) {
80        let count = self.selectable_count(cx);
81        if count == 0 {
82            return;
83        }
84        match event.keystroke.key.as_str() {
85            "up" => {
86                self.selected = self.selected.saturating_sub(1);
87                cx.notify();
88            }
89            "down" => {
90                self.selected = (self.selected + 1).min(count - 1);
91                cx.notify();
92            }
93            "enter" => {
94                // Selection handled by caller via `selected_entry`.
95                cx.notify();
96            }
97            _ => {}
98        }
99    }
100
101    /// The currently highlighted entry, if any.
102    pub fn selected_entry(&self, cx: &App) -> Option<&CommandItem> {
103        self.filtered_indices(cx)
104            .get(self.selected)
105            .and_then(|&i| self.items.get(i))
106    }
107}
108
109impl Render for Command {
110    // NOTE: the trigger-measurement (`canvas` + `trigger_bounds` +
111    // `anchored().snap_to_window_with_margin`) and floating-list
112    // (`deferred(..).with_priority(1)`) logic below intentionally duplicates
113    // `Combobox`'s render (`crates/ui/src/components/combobox.rs`). Command
114    // is a working, tested component — extracting a shared "measured
115    // trigger + floating list" helper is worthwhile but risks destabilizing
116    // both call sites for a purely internal cleanup, so it's deferred.
117    // TODO: extract a shared helper once a third caller needs the same
118    // pattern (or during a dedicated refactor pass), rather than as a
119    // side-effect of this fix.
120    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
121        let query = self.input.read(cx).text().to_string();
122        if query != self.last_query {
123            self.last_query = query;
124            self.selected = 0;
125        }
126        self.clamp_selected(cx);
127        let open = self.open;
128        let filtered = self.filtered_indices(cx);
129        let selected = self.selected;
130        let items = self.items.clone();
131
132        let trigger = h_flex()
133            .id("command-trigger")
134            .w_full()
135            .items_center()
136            .px_3()
137            .py_2()
138            .rounded_md()
139            .bg(semantic::surface(cx))
140            .border_1()
141            .border_color(semantic::border(cx))
142            .child(div().flex_1().min_w_0().child(self.input.clone()))
143            .child(
144                div()
145                    .id("command-toggle")
146                    .cursor_pointer()
147                    .child(Icon::new(IconName::MagnifyingGlass).size(IconSize::Small))
148                    .on_click(cx.listener(|this, _, _, cx| {
149                        this.open = !this.open;
150                        cx.notify();
151                    })),
152            )
153            .on_key_down(cx.listener(Self::on_key_down))
154            .child({
155                let trigger_bounds = self.trigger_bounds.clone();
156                canvas(
157                    move |bounds, _window, _cx| trigger_bounds.set(Some(bounds)),
158                    |_bounds, _state, _window, _cx| {},
159                )
160                .absolute()
161                .top_0()
162                .left_0()
163                .size_full()
164            });
165
166        let trigger_width = px(360.);
167        let hover = semantic::hover_bg(cx);
168
169        v_flex()
170            .w(trigger_width)
171            .gap_1()
172            .child(trigger)
173            .when(open, |this| {
174                let mut list = v_flex()
175                    .w(trigger_width)
176                    .p_1()
177                    .rounded_md()
178                    .bg(semantic::elevated_surface(cx))
179                    .border_1()
180                    .border_color(semantic::border(cx))
181                    .shadow_level(Shadow::Lg)
182                    .max_h(px(280.));
183
184                if filtered.is_empty() {
185                    list = list.child(
186                        div()
187                            .px_3()
188                            .py_2()
189                            .child(Label::new("No results found.").color(Color::Muted)),
190                    );
191                } else {
192                    let mut selectable = 0usize;
193                    let mut last_group: Option<SharedString> = None;
194
195                    for (idx, &item_index) in filtered.iter().enumerate() {
196                        let Some(item) = items.get(item_index) else {
197                            continue;
198                        };
199                        let CommandItem::Entry { label, .. } = item else {
200                            continue;
201                        };
202
203                        for (gi, group_item) in items[..item_index].iter().enumerate().rev() {
204                            if let CommandItem::Group(group) = group_item {
205                                if last_group.as_ref() != Some(group) {
206                                    last_group = Some(group.clone());
207                                    list = list.child(
208                                        ListHeader::new(group.clone())
209                                            .inset(true)
210                                            .into_any_element(),
211                                    );
212                                }
213                                break;
214                            }
215                            if gi == 0 {
216                                break;
217                            }
218                        }
219
220                        let is_selected = idx == selected;
221                        let label = label.clone();
222                        list = list.child(
223                            h_flex()
224                                .id(("command-item", item_index))
225                                .w_full()
226                                .px_3()
227                                .py_2()
228                                .rounded_md()
229                                .cursor_pointer()
230                                .when(is_selected, |this| this.bg(palette::primary(100)))
231                                .when(!is_selected, |this| this.hover(move |s| s.bg(hover)))
232                                .on_click(cx.listener(move |this, _, _, cx| {
233                                    this.selected = selectable;
234                                    cx.notify();
235                                }))
236                                .child(Label::new(label)),
237                        );
238                        selectable += 1;
239                    }
240                }
241
242                let mut anchor = anchored().snap_to_window_with_margin(px(8.));
243                if let Some(bounds) = self.trigger_bounds.get() {
244                    anchor = anchor.position(point(
245                        bounds.origin.x,
246                        bounds.origin.y + bounds.size.height + px(4.),
247                    ));
248                }
249
250                this.child(deferred(anchor.child(div().occlude().child(list))).with_priority(1))
251            })
252    }
253}
254
255impl Component for Command {
256    fn scope() -> ComponentScope {
257        ComponentScope::Overlays
258    }
259
260    fn description() -> Option<&'static str> {
261        Some("A command palette with fuzzy filter and keyboard navigation.")
262    }
263
264    fn preview(_window: &mut Window, cx: &mut App) -> Option<AnyElement> {
265        let items = vec![
266            CommandItem::Group("Suggestions".into()),
267            CommandItem::Entry {
268                id: "cal".into(),
269                label: "Calendar".into(),
270            },
271            CommandItem::Entry {
272                id: "sr".into(),
273                label: "Search Emoji".into(),
274            },
275            CommandItem::Entry {
276                id: "calc".into(),
277                label: "Calculator".into(),
278            },
279            CommandItem::Group("Settings".into()),
280            CommandItem::Entry {
281                id: "prof".into(),
282                label: "Profile".into(),
283            },
284            CommandItem::Entry {
285                id: "bill".into(),
286                label: "Billing".into(),
287            },
288        ];
289        Some(cx.new(|cx| Command::new(cx, items)).into_any_element())
290    }
291}
292
293/// Standalone gallery preview for `Command` (stateful `Entity`).
294pub fn command_preview(_window: &mut Window, cx: &mut App) -> AnyElement {
295    Command::preview(_window, cx).unwrap_or_else(|| div().into_any_element())
296}