command_palette.rs

  1use fuzzy::{StringMatch, StringMatchCandidate};
  2use gpui::{
  3    actions,
  4    elements::{ChildView, Flex, Label, ParentElement},
  5    keymap::Keystroke,
  6    Action, Element, Entity, MutableAppContext, View, ViewContext, ViewHandle,
  7};
  8use picker::{Picker, PickerDelegate};
  9use settings::Settings;
 10use std::cmp;
 11use workspace::Workspace;
 12
 13pub fn init(cx: &mut MutableAppContext) {
 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(cx.window_id(), focused_view_id)
 48            .map(|(name, action, bindings)| Command {
 49                name: humanize_action_name(name),
 50                action,
 51                keystrokes: bindings
 52                    .last()
 53                    .map_or(Vec::new(), |binding| binding.keystrokes().to_vec()),
 54            })
 55            .collect();
 56        let picker = cx.add_view(|cx| Picker::new(this, cx));
 57        Self {
 58            picker,
 59            actions,
 60            matches: vec![],
 61            selected_ix: 0,
 62            focused_view_id,
 63        }
 64    }
 65
 66    fn toggle(_: &mut Workspace, _: &Toggle, cx: &mut ViewContext<Workspace>) {
 67        let workspace = cx.handle();
 68        let window_id = cx.window_id();
 69        let focused_view_id = cx.focused_view_id(window_id).unwrap_or(workspace.id());
 70
 71        cx.as_mut().defer(move |cx| {
 72            let this = cx.add_view(window_id, |cx| Self::new(focused_view_id, cx));
 73            workspace.update(cx, |workspace, cx| {
 74                workspace.toggle_modal(cx, |cx, _| {
 75                    cx.subscribe(&this, Self::on_event).detach();
 76                    this
 77                });
 78            });
 79        });
 80    }
 81
 82    fn on_event(
 83        workspace: &mut Workspace,
 84        _: ViewHandle<Self>,
 85        event: &Event,
 86        cx: &mut ViewContext<Workspace>,
 87    ) {
 88        match event {
 89            Event::Dismissed => workspace.dismiss_modal(cx),
 90            Event::Confirmed {
 91                window_id,
 92                focused_view_id,
 93                action,
 94            } => {
 95                let window_id = *window_id;
 96                let focused_view_id = *focused_view_id;
 97                let action = (*action).boxed_clone();
 98                workspace.dismiss_modal(cx);
 99                cx.as_mut()
100                    .defer(move |cx| cx.dispatch_action_at(window_id, focused_view_id, &*action))
101            }
102        }
103    }
104}
105
106impl Entity for CommandPalette {
107    type Event = Event;
108}
109
110impl View for CommandPalette {
111    fn ui_name() -> &'static str {
112        "CommandPalette"
113    }
114
115    fn render(&mut self, _: &mut gpui::RenderContext<'_, Self>) -> gpui::ElementBox {
116        ChildView::new(self.picker.clone()).boxed()
117    }
118
119    fn on_focus(&mut self, cx: &mut ViewContext<Self>) {
120        cx.focus(&self.picker);
121    }
122}
123
124impl PickerDelegate for CommandPalette {
125    fn match_count(&self) -> usize {
126        self.matches.len()
127    }
128
129    fn selected_index(&self) -> usize {
130        self.selected_ix
131    }
132
133    fn set_selected_index(&mut self, ix: usize, _: &mut ViewContext<Self>) {
134        self.selected_ix = ix;
135    }
136
137    fn update_matches(
138        &mut self,
139        query: String,
140        cx: &mut gpui::ViewContext<Self>,
141    ) -> gpui::Task<()> {
142        let candidates = self
143            .actions
144            .iter()
145            .enumerate()
146            .map(|(ix, command)| StringMatchCandidate {
147                id: ix,
148                string: command.name.to_string(),
149                char_bag: command.name.chars().collect(),
150            })
151            .collect::<Vec<_>>();
152        cx.spawn(move |this, mut cx| async move {
153            let matches = if query.is_empty() {
154                candidates
155                    .into_iter()
156                    .enumerate()
157                    .map(|(index, candidate)| StringMatch {
158                        candidate_id: index,
159                        string: candidate.string,
160                        positions: Vec::new(),
161                        score: 0.0,
162                    })
163                    .collect()
164            } else {
165                fuzzy::match_strings(
166                    &candidates,
167                    &query,
168                    true,
169                    10000,
170                    &Default::default(),
171                    cx.background(),
172                )
173                .await
174            };
175            this.update(&mut cx, |this, _| {
176                this.matches = matches;
177                if this.matches.is_empty() {
178                    this.selected_ix = 0;
179                } else {
180                    this.selected_ix = cmp::min(this.selected_ix, this.matches.len() - 1);
181                }
182            });
183        })
184    }
185
186    fn dismiss(&mut self, cx: &mut ViewContext<Self>) {
187        cx.emit(Event::Dismissed);
188    }
189
190    fn confirm(&mut self, cx: &mut ViewContext<Self>) {
191        if !self.matches.is_empty() {
192            let action_ix = self.matches[self.selected_ix].candidate_id;
193            cx.emit(Event::Confirmed {
194                window_id: cx.window_id(),
195                focused_view_id: self.focused_view_id,
196                action: self.actions.remove(action_ix).action,
197            });
198        } else {
199            cx.emit(Event::Dismissed);
200        }
201    }
202
203    fn render_match(&self, ix: usize, selected: bool, cx: &gpui::AppContext) -> gpui::ElementBox {
204        let mat = &self.matches[ix];
205        let command = &self.actions[mat.candidate_id];
206        let settings = cx.global::<Settings>();
207        let theme = &settings.theme;
208        let style = if selected {
209            &theme.selector.active_item
210        } else {
211            &theme.selector.item
212        };
213        let key_style = &theme.command_palette.key;
214        let keystroke_spacing = theme.command_palette.keystroke_spacing;
215
216        Flex::row()
217            .with_child(
218                Label::new(mat.string.clone(), style.label.clone())
219                    .with_highlights(mat.positions.clone())
220                    .boxed(),
221            )
222            .with_children(command.keystrokes.iter().map(|keystroke| {
223                Flex::row()
224                    .with_children(
225                        [
226                            (keystroke.ctrl, "^"),
227                            (keystroke.alt, ""),
228                            (keystroke.cmd, ""),
229                            (keystroke.shift, ""),
230                        ]
231                        .into_iter()
232                        .filter_map(|(modifier, label)| {
233                            if modifier {
234                                Some(
235                                    Label::new(label.into(), key_style.label.clone())
236                                        .contained()
237                                        .with_style(key_style.container)
238                                        .boxed(),
239                                )
240                            } else {
241                                None
242                            }
243                        }),
244                    )
245                    .with_child(
246                        Label::new(keystroke.key.clone(), key_style.label.clone())
247                            .contained()
248                            .with_style(key_style.container)
249                            .boxed(),
250                    )
251                    .contained()
252                    .with_margin_left(keystroke_spacing)
253                    .flex_float()
254                    .boxed()
255            }))
256            .contained()
257            .with_style(style.container)
258            .boxed()
259    }
260}
261
262fn humanize_action_name(name: &str) -> String {
263    let capacity = name.len() + name.chars().filter(|c| c.is_uppercase()).count();
264    let mut result = String::with_capacity(capacity);
265    for char in name.chars() {
266        if char == ':' {
267            if result.ends_with(':') {
268                result.push(' ');
269            } else {
270                result.push(':');
271            }
272        } else if char.is_uppercase() {
273            if !result.ends_with(' ') {
274                result.push(' ');
275            }
276            result.extend(char.to_lowercase());
277        } else {
278            result.push(char);
279        }
280    }
281    result
282}
283
284impl std::fmt::Debug for Command {
285    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
286        f.debug_struct("Command")
287            .field("name", &self.name)
288            .field("keystrokes", &self.keystrokes)
289            .finish()
290    }
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296    use editor::Editor;
297    use gpui::TestAppContext;
298    use workspace::{Workspace, WorkspaceParams};
299
300    #[test]
301    fn test_humanize_action_name() {
302        assert_eq!(
303            &humanize_action_name("editor::GoToDefinition"),
304            "editor: go to definition"
305        );
306        assert_eq!(
307            &humanize_action_name("editor::Backspace"),
308            "editor: backspace"
309        );
310    }
311
312    #[gpui::test]
313    async fn test_command_palette(cx: &mut TestAppContext) {
314        let params = cx.update(WorkspaceParams::test);
315
316        cx.update(|cx| {
317            editor::init(cx);
318            workspace::init(&params.client, cx);
319            init(cx);
320        });
321
322        let (window_id, workspace) = cx.add_window(|cx| Workspace::new(&params, cx));
323        let editor = cx.add_view(window_id, |cx| {
324            let mut editor = Editor::single_line(None, cx);
325            editor.set_text("abc", cx);
326            editor
327        });
328
329        workspace.update(cx, |workspace, cx| {
330            cx.focus(editor.clone());
331            workspace.add_item(Box::new(editor.clone()), cx)
332        });
333
334        workspace.update(cx, |workspace, cx| {
335            CommandPalette::toggle(workspace, &Toggle, cx)
336        });
337
338        let palette = workspace.read_with(cx, |workspace, _| {
339            workspace
340                .modal()
341                .unwrap()
342                .clone()
343                .downcast::<CommandPalette>()
344                .unwrap()
345        });
346
347        palette
348            .update(cx, |palette, cx| {
349                palette.update_matches("bcksp".to_string(), cx)
350            })
351            .await;
352
353        palette.update(cx, |palette, cx| {
354            assert_eq!(palette.matches[0].string, "editor: backspace");
355            palette.confirm(cx);
356        });
357
358        editor.read_with(cx, |editor, cx| {
359            assert_eq!(editor.text(cx), "ab");
360        });
361    }
362}