command_palette.rs

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