command_palette.rs

  1use collections::HashSet;
  2use fuzzy::{StringMatch, StringMatchCandidate};
  3use gpui::{
  4    actions,
  5    elements::{ChildView, Flex, Label, ParentElement},
  6    keymap::Keystroke,
  7    Action, Element, Entity, MouseState, MutableAppContext, View, ViewContext, ViewHandle,
  8};
  9use picker::{Picker, PickerDelegate};
 10use settings::Settings;
 11use std::cmp;
 12use workspace::Workspace;
 13
 14#[derive(Default)]
 15pub struct CommandPaletteFilter {
 16    pub filtered_namespaces: HashSet<&'static str>,
 17}
 18
 19pub fn init(cx: &mut MutableAppContext) {
 20    cx.add_action(CommandPalette::toggle);
 21    Picker::<CommandPalette>::init(cx);
 22}
 23
 24actions!(command_palette, [Toggle]);
 25
 26pub struct CommandPalette {
 27    picker: ViewHandle<Picker<Self>>,
 28    actions: Vec<Command>,
 29    matches: Vec<StringMatch>,
 30    selected_ix: usize,
 31    focused_view_id: usize,
 32}
 33
 34pub enum Event {
 35    Dismissed,
 36    Confirmed {
 37        window_id: usize,
 38        focused_view_id: usize,
 39        action: Box<dyn Action>,
 40    },
 41}
 42
 43struct Command {
 44    name: String,
 45    action: Box<dyn Action>,
 46    keystrokes: Vec<Keystroke>,
 47}
 48
 49impl CommandPalette {
 50    pub fn new(focused_view_id: usize, cx: &mut ViewContext<Self>) -> Self {
 51        let this = cx.weak_handle();
 52        let actions = cx
 53            .available_actions(cx.window_id(), focused_view_id)
 54            .filter_map(|(name, action, bindings)| {
 55                if cx.has_global::<CommandPaletteFilter>() {
 56                    let filter = cx.global::<CommandPaletteFilter>();
 57                    if filter.filtered_namespaces.contains(action.namespace()) {
 58                        return None;
 59                    }
 60                }
 61
 62                Some(Command {
 63                    name: humanize_action_name(name),
 64                    action,
 65                    keystrokes: bindings
 66                        .last()
 67                        .map_or(Vec::new(), |binding| binding.keystrokes().to_vec()),
 68                })
 69            })
 70            .collect();
 71
 72        let picker = cx.add_view(|cx| Picker::new(this, cx));
 73        Self {
 74            picker,
 75            actions,
 76            matches: vec![],
 77            selected_ix: 0,
 78            focused_view_id,
 79        }
 80    }
 81
 82    fn toggle(_: &mut Workspace, _: &Toggle, cx: &mut ViewContext<Workspace>) {
 83        let workspace = cx.handle();
 84        let window_id = cx.window_id();
 85        let focused_view_id = cx.focused_view_id(window_id).unwrap_or(workspace.id());
 86
 87        cx.as_mut().defer(move |cx| {
 88            workspace.update(cx, |workspace, cx| {
 89                let this = cx.add_view(|cx| Self::new(focused_view_id, cx));
 90                workspace.toggle_modal(cx, |_, cx| {
 91                    cx.subscribe(&this, Self::on_event).detach();
 92                    this
 93                });
 94            });
 95        });
 96    }
 97
 98    fn on_event(
 99        workspace: &mut Workspace,
100        _: ViewHandle<Self>,
101        event: &Event,
102        cx: &mut ViewContext<Workspace>,
103    ) {
104        match event {
105            Event::Dismissed => workspace.dismiss_modal(cx),
106            Event::Confirmed {
107                window_id,
108                focused_view_id,
109                action,
110            } => {
111                let window_id = *window_id;
112                let focused_view_id = *focused_view_id;
113                let action = action.boxed_clone();
114                workspace.dismiss_modal(cx);
115                cx.as_mut()
116                    .defer(move |cx| cx.dispatch_any_action_at(window_id, focused_view_id, action))
117            }
118        }
119    }
120}
121
122impl Entity for CommandPalette {
123    type Event = Event;
124}
125
126impl View for CommandPalette {
127    fn ui_name() -> &'static str {
128        "CommandPalette"
129    }
130
131    fn render(&mut self, _: &mut gpui::RenderContext<'_, Self>) -> gpui::ElementBox {
132        ChildView::new(self.picker.clone()).boxed()
133    }
134
135    fn on_focus(&mut self, cx: &mut ViewContext<Self>) {
136        cx.focus(&self.picker);
137    }
138}
139
140impl PickerDelegate for CommandPalette {
141    fn match_count(&self) -> usize {
142        self.matches.len()
143    }
144
145    fn selected_index(&self) -> usize {
146        self.selected_ix
147    }
148
149    fn set_selected_index(&mut self, ix: usize, _: &mut ViewContext<Self>) {
150        self.selected_ix = ix;
151    }
152
153    fn update_matches(
154        &mut self,
155        query: String,
156        cx: &mut gpui::ViewContext<Self>,
157    ) -> gpui::Task<()> {
158        let candidates = self
159            .actions
160            .iter()
161            .enumerate()
162            .map(|(ix, command)| StringMatchCandidate {
163                id: ix,
164                string: command.name.to_string(),
165                char_bag: command.name.chars().collect(),
166            })
167            .collect::<Vec<_>>();
168        cx.spawn(move |this, mut cx| async move {
169            let matches = if query.is_empty() {
170                candidates
171                    .into_iter()
172                    .enumerate()
173                    .map(|(index, candidate)| StringMatch {
174                        candidate_id: index,
175                        string: candidate.string,
176                        positions: Vec::new(),
177                        score: 0.0,
178                    })
179                    .collect()
180            } else {
181                fuzzy::match_strings(
182                    &candidates,
183                    &query,
184                    true,
185                    10000,
186                    &Default::default(),
187                    cx.background(),
188                )
189                .await
190            };
191            this.update(&mut cx, |this, _| {
192                this.matches = matches;
193                if this.matches.is_empty() {
194                    this.selected_ix = 0;
195                } else {
196                    this.selected_ix = cmp::min(this.selected_ix, this.matches.len() - 1);
197                }
198            });
199        })
200    }
201
202    fn dismiss(&mut self, cx: &mut ViewContext<Self>) {
203        cx.emit(Event::Dismissed);
204    }
205
206    fn confirm(&mut self, cx: &mut ViewContext<Self>) {
207        if !self.matches.is_empty() {
208            let action_ix = self.matches[self.selected_ix].candidate_id;
209            cx.emit(Event::Confirmed {
210                window_id: cx.window_id(),
211                focused_view_id: self.focused_view_id,
212                action: self.actions.remove(action_ix).action,
213            });
214        } else {
215            cx.emit(Event::Dismissed);
216        }
217    }
218
219    fn render_match(
220        &self,
221        ix: usize,
222        mouse_state: MouseState,
223        selected: bool,
224        cx: &gpui::AppContext,
225    ) -> gpui::ElementBox {
226        let mat = &self.matches[ix];
227        let command = &self.actions[mat.candidate_id];
228        let settings = cx.global::<Settings>();
229        let theme = &settings.theme;
230        let style = theme.picker.item.style_for(mouse_state, selected);
231        let key_style = &theme.command_palette.key.style_for(mouse_state, selected);
232        let keystroke_spacing = theme.command_palette.keystroke_spacing;
233
234        Flex::row()
235            .with_child(
236                Label::new(mat.string.clone(), style.label.clone())
237                    .with_highlights(mat.positions.clone())
238                    .boxed(),
239            )
240            .with_children(command.keystrokes.iter().map(|keystroke| {
241                Flex::row()
242                    .with_children(
243                        [
244                            (keystroke.ctrl, "^"),
245                            (keystroke.alt, ""),
246                            (keystroke.cmd, ""),
247                            (keystroke.shift, ""),
248                        ]
249                        .into_iter()
250                        .filter_map(|(modifier, label)| {
251                            if modifier {
252                                Some(
253                                    Label::new(label.into(), key_style.label.clone())
254                                        .contained()
255                                        .with_style(key_style.container)
256                                        .boxed(),
257                                )
258                            } else {
259                                None
260                            }
261                        }),
262                    )
263                    .with_child(
264                        Label::new(keystroke.key.clone(), key_style.label.clone())
265                            .contained()
266                            .with_style(key_style.container)
267                            .boxed(),
268                    )
269                    .contained()
270                    .with_margin_left(keystroke_spacing)
271                    .flex_float()
272                    .boxed()
273            }))
274            .contained()
275            .with_style(style.container)
276            .boxed()
277    }
278}
279
280fn humanize_action_name(name: &str) -> String {
281    let capacity = name.len() + name.chars().filter(|c| c.is_uppercase()).count();
282    let mut result = String::with_capacity(capacity);
283    for char in name.chars() {
284        if char == ':' {
285            if result.ends_with(':') {
286                result.push(' ');
287            } else {
288                result.push(':');
289            }
290        } else if char == '_' {
291            result.push(' ');
292        } else if char.is_uppercase() {
293            if !result.ends_with(' ') {
294                result.push(' ');
295            }
296            result.extend(char.to_lowercase());
297        } else {
298            result.push(char);
299        }
300    }
301    result
302}
303
304impl std::fmt::Debug for Command {
305    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
306        f.debug_struct("Command")
307            .field("name", &self.name)
308            .field("keystrokes", &self.keystrokes)
309            .finish()
310    }
311}
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316    use editor::Editor;
317    use gpui::TestAppContext;
318    use project::Project;
319    use workspace::{AppState, Workspace};
320
321    #[test]
322    fn test_humanize_action_name() {
323        assert_eq!(
324            humanize_action_name("editor::GoToDefinition"),
325            "editor: go to definition"
326        );
327        assert_eq!(
328            humanize_action_name("editor::Backspace"),
329            "editor: backspace"
330        );
331        assert_eq!(
332            humanize_action_name("go_to_line::Deploy"),
333            "go to line: deploy"
334        );
335    }
336
337    #[gpui::test]
338    async fn test_command_palette(cx: &mut TestAppContext) {
339        let app_state = cx.update(AppState::test);
340
341        cx.update(|cx| {
342            editor::init(cx);
343            workspace::init(app_state.clone(), cx);
344            init(cx);
345        });
346
347        let project = Project::test(app_state.fs.clone(), [], cx).await;
348        let (_, workspace) = cx.add_window(|cx| Workspace::new(project, cx));
349        let editor = cx.add_view(&workspace, |cx| {
350            let mut editor = Editor::single_line(None, cx);
351            editor.set_text("abc", cx);
352            editor
353        });
354
355        workspace.update(cx, |workspace, cx| {
356            cx.focus(editor.clone());
357            workspace.add_item(Box::new(editor.clone()), cx)
358        });
359
360        workspace.update(cx, |workspace, cx| {
361            CommandPalette::toggle(workspace, &Toggle, cx)
362        });
363
364        let palette = workspace.read_with(cx, |workspace, _| {
365            workspace.modal::<CommandPalette>().unwrap()
366        });
367
368        palette
369            .update(cx, |palette, cx| {
370                palette.update_matches("bcksp".to_string(), cx)
371            })
372            .await;
373
374        palette.update(cx, |palette, cx| {
375            assert_eq!(palette.matches[0].string, "editor: backspace");
376            palette.confirm(cx);
377        });
378
379        editor.read_with(cx, |editor, cx| {
380            assert_eq!(editor.text(cx), "ab");
381        });
382
383        // Add namespace filter, and redeploy the palette
384        cx.update(|cx| {
385            cx.update_default_global::<CommandPaletteFilter, _, _>(|filter, _| {
386                filter.filtered_namespaces.insert("editor");
387            })
388        });
389
390        workspace.update(cx, |workspace, cx| {
391            CommandPalette::toggle(workspace, &Toggle, cx);
392        });
393
394        // Assert editor command not present
395        let palette = workspace.read_with(cx, |workspace, _| {
396            workspace.modal::<CommandPalette>().unwrap()
397        });
398
399        palette
400            .update(cx, |palette, cx| {
401                palette.update_matches("bcksp".to_string(), cx)
402            })
403            .await;
404
405        palette.update(cx, |palette, _| assert!(palette.matches.is_empty()));
406    }
407}