Skip to main content

rgpui_component/menu/
popup_menu.rs

1use crate::actions::{Cancel, Confirm, SelectDown, SelectUp};
2use crate::actions::{SelectLeft, SelectRight};
3use crate::menu::menu_item::MenuItemElement;
4use crate::scroll::ScrollableElement;
5use crate::{ActiveTheme, ElementExt, Icon, IconName, Sizable as _, h_flex, v_flex};
6use crate::{Side, Size, StyledExt, kbd::Kbd};
7use rgpui::{
8    Action, Anchor, AnyElement, App, AppContext, Bounds, Context, DismissEvent, Edges, Entity,
9    EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, KeyBinding,
10    ParentElement, Pixels, Render, ScrollHandle, SharedString, StatefulInteractiveElement, Styled,
11    WeakEntity, Window, anchored, div, prelude::FluentBuilder, px, rems,
12};
13use rgpui::{ClickEvent, Half, MouseDownEvent, OwnedMenuItem, Point, Subscription};
14
15use std::rc::Rc;
16
17const CONTEXT: &str = "PopupMenu";
18
19pub fn init(cx: &mut App) {
20    cx.bind_keys([
21        KeyBinding::new("enter", Confirm { secondary: false }, Some(CONTEXT)),
22        KeyBinding::new("escape", Cancel, Some(CONTEXT)),
23        KeyBinding::new("up", SelectUp, Some(CONTEXT)),
24        KeyBinding::new("down", SelectDown, Some(CONTEXT)),
25        KeyBinding::new("left", SelectLeft, Some(CONTEXT)),
26        KeyBinding::new("right", SelectRight, Some(CONTEXT)),
27    ]);
28}
29
30/// An menu item in a popup menu.
31pub enum PopupMenuItem {
32    /// A menu separator item.
33    Separator,
34    /// A non-interactive label item.
35    Label(SharedString),
36    /// A standard menu item.
37    Item {
38        icon: Option<Icon>,
39        label: SharedString,
40        disabled: bool,
41        checked: bool,
42        is_link: bool,
43        action: Option<Box<dyn Action>>,
44        // For link item
45        handler: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
46    },
47    /// A menu item with custom element render.
48    ElementItem {
49        icon: Option<Icon>,
50        disabled: bool,
51        checked: bool,
52        action: Option<Box<dyn Action>>,
53        render: Box<dyn Fn(&mut Window, &mut App) -> AnyElement + 'static>,
54        handler: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
55    },
56    /// A submenu item that opens another popup menu.
57    ///
58    /// NOTE: This is only supported when the parent menu is not `scrollable`.
59    Submenu {
60        icon: Option<Icon>,
61        label: SharedString,
62        disabled: bool,
63        menu: Entity<PopupMenu>,
64    },
65}
66
67impl FluentBuilder for PopupMenuItem {}
68impl PopupMenuItem {
69    /// Create a new menu item with the given label.
70    #[inline]
71    pub fn new(label: impl Into<SharedString>) -> Self {
72        PopupMenuItem::Item {
73            icon: None,
74            label: label.into(),
75            disabled: false,
76            checked: false,
77            action: None,
78            is_link: false,
79            handler: None,
80        }
81    }
82
83    /// Create a new menu item with custom element render.
84    #[inline]
85    pub fn element<F, E>(builder: F) -> Self
86    where
87        F: Fn(&mut Window, &mut App) -> E + 'static,
88        E: IntoElement,
89    {
90        PopupMenuItem::ElementItem {
91            icon: None,
92            disabled: false,
93            checked: false,
94            action: None,
95            render: Box::new(move |window, cx| builder(window, cx).into_any_element()),
96            handler: None,
97        }
98    }
99
100    /// Create a new submenu item that opens another popup menu.
101    #[inline]
102    pub fn submenu(label: impl Into<SharedString>, menu: Entity<PopupMenu>) -> Self {
103        PopupMenuItem::Submenu {
104            icon: None,
105            label: label.into(),
106            disabled: false,
107            menu,
108        }
109    }
110
111    /// Create a separator menu item.
112    #[inline]
113    pub fn separator() -> Self {
114        PopupMenuItem::Separator
115    }
116
117    /// Creates a label menu item.
118    #[inline]
119    pub fn label(label: impl Into<SharedString>) -> Self {
120        PopupMenuItem::Label(label.into())
121    }
122
123    /// Set the icon for the menu item.
124    ///
125    /// Only works for [`PopupMenuItem::Item`], [`PopupMenuItem::ElementItem`] and [`PopupMenuItem::Submenu`].
126    pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
127        match &mut self {
128            PopupMenuItem::Item { icon: i, .. } => {
129                *i = Some(icon.into());
130            }
131            PopupMenuItem::ElementItem { icon: i, .. } => {
132                *i = Some(icon.into());
133            }
134            PopupMenuItem::Submenu { icon: i, .. } => {
135                *i = Some(icon.into());
136            }
137            _ => {}
138        }
139        self
140    }
141
142    /// Set the action for the menu item.
143    ///
144    /// Only works for [`PopupMenuItem::Item`] and [`PopupMenuItem::ElementItem`].
145    pub fn action(mut self, action: Box<dyn Action>) -> Self {
146        match &mut self {
147            PopupMenuItem::Item { action: a, .. } => {
148                *a = Some(action);
149            }
150            PopupMenuItem::ElementItem { action: a, .. } => {
151                *a = Some(action);
152            }
153            _ => {}
154        }
155        self
156    }
157
158    /// Set the disabled state for the menu item.
159    ///
160    /// Only works for [`PopupMenuItem::Item`], [`PopupMenuItem::ElementItem`] and [`PopupMenuItem::Submenu`].
161    pub fn disabled(mut self, disabled: bool) -> Self {
162        match &mut self {
163            PopupMenuItem::Item { disabled: d, .. } => {
164                *d = disabled;
165            }
166            PopupMenuItem::ElementItem { disabled: d, .. } => {
167                *d = disabled;
168            }
169            PopupMenuItem::Submenu { disabled: d, .. } => {
170                *d = disabled;
171            }
172            _ => {}
173        }
174        self
175    }
176
177    /// Set checked state for the menu item.
178    ///
179    /// NOTE: If `check_side` is [`Side::Left`], the icon will replace with a check icon.
180    pub fn checked(mut self, checked: bool) -> Self {
181        match &mut self {
182            PopupMenuItem::Item { checked: c, .. } => {
183                *c = checked;
184            }
185            PopupMenuItem::ElementItem { checked: c, .. } => {
186                *c = checked;
187            }
188            _ => {}
189        }
190        self
191    }
192
193    /// Add a click handler for the menu item.
194    ///
195    /// Only works for [`PopupMenuItem::Item`] and [`PopupMenuItem::ElementItem`].
196    pub fn on_click<F>(mut self, handler: F) -> Self
197    where
198        F: Fn(&ClickEvent, &mut Window, &mut App) + 'static,
199    {
200        match &mut self {
201            PopupMenuItem::Item { handler: h, .. } => {
202                *h = Some(Rc::new(handler));
203            }
204            PopupMenuItem::ElementItem { handler: h, .. } => {
205                *h = Some(Rc::new(handler));
206            }
207            _ => {}
208        }
209        self
210    }
211
212    /// Create a link menu item.
213    #[inline]
214    pub fn link(label: impl Into<SharedString>, href: impl Into<String>) -> Self {
215        let href = href.into();
216        PopupMenuItem::Item {
217            icon: None,
218            label: label.into(),
219            disabled: false,
220            checked: false,
221            action: None,
222            is_link: true,
223            handler: Some(Rc::new(move |_, _, cx| cx.open_url(&href))),
224        }
225    }
226
227    #[inline]
228    fn is_clickable(&self) -> bool {
229        !matches!(self, PopupMenuItem::Separator)
230            && matches!(
231                self,
232                PopupMenuItem::Item {
233                    disabled: false,
234                    ..
235                } | PopupMenuItem::ElementItem {
236                    disabled: false,
237                    ..
238                } | PopupMenuItem::Submenu {
239                    disabled: false,
240                    ..
241                }
242            )
243    }
244
245    #[inline]
246    fn is_separator(&self) -> bool {
247        matches!(self, PopupMenuItem::Separator)
248    }
249
250    fn has_left_icon(&self, check_side: Side) -> bool {
251        match self {
252            PopupMenuItem::Item { icon, checked, .. } => {
253                icon.is_some() || (check_side.is_left() && *checked)
254            }
255            PopupMenuItem::ElementItem { icon, checked, .. } => {
256                icon.is_some() || (check_side.is_left() && *checked)
257            }
258            PopupMenuItem::Submenu { icon, .. } => icon.is_some(),
259            _ => false,
260        }
261    }
262
263    #[inline]
264    fn is_checked(&self) -> bool {
265        match self {
266            PopupMenuItem::Item { checked, .. } => *checked,
267            PopupMenuItem::ElementItem { checked, .. } => *checked,
268            _ => false,
269        }
270    }
271}
272
273pub struct PopupMenu {
274    pub(crate) focus_handle: FocusHandle,
275    pub(crate) menu_items: Vec<PopupMenuItem>,
276    /// The focus handle of Entity to handle actions.
277    pub(crate) action_context: Option<FocusHandle>,
278    selected_index: Option<usize>,
279    min_width: Option<Pixels>,
280    max_width: Option<Pixels>,
281    max_height: Option<Pixels>,
282    bounds: Bounds<Pixels>,
283    size: Size,
284    check_side: Side,
285
286    /// The parent menu of this menu, if this is a submenu
287    parent_menu: Option<WeakEntity<Self>>,
288    scrollable: bool,
289    external_link_icon: bool,
290    scroll_handle: ScrollHandle,
291    // This will update on render
292    submenu_anchor: (Anchor, Pixels),
293
294    _subscriptions: Vec<Subscription>,
295}
296
297impl PopupMenu {
298    pub(crate) fn new(cx: &mut App) -> Self {
299        Self {
300            focus_handle: cx.focus_handle(),
301            action_context: None,
302            parent_menu: None,
303            menu_items: Vec::new(),
304            selected_index: None,
305            min_width: None,
306            max_width: None,
307            max_height: None,
308            check_side: Side::Left,
309            bounds: Bounds::default(),
310            scrollable: false,
311            scroll_handle: ScrollHandle::default(),
312            external_link_icon: true,
313            size: Size::default(),
314            submenu_anchor: (Anchor::TopLeft, Pixels::ZERO),
315            _subscriptions: vec![],
316        }
317    }
318
319    pub fn build(
320        window: &mut Window,
321        cx: &mut App,
322        f: impl FnOnce(Self, &mut Window, &mut Context<PopupMenu>) -> Self,
323    ) -> Entity<Self> {
324        cx.new(|cx| f(Self::new(cx), window, cx))
325    }
326
327    /// Set the focus handle of Entity to handle actions.
328    ///
329    /// When the menu is dismissed or before an action is triggered, the focus will be returned to this handle.
330    ///
331    /// Then the action will be dispatched to this handle.
332    pub fn action_context(mut self, handle: FocusHandle) -> Self {
333        self.action_context = Some(handle);
334        self
335    }
336
337    pub(crate) fn set_action_context(
338        &mut self,
339        action_context: Option<FocusHandle>,
340        cx: &mut Context<Self>,
341    ) {
342        self.action_context = action_context.clone();
343
344        for item in &self.menu_items {
345            if let PopupMenuItem::Submenu { menu, .. } = item {
346                menu.update(cx, |menu, cx| {
347                    menu.set_action_context(action_context.clone(), cx);
348                });
349            }
350        }
351    }
352
353    /// Set min width of the popup menu, default is 120px
354    pub fn min_w(mut self, width: impl Into<Pixels>) -> Self {
355        self.min_width = Some(width.into());
356        self
357    }
358
359    /// Set max width of the popup menu, default is 500px
360    pub fn max_w(mut self, width: impl Into<Pixels>) -> Self {
361        self.max_width = Some(width.into());
362        self
363    }
364
365    /// Set max height of the popup menu, default is half of the window height
366    pub fn max_h(mut self, height: impl Into<Pixels>) -> Self {
367        self.max_height = Some(height.into());
368        self
369    }
370
371    /// Set the menu to be scrollable to show vertical scrollbar.
372    ///
373    /// NOTE: If this is true, the sub-menus will cannot be support.
374    pub fn scrollable(mut self, scrollable: bool) -> Self {
375        self.scrollable = scrollable;
376        self
377    }
378
379    /// Set the side to show check icon, default is `Side::Left`.
380    pub fn check_side(mut self, side: Side) -> Self {
381        self.check_side = side;
382        self
383    }
384
385    /// Set the menu to show external link icon, default is true.
386    pub fn external_link_icon(mut self, visible: bool) -> Self {
387        self.external_link_icon = visible;
388        self
389    }
390
391    /// Add Menu Item
392    pub fn menu(self, label: impl Into<SharedString>, action: Box<dyn Action>) -> Self {
393        self.menu_with_disabled(label, action, false)
394    }
395
396    /// Add Menu Item with enable state
397    pub fn menu_with_enable(
398        mut self,
399        label: impl Into<SharedString>,
400        action: Box<dyn Action>,
401        enable: bool,
402    ) -> Self {
403        self.add_menu_item(label, None, action, !enable, false);
404        self
405    }
406
407    /// Add Menu Item with disabled state
408    pub fn menu_with_disabled(
409        mut self,
410        label: impl Into<SharedString>,
411        action: Box<dyn Action>,
412        disabled: bool,
413    ) -> Self {
414        self.add_menu_item(label, None, action, disabled, false);
415        self
416    }
417
418    /// Add label
419    pub fn label(mut self, label: impl Into<SharedString>) -> Self {
420        self.menu_items.push(PopupMenuItem::label(label.into()));
421        self
422    }
423
424    /// Add Menu to open link
425    pub fn link(self, label: impl Into<SharedString>, href: impl Into<String>) -> Self {
426        self.link_with_disabled(label, href, false)
427    }
428
429    /// Add Menu to open link with disabled state
430    pub fn link_with_disabled(
431        mut self,
432        label: impl Into<SharedString>,
433        href: impl Into<String>,
434        disabled: bool,
435    ) -> Self {
436        let href = href.into();
437        self.menu_items
438            .push(PopupMenuItem::link(label, href).disabled(disabled));
439        self
440    }
441
442    /// Add Menu to open link
443    pub fn link_with_icon(
444        self,
445        label: impl Into<SharedString>,
446        icon: impl Into<Icon>,
447        href: impl Into<String>,
448    ) -> Self {
449        self.link_with_icon_and_disabled(label, icon, href, false)
450    }
451
452    /// Add Menu to open link with icon and disabled state
453    fn link_with_icon_and_disabled(
454        mut self,
455        label: impl Into<SharedString>,
456        icon: impl Into<Icon>,
457        href: impl Into<String>,
458        disabled: bool,
459    ) -> Self {
460        let href = href.into();
461        self.menu_items.push(
462            PopupMenuItem::link(label, href)
463                .icon(icon)
464                .disabled(disabled),
465        );
466        self
467    }
468
469    /// Add Menu Item with Icon.
470    pub fn menu_with_icon(
471        self,
472        label: impl Into<SharedString>,
473        icon: impl Into<Icon>,
474        action: Box<dyn Action>,
475    ) -> Self {
476        self.menu_with_icon_and_disabled(label, icon, action, false)
477    }
478
479    /// Add Menu Item with Icon and disabled state
480    pub fn menu_with_icon_and_disabled(
481        mut self,
482        label: impl Into<SharedString>,
483        icon: impl Into<Icon>,
484        action: Box<dyn Action>,
485        disabled: bool,
486    ) -> Self {
487        self.add_menu_item(label, Some(icon.into()), action, disabled, false);
488        self
489    }
490
491    /// Add Menu Item with check icon
492    pub fn menu_with_check(
493        self,
494        label: impl Into<SharedString>,
495        checked: bool,
496        action: Box<dyn Action>,
497    ) -> Self {
498        self.menu_with_check_and_disabled(label, checked, action, false)
499    }
500
501    /// Add Menu Item with check icon and disabled state
502    pub fn menu_with_check_and_disabled(
503        mut self,
504        label: impl Into<SharedString>,
505        checked: bool,
506        action: Box<dyn Action>,
507        disabled: bool,
508    ) -> Self {
509        self.add_menu_item(label, None, action, disabled, checked);
510        self
511    }
512
513    /// Add Menu Item with custom element render.
514    pub fn menu_element<F, E>(self, action: Box<dyn Action>, builder: F) -> Self
515    where
516        F: Fn(&mut Window, &mut App) -> E + 'static,
517        E: IntoElement,
518    {
519        self.menu_element_with_check(false, action, builder)
520    }
521
522    /// Add Menu Item with custom element render with disabled state.
523    pub fn menu_element_with_disabled<F, E>(
524        self,
525        action: Box<dyn Action>,
526        disabled: bool,
527        builder: F,
528    ) -> Self
529    where
530        F: Fn(&mut Window, &mut App) -> E + 'static,
531        E: IntoElement,
532    {
533        self.menu_element_with_check_and_disabled(false, action, disabled, builder)
534    }
535
536    /// Add Menu Item with custom element render with icon.
537    pub fn menu_element_with_icon<F, E>(
538        self,
539        icon: impl Into<Icon>,
540        action: Box<dyn Action>,
541        builder: F,
542    ) -> Self
543    where
544        F: Fn(&mut Window, &mut App) -> E + 'static,
545        E: IntoElement,
546    {
547        self.menu_element_with_icon_and_disabled(icon, action, false, builder)
548    }
549
550    /// Add Menu Item with custom element render with check state
551    pub fn menu_element_with_check<F, E>(
552        self,
553        checked: bool,
554        action: Box<dyn Action>,
555        builder: F,
556    ) -> Self
557    where
558        F: Fn(&mut Window, &mut App) -> E + 'static,
559        E: IntoElement,
560    {
561        self.menu_element_with_check_and_disabled(checked, action, false, builder)
562    }
563
564    /// Add Menu Item with custom element render with icon and disabled state
565    fn menu_element_with_icon_and_disabled<F, E>(
566        mut self,
567        icon: impl Into<Icon>,
568        action: Box<dyn Action>,
569        disabled: bool,
570        builder: F,
571    ) -> Self
572    where
573        F: Fn(&mut Window, &mut App) -> E + 'static,
574        E: IntoElement,
575    {
576        self.menu_items.push(
577            PopupMenuItem::element(builder)
578                .action(action)
579                .icon(icon)
580                .disabled(disabled),
581        );
582        self
583    }
584
585    /// Add Menu Item with custom element render with check state and disabled state
586    fn menu_element_with_check_and_disabled<F, E>(
587        mut self,
588        checked: bool,
589        action: Box<dyn Action>,
590        disabled: bool,
591        builder: F,
592    ) -> Self
593    where
594        F: Fn(&mut Window, &mut App) -> E + 'static,
595        E: IntoElement,
596    {
597        self.menu_items.push(
598            PopupMenuItem::element(builder)
599                .action(action)
600                .checked(checked)
601                .disabled(disabled),
602        );
603        self
604    }
605
606    /// Add a separator Menu Item
607    pub fn separator(mut self) -> Self {
608        if self.menu_items.is_empty() {
609            return self;
610        }
611
612        if let Some(PopupMenuItem::Separator) = self.menu_items.last() {
613            return self;
614        }
615
616        self.menu_items.push(PopupMenuItem::separator());
617        self
618    }
619
620    /// Add a Submenu
621    pub fn submenu(
622        self,
623        label: impl Into<SharedString>,
624        window: &mut Window,
625        cx: &mut Context<Self>,
626        f: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
627    ) -> Self {
628        self.submenu_with_icon(None, label, window, cx, f)
629    }
630
631    /// Add a Submenu item with icon
632    pub fn submenu_with_icon(
633        mut self,
634        icon: Option<Icon>,
635        label: impl Into<SharedString>,
636        window: &mut Window,
637        cx: &mut Context<Self>,
638        f: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
639    ) -> Self {
640        let submenu = PopupMenu::build(window, cx, f);
641        let parent_menu = cx.entity().downgrade();
642        submenu.update(cx, |view, _| {
643            view.parent_menu = Some(parent_menu);
644        });
645
646        self.menu_items.push(
647            PopupMenuItem::submenu(label, submenu).when_some(icon, |this, icon| this.icon(icon)),
648        );
649        self
650    }
651
652    /// Add menu item.
653    pub fn item(mut self, item: impl Into<PopupMenuItem>) -> Self {
654        let item: PopupMenuItem = item.into();
655        self.menu_items.push(item);
656        self
657    }
658
659    fn add_menu_item(
660        &mut self,
661        label: impl Into<SharedString>,
662        icon: Option<Icon>,
663        action: Box<dyn Action>,
664        disabled: bool,
665        checked: bool,
666    ) -> &mut Self {
667        self.menu_items.push(
668            PopupMenuItem::new(label)
669                .when_some(icon, |item, icon| item.icon(icon))
670                .disabled(disabled)
671                .checked(checked)
672                .action(action),
673        );
674        self
675    }
676
677    pub(super) fn with_menu_items<I>(
678        mut self,
679        items: impl IntoIterator<Item = I>,
680        window: &mut Window,
681        cx: &mut Context<Self>,
682    ) -> Self
683    where
684        I: Into<OwnedMenuItem>,
685    {
686        for item in items {
687            match item.into() {
688                OwnedMenuItem::Action {
689                    name,
690                    action,
691                    checked,
692                    disabled,
693                    ..
694                } => {
695                    self = self.menu_with_check_and_disabled(
696                        name,
697                        checked,
698                        action.boxed_clone(),
699                        disabled,
700                    )
701                }
702                OwnedMenuItem::Separator => {
703                    self = self.separator();
704                }
705                OwnedMenuItem::Submenu(submenu) => {
706                    self = self.submenu(submenu.name, window, cx, move |menu, window, cx| {
707                        menu.with_menu_items(submenu.items.clone(), window, cx)
708                    })
709                }
710                OwnedMenuItem::SystemMenu(_) => {}
711            }
712        }
713
714        if self.menu_items.len() > 20 {
715            self.scrollable = true;
716        }
717
718        self
719    }
720
721    pub(crate) fn active_submenu(&self) -> Option<Entity<PopupMenu>> {
722        if let Some(ix) = self.selected_index {
723            if let Some(item) = self.menu_items.get(ix) {
724                return match item {
725                    PopupMenuItem::Submenu { menu, .. } => Some(menu.clone()),
726                    _ => None,
727                };
728            }
729        }
730
731        None
732    }
733
734    pub fn is_empty(&self) -> bool {
735        self.menu_items.is_empty()
736    }
737
738    fn clickable_menu_items(&self) -> impl Iterator<Item = (usize, &PopupMenuItem)> {
739        self.menu_items
740            .iter()
741            .enumerate()
742            .filter(|(_, item)| item.is_clickable())
743    }
744
745    fn on_click(&mut self, ix: usize, window: &mut Window, cx: &mut Context<Self>) {
746        cx.stop_propagation();
747        window.prevent_default();
748        self.selected_index = Some(ix);
749        self.confirm(&Confirm { secondary: false }, window, cx);
750    }
751
752    fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
753        match self.selected_index {
754            Some(index) => {
755                let item = self.menu_items.get(index);
756                match item {
757                    Some(PopupMenuItem::Item {
758                        handler, action, ..
759                    }) => {
760                        if let Some(handler) = handler {
761                            handler(&ClickEvent::default(), window, cx);
762                        } else if let Some(action) = action.as_ref() {
763                            self.dispatch_confirm_action(action, window, cx);
764                        }
765
766                        self.dismiss(&Cancel, window, cx)
767                    }
768                    Some(PopupMenuItem::ElementItem {
769                        handler, action, ..
770                    }) => {
771                        if let Some(handler) = handler {
772                            handler(&ClickEvent::default(), window, cx);
773                        } else if let Some(action) = action.as_ref() {
774                            self.dispatch_confirm_action(action, window, cx);
775                        }
776                        self.dismiss(&Cancel, window, cx)
777                    }
778                    _ => {}
779                }
780            }
781            _ => {}
782        }
783    }
784
785    fn dispatch_confirm_action(
786        &self,
787        action: &Box<dyn Action>,
788        window: &mut Window,
789        cx: &mut Context<Self>,
790    ) {
791        if let Some(context) = self.action_context.as_ref() {
792            context.focus(window, cx);
793        }
794
795        window.dispatch_action(action.boxed_clone(), cx);
796    }
797
798    fn set_selected_index(&mut self, ix: usize, cx: &mut Context<Self>) {
799        if self.selected_index != Some(ix) {
800            self.selected_index = Some(ix);
801            self.scroll_handle.scroll_to_item(ix);
802            cx.notify();
803        }
804    }
805
806    fn select_up(&mut self, _: &SelectUp, _: &mut Window, cx: &mut Context<Self>) {
807        cx.stop_propagation();
808        let ix = self.selected_index.unwrap_or(0);
809
810        if let Some((prev_ix, _)) = self
811            .menu_items
812            .iter()
813            .enumerate()
814            .rev()
815            .find(|(i, item)| *i < ix && item.is_clickable())
816        {
817            self.set_selected_index(prev_ix, cx);
818            return;
819        }
820
821        let last_clickable_ix = self.clickable_menu_items().last().map(|(ix, _)| ix);
822        self.set_selected_index(last_clickable_ix.unwrap_or(0), cx);
823    }
824
825    fn select_down(&mut self, _: &SelectDown, _: &mut Window, cx: &mut Context<Self>) {
826        cx.stop_propagation();
827        let Some(ix) = self.selected_index else {
828            self.set_selected_index(0, cx);
829            return;
830        };
831
832        if let Some((next_ix, _)) = self
833            .menu_items
834            .iter()
835            .enumerate()
836            .find(|(i, item)| *i > ix && item.is_clickable())
837        {
838            self.set_selected_index(next_ix, cx);
839            return;
840        }
841
842        self.set_selected_index(0, cx);
843    }
844
845    fn select_left(&mut self, _: &SelectLeft, window: &mut Window, cx: &mut Context<Self>) {
846        let handled = if matches!(self.submenu_anchor.0, Anchor::TopLeft | Anchor::BottomLeft) {
847            self._unselect_submenu(window, cx)
848        } else {
849            self._select_submenu(window, cx)
850        };
851
852        if self.parent_side(cx).is_left() {
853            self._focus_parent_menu(window, cx);
854        }
855
856        if handled {
857            return;
858        }
859
860        // For parent AppMenuBar to handle.
861        if self.parent_menu.is_none() {
862            cx.propagate();
863        }
864    }
865
866    fn select_right(&mut self, _: &SelectRight, window: &mut Window, cx: &mut Context<Self>) {
867        let handled = if matches!(self.submenu_anchor.0, Anchor::TopLeft | Anchor::BottomLeft) {
868            self._select_submenu(window, cx)
869        } else {
870            self._unselect_submenu(window, cx)
871        };
872
873        if self.parent_side(cx).is_right() {
874            self._focus_parent_menu(window, cx);
875        }
876
877        if handled {
878            return;
879        }
880
881        // For parent AppMenuBar to handle.
882        if self.parent_menu.is_none() {
883            cx.propagate();
884        }
885    }
886
887    fn _select_submenu(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
888        if let Some(active_submenu) = self.active_submenu() {
889            // Focus the submenu, so that can be handle the action.
890            active_submenu.update(cx, |view, cx| {
891                view.set_selected_index(0, cx);
892                view.focus_handle.focus(window, cx);
893            });
894            cx.notify();
895            return true;
896        }
897
898        return false;
899    }
900
901    fn _unselect_submenu(&mut self, _: &mut Window, cx: &mut Context<Self>) -> bool {
902        if let Some(active_submenu) = self.active_submenu() {
903            active_submenu.update(cx, |view, cx| {
904                view.selected_index = None;
905                cx.notify();
906            });
907            return true;
908        }
909
910        return false;
911    }
912
913    fn _focus_parent_menu(&mut self, window: &mut Window, cx: &mut Context<Self>) {
914        let Some(parent) = self.parent_menu.as_ref() else {
915            return;
916        };
917        let Some(parent) = parent.upgrade() else {
918            return;
919        };
920
921        self.selected_index = None;
922        parent.update(cx, |view, cx| {
923            view.focus_handle.focus(window, cx);
924            cx.notify();
925        });
926    }
927
928    fn parent_side(&self, cx: &App) -> Side {
929        let Some(parent) = self.parent_menu.as_ref() else {
930            return Side::Left;
931        };
932
933        let Some(parent) = parent.upgrade() else {
934            return Side::Left;
935        };
936
937        match parent.read(cx).submenu_anchor.0 {
938            Anchor::TopLeft | Anchor::BottomLeft => Side::Left,
939            Anchor::TopRight | Anchor::BottomRight => Side::Right,
940            // Center anchors are not used for submenu positioning, but we must cover them.
941            _ => Side::Left,
942        }
943    }
944
945    fn dismiss(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context<Self>) {
946        if self.active_submenu().is_some() {
947            return;
948        }
949
950        cx.emit(DismissEvent);
951
952        // Focus back to the previous focused handle.
953        if let Some(action_context) = self.action_context.as_ref() {
954            window.focus(action_context, cx);
955        }
956
957        let Some(parent_menu) = self.parent_menu.clone() else {
958            return;
959        };
960
961        // Dismiss parent menu, when this menu is dismissed
962        _ = parent_menu.update(cx, |view, cx| {
963            view.selected_index = None;
964            view.dismiss(&Cancel, window, cx);
965        });
966    }
967
968    fn handle_dismiss(
969        &mut self,
970        position: &Point<Pixels>,
971        window: &mut Window,
972        cx: &mut Context<Self>,
973    ) {
974        // Do not dismiss, if click inside the parent menu
975        if let Some(parent) = self.parent_menu.as_ref() {
976            if let Some(parent) = parent.upgrade() {
977                if parent.read(cx).bounds.contains(position) {
978                    return;
979                }
980            }
981        }
982
983        self.dismiss(&Cancel, window, cx);
984    }
985
986    fn on_mouse_down_out(
987        &mut self,
988        e: &MouseDownEvent,
989        window: &mut Window,
990        cx: &mut Context<Self>,
991    ) {
992        self.handle_dismiss(&e.position, window, cx);
993    }
994
995    fn render_key_binding(
996        &self,
997        action: Option<Box<dyn Action>>,
998        window: &mut Window,
999        _: &mut Context<Self>,
1000    ) -> Option<Kbd> {
1001        let action = action?;
1002
1003        match self
1004            .action_context
1005            .as_ref()
1006            .and_then(|handle| Kbd::binding_for_action_in(action.as_ref(), handle, window))
1007        {
1008            Some(kbd) => Some(kbd),
1009            // Fallback to App level key binding
1010            None => Kbd::binding_for_action(action.as_ref(), None, window),
1011        }
1012        .map(|this| {
1013            this.p_0()
1014                .flex_nowrap()
1015                .border_0()
1016                .bg(rgpui::transparent_white())
1017        })
1018    }
1019
1020    fn render_icon(
1021        has_icon: bool,
1022        checked: bool,
1023        icon: Option<Icon>,
1024        _: &mut Window,
1025        _: &mut Context<Self>,
1026    ) -> Option<impl IntoElement> {
1027        if !has_icon {
1028            return None;
1029        }
1030
1031        let icon = if let Some(icon) = icon {
1032            icon.clone()
1033        } else if checked {
1034            Icon::new(IconName::Check)
1035        } else {
1036            Icon::empty()
1037        };
1038
1039        Some(icon.xsmall())
1040    }
1041
1042    #[inline]
1043    fn max_width(&self) -> Pixels {
1044        self.max_width.unwrap_or(px(500.))
1045    }
1046
1047    /// Calculate the anchor corner and left offset for child submenu
1048    fn update_submenu_menu_anchor(&mut self, window: &Window) {
1049        let bounds = self.bounds;
1050        let max_width = self.max_width();
1051        let (anchor, left) = if max_width + bounds.origin.x > window.bounds().size.width {
1052            (Anchor::TopRight, -px(16.))
1053        } else {
1054            (Anchor::TopLeft, bounds.size.width - px(8.))
1055        };
1056
1057        let is_bottom_pos = bounds.origin.y + bounds.size.height > window.bounds().size.height;
1058        self.submenu_anchor = if is_bottom_pos {
1059            (anchor.other_side_along(rgpui::Axis::Vertical), left)
1060        } else {
1061            (anchor, left)
1062        };
1063    }
1064
1065    fn render_item(
1066        &self,
1067        ix: usize,
1068        item: &PopupMenuItem,
1069        options: RenderOptions,
1070        window: &mut Window,
1071        cx: &mut Context<Self>,
1072    ) -> MenuItemElement {
1073        let has_left_icon = options.has_left_icon;
1074        let is_left_check = options.check_side.is_left() && item.is_checked();
1075        let right_check_icon = if options.check_side.is_right() && item.is_checked() {
1076            Some(Icon::new(IconName::Check).xsmall())
1077        } else {
1078            None
1079        };
1080
1081        let selected = self.selected_index == Some(ix);
1082        const EDGE_PADDING: Pixels = px(4.);
1083        const INNER_PADDING: Pixels = px(8.);
1084
1085        let is_submenu = matches!(item, PopupMenuItem::Submenu { .. });
1086        let group_name = format!("{}:item-{}", cx.entity().entity_id(), ix);
1087
1088        let (item_height, radius) = match self.size {
1089            Size::Small => (px(20.), options.radius.half()),
1090            _ => (px(26.), options.radius),
1091        };
1092
1093        let this = MenuItemElement::new(ix, &group_name)
1094            .relative()
1095            .text_sm()
1096            .py_0()
1097            .px(INNER_PADDING)
1098            .rounded(radius)
1099            .items_center()
1100            .selected(selected)
1101            .on_hover(cx.listener(move |this, hovered, _, cx| {
1102                if *hovered {
1103                    this.selected_index = Some(ix);
1104                } else if !is_submenu && this.selected_index == Some(ix) {
1105                    // TODO: Better handle the submenu unselection when hover out
1106                    this.selected_index = None;
1107                }
1108
1109                cx.notify();
1110            }));
1111
1112        match item {
1113            PopupMenuItem::Separator => this
1114                .h_auto()
1115                .p_0()
1116                .my_0p5()
1117                .mx_neg_1()
1118                .border_b(px(2.))
1119                .border_color(cx.theme().border)
1120                .disabled(true),
1121            PopupMenuItem::Label(label) => this.disabled(true).cursor_default().child(
1122                h_flex()
1123                    .cursor_default()
1124                    .items_center()
1125                    .gap_x_1()
1126                    .children(Self::render_icon(has_left_icon, false, None, window, cx))
1127                    .child(div().flex_1().child(label.clone())),
1128            ),
1129            PopupMenuItem::ElementItem {
1130                render,
1131                icon,
1132                disabled,
1133                ..
1134            } => this
1135                .when(!disabled, |this| {
1136                    this.on_click(
1137                        cx.listener(move |this, _, window, cx| this.on_click(ix, window, cx)),
1138                    )
1139                })
1140                .disabled(*disabled)
1141                .child(
1142                    h_flex()
1143                        .flex_1()
1144                        .min_h(item_height)
1145                        .items_center()
1146                        .gap_x_1()
1147                        .children(Self::render_icon(
1148                            has_left_icon,
1149                            is_left_check,
1150                            icon.clone(),
1151                            window,
1152                            cx,
1153                        ))
1154                        .child((render)(window, cx))
1155                        .children(right_check_icon.map(|icon| icon.ml_3())),
1156                ),
1157            PopupMenuItem::Item {
1158                icon,
1159                label,
1160                action,
1161                disabled,
1162                is_link,
1163                ..
1164            } => {
1165                let show_link_icon = *is_link && self.external_link_icon;
1166                let action = action.as_ref().map(|action| action.boxed_clone());
1167                let key = self.render_key_binding(action, window, cx);
1168
1169                this.when(!disabled, |this| {
1170                    this.on_click(
1171                        cx.listener(move |this, _, window, cx| this.on_click(ix, window, cx)),
1172                    )
1173                })
1174                .disabled(*disabled)
1175                .h(item_height)
1176                .gap_x_1()
1177                .children(Self::render_icon(
1178                    has_left_icon,
1179                    is_left_check,
1180                    icon.clone(),
1181                    window,
1182                    cx,
1183                ))
1184                .child(
1185                    h_flex()
1186                        .w_full()
1187                        .gap_3()
1188                        .items_center()
1189                        .justify_between()
1190                        .when(!show_link_icon, |this| this.child(label.clone()))
1191                        .children(right_check_icon)
1192                        .when(show_link_icon, |this| {
1193                            this.child(
1194                                h_flex()
1195                                    .w_full()
1196                                    .justify_between()
1197                                    .gap_1p5()
1198                                    .child(label.clone())
1199                                    .child(
1200                                        Icon::new(IconName::ExternalLink)
1201                                            .xsmall()
1202                                            .text_color(cx.theme().muted_foreground),
1203                                    ),
1204                            )
1205                        })
1206                        .children(key),
1207                )
1208            }
1209            PopupMenuItem::Submenu {
1210                icon,
1211                label,
1212                menu,
1213                disabled,
1214            } => this
1215                .selected(selected)
1216                .disabled(*disabled)
1217                .items_start()
1218                .child(
1219                    h_flex()
1220                        .min_h(item_height)
1221                        .size_full()
1222                        .items_center()
1223                        .gap_x_1()
1224                        .children(Self::render_icon(
1225                            has_left_icon,
1226                            false,
1227                            icon.clone(),
1228                            window,
1229                            cx,
1230                        ))
1231                        .child(
1232                            h_flex()
1233                                .flex_1()
1234                                .gap_2()
1235                                .items_center()
1236                                .justify_between()
1237                                .child(label.clone())
1238                                .child(
1239                                    Icon::new(IconName::ChevronRight)
1240                                        .xsmall()
1241                                        .text_color(cx.theme().muted_foreground),
1242                                ),
1243                        ),
1244                )
1245                .when(selected, |this| {
1246                    this.child({
1247                        let (anchor, left) = self.submenu_anchor;
1248                        let is_bottom_pos =
1249                            matches!(anchor, Anchor::BottomLeft | Anchor::BottomRight);
1250                        anchored()
1251                            .anchor(anchor)
1252                            .child(
1253                                div()
1254                                    .id("submenu")
1255                                    .occlude()
1256                                    .when(is_bottom_pos, |this| this.bottom_0())
1257                                    .when(!is_bottom_pos, |this| this.top_neg_1())
1258                                    .left(left)
1259                                    .child(menu.clone()),
1260                            )
1261                            .snap_to_window_with_margin(Edges::all(EDGE_PADDING))
1262                    })
1263                }),
1264        }
1265    }
1266}
1267
1268impl FluentBuilder for PopupMenu {}
1269impl EventEmitter<DismissEvent> for PopupMenu {}
1270impl Focusable for PopupMenu {
1271    fn focus_handle(&self, _: &App) -> FocusHandle {
1272        self.focus_handle.clone()
1273    }
1274}
1275
1276#[derive(Clone, Copy)]
1277struct RenderOptions {
1278    has_left_icon: bool,
1279    check_side: Side,
1280    radius: Pixels,
1281}
1282
1283impl Render for PopupMenu {
1284    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1285        self.update_submenu_menu_anchor(window);
1286
1287        let view = cx.entity().clone();
1288        let items_count = self.menu_items.len();
1289
1290        let max_height = self.max_height.unwrap_or_else(|| {
1291            let window_half_height = window.window_bounds().get_bounds().size.height * 0.5;
1292            window_half_height.min(px(450.))
1293        });
1294
1295        let has_left_icon = self
1296            .menu_items
1297            .iter()
1298            .any(|item| item.has_left_icon(self.check_side));
1299
1300        let max_width = self.max_width();
1301        let options = RenderOptions {
1302            has_left_icon,
1303            check_side: self.check_side,
1304            radius: cx.theme().radius.min(px(8.)),
1305        };
1306
1307        v_flex()
1308            .id("popup-menu")
1309            .key_context(CONTEXT)
1310            .track_focus(&self.focus_handle)
1311            .on_action(cx.listener(Self::select_up))
1312            .on_action(cx.listener(Self::select_down))
1313            .on_action(cx.listener(Self::select_left))
1314            .on_action(cx.listener(Self::select_right))
1315            .on_action(cx.listener(Self::confirm))
1316            .on_action(cx.listener(Self::dismiss))
1317            .on_mouse_down_out(cx.listener(Self::on_mouse_down_out))
1318            .popover_style(cx)
1319            .text_color(cx.theme().popover_foreground)
1320            .relative()
1321            .occlude()
1322            .child(
1323                v_flex()
1324                    .id("items")
1325                    .p_1()
1326                    .gap_y_0p5()
1327                    .min_w(rems(8.))
1328                    .when_some(self.min_width, |this, min_width| this.min_w(min_width))
1329                    .max_w(max_width)
1330                    .when(self.scrollable, |this| {
1331                        this.max_h(max_height)
1332                            .overflow_y_scroll()
1333                            .track_scroll(&self.scroll_handle)
1334                    })
1335                    .children(
1336                        self.menu_items
1337                            .iter()
1338                            .enumerate()
1339                            // Ignore last separator
1340                            .filter(|(ix, item)| !(*ix + 1 == items_count && item.is_separator()))
1341                            .map(|(ix, item)| self.render_item(ix, item, options, window, cx)),
1342                    )
1343                    .on_prepaint(move |bounds, _, cx| view.update(cx, |r, _| r.bounds = bounds)),
1344            )
1345            .when(self.scrollable, |this| {
1346                // TODO: When the menu is limited by `overflow_y_scroll`, the sub-menu will cannot be displayed.
1347                this.vertical_scrollbar(&self.scroll_handle)
1348            })
1349    }
1350}