gpui_component/menu/
popup_menu.rs

1use crate::actions::{Cancel, Confirm, SelectNext, SelectPrev};
2use crate::input::{SelectLeft, SelectRight};
3use crate::menu::menu_item::MenuItem;
4use crate::scroll::{Scrollbar, ScrollbarState};
5use crate::{
6    button::Button, h_flex, popover::Popover, v_flex, ActiveTheme, Icon, IconName, Selectable,
7    Sizable as _,
8};
9use crate::{Kbd, Side, Size, StyledExt};
10use gpui::{
11    anchored, canvas, div, prelude::FluentBuilder, px, rems, Action, AnyElement, App, AppContext,
12    Bounds, Context, Corner, DismissEvent, Edges, Entity, EventEmitter, FocusHandle, Focusable,
13    InteractiveElement, IntoElement, KeyBinding, ParentElement, Pixels, Render, ScrollHandle,
14    SharedString, StatefulInteractiveElement, Styled, WeakEntity, Window,
15};
16use gpui::{Half, MouseDownEvent, Subscription};
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", SelectPrev, Some(CONTEXT)),
26        KeyBinding::new("down", SelectNext, Some(CONTEXT)),
27        KeyBinding::new("left", SelectLeft, Some(CONTEXT)),
28        KeyBinding::new("right", SelectRight, Some(CONTEXT)),
29    ]);
30}
31
32pub trait PopupMenuExt: Styled + Selectable + InteractiveElement + IntoElement + 'static {
33    /// Create a popup menu with the given items, anchored to the TopLeft corner
34    fn popup_menu(
35        self,
36        f: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
37    ) -> Popover<PopupMenu> {
38        self.popup_menu_with_anchor(Corner::TopLeft, f)
39    }
40
41    /// Create a popup menu with the given items, anchored to the given corner
42    fn popup_menu_with_anchor(
43        mut self,
44        anchor: impl Into<Corner>,
45        f: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
46    ) -> Popover<PopupMenu> {
47        let style = self.style().clone();
48        let id = self.interactivity().element_id.clone();
49
50        Popover::new(SharedString::from(format!("popup-menu:{:?}", id)))
51            .no_style()
52            .trigger(self)
53            .trigger_style(style)
54            .anchor(anchor.into())
55            .content(move |window, cx| {
56                PopupMenu::build(window, cx, |menu, window, cx| f(menu, window, cx))
57            })
58    }
59}
60impl PopupMenuExt for Button {}
61
62pub(crate) enum PopupMenuItem {
63    Separator,
64    Label(SharedString),
65    Item {
66        icon: Option<Icon>,
67        label: SharedString,
68        disabled: bool,
69        is_link: bool,
70        action: Option<Box<dyn Action>>,
71        handler: Rc<dyn Fn(&mut Window, &mut App)>,
72    },
73    ElementItem {
74        icon: Option<Icon>,
75        disabled: bool,
76        render: Box<dyn Fn(&mut Window, &mut App) -> AnyElement + 'static>,
77        handler: Rc<dyn Fn(&mut Window, &mut App)>,
78    },
79    Submenu {
80        icon: Option<Icon>,
81        label: SharedString,
82        disabled: bool,
83        menu: Entity<PopupMenu>,
84    },
85}
86
87impl PopupMenuItem {
88    fn is_clickable(&self) -> bool {
89        !matches!(self, PopupMenuItem::Separator)
90            && matches!(
91                self,
92                PopupMenuItem::Item {
93                    disabled: false,
94                    ..
95                } | PopupMenuItem::ElementItem {
96                    disabled: false,
97                    ..
98                } | PopupMenuItem::Submenu {
99                    disabled: false,
100                    ..
101                }
102            )
103    }
104
105    fn is_separator(&self) -> bool {
106        matches!(self, PopupMenuItem::Separator)
107    }
108}
109
110pub struct PopupMenu {
111    focus_handle: FocusHandle,
112    pub(crate) menu_items: Vec<PopupMenuItem>,
113    /// The focus handle of Entity to handle actions.
114    pub(crate) action_context: Option<FocusHandle>,
115    has_icon: bool,
116    selected_index: Option<usize>,
117    min_width: Option<Pixels>,
118    max_width: Option<Pixels>,
119    max_height: Option<Pixels>,
120    bounds: Bounds<Pixels>,
121    size: Size,
122
123    /// The parent menu of this menu, if this is a submenu
124    parent_menu: Option<WeakEntity<Self>>,
125    scrollable: bool,
126    external_link_icon: bool,
127    scroll_handle: ScrollHandle,
128    scroll_state: ScrollbarState,
129
130    _subscriptions: Vec<Subscription>,
131}
132
133impl PopupMenu {
134    pub(crate) fn new(cx: &mut App) -> Self {
135        Self {
136            focus_handle: cx.focus_handle(),
137            action_context: None,
138            parent_menu: None,
139            menu_items: Vec::new(),
140            selected_index: None,
141            min_width: None,
142            max_width: None,
143            max_height: None,
144            has_icon: false,
145            bounds: Bounds::default(),
146            scrollable: false,
147            scroll_handle: ScrollHandle::default(),
148            scroll_state: ScrollbarState::default(),
149            external_link_icon: true,
150            size: Size::default(),
151            _subscriptions: vec![],
152        }
153    }
154
155    pub fn build(
156        window: &mut Window,
157        cx: &mut App,
158        f: impl FnOnce(Self, &mut Window, &mut Context<PopupMenu>) -> Self,
159    ) -> Entity<Self> {
160        cx.new(|cx| {
161            let mut menu = Self::new(cx);
162            menu.action_context = window.focused(cx);
163            f(menu, window, cx)
164        })
165    }
166
167    /// Set min width of the popup menu, default is 120px
168    pub fn min_w(mut self, width: impl Into<Pixels>) -> Self {
169        self.min_width = Some(width.into());
170        self
171    }
172
173    /// Set max width of the popup menu, default is 500px
174    pub fn max_w(mut self, width: impl Into<Pixels>) -> Self {
175        self.max_width = Some(width.into());
176        self
177    }
178
179    /// Set max height of the popup menu, default is half of the window height
180    pub fn max_h(mut self, height: impl Into<Pixels>) -> Self {
181        self.max_height = Some(height.into());
182        self
183    }
184
185    /// Set the menu to be scrollable to show vertical scrollbar.
186    ///
187    /// NOTE: If this is true, the sub-menus will cannot be support.
188    pub fn scrollable(mut self) -> Self {
189        self.scrollable = true;
190        self
191    }
192
193    /// Set the menu to show external link icon, default is true.
194    pub fn external_link_icon(mut self, visible: bool) -> Self {
195        self.external_link_icon = visible;
196        self
197    }
198
199    /// Add Menu Item
200    pub fn menu(self, label: impl Into<SharedString>, action: Box<dyn Action>) -> Self {
201        self.menu_with_disabled(label, action, false)
202    }
203
204    /// Add Menu Item with enable state
205    pub fn menu_with_enable(
206        mut self,
207        label: impl Into<SharedString>,
208        action: Box<dyn Action>,
209        enable: bool,
210    ) -> Self {
211        self.add_menu_item(label, None, action, !enable);
212        self
213    }
214
215    /// Add Menu Item with disabled state
216    pub fn menu_with_disabled(
217        mut self,
218        label: impl Into<SharedString>,
219        action: Box<dyn Action>,
220        disabled: bool,
221    ) -> Self {
222        self.add_menu_item(label, None, action, disabled);
223        self
224    }
225
226    /// Add label
227    pub fn label(mut self, label: impl Into<SharedString>) -> Self {
228        self.menu_items.push(PopupMenuItem::Label(label.into()));
229        self
230    }
231
232    /// Add Menu to open link
233    pub fn link(self, label: impl Into<SharedString>, href: impl Into<String>) -> Self {
234        self.link_with_disabled(label, href, false)
235    }
236
237    /// Add Menu to open link with disabled state
238    pub fn link_with_disabled(
239        mut self,
240        label: impl Into<SharedString>,
241        href: impl Into<String>,
242        disabled: bool,
243    ) -> Self {
244        let href = href.into();
245        self.menu_items.push(PopupMenuItem::Item {
246            icon: None,
247            label: label.into(),
248            disabled,
249            action: None,
250            is_link: true,
251            handler: Rc::new(move |_, cx| cx.open_url(&href)),
252        });
253        self
254    }
255
256    /// Add Menu to open link
257    pub fn link_with_icon(
258        self,
259        label: impl Into<SharedString>,
260        icon: impl Into<Icon>,
261        href: impl Into<String>,
262    ) -> Self {
263        self.link_with_icon_and_disabled(label, icon, href, false)
264    }
265
266    /// Add Menu to open link with icon and disabled state
267    pub fn link_with_icon_and_disabled(
268        mut self,
269        label: impl Into<SharedString>,
270        icon: impl Into<Icon>,
271        href: impl Into<String>,
272        disabled: bool,
273    ) -> Self {
274        let href = href.into();
275        self.menu_items.push(PopupMenuItem::Item {
276            icon: Some(icon.into()),
277            label: label.into(),
278            disabled,
279            action: None,
280            is_link: true,
281            handler: Rc::new(move |_, cx| cx.open_url(&href)),
282        });
283        self
284    }
285
286    /// Add Menu Item with Icon.
287    pub fn menu_with_icon(
288        self,
289        label: impl Into<SharedString>,
290        icon: impl Into<Icon>,
291        action: Box<dyn Action>,
292    ) -> Self {
293        self.menu_with_icon_and_disabled(label, icon, action, false)
294    }
295
296    /// Add Menu Item with Icon and disabled state
297    pub fn menu_with_icon_and_disabled(
298        mut self,
299        label: impl Into<SharedString>,
300        icon: impl Into<Icon>,
301        action: Box<dyn Action>,
302        disabled: bool,
303    ) -> Self {
304        self.add_menu_item(label, Some(icon.into()), action, disabled);
305        self
306    }
307
308    /// Add Menu Item with check icon
309    pub fn menu_with_check(
310        self,
311        label: impl Into<SharedString>,
312        checked: bool,
313        action: Box<dyn Action>,
314    ) -> Self {
315        self.menu_with_check_and_disabled(label, checked, action, false)
316    }
317
318    /// Add Menu Item with check icon and disabled state
319    pub fn menu_with_check_and_disabled(
320        mut self,
321        label: impl Into<SharedString>,
322        checked: bool,
323        action: Box<dyn Action>,
324        disabled: bool,
325    ) -> Self {
326        if checked {
327            self.add_menu_item(label, Some(IconName::Check.into()), action, disabled);
328        } else {
329            self.add_menu_item(label, None, action, disabled);
330        }
331
332        self
333    }
334
335    /// Add Menu Item with custom element render.
336    pub fn menu_element<F, E>(self, action: Box<dyn Action>, builder: F) -> Self
337    where
338        F: Fn(&mut Window, &mut App) -> E + 'static,
339        E: IntoElement,
340    {
341        self.menu_element_with_check(false, action, builder)
342    }
343
344    /// Add Menu Item with custom element render with disabled state.
345    pub fn menu_element_with_disabled<F, E>(
346        self,
347        action: Box<dyn Action>,
348        disabled: bool,
349        builder: F,
350    ) -> Self
351    where
352        F: Fn(&mut Window, &mut App) -> E + 'static,
353        E: IntoElement,
354    {
355        self.menu_element_with_check_and_disabled(false, action, disabled, builder)
356    }
357
358    /// Add Menu Item with custom element render with icon.
359    pub fn menu_element_with_icon<F, E>(
360        self,
361        icon: impl Into<Icon>,
362        action: Box<dyn Action>,
363        builder: F,
364    ) -> Self
365    where
366        F: Fn(&mut Window, &mut App) -> E + 'static,
367        E: IntoElement,
368    {
369        self.menu_element_with_icon_and_disabled(icon, action, false, builder)
370    }
371
372    /// Add Menu Item with custom element render with icon and disabled state
373    pub fn menu_element_with_icon_and_disabled<F, E>(
374        mut self,
375        icon: impl Into<Icon>,
376        action: Box<dyn Action>,
377        disabled: bool,
378        builder: F,
379    ) -> Self
380    where
381        F: Fn(&mut Window, &mut App) -> E + 'static,
382        E: IntoElement,
383    {
384        self.menu_items.push(PopupMenuItem::ElementItem {
385            render: Box::new(move |window, cx| builder(window, cx).into_any_element()),
386            handler: self.wrap_handler(action),
387            icon: Some(icon.into()),
388            disabled,
389        });
390        self.has_icon = true;
391        self
392    }
393
394    /// Add Menu Item with custom element render with check state
395    pub fn menu_element_with_check<F, E>(
396        self,
397        checked: bool,
398        action: Box<dyn Action>,
399        builder: F,
400    ) -> Self
401    where
402        F: Fn(&mut Window, &mut App) -> E + 'static,
403        E: IntoElement,
404    {
405        self.menu_element_with_check_and_disabled(checked, action, false, builder)
406    }
407
408    /// Add Menu Item with custom element render with check state and disabled state
409    pub fn menu_element_with_check_and_disabled<F, E>(
410        mut self,
411        checked: bool,
412        action: Box<dyn Action>,
413        disabled: bool,
414        builder: F,
415    ) -> Self
416    where
417        F: Fn(&mut Window, &mut App) -> E + 'static,
418        E: IntoElement,
419    {
420        if checked {
421            self.menu_items.push(PopupMenuItem::ElementItem {
422                render: Box::new(move |window, cx| builder(window, cx).into_any_element()),
423                handler: self.wrap_handler(action),
424                icon: Some(IconName::Check.into()),
425                disabled,
426            });
427            self.has_icon = true;
428        } else {
429            self.menu_items.push(PopupMenuItem::ElementItem {
430                render: Box::new(move |window, cx| builder(window, cx).into_any_element()),
431                handler: self.wrap_handler(action),
432                icon: None,
433                disabled,
434            });
435        }
436        self
437    }
438
439    fn wrap_handler(&self, action: Box<dyn Action>) -> Rc<dyn Fn(&mut Window, &mut App)> {
440        Rc::new(move |window, cx| {
441            window.dispatch_action(action.boxed_clone(), cx);
442        })
443    }
444
445    /// Use small size, the menu item will have smaller height.
446    pub(crate) fn small(mut self) -> Self {
447        self.size = Size::Small;
448        self
449    }
450
451    /// Add a separator Menu Item
452    pub fn separator(mut self) -> Self {
453        if self.menu_items.is_empty() {
454            return self;
455        }
456
457        if let Some(PopupMenuItem::Separator) = self.menu_items.last() {
458            return self;
459        }
460
461        self.menu_items.push(PopupMenuItem::Separator);
462        self
463    }
464
465    /// Add a Submenu
466    pub fn submenu(
467        self,
468        label: impl Into<SharedString>,
469        window: &mut Window,
470        cx: &mut Context<Self>,
471        f: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
472    ) -> Self {
473        self.submenu_with_icon(None, label, window, cx, f)
474    }
475
476    /// Add a Submenu item with disabled state
477    pub fn submenu_with_disabled(
478        self,
479        label: impl Into<SharedString>,
480        disabled: bool,
481        window: &mut Window,
482        cx: &mut Context<Self>,
483        f: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
484    ) -> Self {
485        self.submenu_with_icon_with_disabled(None, label, disabled, window, cx, f)
486    }
487
488    /// Add a Submenu item with icon
489    pub fn submenu_with_icon(
490        self,
491        icon: Option<Icon>,
492        label: impl Into<SharedString>,
493        window: &mut Window,
494        cx: &mut Context<Self>,
495        f: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
496    ) -> Self {
497        self.submenu_with_icon_with_disabled(icon, label, false, window, cx, f)
498    }
499
500    /// Add a Submenu item with icon and disabled state
501    pub fn submenu_with_icon_with_disabled(
502        mut self,
503        icon: Option<Icon>,
504        label: impl Into<SharedString>,
505        disabled: bool,
506        window: &mut Window,
507        cx: &mut Context<Self>,
508        f: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
509    ) -> Self {
510        let submenu = PopupMenu::build(window, cx, f);
511        let parent_menu = cx.entity().downgrade();
512        submenu.update(cx, |view, _| {
513            view.parent_menu = Some(parent_menu);
514        });
515
516        self.menu_items.push(PopupMenuItem::Submenu {
517            icon,
518            label: label.into(),
519            menu: submenu,
520            disabled,
521        });
522        self
523    }
524
525    fn add_menu_item(
526        &mut self,
527        label: impl Into<SharedString>,
528        icon: Option<Icon>,
529        action: Box<dyn Action>,
530        disabled: bool,
531    ) -> &mut Self {
532        if icon.is_some() {
533            self.has_icon = true;
534        }
535
536        self.menu_items.push(PopupMenuItem::Item {
537            icon,
538            label: label.into(),
539            disabled,
540            action: Some(action.boxed_clone()),
541            is_link: false,
542            handler: self.wrap_handler(action),
543        });
544        self
545    }
546
547    pub(crate) fn active_submenu(&self) -> Option<Entity<PopupMenu>> {
548        if let Some(ix) = self.selected_index {
549            if let Some(item) = self.menu_items.get(ix) {
550                return match item {
551                    PopupMenuItem::Submenu { menu, .. } => Some(menu.clone()),
552                    _ => None,
553                };
554            }
555        }
556
557        None
558    }
559
560    pub fn is_empty(&self) -> bool {
561        self.menu_items.is_empty()
562    }
563
564    fn clickable_menu_items(&self) -> impl Iterator<Item = (usize, &PopupMenuItem)> {
565        self.menu_items
566            .iter()
567            .enumerate()
568            .filter(|(_, item)| item.is_clickable())
569    }
570
571    fn on_click(&mut self, ix: usize, window: &mut Window, cx: &mut Context<Self>) {
572        cx.stop_propagation();
573        window.prevent_default();
574        self.selected_index = Some(ix);
575        self.confirm(&Confirm { secondary: false }, window, cx);
576    }
577
578    fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
579        match self.selected_index {
580            Some(index) => {
581                let item = self.menu_items.get(index);
582                match item {
583                    Some(PopupMenuItem::Item { handler, .. }) => {
584                        handler(window, cx);
585                        self.dismiss(&Cancel, window, cx)
586                    }
587                    Some(PopupMenuItem::ElementItem { handler, .. }) => {
588                        handler(window, cx);
589                        self.dismiss(&Cancel, window, cx)
590                    }
591                    _ => {}
592                }
593            }
594            _ => {}
595        }
596    }
597
598    fn set_selected_index(&mut self, ix: usize, cx: &mut Context<Self>) {
599        if self.selected_index != Some(ix) {
600            self.selected_index = Some(ix);
601            self.scroll_handle.scroll_to_item(ix);
602            cx.notify();
603        }
604    }
605
606    fn select_next(&mut self, _: &SelectNext, _: &mut Window, cx: &mut Context<Self>) {
607        cx.stop_propagation();
608        let Some(ix) = self.selected_index else {
609            self.set_selected_index(0, cx);
610            return;
611        };
612
613        if let Some((next_ix, _)) = self
614            .menu_items
615            .iter()
616            .enumerate()
617            .find(|(i, item)| *i > ix && item.is_clickable())
618        {
619            self.set_selected_index(next_ix, cx);
620            return;
621        }
622
623        self.set_selected_index(0, cx);
624    }
625
626    fn select_prev(&mut self, _: &SelectPrev, _: &mut Window, cx: &mut Context<Self>) {
627        cx.stop_propagation();
628        let ix = self.selected_index.unwrap_or(0);
629
630        if let Some((prev_ix, _)) = self
631            .menu_items
632            .iter()
633            .enumerate()
634            .rev()
635            .find(|(i, item)| *i < ix && item.is_clickable())
636        {
637            self.set_selected_index(prev_ix, cx);
638            return;
639        }
640
641        let last_clickable_ix = self.clickable_menu_items().last().map(|(ix, _)| ix);
642        self.set_selected_index(last_clickable_ix.unwrap_or(0), cx);
643    }
644
645    fn select_left(&mut self, _: &SelectLeft, window: &mut Window, cx: &mut Context<Self>) {
646        let (anchor, _) = self.child_menu_anchor(window);
647        if matches!(anchor, Corner::TopLeft | Corner::BottomLeft) {
648            self._unselect_submenu(window, cx);
649        } else {
650            self._select_submenu(window, cx);
651        }
652
653        if self.parent_side(cx).is_left() {
654            self._focus_parent_menu(window, cx);
655        }
656    }
657
658    fn select_right(&mut self, _: &SelectRight, window: &mut Window, cx: &mut Context<Self>) {
659        let (anchor, _) = self.child_menu_anchor(window);
660        if matches!(anchor, Corner::TopLeft | Corner::BottomLeft) {
661            self._select_submenu(window, cx);
662        } else {
663            self._unselect_submenu(window, cx);
664        }
665
666        if self.parent_side(cx).is_right() {
667            self._focus_parent_menu(window, cx);
668        }
669    }
670
671    fn _select_submenu(&mut self, window: &mut Window, cx: &mut Context<Self>) {
672        if let Some(active_submenu) = self.active_submenu() {
673            // Focus the submenu, so that can be handle the action.
674            active_submenu.update(cx, |view, cx| {
675                view.focus_handle(cx).focus(window);
676                view.set_selected_index(0, cx);
677            });
678            cx.notify();
679        }
680    }
681
682    fn _unselect_submenu(&mut self, _: &mut Window, cx: &mut Context<Self>) {
683        if let Some(active_submenu) = self.active_submenu() {
684            active_submenu.update(cx, |view, cx| {
685                view.selected_index = None;
686                cx.notify();
687            });
688        }
689    }
690
691    fn _focus_parent_menu(&mut self, window: &mut Window, cx: &mut Context<Self>) {
692        if let Some(parent) = self.parent_menu.as_ref() {
693            self.selected_index = None;
694            if let Some(parent) = parent.upgrade() {
695                parent.update(cx, |view, cx| {
696                    view.focus_handle.focus(window);
697                    cx.notify();
698                });
699            }
700        }
701    }
702
703    fn parent_side(&self, cx: &App) -> Side {
704        let Some(parent) = self.parent_menu.as_ref() else {
705            return Side::Left;
706        };
707
708        let Some(parent) = parent.upgrade() else {
709            return Side::Left;
710        };
711
712        let parent_x = parent.read(cx).bounds.origin.x;
713        if parent_x < self.bounds.origin.x {
714            Side::Left
715        } else {
716            Side::Right
717        }
718    }
719
720    fn dismiss(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context<Self>) {
721        if self.active_submenu().is_some() {
722            return;
723        }
724
725        cx.emit(DismissEvent);
726
727        // Focus back to the previous focused handle.
728        if let Some(action_context) = self.action_context.as_ref() {
729            window.focus(action_context);
730        }
731
732        let Some(parent_menu) = self.parent_menu.clone() else {
733            return;
734        };
735
736        // Dismiss parent menu, when this menu is dismissed
737        _ = parent_menu.update(cx, |view, cx| {
738            view.selected_index = None;
739            view.dismiss(&Cancel, window, cx);
740        });
741    }
742
743    fn render_key_binding(
744        action: Option<Box<dyn Action>>,
745        window: &mut Window,
746        _: &mut Context<Self>,
747    ) -> Option<impl IntoElement> {
748        let action = action?;
749        Kbd::binding_for_action(action.as_ref(), None, window).map(|this| {
750            this.p_0()
751                .flex_nowrap()
752                .border_0()
753                .bg(gpui::transparent_white())
754        })
755    }
756
757    fn render_icon(
758        has_icon: bool,
759        icon: Option<Icon>,
760        _: &mut Window,
761        _: &mut Context<Self>,
762    ) -> Option<impl IntoElement> {
763        let icon_placeholder = if has_icon { Some(Icon::empty()) } else { None };
764
765        if !has_icon {
766            return None;
767        }
768
769        let icon = h_flex()
770            .w_3p5()
771            .h_3p5()
772            .justify_center()
773            .text_sm()
774            .map(|this| {
775                if let Some(icon) = icon {
776                    this.child(icon.clone().xsmall())
777                } else {
778                    this.children(icon_placeholder.clone())
779                }
780            });
781
782        Some(icon)
783    }
784
785    #[inline]
786    fn max_width(&self) -> Pixels {
787        self.max_width.unwrap_or(px(500.))
788    }
789
790    /// Calculate the anchor corner and left offset for child submenu
791    fn child_menu_anchor(&self, window: &Window) -> (Corner, Pixels) {
792        let bounds = self.bounds;
793        let max_width = self.max_width();
794        let (anchor, left) = if max_width + bounds.origin.x > window.bounds().size.width {
795            (Corner::TopRight, -px(16.))
796        } else {
797            (Corner::TopLeft, bounds.size.width - px(8.))
798        };
799
800        let is_bottom_pos = bounds.origin.y + bounds.size.height > window.bounds().size.height;
801        if is_bottom_pos {
802            (anchor.other_side_corner_along(gpui::Axis::Vertical), left)
803        } else {
804            (anchor, left)
805        }
806    }
807
808    fn render_item(
809        &self,
810        ix: usize,
811        item: &PopupMenuItem,
812        state: ItemState,
813        window: &mut Window,
814        cx: &mut Context<Self>,
815    ) -> impl IntoElement {
816        let has_icon = self.has_icon;
817        let selected = self.selected_index == Some(ix);
818        const EDGE_PADDING: Pixels = px(4.);
819        const INNER_PADDING: Pixels = px(8.);
820
821        let is_submenu = matches!(item, PopupMenuItem::Submenu { .. });
822        let group_name = format!("popup-menu-item-{}", ix);
823
824        let (item_height, radius) = match self.size {
825            Size::Small => (px(20.), state.radius.half()),
826            _ => (px(26.), state.radius),
827        };
828
829        let this = MenuItem::new(ix, &group_name)
830            .relative()
831            .text_sm()
832            .py_0()
833            .px(INNER_PADDING)
834            .rounded(radius)
835            .items_center()
836            .selected(selected)
837            .on_hover(cx.listener(move |this, hovered, _, cx| {
838                if *hovered {
839                    this.selected_index = Some(ix);
840                } else if !is_submenu && this.selected_index == Some(ix) {
841                    // TODO: Better handle the submenu unselection when hover out
842                    this.selected_index = None;
843                }
844
845                cx.notify();
846            }));
847
848        match item {
849            PopupMenuItem::Separator => this
850                .h_auto()
851                .p_0()
852                .my_0p5()
853                .mx_neg_1()
854                .h(px(1.))
855                .bg(cx.theme().border)
856                .disabled(true),
857            PopupMenuItem::Label(label) => this.disabled(true).cursor_default().child(
858                h_flex()
859                    .cursor_default()
860                    .items_center()
861                    .gap_x_1()
862                    .children(Self::render_icon(has_icon, None, window, cx))
863                    .child(label.clone()),
864            ),
865            PopupMenuItem::ElementItem {
866                render,
867                icon,
868                disabled,
869                ..
870            } => this
871                .when(!disabled, |this| {
872                    this.on_click(
873                        cx.listener(move |this, _, window, cx| this.on_click(ix, window, cx)),
874                    )
875                })
876                .disabled(*disabled)
877                .child(
878                    h_flex()
879                        .flex_1()
880                        .min_h(item_height)
881                        .items_center()
882                        .gap_x_1()
883                        .children(Self::render_icon(has_icon, icon.clone(), window, cx))
884                        .child((render)(window, cx)),
885                ),
886            PopupMenuItem::Item {
887                icon,
888                label,
889                action,
890                disabled,
891                is_link,
892                ..
893            } => {
894                let show_link_icon = *is_link && self.external_link_icon;
895                let action = action.as_ref().map(|action| action.boxed_clone());
896                let key = Self::render_key_binding(action, window, cx);
897
898                this.when(!disabled, |this| {
899                    this.on_click(
900                        cx.listener(move |this, _, window, cx| this.on_click(ix, window, cx)),
901                    )
902                })
903                .disabled(*disabled)
904                .h(item_height)
905                .children(Self::render_icon(has_icon, icon.clone(), window, cx))
906                .child(
907                    h_flex()
908                        .w_full()
909                        .gap_2()
910                        .items_center()
911                        .justify_between()
912                        .when(!show_link_icon, |this| this.child(label.clone()))
913                        .when(show_link_icon, |this| {
914                            this.child(
915                                h_flex()
916                                    .w_full()
917                                    .justify_between()
918                                    .gap_1p5()
919                                    .child(label.clone())
920                                    .child(
921                                        Icon::new(IconName::ExternalLink)
922                                            .xsmall()
923                                            .text_color(cx.theme().muted_foreground),
924                                    ),
925                            )
926                        })
927                        .children(key),
928                )
929            }
930            PopupMenuItem::Submenu {
931                icon,
932                label,
933                menu,
934                disabled,
935            } => this
936                .selected(selected)
937                .disabled(*disabled)
938                .items_start()
939                .child(
940                    h_flex()
941                        .min_h(item_height)
942                        .size_full()
943                        .items_center()
944                        .gap_x_1()
945                        .children(Self::render_icon(has_icon, icon.clone(), window, cx))
946                        .child(
947                            h_flex()
948                                .flex_1()
949                                .gap_2()
950                                .items_center()
951                                .justify_between()
952                                .child(label.clone())
953                                .child(IconName::ChevronRight),
954                        ),
955                )
956                .child({
957                    let (anchor, left) = self.child_menu_anchor(window);
958                    let is_bottom_pos = matches!(anchor, Corner::BottomLeft | Corner::BottomRight);
959
960                    anchored()
961                        .anchor(anchor)
962                        .child(
963                            div()
964                                .id("submenu")
965                                .group(&group_name)
966                                .when(!selected, |this| this.invisible())
967                                .group_hover(&group_name, |this| this.visible())
968                                .occlude()
969                                .when(is_bottom_pos, |this| this.bottom_0())
970                                .when(!is_bottom_pos, |this| this.top_neg_1())
971                                .left(left)
972                                .child(menu.clone()),
973                        )
974                        .snap_to_window_with_margin(Edges::all(EDGE_PADDING))
975                }),
976        }
977    }
978}
979
980impl FluentBuilder for PopupMenu {}
981impl EventEmitter<DismissEvent> for PopupMenu {}
982impl Focusable for PopupMenu {
983    fn focus_handle(&self, _: &App) -> FocusHandle {
984        self.focus_handle.clone()
985    }
986}
987
988#[derive(Clone, Copy)]
989struct ItemState {
990    radius: Pixels,
991}
992
993impl Render for PopupMenu {
994    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
995        let view = cx.entity().clone();
996        let items_count = self.menu_items.len();
997
998        let max_height = self.max_height.map_or_else(
999            || {
1000                let window_half_height = window.window_bounds().get_bounds().size.height * 0.5;
1001                window_half_height.min(px(450.))
1002            },
1003            |height| height,
1004        );
1005
1006        let max_width = self.max_width();
1007        let item_state = ItemState {
1008            radius: cx.theme().radius.min(px(8.)),
1009        };
1010
1011        v_flex()
1012            .id("popup-menu")
1013            .key_context(CONTEXT)
1014            .track_focus(&self.focus_handle)
1015            .on_action(cx.listener(Self::select_next))
1016            .on_action(cx.listener(Self::select_prev))
1017            .on_action(cx.listener(Self::select_left))
1018            .on_action(cx.listener(Self::select_right))
1019            .on_action(cx.listener(Self::confirm))
1020            .on_action(cx.listener(Self::dismiss))
1021            .on_mouse_down_out(cx.listener(|this, ev: &MouseDownEvent, window, cx| {
1022                // Do not dismiss, if click inside the parent menu
1023                if let Some(parent) = this.parent_menu.as_ref() {
1024                    if let Some(parent) = parent.upgrade() {
1025                        if parent.read(cx).bounds.contains(&ev.position) {
1026                            return;
1027                        }
1028                    }
1029                }
1030
1031                this.dismiss(&Cancel, window, cx);
1032            }))
1033            .popover_style(cx)
1034            .text_color(cx.theme().popover_foreground)
1035            .relative()
1036            .child(
1037                v_flex()
1038                    .id("items")
1039                    .p_1()
1040                    .gap_y_0p5()
1041                    .min_w(rems(8.))
1042                    .when_some(self.min_width, |this, min_width| this.min_w(min_width))
1043                    .max_w(max_width)
1044                    .when(self.scrollable, |this| {
1045                        this.max_h(max_height)
1046                            .overflow_y_scroll()
1047                            .track_scroll(&self.scroll_handle)
1048                    })
1049                    .children(
1050                        self.menu_items
1051                            .iter()
1052                            .enumerate()
1053                            // Ignore last separator
1054                            .filter(|(ix, item)| !(*ix + 1 == items_count && item.is_separator()))
1055                            .map(|(ix, item)| self.render_item(ix, item, item_state, window, cx)),
1056                    )
1057                    .child({
1058                        canvas(
1059                            move |bounds, _, cx| view.update(cx, |r, _| r.bounds = bounds),
1060                            |_, _, _, _| {},
1061                        )
1062                        .absolute()
1063                        .size_full()
1064                    }),
1065            )
1066            .when(self.scrollable, |this| {
1067                // TODO: When the menu is limited by `overflow_y_scroll`, the sub-menu will cannot be displayed.
1068                this.child(
1069                    div()
1070                        .absolute()
1071                        .top_0()
1072                        .left_0()
1073                        .right_0()
1074                        .bottom_0()
1075                        .child(Scrollbar::vertical(&self.scroll_state, &self.scroll_handle)),
1076                )
1077            })
1078    }
1079}