Skip to main content

gpui_component/menu/
popup_menu.rs

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