command_palette.rs

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