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