context_menu.rs

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