command_palette.rs

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