command_palette.rs

  1use collections::CommandPaletteFilter;
  2use fuzzy::{StringMatch, StringMatchCandidate};
  3use gpui::{
  4    actions, elements::*, keymap_matcher::Keystroke, Action, AppContext, Drawable, MouseState,
  5    ViewContext,
  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    Picker::<CommandPaletteDelegate>::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.defer(move |workspace, cx| {
 49        workspace.toggle_modal(cx, |_, cx| {
 50            cx.add_view(|cx| Picker::new(CommandPaletteDelegate::new(focused_view_id, cx), cx))
 51        });
 52    });
 53}
 54
 55impl CommandPaletteDelegate {
 56    pub fn new(focused_view_id: usize, cx: &mut ViewContext<Picker<Self>>) -> Self {
 57        let actions = cx
 58            .available_actions(focused_view_id)
 59            .filter_map(|(name, action, bindings)| {
 60                if cx.has_global::<CommandPaletteFilter>() {
 61                    let filter = cx.global::<CommandPaletteFilter>();
 62                    if filter.filtered_namespaces.contains(action.namespace()) {
 63                        return None;
 64                    }
 65                }
 66
 67                Some(Command {
 68                    name: humanize_action_name(name),
 69                    action,
 70                    keystrokes: bindings
 71                        .iter()
 72                        .map(|binding| binding.keystrokes())
 73                        .last()
 74                        .map_or(Vec::new(), |keystrokes| keystrokes.to_vec()),
 75                })
 76            })
 77            .collect();
 78
 79        Self {
 80            actions,
 81            matches: vec![],
 82            selected_ix: 0,
 83            focused_view_id,
 84        }
 85    }
 86}
 87
 88impl PickerDelegate for CommandPaletteDelegate {
 89    fn placeholder_text(&self) -> std::sync::Arc<str> {
 90        "Execute a command...".into()
 91    }
 92
 93    fn match_count(&self) -> usize {
 94        self.matches.len()
 95    }
 96
 97    fn selected_index(&self) -> usize {
 98        self.selected_ix
 99    }
100
101    fn set_selected_index(&mut self, ix: usize, _: &mut ViewContext<Picker<Self>>) {
102        self.selected_ix = ix;
103    }
104
105    fn update_matches(
106        &mut self,
107        query: String,
108        cx: &mut ViewContext<Picker<Self>>,
109    ) -> gpui::Task<()> {
110        let candidates = self
111            .actions
112            .iter()
113            .enumerate()
114            .map(|(ix, command)| StringMatchCandidate {
115                id: ix,
116                string: command.name.to_string(),
117                char_bag: command.name.chars().collect(),
118            })
119            .collect::<Vec<_>>();
120        cx.spawn(move |picker, mut cx| async move {
121            let matches = if query.is_empty() {
122                candidates
123                    .into_iter()
124                    .enumerate()
125                    .map(|(index, candidate)| StringMatch {
126                        candidate_id: index,
127                        string: candidate.string,
128                        positions: Vec::new(),
129                        score: 0.0,
130                    })
131                    .collect()
132            } else {
133                fuzzy::match_strings(
134                    &candidates,
135                    &query,
136                    true,
137                    10000,
138                    &Default::default(),
139                    cx.background(),
140                )
141                .await
142            };
143            picker
144                .update(&mut cx, |picker, _| {
145                    let delegate = picker.delegate_mut();
146                    delegate.matches = matches;
147                    if delegate.matches.is_empty() {
148                        delegate.selected_ix = 0;
149                    } else {
150                        delegate.selected_ix =
151                            cmp::min(delegate.selected_ix, delegate.matches.len() - 1);
152                    }
153                })
154                .log_err();
155        })
156    }
157
158    fn dismissed(&mut self, cx: &mut ViewContext<Picker<Self>>) {
159        cx.emit(PickerEvent::Dismiss);
160    }
161
162    fn confirm(&mut self, cx: &mut ViewContext<Picker<Self>>) {
163        if !self.matches.is_empty() {
164            let window_id = cx.window_id();
165            let focused_view_id = self.focused_view_id;
166            let action_ix = self.matches[self.selected_ix].candidate_id;
167            let action = self.actions.remove(action_ix).action;
168            cx.defer(move |_, cx| {
169                cx.dispatch_any_action_at(window_id, focused_view_id, action);
170            });
171        }
172        cx.emit(PickerEvent::Dismiss);
173    }
174
175    fn render_match(
176        &self,
177        ix: usize,
178        mouse_state: &mut MouseState,
179        selected: bool,
180        cx: &gpui::AppContext,
181    ) -> Element<Picker<Self>> {
182        let mat = &self.matches[ix];
183        let command = &self.actions[mat.candidate_id];
184        let settings = cx.global::<Settings>();
185        let theme = &settings.theme;
186        let style = theme.picker.item.style_for(mouse_state, selected);
187        let key_style = &theme.command_palette.key.style_for(mouse_state, selected);
188        let keystroke_spacing = theme.command_palette.keystroke_spacing;
189
190        Flex::row()
191            .with_child(
192                Label::new(mat.string.clone(), style.label.clone())
193                    .with_highlights(mat.positions.clone())
194                    .boxed(),
195            )
196            .with_children(command.keystrokes.iter().map(|keystroke| {
197                Flex::row()
198                    .with_children(
199                        [
200                            (keystroke.ctrl, "^"),
201                            (keystroke.alt, ""),
202                            (keystroke.cmd, ""),
203                            (keystroke.shift, ""),
204                        ]
205                        .into_iter()
206                        .filter_map(|(modifier, label)| {
207                            if modifier {
208                                Some(
209                                    Label::new(label, key_style.label.clone())
210                                        .contained()
211                                        .with_style(key_style.container)
212                                        .boxed(),
213                                )
214                            } else {
215                                None
216                            }
217                        }),
218                    )
219                    .with_child(
220                        Label::new(keystroke.key.clone(), key_style.label.clone())
221                            .contained()
222                            .with_style(key_style.container)
223                            .boxed(),
224                    )
225                    .contained()
226                    .with_margin_left(keystroke_spacing)
227                    .flex_float()
228                    .boxed()
229            }))
230            .contained()
231            .with_style(style.container)
232            .boxed()
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 super::*;
272    use editor::Editor;
273    use gpui::TestAppContext;
274    use project::Project;
275    use workspace::{AppState, Workspace};
276
277    #[test]
278    fn test_humanize_action_name() {
279        assert_eq!(
280            humanize_action_name("editor::GoToDefinition"),
281            "editor: go to definition"
282        );
283        assert_eq!(
284            humanize_action_name("editor::Backspace"),
285            "editor: backspace"
286        );
287        assert_eq!(
288            humanize_action_name("go_to_line::Deploy"),
289            "go to line: deploy"
290        );
291    }
292
293    #[gpui::test]
294    async fn test_command_palette(cx: &mut TestAppContext) {
295        let app_state = cx.update(AppState::test);
296
297        cx.update(|cx| {
298            editor::init(cx);
299            workspace::init(app_state.clone(), cx);
300            init(cx);
301        });
302
303        let project = Project::test(app_state.fs.clone(), [], cx).await;
304        let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
305        let editor = cx.add_view(&workspace, |cx| {
306            let mut editor = Editor::single_line(None, cx);
307            editor.set_text("abc", cx);
308            editor
309        });
310
311        workspace.update(cx, |workspace, cx| {
312            cx.focus(&editor);
313            workspace.add_item(Box::new(editor.clone()), cx)
314        });
315
316        workspace.update(cx, |workspace, cx| {
317            toggle_command_palette(workspace, &Toggle, cx);
318        });
319
320        let palette = workspace.read_with(cx, |workspace, _| {
321            workspace.modal::<CommandPalette>().unwrap()
322        });
323
324        palette
325            .update(cx, |palette, cx| {
326                palette
327                    .delegate_mut()
328                    .update_matches("bcksp".to_string(), cx)
329            })
330            .await;
331
332        palette.update(cx, |palette, cx| {
333            assert_eq!(palette.matches[0].string, "editor: backspace");
334            palette.confirm(cx);
335        });
336
337        editor.read_with(cx, |editor, cx| {
338            assert_eq!(editor.text(cx), "ab");
339        });
340
341        // Add namespace filter, and redeploy the palette
342        cx.update(|cx| {
343            cx.update_default_global::<CommandPaletteFilter, _, _>(|filter, _| {
344                filter.filtered_namespaces.insert("editor");
345            })
346        });
347
348        workspace.update(cx, |workspace, cx| {
349            CommandPaletteDelegate::toggle(workspace, &Toggle, cx);
350        });
351
352        // Assert editor command not present
353        let palette = workspace.read_with(cx, |workspace, _| {
354            workspace.modal::<CommandPaletteDelegate>().unwrap()
355        });
356
357        palette
358            .update(cx, |palette, cx| {
359                palette.update_matches("bcksp".to_string(), cx)
360            })
361            .await;
362
363        palette.update(cx, |palette, _| assert!(palette.matches.is_empty()));
364    }
365}