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