context_menu.rs

  1use crate::{
  2    h_flex, prelude::*, v_flex, Icon, IconName, KeyBinding, Label, List, ListItem, ListSeparator,
  3    ListSubHeader,
  4};
  5use gpui::{
  6    px, Action, AnyElement, AppContext, DismissEvent, EventEmitter, FocusHandle, FocusableView,
  7    IntoElement, Render, Subscription, View, VisualContext,
  8};
  9use menu::{SelectFirst, SelectLast, SelectNext, SelectPrev};
 10use std::{rc::Rc, time::Duration};
 11
 12enum ContextMenuItem {
 13    Separator,
 14    Header(SharedString),
 15    Entry {
 16        toggled: Option<bool>,
 17        label: SharedString,
 18        icon: Option<IconName>,
 19        handler: Rc<dyn Fn(&mut WindowContext)>,
 20        action: Option<Box<dyn Action>>,
 21    },
 22    CustomEntry {
 23        entry_render: Box<dyn Fn(&mut WindowContext) -> AnyElement>,
 24        handler: Rc<dyn Fn(&mut WindowContext)>,
 25    },
 26}
 27
 28pub struct ContextMenu {
 29    items: Vec<ContextMenuItem>,
 30    focus_handle: FocusHandle,
 31    action_context: Option<FocusHandle>,
 32    selected_index: Option<usize>,
 33    delayed: bool,
 34    clicked: bool,
 35    _on_blur_subscription: Subscription,
 36}
 37
 38impl FocusableView for ContextMenu {
 39    fn focus_handle(&self, _cx: &AppContext) -> FocusHandle {
 40        self.focus_handle.clone()
 41    }
 42}
 43
 44impl EventEmitter<DismissEvent> for ContextMenu {}
 45
 46impl FluentBuilder for ContextMenu {}
 47
 48impl ContextMenu {
 49    pub fn build(
 50        cx: &mut WindowContext,
 51        f: impl FnOnce(Self, &mut WindowContext) -> Self,
 52    ) -> View<Self> {
 53        cx.new_view(|cx| {
 54            let focus_handle = cx.focus_handle();
 55            let _on_blur_subscription = cx.on_blur(&focus_handle, |this: &mut ContextMenu, cx| {
 56                this.cancel(&menu::Cancel, cx)
 57            });
 58            cx.refresh();
 59            f(
 60                Self {
 61                    items: Default::default(),
 62                    focus_handle,
 63                    action_context: None,
 64                    selected_index: None,
 65                    delayed: false,
 66                    clicked: false,
 67                    _on_blur_subscription,
 68                },
 69                cx,
 70            )
 71        })
 72    }
 73
 74    pub fn context(mut self, focus: FocusHandle) -> Self {
 75        self.action_context = Some(focus);
 76        self
 77    }
 78
 79    pub fn header(mut self, title: impl Into<SharedString>) -> Self {
 80        self.items.push(ContextMenuItem::Header(title.into()));
 81        self
 82    }
 83
 84    pub fn separator(mut self) -> Self {
 85        self.items.push(ContextMenuItem::Separator);
 86        self
 87    }
 88
 89    pub fn entry(
 90        mut self,
 91        label: impl Into<SharedString>,
 92        action: Option<Box<dyn Action>>,
 93        handler: impl Fn(&mut WindowContext) + 'static,
 94    ) -> Self {
 95        self.items.push(ContextMenuItem::Entry {
 96            toggled: None,
 97            label: label.into(),
 98            handler: Rc::new(handler),
 99            icon: None,
100            action,
101        });
102        self
103    }
104
105    pub fn toggleable_entry(
106        mut self,
107        label: impl Into<SharedString>,
108        toggled: bool,
109        action: Option<Box<dyn Action>>,
110        handler: impl Fn(&mut WindowContext) + 'static,
111    ) -> Self {
112        self.items.push(ContextMenuItem::Entry {
113            toggled: Some(toggled),
114            label: label.into(),
115            handler: Rc::new(handler),
116            icon: None,
117            action,
118        });
119        self
120    }
121
122    pub fn custom_entry(
123        mut self,
124        entry_render: impl Fn(&mut WindowContext) -> AnyElement + 'static,
125        handler: impl Fn(&mut WindowContext) + 'static,
126    ) -> Self {
127        self.items.push(ContextMenuItem::CustomEntry {
128            entry_render: Box::new(entry_render),
129            handler: Rc::new(handler),
130        });
131        self
132    }
133
134    pub fn action(mut self, label: impl Into<SharedString>, action: Box<dyn Action>) -> Self {
135        self.items.push(ContextMenuItem::Entry {
136            toggled: None,
137            label: label.into(),
138            action: Some(action.boxed_clone()),
139            handler: Rc::new(move |cx| cx.dispatch_action(action.boxed_clone())),
140            icon: None,
141        });
142        self
143    }
144
145    pub fn link(mut self, label: impl Into<SharedString>, action: Box<dyn Action>) -> Self {
146        self.items.push(ContextMenuItem::Entry {
147            toggled: None,
148            label: label.into(),
149            action: Some(action.boxed_clone()),
150            handler: Rc::new(move |cx| cx.dispatch_action(action.boxed_clone())),
151            icon: Some(IconName::Link),
152        });
153        self
154    }
155
156    pub fn confirm(&mut self, _: &menu::Confirm, cx: &mut ViewContext<Self>) {
157        match self.selected_index.and_then(|ix| self.items.get(ix)) {
158            Some(
159                ContextMenuItem::Entry { handler, .. }
160                | ContextMenuItem::CustomEntry { handler, .. },
161            ) => (handler)(cx),
162            _ => {}
163        }
164
165        cx.emit(DismissEvent);
166    }
167
168    pub fn cancel(&mut self, _: &menu::Cancel, cx: &mut ViewContext<Self>) {
169        cx.emit(DismissEvent);
170        cx.emit(DismissEvent);
171    }
172
173    fn select_first(&mut self, _: &SelectFirst, cx: &mut ViewContext<Self>) {
174        self.selected_index = self.items.iter().position(|item| item.is_selectable());
175        cx.notify();
176    }
177
178    pub fn select_last(&mut self) -> Option<usize> {
179        for (ix, item) in self.items.iter().enumerate().rev() {
180            if item.is_selectable() {
181                self.selected_index = Some(ix);
182                return Some(ix);
183            }
184        }
185        None
186    }
187
188    fn handle_select_last(&mut self, _: &SelectLast, cx: &mut ViewContext<Self>) {
189        if self.select_last().is_some() {
190            cx.notify();
191        }
192    }
193
194    fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
195        if let Some(ix) = self.selected_index {
196            for (ix, item) in self.items.iter().enumerate().skip(ix + 1) {
197                if item.is_selectable() {
198                    self.selected_index = Some(ix);
199                    cx.notify();
200                    break;
201                }
202            }
203        } else {
204            self.select_first(&Default::default(), cx);
205        }
206    }
207
208    pub fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
209        if let Some(ix) = self.selected_index {
210            for (ix, item) in self.items.iter().enumerate().take(ix).rev() {
211                if item.is_selectable() {
212                    self.selected_index = Some(ix);
213                    cx.notify();
214                    break;
215                }
216            }
217        } else {
218            self.handle_select_last(&Default::default(), cx);
219        }
220    }
221
222    pub fn on_action_dispatch(&mut self, dispatched: &Box<dyn Action>, cx: &mut ViewContext<Self>) {
223        if self.clicked {
224            cx.propagate();
225            return;
226        }
227
228        if let Some(ix) = self.items.iter().position(|item| {
229            if let ContextMenuItem::Entry {
230                action: Some(action),
231                ..
232            } = item
233            {
234                action.partial_eq(&**dispatched)
235            } else {
236                false
237            }
238        }) {
239            self.selected_index = Some(ix);
240            self.delayed = true;
241            cx.notify();
242            let action = dispatched.boxed_clone();
243            cx.spawn(|this, mut cx| async move {
244                cx.background_executor()
245                    .timer(Duration::from_millis(50))
246                    .await;
247                this.update(&mut cx, |this, cx| {
248                    this.cancel(&menu::Cancel, cx);
249                    cx.dispatch_action(action);
250                })
251            })
252            .detach_and_log_err(cx);
253        } else {
254            cx.propagate()
255        }
256    }
257}
258
259impl ContextMenuItem {
260    fn is_selectable(&self) -> bool {
261        matches!(self, Self::Entry { .. } | Self::CustomEntry { .. })
262    }
263}
264
265impl Render for ContextMenu {
266    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
267        div().occlude().elevation_2(cx).flex().flex_row().child(
268            v_flex()
269                .min_w(px(200.))
270                .track_focus(&self.focus_handle)
271                .on_mouse_down_out(cx.listener(|this, _, cx| this.cancel(&menu::Cancel, cx)))
272                .key_context("menu")
273                .on_action(cx.listener(ContextMenu::select_first))
274                .on_action(cx.listener(ContextMenu::handle_select_last))
275                .on_action(cx.listener(ContextMenu::select_next))
276                .on_action(cx.listener(ContextMenu::select_prev))
277                .on_action(cx.listener(ContextMenu::confirm))
278                .on_action(cx.listener(ContextMenu::cancel))
279                .when(!self.delayed, |mut el| {
280                    for item in self.items.iter() {
281                        if let ContextMenuItem::Entry {
282                            action: Some(action),
283                            ..
284                        } = item
285                        {
286                            el = el.on_boxed_action(
287                                &**action,
288                                cx.listener(ContextMenu::on_action_dispatch),
289                            );
290                        }
291                    }
292                    el
293                })
294                .flex_none()
295                .child(List::new().children(self.items.iter_mut().enumerate().map(
296                    |(ix, item)| {
297                        match item {
298                            ContextMenuItem::Separator => ListSeparator.into_any_element(),
299                            ContextMenuItem::Header(header) => ListSubHeader::new(header.clone())
300                                .inset(true)
301                                .into_any_element(),
302                            ContextMenuItem::Entry {
303                                toggled,
304                                label,
305                                handler,
306                                icon,
307                                action,
308                            } => {
309                                let handler = handler.clone();
310                                let menu = cx.view().downgrade();
311
312                                let label_element = if let Some(icon) = icon {
313                                    h_flex()
314                                        .gap_1()
315                                        .child(Label::new(label.clone()))
316                                        .child(Icon::new(*icon))
317                                        .into_any_element()
318                                } else {
319                                    Label::new(label.clone()).into_any_element()
320                                };
321
322                                ListItem::new(ix)
323                                    .inset(true)
324                                    .selected(Some(ix) == self.selected_index)
325                                    .when_some(*toggled, |list_item, toggled| {
326                                        list_item.start_slot(if toggled {
327                                            v_flex().flex_none().child(
328                                                Icon::new(IconName::Check).color(Color::Accent),
329                                            )
330                                        } else {
331                                            v_flex().flex_none().size(IconSize::default().rems())
332                                        })
333                                    })
334                                    .child(
335                                        h_flex()
336                                            .w_full()
337                                            .justify_between()
338                                            .child(label_element)
339                                            .debug_selector(|| format!("MENU_ITEM-{}", label))
340                                            .children(action.as_ref().and_then(|action| {
341                                                self.action_context
342                                                    .as_ref()
343                                                    .map(|focus| {
344                                                        KeyBinding::for_action_in(
345                                                            &**action, focus, cx,
346                                                        )
347                                                    })
348                                                    .unwrap_or_else(|| {
349                                                        KeyBinding::for_action(&**action, cx)
350                                                    })
351                                                    .map(|binding| div().ml_1().child(binding))
352                                            })),
353                                    )
354                                    .on_click(move |_, cx| {
355                                        handler(cx);
356                                        menu.update(cx, |menu, cx| {
357                                            menu.clicked = true;
358                                            cx.emit(DismissEvent);
359                                        })
360                                        .ok();
361                                    })
362                                    .into_any_element()
363                            }
364                            ContextMenuItem::CustomEntry {
365                                entry_render,
366                                handler,
367                            } => {
368                                let handler = handler.clone();
369                                let menu = cx.view().downgrade();
370                                ListItem::new(ix)
371                                    .inset(true)
372                                    .selected(Some(ix) == self.selected_index)
373                                    .on_click(move |_, cx| {
374                                        handler(cx);
375                                        menu.update(cx, |menu, cx| {
376                                            menu.clicked = true;
377                                            cx.emit(DismissEvent);
378                                        })
379                                        .ok();
380                                    })
381                                    .child(entry_render(cx))
382                                    .into_any_element()
383                            }
384                        }
385                    },
386                ))),
387        )
388    }
389}