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