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