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