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