Skip to main content

ui/components/command_palette/
palette.rs

1use std::rc::Rc;
2
3use gpui::{
4    AnyElement, App, BoxShadow, Context, Entity, Focusable, IntoElement, KeyDownEvent, Pixels,
5    Render, Window, black, div, point, px, rgb,
6};
7
8use crate::{IconName, List, ListItem, TextInput, prelude::*};
9
10use super::fuzzy::score;
11
12/// Default panel width for a [`CommandPalette`] (matches the mockup's
13/// 600px-wide overlay).
14pub const COMMAND_PALETTE_WIDTH: Pixels = px(600.);
15
16/// A single entry rendered in a [`CommandPalette`]'s results list.
17pub struct CommandPaletteItem {
18    label: SharedString,
19    subtitle: Option<SharedString>,
20    icon: Option<IconName>,
21    keybinding: Option<SharedString>,
22    on_select: Rc<dyn Fn(&mut Window, &mut App)>,
23}
24
25impl CommandPaletteItem {
26    pub fn new(
27        label: impl Into<SharedString>,
28        on_select: impl Fn(&mut Window, &mut App) + 'static,
29    ) -> Self {
30        Self {
31            label: label.into(),
32            subtitle: None,
33            icon: None,
34            keybinding: None,
35            on_select: Rc::new(on_select),
36        }
37    }
38
39    pub fn subtitle(mut self, subtitle: impl Into<SharedString>) -> Self {
40        self.subtitle = Some(subtitle.into());
41        self
42    }
43
44    pub fn icon(mut self, icon: IconName) -> Self {
45        self.icon = Some(icon);
46        self
47    }
48
49    pub fn keybinding(mut self, keybinding: impl Into<SharedString>) -> Self {
50        self.keybinding = Some(keybinding.into());
51        self
52    }
53}
54
55/// A searchable, keyboard-driven command overlay: a text input with a live
56/// fuzzy-filtered list of [`CommandPaletteItem`]s. ↑/↓ move the highlighted row,
57/// Enter runs it, Esc requests dismissal via [`CommandPalette::on_dismiss`].
58///
59/// `crate::Modal` intentionally renders no backdrop/centering of its own
60/// (callers supply that, same as `crate::Drawer`'s convention) — so
61/// `CommandPalette` supplies its own backdrop + centered panel wrapper here
62/// rather than duplicating logic `Modal` doesn't have. The panel needs exact,
63/// non-theme-driven mockup colors (`#12161C`/`#2A313B`/etc.), which is why it
64/// doesn't call `Modal::new()` directly (that would pull in `Modal`'s
65/// theme-driven `bg`/`border`/`radius`, which can't be overridden from
66/// outside); it still reuses the same header/list conventions.
67///
68/// Caller-owned open/closed state: like `Modal`/`Drawer`, there is no
69/// internal open flag. The caller mounts a `CommandPalette` into the tree
70/// only while it should be visible, and reacts to `on_dismiss` (Esc) to
71/// unmount it again.
72pub struct CommandPalette {
73    query_input: Entity<TextInput>,
74    items: Vec<CommandPaletteItem>,
75    selected_ix: usize,
76    on_dismiss: Option<Rc<dyn Fn(&mut Window, &mut App)>>,
77}
78
79impl CommandPalette {
80    pub fn new(cx: &mut Context<Self>, items: Vec<CommandPaletteItem>) -> Self {
81        let query_input =
82            cx.new(|cx| TextInput::new(cx).placeholder("Type a command or search…"));
83
84        cx.observe(&query_input, |this, _, cx| {
85            this.selected_ix = 0;
86            cx.notify();
87        })
88        .detach();
89
90        Self {
91            query_input,
92            items,
93            selected_ix: 0,
94            on_dismiss: None,
95        }
96    }
97
98    /// Registers a callback invoked when the user presses Esc, requesting
99    /// that the caller unmount/close this palette.
100    pub fn on_dismiss(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
101        self.on_dismiss = Some(Rc::new(handler));
102        self
103    }
104
105    /// Moves keyboard focus into the query input. Callers should invoke this
106    /// once after mounting the palette so typing works immediately.
107    pub fn focus_input(&self, window: &mut Window, cx: &mut App) {
108        let focus_handle = self.query_input.read(cx).focus_handle(cx);
109        window.focus(&focus_handle, cx);
110    }
111
112    /// Indices into `self.items`, fuzzy-scored against the current query and
113    /// sorted best-match-first. An empty query yields every item in its
114    /// original order (unfiltered default state).
115    fn matched_indices(&self, cx: &App) -> Vec<usize> {
116        let query = self.query_input.read(cx).text();
117        let mut matches: Vec<(usize, i32)> = self
118            .items
119            .iter()
120            .enumerate()
121            .filter_map(|(ix, item)| score(query, item.label.as_ref()).map(|s| (ix, s)))
122            .collect();
123        matches.sort_by(|a, b| b.1.cmp(&a.1));
124        matches.into_iter().map(|(ix, _)| ix).collect()
125    }
126
127    fn handle_key_down(
128        &mut self,
129        event: &KeyDownEvent,
130        window: &mut Window,
131        cx: &mut Context<Self>,
132    ) {
133        let matched = self.matched_indices(cx);
134
135        match event.keystroke.key.as_str() {
136            "up" if !matched.is_empty() => {
137                self.selected_ix = if self.selected_ix == 0 {
138                    matched.len() - 1
139                } else {
140                    self.selected_ix - 1
141                };
142                cx.notify();
143            }
144            "down" if !matched.is_empty() => {
145                self.selected_ix = (self.selected_ix + 1) % matched.len();
146                cx.notify();
147            }
148            "enter" => {
149                if let Some(item_ix) = matched.get(self.selected_ix).copied() {
150                    let on_select = self.items[item_ix].on_select.clone();
151                    on_select(window, cx);
152                }
153            }
154            "escape" => {
155                if let Some(on_dismiss) = self.on_dismiss.clone() {
156                    on_dismiss(window, cx);
157                }
158            }
159            _ => {}
160        }
161    }
162
163    fn render_row(&self, item_ix: usize, is_selected: bool) -> AnyElement {
164        let item = &self.items[item_ix];
165        let on_select = item.on_select.clone();
166
167        ListItem::new(("command-palette-item", item_ix))
168            .focused(is_selected)
169            .when_some(item.icon, |this, icon| this.start_slot(Icon::new(icon)))
170            .child(
171                v_flex()
172                    .gap_0p5()
173                    .child(Label::new(item.label.clone()))
174                    .when_some(item.subtitle.clone(), |this, subtitle| {
175                        this.child(
176                            Label::new(subtitle)
177                                .size(LabelSize::Small)
178                                .color(Color::Muted),
179                        )
180                    }),
181            )
182            .when_some(item.keybinding.clone(), |this, keybinding| {
183                this.end_slot(kbd_badge(keybinding))
184            })
185            .on_click(move |_, window, cx| on_select(window, cx))
186            .into_any_element()
187    }
188
189    fn render_query_row(&self) -> impl IntoElement {
190        h_flex()
191            .w_full()
192            .items_center()
193            .justify_between()
194            .gap_3()
195            .px(px(20.))
196            .py(px(16.))
197            .border_b_1()
198            .border_color(rgb(0x2A313B))
199            .child(
200                div()
201                    .flex_1()
202                    .text_size(px(15.))
203                    .child(self.query_input.clone()),
204            )
205            .child(kbd_badge("ESC"))
206    }
207}
208
209impl Render for CommandPalette {
210    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
211        let matched = self.matched_indices(cx);
212        if self.selected_ix >= matched.len() {
213            self.selected_ix = 0;
214        }
215        let selected_ix = self.selected_ix;
216
217        let rows: Vec<AnyElement> = matched
218            .iter()
219            .enumerate()
220            .map(|(row_ix, item_ix)| self.render_row(*item_ix, row_ix == selected_ix))
221            .collect();
222
223        div()
224            .absolute()
225            .inset_0()
226            .flex()
227            .items_center()
228            .justify_center()
229            .bg(black().opacity(0.5))
230            .on_key_down(cx.listener(Self::handle_key_down))
231            .child(
232                v_flex()
233                    .w(COMMAND_PALETTE_WIDTH)
234                    .max_w(vw(0.9, window))
235                    .max_h(px(420.))
236                    .bg(rgb(0x12161C))
237                    .border_1()
238                    .border_color(rgb(0x2A313B))
239                    .rounded(px(14.))
240                    .shadow(vec![BoxShadow {
241                        color: black().opacity(0.6),
242                        offset: point(px(0.), px(24.)),
243                        blur_radius: px(70.),
244                        spread_radius: px(0.),
245                    }])
246                    .overflow_hidden()
247                    .child(self.render_query_row())
248                    .child(
249                        div()
250                            .id("command-palette-results")
251                            .flex_1()
252                            .overflow_y_scroll()
253                            .child(
254                                List::new()
255                                    .empty_message("No matching commands")
256                                    .children(rows),
257                            ),
258                    ),
259            )
260    }
261}
262
263fn kbd_badge(label: impl Into<SharedString>) -> impl IntoElement {
264    div()
265        .flex()
266        .items_center()
267        .justify_center()
268        .px(px(6.))
269        .h(px(20.))
270        .rounded(px(4.))
271        .bg(rgb(0x1B212A))
272        .border_1()
273        .border_color(rgb(0x2A313B))
274        .child(
275            Label::new(label.into())
276                .size(LabelSize::XSmall)
277                .color(Color::Muted),
278        )
279}