context_menu.rs

  1use crate::{
  2    h_flex, prelude::*, v_flex, Icon, IconName, KeyBinding, Label, List, ListItem, ListSeparator,
  3    ListSubHeader, WithRemSize,
  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 settings::Settings;
 11use std::{rc::Rc, time::Duration};
 12use theme::ThemeSettings;
 13
 14enum ContextMenuItem {
 15    Separator,
 16    Header(SharedString),
 17    Label(SharedString),
 18    Entry {
 19        toggle: Option<(IconPosition, bool)>,
 20        label: SharedString,
 21        icon: Option<IconName>,
 22        handler: Rc<dyn Fn(Option<&FocusHandle>, &mut WindowContext)>,
 23        action: Option<Box<dyn Action>>,
 24        disabled: bool,
 25    },
 26    CustomEntry {
 27        entry_render: Box<dyn Fn(&mut WindowContext) -> AnyElement>,
 28        handler: Rc<dyn Fn(Option<&FocusHandle>, &mut WindowContext)>,
 29        selectable: bool,
 30    },
 31}
 32
 33pub struct ContextMenu {
 34    items: Vec<ContextMenuItem>,
 35    focus_handle: FocusHandle,
 36    action_context: Option<FocusHandle>,
 37    selected_index: Option<usize>,
 38    delayed: bool,
 39    clicked: bool,
 40    _on_blur_subscription: Subscription,
 41}
 42
 43impl FocusableView for ContextMenu {
 44    fn focus_handle(&self, _cx: &AppContext) -> FocusHandle {
 45        self.focus_handle.clone()
 46    }
 47}
 48
 49impl EventEmitter<DismissEvent> for ContextMenu {}
 50
 51impl FluentBuilder for ContextMenu {}
 52
 53impl ContextMenu {
 54    pub fn build(
 55        cx: &mut WindowContext,
 56        f: impl FnOnce(Self, &mut ViewContext<Self>) -> Self,
 57    ) -> View<Self> {
 58        cx.new_view(|cx| {
 59            let focus_handle = cx.focus_handle();
 60            let _on_blur_subscription = cx.on_blur(&focus_handle, |this: &mut ContextMenu, cx| {
 61                this.cancel(&menu::Cancel, cx)
 62            });
 63            cx.refresh();
 64            f(
 65                Self {
 66                    items: Default::default(),
 67                    focus_handle,
 68                    action_context: None,
 69                    selected_index: None,
 70                    delayed: false,
 71                    clicked: false,
 72                    _on_blur_subscription,
 73                },
 74                cx,
 75            )
 76        })
 77    }
 78
 79    pub fn context(mut self, focus: FocusHandle) -> Self {
 80        self.action_context = Some(focus);
 81        self
 82    }
 83
 84    pub fn header(mut self, title: impl Into<SharedString>) -> Self {
 85        self.items.push(ContextMenuItem::Header(title.into()));
 86        self
 87    }
 88
 89    pub fn separator(mut self) -> Self {
 90        self.items.push(ContextMenuItem::Separator);
 91        self
 92    }
 93
 94    pub fn entry(
 95        mut self,
 96        label: impl Into<SharedString>,
 97        action: Option<Box<dyn Action>>,
 98        handler: impl Fn(&mut WindowContext) + 'static,
 99    ) -> Self {
100        self.items.push(ContextMenuItem::Entry {
101            toggle: None,
102            label: label.into(),
103            handler: Rc::new(move |_, cx| handler(cx)),
104            icon: None,
105            action,
106            disabled: false,
107        });
108        self
109    }
110
111    pub fn toggleable_entry(
112        mut self,
113        label: impl Into<SharedString>,
114        toggled: bool,
115        position: IconPosition,
116        action: Option<Box<dyn Action>>,
117        handler: impl Fn(&mut WindowContext) + 'static,
118    ) -> Self {
119        self.items.push(ContextMenuItem::Entry {
120            toggle: Some((position, toggled)),
121            label: label.into(),
122            handler: Rc::new(move |_, cx| handler(cx)),
123            icon: None,
124            action,
125            disabled: false,
126        });
127        self
128    }
129
130    pub fn custom_row(
131        mut self,
132        entry_render: impl Fn(&mut WindowContext) -> AnyElement + 'static,
133    ) -> Self {
134        self.items.push(ContextMenuItem::CustomEntry {
135            entry_render: Box::new(entry_render),
136            handler: Rc::new(|_, _| {}),
137            selectable: false,
138        });
139        self
140    }
141
142    pub fn custom_entry(
143        mut self,
144        entry_render: impl Fn(&mut WindowContext) -> AnyElement + 'static,
145        handler: impl Fn(&mut WindowContext) + 'static,
146    ) -> Self {
147        self.items.push(ContextMenuItem::CustomEntry {
148            entry_render: Box::new(entry_render),
149            handler: Rc::new(move |_, cx| handler(cx)),
150            selectable: true,
151        });
152        self
153    }
154
155    pub fn label(mut self, label: impl Into<SharedString>) -> Self {
156        self.items.push(ContextMenuItem::Label(label.into()));
157        self
158    }
159
160    pub fn action(mut self, label: impl Into<SharedString>, action: Box<dyn Action>) -> Self {
161        self.items.push(ContextMenuItem::Entry {
162            toggle: None,
163            label: label.into(),
164            action: Some(action.boxed_clone()),
165
166            handler: Rc::new(move |context, cx| {
167                if let Some(context) = &context {
168                    cx.focus(context);
169                }
170                cx.dispatch_action(action.boxed_clone());
171            }),
172            icon: None,
173            disabled: false,
174        });
175        self
176    }
177
178    pub fn disabled_action(
179        mut self,
180        label: impl Into<SharedString>,
181        action: Box<dyn Action>,
182    ) -> Self {
183        self.items.push(ContextMenuItem::Entry {
184            toggle: None,
185            label: label.into(),
186            action: Some(action.boxed_clone()),
187
188            handler: Rc::new(move |context, cx| {
189                if let Some(context) = &context {
190                    cx.focus(context);
191                }
192                cx.dispatch_action(action.boxed_clone());
193            }),
194            icon: None,
195            disabled: true,
196        });
197        self
198    }
199
200    pub fn link(mut self, label: impl Into<SharedString>, action: Box<dyn Action>) -> Self {
201        self.items.push(ContextMenuItem::Entry {
202            toggle: None,
203            label: label.into(),
204
205            action: Some(action.boxed_clone()),
206            handler: Rc::new(move |_, cx| cx.dispatch_action(action.boxed_clone())),
207            icon: Some(IconName::ArrowUpRight),
208            disabled: false,
209        });
210        self
211    }
212
213    pub fn confirm(&mut self, _: &menu::Confirm, cx: &mut ViewContext<Self>) {
214        let context = self.action_context.as_ref();
215        match self.selected_index.and_then(|ix| self.items.get(ix)) {
216            Some(
217                ContextMenuItem::Entry {
218                    handler,
219                    disabled: false,
220                    ..
221                }
222                | ContextMenuItem::CustomEntry { handler, .. },
223            ) => (handler)(context, cx),
224            _ => {}
225        }
226
227        cx.emit(DismissEvent);
228    }
229
230    pub fn cancel(&mut self, _: &menu::Cancel, cx: &mut ViewContext<Self>) {
231        cx.emit(DismissEvent);
232        cx.emit(DismissEvent);
233    }
234
235    fn select_first(&mut self, _: &SelectFirst, cx: &mut ViewContext<Self>) {
236        self.selected_index = self.items.iter().position(|item| item.is_selectable());
237        cx.notify();
238    }
239
240    pub fn select_last(&mut self) -> Option<usize> {
241        for (ix, item) in self.items.iter().enumerate().rev() {
242            if item.is_selectable() {
243                self.selected_index = Some(ix);
244                return Some(ix);
245            }
246        }
247        None
248    }
249
250    fn handle_select_last(&mut self, _: &SelectLast, cx: &mut ViewContext<Self>) {
251        if self.select_last().is_some() {
252            cx.notify();
253        }
254    }
255
256    fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
257        if let Some(ix) = self.selected_index {
258            for (ix, item) in self.items.iter().enumerate().skip(ix + 1) {
259                if item.is_selectable() {
260                    self.selected_index = Some(ix);
261                    cx.notify();
262                    break;
263                }
264            }
265        } else {
266            self.select_first(&Default::default(), cx);
267        }
268    }
269
270    pub fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
271        if let Some(ix) = self.selected_index {
272            for (ix, item) in self.items.iter().enumerate().take(ix).rev() {
273                if item.is_selectable() {
274                    self.selected_index = Some(ix);
275                    cx.notify();
276                    break;
277                }
278            }
279        } else {
280            self.handle_select_last(&Default::default(), cx);
281        }
282    }
283
284    pub fn on_action_dispatch(&mut self, dispatched: &dyn Action, cx: &mut ViewContext<Self>) {
285        if self.clicked {
286            cx.propagate();
287            return;
288        }
289
290        if let Some(ix) = self.items.iter().position(|item| {
291            if let ContextMenuItem::Entry {
292                action: Some(action),
293                disabled: false,
294                ..
295            } = item
296            {
297                action.partial_eq(dispatched)
298            } else {
299                false
300            }
301        }) {
302            self.selected_index = Some(ix);
303            self.delayed = true;
304            cx.notify();
305            let action = dispatched.boxed_clone();
306            cx.spawn(|this, mut cx| async move {
307                cx.background_executor()
308                    .timer(Duration::from_millis(50))
309                    .await;
310                this.update(&mut cx, |this, cx| {
311                    this.cancel(&menu::Cancel, cx);
312                    cx.dispatch_action(action);
313                })
314            })
315            .detach_and_log_err(cx);
316        } else {
317            cx.propagate()
318        }
319    }
320
321    pub fn on_blur_subscription(mut self, new_subscription: Subscription) -> Self {
322        self._on_blur_subscription = new_subscription;
323        self
324    }
325}
326
327impl ContextMenuItem {
328    fn is_selectable(&self) -> bool {
329        match self {
330            ContextMenuItem::Header(_)
331            | ContextMenuItem::Separator
332            | ContextMenuItem::Label { .. } => false,
333            ContextMenuItem::Entry { disabled, .. } => !disabled,
334            ContextMenuItem::CustomEntry { selectable, .. } => *selectable,
335        }
336    }
337}
338
339impl Render for ContextMenu {
340    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
341        let ui_font_size = ThemeSettings::get_global(cx).ui_font_size;
342
343        div().occlude().elevation_2(cx).flex().flex_row().child(
344            WithRemSize::new(ui_font_size).flex().child(
345                v_flex()
346                    .id("context-menu")
347                    .min_w(px(200.))
348                    .max_h(vh(0.75, cx))
349                    .overflow_y_scroll()
350                    .track_focus(&self.focus_handle)
351                    .on_mouse_down_out(cx.listener(|this, _, cx| this.cancel(&menu::Cancel, cx)))
352                    .key_context("menu")
353                    .on_action(cx.listener(ContextMenu::select_first))
354                    .on_action(cx.listener(ContextMenu::handle_select_last))
355                    .on_action(cx.listener(ContextMenu::select_next))
356                    .on_action(cx.listener(ContextMenu::select_prev))
357                    .on_action(cx.listener(ContextMenu::confirm))
358                    .on_action(cx.listener(ContextMenu::cancel))
359                    .when(!self.delayed, |mut el| {
360                        for item in self.items.iter() {
361                            if let ContextMenuItem::Entry {
362                                action: Some(action),
363                                disabled: false,
364                                ..
365                            } = item
366                            {
367                                el = el.on_boxed_action(
368                                    &**action,
369                                    cx.listener(ContextMenu::on_action_dispatch),
370                                );
371                            }
372                        }
373                        el
374                    })
375                    .flex_none()
376                    .child(List::new().children(self.items.iter_mut().enumerate().map(
377                        |(ix, item)| {
378                            match item {
379                                ContextMenuItem::Separator => ListSeparator.into_any_element(),
380                                ContextMenuItem::Header(header) => {
381                                    ListSubHeader::new(header.clone())
382                                        .inset(true)
383                                        .into_any_element()
384                                }
385                                ContextMenuItem::Label(label) => ListItem::new(ix)
386                                    .inset(true)
387                                    .disabled(true)
388                                    .child(Label::new(label.clone()))
389                                    .into_any_element(),
390                                ContextMenuItem::Entry {
391                                    toggle,
392                                    label,
393                                    handler,
394                                    icon,
395                                    action,
396                                    disabled,
397                                } => {
398                                    let handler = handler.clone();
399                                    let menu = cx.view().downgrade();
400                                    let color = if *disabled {
401                                        Color::Muted
402                                    } else {
403                                        Color::Default
404                                    };
405                                    let label_element = if let Some(icon) = icon {
406                                        h_flex()
407                                            .gap_1()
408                                            .child(Label::new(label.clone()).color(color))
409                                            .child(
410                                                Icon::new(*icon).size(IconSize::Small).color(color),
411                                            )
412                                            .into_any_element()
413                                    } else {
414                                        Label::new(label.clone()).color(color).into_any_element()
415                                    };
416
417                                    ListItem::new(ix)
418                                        .inset(true)
419                                        .disabled(*disabled)
420                                        .selected(Some(ix) == self.selected_index)
421                                        .when_some(*toggle, |list_item, (position, toggled)| {
422                                            let contents = if toggled {
423                                                v_flex().flex_none().child(
424                                                    Icon::new(IconName::Check).color(Color::Accent),
425                                                )
426                                            } else {
427                                                v_flex()
428                                                    .flex_none()
429                                                    .size(IconSize::default().rems())
430                                            };
431                                            match position {
432                                                IconPosition::Start => {
433                                                    list_item.start_slot(contents)
434                                                }
435                                                IconPosition::End => list_item.end_slot(contents),
436                                            }
437                                        })
438                                        .child(
439                                            h_flex()
440                                                .w_full()
441                                                .justify_between()
442                                                .child(label_element)
443                                                .debug_selector(|| format!("MENU_ITEM-{}", label))
444                                                .children(action.as_ref().and_then(|action| {
445                                                    self.action_context
446                                                        .as_ref()
447                                                        .map(|focus| {
448                                                            KeyBinding::for_action_in(
449                                                                &**action, focus, cx,
450                                                            )
451                                                        })
452                                                        .unwrap_or_else(|| {
453                                                            KeyBinding::for_action(&**action, cx)
454                                                        })
455                                                        .map(|binding| div().ml_4().child(binding))
456                                                })),
457                                        )
458                                        .on_click({
459                                            let context = self.action_context.clone();
460                                            move |_, cx| {
461                                                handler(context.as_ref(), cx);
462                                                menu.update(cx, |menu, cx| {
463                                                    menu.clicked = true;
464                                                    cx.emit(DismissEvent);
465                                                })
466                                                .ok();
467                                            }
468                                        })
469                                        .into_any_element()
470                                }
471                                ContextMenuItem::CustomEntry {
472                                    entry_render,
473                                    handler,
474                                    selectable,
475                                } => {
476                                    let handler = handler.clone();
477                                    let menu = cx.view().downgrade();
478                                    let selectable = *selectable;
479                                    ListItem::new(ix)
480                                        .inset(true)
481                                        .selected(if selectable {
482                                            Some(ix) == self.selected_index
483                                        } else {
484                                            false
485                                        })
486                                        .selectable(selectable)
487                                        .when(selectable, |item| {
488                                            item.on_click({
489                                                let context = self.action_context.clone();
490                                                move |_, cx| {
491                                                    handler(context.as_ref(), cx);
492                                                    menu.update(cx, |menu, cx| {
493                                                        menu.clicked = true;
494                                                        cx.emit(DismissEvent);
495                                                    })
496                                                    .ok();
497                                                }
498                                            })
499                                        })
500                                        .child(entry_render(cx))
501                                        .into_any_element()
502                                }
503                            }
504                        },
505                    ))),
506            ),
507        )
508    }
509}