command_palette.rs

  1use collections::CommandPaletteFilter;
  2use fuzzy::{StringMatch, StringMatchCandidate};
  3use gpui::{
  4    actions, elements::*, keymap_matcher::Keystroke, Action, AppContext, Element, 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    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.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
160    fn confirm(&mut self, cx: &mut ViewContext<Picker<Self>>) {
161        if !self.matches.is_empty() {
162            let window_id = cx.window_id();
163            let focused_view_id = self.focused_view_id;
164            let action_ix = self.matches[self.selected_ix].candidate_id;
165            let action = self.actions.remove(action_ix).action;
166            cx.defer(move |_, cx| {
167                cx.dispatch_any_action_at(window_id, focused_view_id, action);
168            });
169        }
170        cx.emit(PickerEvent::Dismiss);
171    }
172
173    fn render_match(
174        &self,
175        ix: usize,
176        mouse_state: &mut MouseState,
177        selected: bool,
178        cx: &gpui::AppContext,
179    ) -> AnyElement<Picker<Self>> {
180        let mat = &self.matches[ix];
181        let command = &self.actions[mat.candidate_id];
182        let settings = cx.global::<Settings>();
183        let theme = &settings.theme;
184        let style = theme.picker.item.style_for(mouse_state, selected);
185        let key_style = &theme.command_palette.key.style_for(mouse_state, selected);
186        let keystroke_spacing = theme.command_palette.keystroke_spacing;
187
188        Flex::row()
189            .with_child(
190                Label::new(mat.string.clone(), style.label.clone())
191                    .with_highlights(mat.positions.clone()),
192            )
193            .with_children(command.keystrokes.iter().map(|keystroke| {
194                Flex::row()
195                    .with_children(
196                        [
197                            (keystroke.ctrl, "^"),
198                            (keystroke.alt, ""),
199                            (keystroke.cmd, ""),
200                            (keystroke.shift, ""),
201                        ]
202                        .into_iter()
203                        .filter_map(|(modifier, label)| {
204                            if modifier {
205                                Some(
206                                    Label::new(label, key_style.label.clone())
207                                        .contained()
208                                        .with_style(key_style.container),
209                                )
210                            } else {
211                                None
212                            }
213                        }),
214                    )
215                    .with_child(
216                        Label::new(keystroke.key.clone(), key_style.label.clone())
217                            .contained()
218                            .with_style(key_style.container),
219                    )
220                    .contained()
221                    .with_margin_left(keystroke_spacing)
222                    .flex_float()
223            }))
224            .contained()
225            .with_style(style.container)
226            .into_any()
227    }
228}
229
230fn humanize_action_name(name: &str) -> String {
231    let capacity = name.len() + name.chars().filter(|c| c.is_uppercase()).count();
232    let mut result = String::with_capacity(capacity);
233    for char in name.chars() {
234        if char == ':' {
235            if result.ends_with(':') {
236                result.push(' ');
237            } else {
238                result.push(':');
239            }
240        } else if char == '_' {
241            result.push(' ');
242        } else if char.is_uppercase() {
243            if !result.ends_with(' ') {
244                result.push(' ');
245            }
246            result.extend(char.to_lowercase());
247        } else {
248            result.push(char);
249        }
250    }
251    result
252}
253
254impl std::fmt::Debug for Command {
255    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
256        f.debug_struct("Command")
257            .field("name", &self.name)
258            .field("keystrokes", &self.keystrokes)
259            .finish()
260    }
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266    use editor::Editor;
267    use gpui::TestAppContext;
268    use project::Project;
269    use workspace::{AppState, Workspace};
270
271    #[test]
272    fn test_humanize_action_name() {
273        assert_eq!(
274            humanize_action_name("editor::GoToDefinition"),
275            "editor: go to definition"
276        );
277        assert_eq!(
278            humanize_action_name("editor::Backspace"),
279            "editor: backspace"
280        );
281        assert_eq!(
282            humanize_action_name("go_to_line::Deploy"),
283            "go to line: deploy"
284        );
285    }
286
287    #[gpui::test]
288    async fn test_command_palette(cx: &mut TestAppContext) {
289        let app_state = cx.update(AppState::test);
290
291        cx.update(|cx| {
292            editor::init(cx);
293            workspace::init(app_state.clone(), cx);
294            init(cx);
295        });
296
297        let project = Project::test(app_state.fs.clone(), [], cx).await;
298        let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
299        let editor = cx.add_view(&workspace, |cx| {
300            let mut editor = Editor::single_line(None, cx);
301            editor.set_text("abc", cx);
302            editor
303        });
304
305        workspace.update(cx, |workspace, cx| {
306            cx.focus(&editor);
307            workspace.add_item(Box::new(editor.clone()), cx)
308        });
309
310        workspace.update(cx, |workspace, cx| {
311            toggle_command_palette(workspace, &Toggle, cx);
312        });
313
314        let palette = workspace.read_with(cx, |workspace, _| {
315            workspace.modal::<CommandPalette>().unwrap()
316        });
317
318        palette
319            .update(cx, |palette, cx| {
320                palette
321                    .delegate_mut()
322                    .update_matches("bcksp".to_string(), cx)
323            })
324            .await;
325
326        palette.update(cx, |palette, cx| {
327            assert_eq!(palette.delegate().matches[0].string, "editor: backspace");
328            palette.confirm(&Default::default(), cx);
329        });
330
331        editor.read_with(cx, |editor, cx| {
332            assert_eq!(editor.text(cx), "ab");
333        });
334
335        // Add namespace filter, and redeploy the palette
336        cx.update(|cx| {
337            cx.update_default_global::<CommandPaletteFilter, _, _>(|filter, _| {
338                filter.filtered_namespaces.insert("editor");
339            })
340        });
341
342        workspace.update(cx, |workspace, cx| {
343            toggle_command_palette(workspace, &Toggle, cx);
344        });
345
346        // Assert editor command not present
347        let palette = workspace.read_with(cx, |workspace, _| {
348            workspace.modal::<CommandPalette>().unwrap()
349        });
350
351        palette
352            .update(cx, |palette, cx| {
353                palette
354                    .delegate_mut()
355                    .update_matches("bcksp".to_string(), cx)
356            })
357            .await;
358
359        palette.update(cx, |palette, _| {
360            assert!(palette.delegate().matches.is_empty())
361        });
362    }
363}