command_palette.rs

  1use fuzzy::{StringMatch, StringMatchCandidate};
  2use gpui::{
  3    actions,
  4    elements::{ChildView, Flex, Label, MouseState, 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(
204        &self,
205        ix: usize,
206        mouse_state: &MouseState,
207        selected: bool,
208        cx: &gpui::AppContext,
209    ) -> gpui::ElementBox {
210        let mat = &self.matches[ix];
211        let command = &self.actions[mat.candidate_id];
212        let settings = cx.global::<Settings>();
213        let theme = &settings.theme;
214        let style = theme.picker.item.style_for(mouse_state, selected);
215        let key_style = &theme.command_palette.key.style_for(mouse_state, selected);
216        let keystroke_spacing = theme.command_palette.keystroke_spacing;
217
218        Flex::row()
219            .with_child(
220                Label::new(mat.string.clone(), style.label.clone())
221                    .with_highlights(mat.positions.clone())
222                    .boxed(),
223            )
224            .with_children(command.keystrokes.iter().map(|keystroke| {
225                Flex::row()
226                    .with_children(
227                        [
228                            (keystroke.ctrl, "^"),
229                            (keystroke.alt, ""),
230                            (keystroke.cmd, ""),
231                            (keystroke.shift, ""),
232                        ]
233                        .into_iter()
234                        .filter_map(|(modifier, label)| {
235                            if modifier {
236                                Some(
237                                    Label::new(label.into(), key_style.label.clone())
238                                        .contained()
239                                        .with_style(key_style.container)
240                                        .boxed(),
241                                )
242                            } else {
243                                None
244                            }
245                        }),
246                    )
247                    .with_child(
248                        Label::new(keystroke.key.clone(), key_style.label.clone())
249                            .contained()
250                            .with_style(key_style.container)
251                            .boxed(),
252                    )
253                    .contained()
254                    .with_margin_left(keystroke_spacing)
255                    .flex_float()
256                    .boxed()
257            }))
258            .contained()
259            .with_style(style.container)
260            .boxed()
261    }
262}
263
264fn humanize_action_name(name: &str) -> String {
265    let capacity = name.len() + name.chars().filter(|c| c.is_uppercase()).count();
266    let mut result = String::with_capacity(capacity);
267    for char in name.chars() {
268        if char == ':' {
269            if result.ends_with(':') {
270                result.push(' ');
271            } else {
272                result.push(':');
273            }
274        } else if char == '_' {
275            result.push(' ');
276        } else if char.is_uppercase() {
277            if !result.ends_with(' ') {
278                result.push(' ');
279            }
280            result.extend(char.to_lowercase());
281        } else {
282            result.push(char);
283        }
284    }
285    result
286}
287
288impl std::fmt::Debug for Command {
289    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
290        f.debug_struct("Command")
291            .field("name", &self.name)
292            .field("keystrokes", &self.keystrokes)
293            .finish()
294    }
295}
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300    use editor::Editor;
301    use gpui::TestAppContext;
302    use workspace::{Workspace, WorkspaceParams};
303
304    #[test]
305    fn test_humanize_action_name() {
306        assert_eq!(
307            humanize_action_name("editor::GoToDefinition"),
308            "editor: go to definition"
309        );
310        assert_eq!(
311            humanize_action_name("editor::Backspace"),
312            "editor: backspace"
313        );
314        assert_eq!(
315            humanize_action_name("go_to_line::Deploy"),
316            "go to line: deploy"
317        );
318    }
319
320    #[gpui::test]
321    async fn test_command_palette(cx: &mut TestAppContext) {
322        let params = cx.update(WorkspaceParams::test);
323
324        cx.update(|cx| {
325            editor::init(cx);
326            workspace::init(&params.client, cx);
327            init(cx);
328        });
329
330        let (window_id, workspace) = cx.add_window(|cx| Workspace::new(&params, cx));
331        let editor = cx.add_view(window_id, |cx| {
332            let mut editor = Editor::single_line(None, cx);
333            editor.set_text("abc", cx);
334            editor
335        });
336
337        workspace.update(cx, |workspace, cx| {
338            cx.focus(editor.clone());
339            workspace.add_item(Box::new(editor.clone()), cx)
340        });
341
342        workspace.update(cx, |workspace, cx| {
343            CommandPalette::toggle(workspace, &Toggle, cx)
344        });
345
346        let palette = workspace.read_with(cx, |workspace, _| {
347            workspace
348                .modal()
349                .unwrap()
350                .clone()
351                .downcast::<CommandPalette>()
352                .unwrap()
353        });
354
355        palette
356            .update(cx, |palette, cx| {
357                palette.update_matches("bcksp".to_string(), cx)
358            })
359            .await;
360
361        palette.update(cx, |palette, cx| {
362            assert_eq!(palette.matches[0].string, "editor: backspace");
363            palette.confirm(cx);
364        });
365
366        editor.read_with(cx, |editor, cx| {
367            assert_eq!(editor.text(cx), "ab");
368        });
369    }
370}