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