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