command_palette.rs

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