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