Skip to main content

ui/components/command_palette/
palette.rs

1use std::rc::Rc;
2
3use gpui::{
4    AnyElement, App, Context, Entity, Focusable, IntoElement, KeyDownEvent, Pixels, Render, Window,
5    div, px, rgb,
6};
7
8use crate::{IconName, List, ListItem, TextInput, overlay_backdrop, overlay_panel, 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 = cx.new(|cx| TextInput::new(cx).placeholder("Type a command or search…"));
81
82        cx.observe(&query_input, |this, _, cx| {
83            this.selected_ix = 0;
84            cx.notify();
85        })
86        .detach();
87
88        Self {
89            query_input,
90            items,
91            selected_ix: 0,
92            on_dismiss: None,
93        }
94    }
95
96    /// Registers a callback invoked when the user presses Esc, requesting
97    /// that the caller unmount/close this palette.
98    pub fn on_dismiss(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
99        self.on_dismiss = Some(Rc::new(handler));
100        self
101    }
102
103    /// Moves keyboard focus into the query input. Callers should invoke this
104    /// once after mounting the palette so typing works immediately.
105    pub fn focus_input(&self, window: &mut Window, cx: &mut App) {
106        let focus_handle = self.query_input.read(cx).focus_handle(cx);
107        window.focus(&focus_handle, cx);
108    }
109
110    /// Indices into `self.items`, fuzzy-scored against the current query and
111    /// sorted best-match-first. An empty query yields every item in its
112    /// original order (unfiltered default state).
113    fn matched_indices(&self, cx: &App) -> Vec<usize> {
114        let query = self.query_input.read(cx).text();
115        let mut matches: Vec<(usize, i32)> = self
116            .items
117            .iter()
118            .enumerate()
119            .filter_map(|(ix, item)| score(query, item.label.as_ref()).map(|s| (ix, s)))
120            .collect();
121        matches.sort_by(|a, b| b.1.cmp(&a.1));
122        matches.into_iter().map(|(ix, _)| ix).collect()
123    }
124
125    fn handle_key_down(
126        &mut self,
127        event: &KeyDownEvent,
128        window: &mut Window,
129        cx: &mut Context<Self>,
130    ) {
131        let matched = self.matched_indices(cx);
132
133        match event.keystroke.key.as_str() {
134            "up" if !matched.is_empty() => {
135                self.selected_ix = if self.selected_ix == 0 {
136                    matched.len() - 1
137                } else {
138                    self.selected_ix - 1
139                };
140                cx.notify();
141            }
142            "down" if !matched.is_empty() => {
143                self.selected_ix = (self.selected_ix + 1) % matched.len();
144                cx.notify();
145            }
146            "enter" => {
147                if let Some(item_ix) = matched.get(self.selected_ix).copied() {
148                    let on_select = self.items[item_ix].on_select.clone();
149                    on_select(window, cx);
150                }
151            }
152            "escape" => {
153                if let Some(on_dismiss) = self.on_dismiss.clone() {
154                    on_dismiss(window, cx);
155                }
156            }
157            _ => {}
158        }
159    }
160
161    fn render_row(&self, item_ix: usize, is_selected: bool) -> AnyElement {
162        let item = &self.items[item_ix];
163        let on_select = item.on_select.clone();
164
165        ListItem::new(("command-palette-item", item_ix))
166            .focused(is_selected)
167            .when_some(item.icon, |this, icon| this.start_slot(Icon::new(icon)))
168            .child(
169                v_flex()
170                    .gap_0p5()
171                    .child(Label::new(item.label.clone()))
172                    .when_some(item.subtitle.clone(), |this, subtitle| {
173                        this.child(
174                            Label::new(subtitle)
175                                .size(LabelSize::Small)
176                                .color(Color::Muted),
177                        )
178                    }),
179            )
180            .when_some(item.keybinding.clone(), |this, keybinding| {
181                this.end_slot(kbd_badge(keybinding))
182            })
183            .on_click(move |_, window, cx| on_select(window, cx))
184            .into_any_element()
185    }
186
187    fn render_query_row(&self) -> impl IntoElement {
188        h_flex()
189            .w_full()
190            .items_center()
191            .justify_between()
192            .gap_3()
193            .px(px(20.))
194            .py(px(16.))
195            .border_b_1()
196            .border_color(rgb(0x2A313B))
197            .child(
198                div()
199                    .flex_1()
200                    .text_size(px(15.))
201                    .child(self.query_input.clone()),
202            )
203            .child(kbd_badge("ESC"))
204    }
205}
206
207impl Render for CommandPalette {
208    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
209        let matched = self.matched_indices(cx);
210        if self.selected_ix >= matched.len() {
211            self.selected_ix = 0;
212        }
213        let selected_ix = self.selected_ix;
214
215        let rows: Vec<AnyElement> = matched
216            .iter()
217            .enumerate()
218            .map(|(row_ix, item_ix)| self.render_row(*item_ix, row_ix == selected_ix))
219            .collect();
220
221        overlay_backdrop()
222            .on_key_down(cx.listener(Self::handle_key_down))
223            .child(
224                overlay_panel()
225                    .w(COMMAND_PALETTE_WIDTH)
226                    .max_w(vw(0.9, window))
227                    .max_h(px(420.))
228                    .child(self.render_query_row())
229                    .child(
230                        div()
231                            .id("command-palette-results")
232                            .flex_1()
233                            .overflow_y_scroll()
234                            .child(
235                                List::new()
236                                    .empty_message("No matching commands")
237                                    .children(rows),
238                            ),
239                    ),
240            )
241    }
242}
243
244fn kbd_badge(label: impl Into<SharedString>) -> impl IntoElement {
245    div()
246        .flex()
247        .items_center()
248        .justify_center()
249        .px(px(6.))
250        .h(px(20.))
251        .rounded(px(4.))
252        .bg(rgb(0x1B212A))
253        .border_1()
254        .border_color(rgb(0x2A313B))
255        .child(
256            Label::new(label.into())
257                .size(LabelSize::XSmall)
258                .color(Color::Muted),
259        )
260}