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