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::collections::HashSet;
18use std::rc::Rc;
19
20const CONTEXT: &str = "PopupMenu";
21
22pub fn init(cx: &mut App) {
23 cx.bind_keys([
24 KeyBinding::new("enter", Confirm { secondary: false }, Some(CONTEXT)),
25 KeyBinding::new("escape", Cancel, Some(CONTEXT)),
26 KeyBinding::new("up", SelectUp, Some(CONTEXT)),
27 KeyBinding::new("down", SelectDown, Some(CONTEXT)),
28 KeyBinding::new("left", SelectLeft, Some(CONTEXT)),
29 KeyBinding::new("right", SelectRight, Some(CONTEXT)),
30 ]);
31}
32
33pub enum PopupMenuItem {
35 Separator,
37 Label(SharedString),
39 Item {
41 icon: Option<Icon>,
42 label: SharedString,
43 disabled: bool,
44 checked: bool,
45 is_link: bool,
46 action: Option<Box<dyn Action>>,
47 handler: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
49 },
50 ElementItem {
52 icon: Option<Icon>,
53 disabled: bool,
54 checked: bool,
55 action: Option<Box<dyn Action>>,
56 render: Box<dyn Fn(&mut Window, &mut App) -> AnyElement + 'static>,
57 handler: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
58 },
59 Submenu {
61 icon: Option<Icon>,
62 label: SharedString,
63 disabled: bool,
64 menu: Entity<PopupMenu>,
65 },
66}
67
68impl FluentBuilder for PopupMenuItem {}
69impl PopupMenuItem {
70 #[inline]
72 pub fn new(label: impl Into<SharedString>) -> Self {
73 PopupMenuItem::Item {
74 icon: None,
75 label: label.into(),
76 disabled: false,
77 checked: false,
78 action: None,
79 is_link: false,
80 handler: None,
81 }
82 }
83
84 #[inline]
86 pub fn element<F, E>(builder: F) -> Self
87 where
88 F: Fn(&mut Window, &mut App) -> E + 'static,
89 E: IntoElement,
90 {
91 PopupMenuItem::ElementItem {
92 icon: None,
93 disabled: false,
94 checked: false,
95 action: None,
96 render: Box::new(move |window, cx| builder(window, cx).into_any_element()),
97 handler: None,
98 }
99 }
100
101 #[inline]
103 pub fn submenu(label: impl Into<SharedString>, menu: Entity<PopupMenu>) -> Self {
104 PopupMenuItem::Submenu {
105 icon: None,
106 label: label.into(),
107 disabled: false,
108 menu,
109 }
110 }
111
112 #[inline]
114 pub fn separator() -> Self {
115 PopupMenuItem::Separator
116 }
117
118 #[inline]
120 pub fn label(label: impl Into<SharedString>) -> Self {
121 PopupMenuItem::Label(label.into())
122 }
123
124 pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
128 match &mut self {
129 PopupMenuItem::Item { icon: i, .. } => {
130 *i = Some(icon.into());
131 }
132 PopupMenuItem::ElementItem { icon: i, .. } => {
133 *i = Some(icon.into());
134 }
135 PopupMenuItem::Submenu { icon: i, .. } => {
136 *i = Some(icon.into());
137 }
138 _ => {}
139 }
140 self
141 }
142
143 pub fn action(mut self, action: Box<dyn Action>) -> Self {
147 match &mut self {
148 PopupMenuItem::Item { action: a, .. } => {
149 *a = Some(action);
150 }
151 PopupMenuItem::ElementItem { action: a, .. } => {
152 *a = Some(action);
153 }
154 _ => {}
155 }
156 self
157 }
158
159 pub fn disabled(mut self, disabled: bool) -> Self {
163 match &mut self {
164 PopupMenuItem::Item { disabled: d, .. } => {
165 *d = disabled;
166 }
167 PopupMenuItem::ElementItem { disabled: d, .. } => {
168 *d = disabled;
169 }
170 PopupMenuItem::Submenu { disabled: d, .. } => {
171 *d = disabled;
172 }
173 _ => {}
174 }
175 self
176 }
177
178 pub fn checked(mut self, checked: bool) -> Self {
182 match &mut self {
183 PopupMenuItem::Item { checked: c, .. } => {
184 *c = checked;
185 }
186 PopupMenuItem::ElementItem { checked: c, .. } => {
187 *c = checked;
188 }
189 _ => {}
190 }
191 self
192 }
193
194 pub fn on_click<F>(mut self, handler: F) -> Self
198 where
199 F: Fn(&ClickEvent, &mut Window, &mut App) + 'static,
200 {
201 match &mut self {
202 PopupMenuItem::Item { handler: h, .. } => {
203 *h = Some(Rc::new(handler));
204 }
205 PopupMenuItem::ElementItem { handler: h, .. } => {
206 *h = Some(Rc::new(handler));
207 }
208 _ => {}
209 }
210 self
211 }
212
213 #[inline]
215 pub fn link(label: impl Into<SharedString>, href: impl Into<String>) -> Self {
216 let href = href.into();
217 PopupMenuItem::Item {
218 icon: None,
219 label: label.into(),
220 disabled: false,
221 checked: false,
222 action: None,
223 is_link: true,
224 handler: Some(Rc::new(move |_, _, cx| cx.open_url(&href))),
225 }
226 }
227
228 #[inline]
229 fn is_clickable(&self) -> bool {
230 !matches!(self, PopupMenuItem::Separator)
231 && matches!(
232 self,
233 PopupMenuItem::Item {
234 disabled: false,
235 ..
236 } | PopupMenuItem::ElementItem {
237 disabled: false,
238 ..
239 } | PopupMenuItem::Submenu {
240 disabled: false,
241 ..
242 }
243 )
244 }
245
246 #[inline]
247 fn is_separator(&self) -> bool {
248 matches!(self, PopupMenuItem::Separator)
249 }
250
251 fn has_left_icon(&self, check_side: Side) -> bool {
252 match self {
253 PopupMenuItem::Item { icon, checked, .. } => {
254 icon.is_some() || (check_side.is_left() && *checked)
255 }
256 PopupMenuItem::ElementItem { icon, checked, .. } => {
257 icon.is_some() || (check_side.is_left() && *checked)
258 }
259 PopupMenuItem::Submenu { icon, .. } => icon.is_some(),
260 _ => false,
261 }
262 }
263
264 #[inline]
265 fn is_checked(&self) -> bool {
266 match self {
267 PopupMenuItem::Item { checked, .. } => *checked,
268 PopupMenuItem::ElementItem { checked, .. } => *checked,
269 _ => false,
270 }
271 }
272
273 fn a11y_label(&self) -> Option<SharedString> {
274 match self {
275 PopupMenuItem::Item { label, .. }
276 | PopupMenuItem::Label(label)
277 | PopupMenuItem::Submenu { label, .. } => Some(label.clone()),
278 PopupMenuItem::Separator | PopupMenuItem::ElementItem { .. } => None,
279 }
280 }
281}
282
283pub struct PopupMenu {
284 pub(crate) focus_handle: FocusHandle,
285 pub(crate) menu_items: Vec<PopupMenuItem>,
286 pub(crate) action_context: Option<FocusHandle>,
288 pub(crate) previous_focus_handle: Option<FocusHandle>,
292 pub(crate) trigger_focus_handle: Option<FocusHandle>,
297 selected_index: Option<usize>,
298 min_width: Option<Pixels>,
299 max_width: Option<Pixels>,
300 max_height: Option<Pixels>,
301 bounds: Bounds<Pixels>,
302 size: Size,
303 check_side: Side,
304 checkable: HashSet<usize>,
307
308 parent_menu: Option<WeakEntity<Self>>,
310 scrollable: bool,
311 external_link_icon: bool,
312 scroll_handle: ScrollHandle,
313 submenu_anchor: (Anchor, Pixels),
315
316 priority: usize,
333
334 _subscriptions: Vec<Subscription>,
335}
336
337impl PopupMenu {
338 pub(crate) fn new(cx: &mut App) -> Self {
339 Self {
340 focus_handle: cx.focus_handle(),
341 action_context: None,
342 previous_focus_handle: None,
343 trigger_focus_handle: None,
344 parent_menu: None,
345 menu_items: Vec::new(),
346 selected_index: None,
347 min_width: None,
348 max_width: None,
349 max_height: None,
350 check_side: Side::Left,
351 checkable: HashSet::new(),
352 bounds: Bounds::default(),
353 scrollable: false,
354 scroll_handle: ScrollHandle::default(),
355 external_link_icon: true,
356 size: Size::default(),
357 submenu_anchor: (Anchor::TopLeft, Pixels::ZERO),
358 priority: gpui_base::POPUP_PRIORITY,
359 _subscriptions: vec![],
360 }
361 }
362
363 pub fn build(
364 window: &mut Window,
365 cx: &mut App,
366 f: impl FnOnce(Self, &mut Window, &mut Context<PopupMenu>) -> Self,
367 ) -> Entity<Self> {
368 cx.new(|cx| f(Self::new(cx), window, cx))
369 }
370
371 pub fn action_context(mut self, handle: FocusHandle) -> Self {
379 self.action_context = Some(handle);
380 self
381 }
382
383 pub(crate) fn set_action_context(
384 &mut self,
385 action_context: Option<FocusHandle>,
386 cx: &mut Context<Self>,
387 ) {
388 self.action_context = action_context.clone();
389
390 for item in &self.menu_items {
391 if let PopupMenuItem::Submenu { menu, .. } = item {
392 menu.update(cx, |menu, cx| {
393 menu.set_action_context(action_context.clone(), cx);
394 });
395 }
396 }
397 }
398
399 pub(crate) fn set_previous_focus(
402 &mut self,
403 handle: Option<FocusHandle>,
404 cx: &mut Context<Self>,
405 ) {
406 self.previous_focus_handle = handle.clone();
407
408 for item in &self.menu_items {
409 if let PopupMenuItem::Submenu { menu, .. } = item {
410 menu.update(cx, |menu, cx| {
411 menu.set_previous_focus(handle.clone(), cx);
412 });
413 }
414 }
415 }
416
417 pub(crate) fn set_trigger_focus(
420 &mut self,
421 handle: Option<FocusHandle>,
422 cx: &mut Context<Self>,
423 ) {
424 self.trigger_focus_handle = handle.clone();
425
426 for item in &self.menu_items {
427 if let PopupMenuItem::Submenu { menu, .. } = item {
428 menu.update(cx, |menu, cx| {
429 menu.set_trigger_focus(handle.clone(), cx);
430 });
431 }
432 }
433 }
434
435 pub fn min_w(mut self, width: impl Into<Pixels>) -> Self {
437 self.min_width = Some(width.into());
438 self
439 }
440
441 pub fn max_w(mut self, width: impl Into<Pixels>) -> Self {
443 self.max_width = Some(width.into());
444 self
445 }
446
447 pub fn max_h(mut self, height: impl Into<Pixels>) -> Self {
449 self.max_height = Some(height.into());
450 self
451 }
452
453 pub fn scrollable(mut self, scrollable: bool) -> Self {
455 self.scrollable = scrollable;
456 self
457 }
458
459 pub fn check_side(mut self, side: Side) -> Self {
461 self.check_side = side;
462 self
463 }
464
465 pub fn external_link_icon(mut self, visible: bool) -> Self {
467 self.external_link_icon = visible;
468 self
469 }
470
471 pub fn menu(self, label: impl Into<SharedString>, action: Box<dyn Action>) -> Self {
473 self.menu_with_disabled(label, action, false)
474 }
475
476 pub fn menu_with_enable(
478 mut self,
479 label: impl Into<SharedString>,
480 action: Box<dyn Action>,
481 enable: bool,
482 ) -> Self {
483 self.add_menu_item(label, None, action, !enable, false);
484 self
485 }
486
487 pub fn menu_with_disabled(
489 mut self,
490 label: impl Into<SharedString>,
491 action: Box<dyn Action>,
492 disabled: bool,
493 ) -> Self {
494 self.add_menu_item(label, None, action, disabled, false);
495 self
496 }
497
498 pub fn label(mut self, label: impl Into<SharedString>) -> Self {
500 self.menu_items.push(PopupMenuItem::label(label.into()));
501 self
502 }
503
504 pub fn link(self, label: impl Into<SharedString>, href: impl Into<String>) -> Self {
506 self.link_with_disabled(label, href, false)
507 }
508
509 pub fn link_with_disabled(
511 mut self,
512 label: impl Into<SharedString>,
513 href: impl Into<String>,
514 disabled: bool,
515 ) -> Self {
516 let href = href.into();
517 self.menu_items
518 .push(PopupMenuItem::link(label, href).disabled(disabled));
519 self
520 }
521
522 pub fn link_with_icon(
524 self,
525 label: impl Into<SharedString>,
526 icon: impl Into<Icon>,
527 href: impl Into<String>,
528 ) -> Self {
529 self.link_with_icon_and_disabled(label, icon, href, false)
530 }
531
532 fn link_with_icon_and_disabled(
534 mut self,
535 label: impl Into<SharedString>,
536 icon: impl Into<Icon>,
537 href: impl Into<String>,
538 disabled: bool,
539 ) -> Self {
540 let href = href.into();
541 self.menu_items.push(
542 PopupMenuItem::link(label, href)
543 .icon(icon)
544 .disabled(disabled),
545 );
546 self
547 }
548
549 pub fn menu_with_icon(
551 self,
552 label: impl Into<SharedString>,
553 icon: impl Into<Icon>,
554 action: Box<dyn Action>,
555 ) -> Self {
556 self.menu_with_icon_and_disabled(label, icon, action, false)
557 }
558
559 pub fn menu_with_icon_and_disabled(
561 mut self,
562 label: impl Into<SharedString>,
563 icon: impl Into<Icon>,
564 action: Box<dyn Action>,
565 disabled: bool,
566 ) -> Self {
567 self.add_menu_item(label, Some(icon.into()), action, disabled, false);
568 self
569 }
570
571 pub fn menu_with_check(
573 self,
574 label: impl Into<SharedString>,
575 checked: bool,
576 action: Box<dyn Action>,
577 ) -> Self {
578 self.menu_with_check_and_disabled(label, checked, action, false)
579 }
580
581 pub fn menu_with_check_and_disabled(
583 mut self,
584 label: impl Into<SharedString>,
585 checked: bool,
586 action: Box<dyn Action>,
587 disabled: bool,
588 ) -> Self {
589 self.checkable.insert(self.menu_items.len());
590 self.add_menu_item(label, None, action, disabled, checked);
591 self
592 }
593
594 pub fn menu_element<F, E>(self, action: Box<dyn Action>, builder: F) -> Self
596 where
597 F: Fn(&mut Window, &mut App) -> E + 'static,
598 E: IntoElement,
599 {
600 self.menu_element_with_check(false, action, builder)
601 }
602
603 pub fn menu_element_with_disabled<F, E>(
605 self,
606 action: Box<dyn Action>,
607 disabled: bool,
608 builder: F,
609 ) -> Self
610 where
611 F: Fn(&mut Window, &mut App) -> E + 'static,
612 E: IntoElement,
613 {
614 self.menu_element_with_check_and_disabled(false, action, disabled, builder)
615 }
616
617 pub fn menu_element_with_icon<F, E>(
619 self,
620 icon: impl Into<Icon>,
621 action: Box<dyn Action>,
622 builder: F,
623 ) -> Self
624 where
625 F: Fn(&mut Window, &mut App) -> E + 'static,
626 E: IntoElement,
627 {
628 self.menu_element_with_icon_and_disabled(icon, action, false, builder)
629 }
630
631 pub fn menu_element_with_check<F, E>(
633 self,
634 checked: bool,
635 action: Box<dyn Action>,
636 builder: F,
637 ) -> Self
638 where
639 F: Fn(&mut Window, &mut App) -> E + 'static,
640 E: IntoElement,
641 {
642 self.menu_element_with_check_and_disabled(checked, action, false, builder)
643 }
644
645 fn menu_element_with_icon_and_disabled<F, E>(
647 mut self,
648 icon: impl Into<Icon>,
649 action: Box<dyn Action>,
650 disabled: bool,
651 builder: F,
652 ) -> Self
653 where
654 F: Fn(&mut Window, &mut App) -> E + 'static,
655 E: IntoElement,
656 {
657 self.menu_items.push(
658 PopupMenuItem::element(builder)
659 .action(action)
660 .icon(icon)
661 .disabled(disabled),
662 );
663 self
664 }
665
666 fn menu_element_with_check_and_disabled<F, E>(
668 mut self,
669 checked: bool,
670 action: Box<dyn Action>,
671 disabled: bool,
672 builder: F,
673 ) -> Self
674 where
675 F: Fn(&mut Window, &mut App) -> E + 'static,
676 E: IntoElement,
677 {
678 self.checkable.insert(self.menu_items.len());
679 self.menu_items.push(
680 PopupMenuItem::element(builder)
681 .action(action)
682 .checked(checked)
683 .disabled(disabled),
684 );
685 self
686 }
687
688 pub fn separator(mut self) -> Self {
690 if self.menu_items.is_empty() {
691 return self;
692 }
693
694 if let Some(PopupMenuItem::Separator) = self.menu_items.last() {
695 return self;
696 }
697
698 self.menu_items.push(PopupMenuItem::separator());
699 self
700 }
701
702 pub fn submenu(
704 self,
705 label: impl Into<SharedString>,
706 window: &mut Window,
707 cx: &mut Context<Self>,
708 f: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
709 ) -> Self {
710 self.submenu_with_icon(None, label, window, cx, f)
711 }
712
713 pub fn submenu_with_icon(
715 mut self,
716 icon: Option<Icon>,
717 label: impl Into<SharedString>,
718 window: &mut Window,
719 cx: &mut Context<Self>,
720 f: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
721 ) -> Self {
722 let submenu = PopupMenu::build(window, cx, f);
723 let parent_menu = cx.entity().downgrade();
724 let parent_priority = self.priority;
725 submenu.update(cx, |view, _| {
726 view.parent_menu = Some(parent_menu);
727 view.priority = parent_priority + 1;
728 });
729
730 self.menu_items.push(
731 PopupMenuItem::submenu(label, submenu).when_some(icon, |this, icon| this.icon(icon)),
732 );
733 self
734 }
735
736 pub fn item(mut self, item: impl Into<PopupMenuItem>) -> Self {
738 let item: PopupMenuItem = item.into();
739 self.menu_items.push(item);
740 self
741 }
742
743 pub fn rebuild(
763 &mut self,
764 window: &mut Window,
765 cx: &mut Context<Self>,
766 f: impl FnOnce(Self, &mut Window, &mut Context<Self>) -> Self,
767 ) {
768 let mut menu = std::mem::replace(self, Self::new(cx));
769 menu.menu_items.clear();
770 menu.selected_index = None;
771 *self = f(menu, window, cx);
772 cx.notify();
773 }
774
775 fn add_menu_item(
776 &mut self,
777 label: impl Into<SharedString>,
778 icon: Option<Icon>,
779 action: Box<dyn Action>,
780 disabled: bool,
781 checked: bool,
782 ) -> &mut Self {
783 self.menu_items.push(
784 PopupMenuItem::new(label)
785 .when_some(icon, |item, icon| item.icon(icon))
786 .disabled(disabled)
787 .checked(checked)
788 .action(action),
789 );
790 self
791 }
792
793 pub(super) fn with_menu_items<I>(
794 mut self,
795 items: impl IntoIterator<Item = I>,
796 window: &mut Window,
797 cx: &mut Context<Self>,
798 ) -> Self
799 where
800 I: Into<OwnedMenuItem>,
801 {
802 for item in items {
803 match item.into() {
804 OwnedMenuItem::Action {
805 name,
806 action,
807 checked,
808 disabled,
809 ..
810 } => {
811 self = self.item(
814 PopupMenuItem::new(name)
815 .action(action.boxed_clone())
816 .disabled(disabled)
817 .checked(checked),
818 )
819 }
820 OwnedMenuItem::Separator => {
821 self = self.separator();
822 }
823 OwnedMenuItem::Submenu(submenu) => {
824 self = self.submenu(submenu.name, window, cx, move |menu, window, cx| {
825 menu.with_menu_items(submenu.items.clone(), window, cx)
826 })
827 }
828 OwnedMenuItem::SystemMenu(_) => {}
829 }
830 }
831
832 if self.menu_items.len() > 20 {
833 self.scrollable = true;
834 }
835
836 self
837 }
838
839 pub(crate) fn active_submenu(&self) -> Option<Entity<PopupMenu>> {
840 if let Some(ix) = self.selected_index {
841 if let Some(item) = self.menu_items.get(ix) {
842 return match item {
843 PopupMenuItem::Submenu { menu, .. } => Some(menu.clone()),
844 _ => None,
845 };
846 }
847 }
848
849 None
850 }
851
852 pub fn is_empty(&self) -> bool {
853 self.menu_items.is_empty()
854 }
855
856 fn clickable_menu_items(&self) -> impl Iterator<Item = (usize, &PopupMenuItem)> {
857 self.menu_items
858 .iter()
859 .enumerate()
860 .filter(|(_, item)| item.is_clickable())
861 }
862
863 fn on_click(
864 &mut self,
865 ix: usize,
866 event: &ClickEvent,
867 window: &mut Window,
868 cx: &mut Context<Self>,
869 ) {
870 cx.stop_propagation();
871 window.prevent_default();
872 self.selected_index = Some(ix);
873 self.activate(event, window, cx);
874 }
875
876 fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
877 self.activate(&ClickEvent::default(), window, cx);
878 }
879
880 fn activate(&mut self, event: &ClickEvent, window: &mut Window, cx: &mut Context<Self>) {
881 match self.selected_index {
882 Some(index) => {
883 let item = self.menu_items.get(index);
884 if !item.is_some_and(PopupMenuItem::is_clickable) {
885 return;
886 }
887 match item {
888 Some(PopupMenuItem::Item {
889 handler, action, ..
890 }) => {
891 if let Some(handler) = handler {
892 handler(event, window, cx);
893 } else if let Some(action) = action.as_ref() {
894 self.dispatch_confirm_action(action, window, cx);
895 }
896
897 self.dismiss(&Cancel, window, cx)
898 }
899 Some(PopupMenuItem::ElementItem {
900 handler, action, ..
901 }) => {
902 if let Some(handler) = handler {
903 handler(event, window, cx);
904 } else if let Some(action) = action.as_ref() {
905 self.dispatch_confirm_action(action, window, cx);
906 }
907 self.dismiss(&Cancel, window, cx)
908 }
909 Some(PopupMenuItem::Submenu {
910 disabled: false, ..
911 }) => {
912 self._select_submenu(window, cx);
913 }
914 _ => {}
915 }
916 }
917 _ => {}
918 }
919 }
920
921 fn dispatch_confirm_action(
922 &self,
923 action: &Box<dyn Action>,
924 window: &mut Window,
925 cx: &mut Context<Self>,
926 ) {
927 if let Some(context) = self.resolved_action_context(cx) {
928 context.focus(window, cx);
929 }
930
931 window.dispatch_action(action.boxed_clone(), cx);
932 }
933
934 fn resolved_action_context(&self, cx: &App) -> Option<FocusHandle> {
935 self.action_context.clone().or_else(|| {
936 let parent = self.parent_menu.as_ref()?.upgrade()?;
937 parent.read(cx).resolved_action_context(cx)
938 })
939 }
940
941 fn set_selected_index(&mut self, ix: usize, cx: &mut Context<Self>) {
942 if self.selected_index != Some(ix) {
943 self.selected_index = Some(ix);
944 self.scroll_handle.scroll_to_item(ix);
945 cx.notify();
946 }
947 }
948
949 fn select_up(&mut self, _: &SelectUp, _: &mut Window, cx: &mut Context<Self>) {
950 cx.stop_propagation();
951 let ix = self.selected_index.unwrap_or(0);
952
953 if let Some((prev_ix, _)) = self
954 .menu_items
955 .iter()
956 .enumerate()
957 .rev()
958 .find(|(i, item)| *i < ix && item.is_clickable())
959 {
960 self.set_selected_index(prev_ix, cx);
961 return;
962 }
963
964 let last_clickable_ix = self.clickable_menu_items().last().map(|(ix, _)| ix);
965 if let Some(ix) = last_clickable_ix {
966 self.set_selected_index(ix, cx);
967 }
968 }
969
970 fn select_down(&mut self, _: &SelectDown, _: &mut Window, cx: &mut Context<Self>) {
971 cx.stop_propagation();
972 let Some(ix) = self.selected_index else {
973 let first_clickable_ix = self.clickable_menu_items().next().map(|(ix, _)| ix);
974 if let Some(ix) = first_clickable_ix {
975 self.set_selected_index(ix, cx);
976 }
977 return;
978 };
979
980 if let Some((next_ix, _)) = self
981 .menu_items
982 .iter()
983 .enumerate()
984 .find(|(i, item)| *i > ix && item.is_clickable())
985 {
986 self.set_selected_index(next_ix, cx);
987 return;
988 }
989
990 let first_clickable_ix = self.clickable_menu_items().next().map(|(ix, _)| ix);
991 if let Some(ix) = first_clickable_ix {
992 self.set_selected_index(ix, cx);
993 }
994 }
995
996 fn select_left(&mut self, _: &SelectLeft, window: &mut Window, cx: &mut Context<Self>) {
997 let handled = if matches!(self.submenu_anchor.0, Anchor::TopLeft | Anchor::BottomLeft) {
998 self._unselect_submenu(window, cx)
999 } else {
1000 self._select_submenu(window, cx)
1001 };
1002
1003 if self.parent_side(cx).is_left() {
1004 self._focus_parent_menu(window, cx);
1005 }
1006
1007 if handled {
1008 return;
1009 }
1010
1011 if self.parent_menu.is_none() {
1013 cx.propagate();
1014 }
1015 }
1016
1017 fn select_right(&mut self, _: &SelectRight, window: &mut Window, cx: &mut Context<Self>) {
1018 let handled = if matches!(self.submenu_anchor.0, Anchor::TopLeft | Anchor::BottomLeft) {
1019 self._select_submenu(window, cx)
1020 } else {
1021 self._unselect_submenu(window, cx)
1022 };
1023
1024 if self.parent_side(cx).is_right() {
1025 self._focus_parent_menu(window, cx);
1026 }
1027
1028 if handled {
1029 return;
1030 }
1031
1032 if self.parent_menu.is_none() {
1034 cx.propagate();
1035 }
1036 }
1037
1038 fn _select_submenu(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
1039 if let Some(active_submenu) = self.active_submenu() {
1040 active_submenu.update(cx, |view, cx| {
1042 let first_clickable_ix = view.clickable_menu_items().next().map(|(ix, _)| ix);
1043 if let Some(ix) = first_clickable_ix {
1044 view.set_selected_index(ix, cx);
1045 } else {
1046 view.selected_index = None;
1047 cx.notify();
1048 }
1049 view.focus_handle.focus(window, cx);
1050 });
1051 cx.notify();
1052 return true;
1053 }
1054
1055 return false;
1056 }
1057
1058 fn _unselect_submenu(&mut self, _: &mut Window, cx: &mut Context<Self>) -> bool {
1059 if let Some(active_submenu) = self.active_submenu() {
1060 active_submenu.update(cx, |view, cx| {
1061 view.selected_index = None;
1062 cx.notify();
1063 });
1064 return true;
1065 }
1066
1067 return false;
1068 }
1069
1070 fn _focus_parent_menu(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1071 let Some(parent) = self.parent_menu.as_ref() else {
1072 return;
1073 };
1074 let Some(parent) = parent.upgrade() else {
1075 return;
1076 };
1077
1078 self.selected_index = None;
1079 parent.update(cx, |view, cx| {
1080 view.focus_handle.focus(window, cx);
1081 cx.notify();
1082 });
1083 }
1084
1085 fn parent_side(&self, cx: &App) -> Side {
1086 let Some(parent) = self.parent_menu.as_ref() else {
1087 return Side::Left;
1088 };
1089
1090 let Some(parent) = parent.upgrade() else {
1091 return Side::Left;
1092 };
1093
1094 match parent.read(cx).submenu_anchor.0 {
1095 Anchor::TopLeft | Anchor::BottomLeft => Side::Left,
1096 Anchor::TopRight | Anchor::BottomRight => Side::Right,
1097 _ => Side::Left,
1099 }
1100 }
1101
1102 fn dismiss(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context<Self>) {
1106 self.selected_index = None;
1107 cx.emit(DismissEvent);
1108
1109 let focus_moved_away =
1113 window.focused(cx).is_some() && !self.focus_handle.contains_focused(window, cx);
1114 if !focus_moved_away {
1115 if let Some(handle) = self
1116 .previous_focus_handle
1117 .as_ref()
1118 .or(self.action_context.as_ref())
1119 {
1120 window.focus(handle, cx);
1121 }
1122 }
1123
1124 let Some(parent_menu) = self.parent_menu.clone() else {
1125 return;
1126 };
1127
1128 _ = parent_menu.update(cx, |view, cx| {
1130 view.dismiss(&Cancel, window, cx);
1131 });
1132 }
1133
1134 fn handle_dismiss(
1135 &mut self,
1136 position: &Point<Pixels>,
1137 window: &mut Window,
1138 cx: &mut Context<Self>,
1139 ) {
1140 if let Some(parent) = self.parent_menu.as_ref() {
1142 if let Some(parent) = parent.upgrade() {
1143 if parent.read(cx).bounds.contains(position) {
1144 return;
1145 }
1146 }
1147 }
1148
1149 if self.active_submenu().is_some() {
1154 return;
1155 }
1156
1157 self.dismiss(&Cancel, window, cx);
1158 }
1159
1160 fn on_mouse_down_out(
1161 &mut self,
1162 e: &MouseDownEvent,
1163 window: &mut Window,
1164 cx: &mut Context<Self>,
1165 ) {
1166 self.handle_dismiss(&e.position, window, cx);
1167 }
1168
1169 fn render_key_binding(
1170 &self,
1171 action: Option<Box<dyn Action>>,
1172 window: &mut Window,
1173 cx: &mut Context<Self>,
1174 ) -> Option<Kbd> {
1175 let action = action?;
1176
1177 let action_context = self.resolved_action_context(cx);
1186 [
1187 action_context.as_ref(),
1188 self.trigger_focus_handle.as_ref(),
1189 self.previous_focus_handle.as_ref(),
1190 Some(&self.focus_handle),
1191 ]
1192 .into_iter()
1193 .flatten()
1194 .find_map(|handle| Kbd::binding_for_action_in(action.as_ref(), handle, window))
1195 .or_else(|| Kbd::global_binding_for_action(action.as_ref(), window))
1196 .map(|this| {
1197 this.p_0()
1198 .flex_nowrap()
1199 .border_0()
1200 .bg(gpui::transparent_white())
1201 })
1202 }
1203
1204 fn render_icon(
1205 has_icon: bool,
1206 checked: bool,
1207 icon: Option<Icon>,
1208 _: &mut Window,
1209 _: &mut Context<Self>,
1210 ) -> Option<impl IntoElement> {
1211 if !has_icon {
1212 return None;
1213 }
1214
1215 let icon = if let Some(icon) = icon {
1216 icon.clone()
1217 } else if checked {
1218 Icon::new(IconName::Check)
1219 } else {
1220 Icon::empty()
1221 };
1222
1223 Some(icon.xsmall())
1224 }
1225
1226 #[inline]
1227 fn max_width(&self) -> Pixels {
1228 self.max_width.unwrap_or(px(500.))
1229 }
1230
1231 fn update_submenu_menu_anchor(&mut self, window: &Window, cx: &App) {
1233 let bounds = self.bounds;
1234 let submenu_width = self
1238 .selected_index
1239 .and_then(|ix| match self.menu_items.get(ix) {
1240 Some(PopupMenuItem::Submenu { menu, .. }) => Some(menu.read(cx).bounds.size.width),
1241 _ => None,
1242 })
1243 .filter(|width| *width > Pixels::ZERO)
1244 .unwrap_or(bounds.size.width);
1245 let (anchor, left) = if bounds.right() + submenu_width > window.viewport_size().width {
1246 (Anchor::TopRight, -px(16.))
1247 } else {
1248 (Anchor::TopLeft, bounds.size.width - px(8.))
1249 };
1250
1251 let is_bottom_pos = bounds.origin.y + bounds.size.height > window.bounds().size.height;
1252 self.submenu_anchor = if is_bottom_pos {
1253 (anchor.other_side_along(gpui::Axis::Vertical), left)
1254 } else {
1255 (anchor, left)
1256 };
1257 }
1258
1259 fn render_item(
1260 &self,
1261 ix: usize,
1262 item: &PopupMenuItem,
1263 options: RenderOptions,
1264 window: &mut Window,
1265 cx: &mut Context<Self>,
1266 ) -> MenuItemElement {
1267 let has_left_icon = options.has_left_icon;
1268 let is_left_check = options.check_side.is_left() && item.is_checked();
1269 let right_check_icon = if options.check_side.is_right() && item.is_checked() {
1270 Some(Icon::new(IconName::Check).xsmall())
1271 } else {
1272 None
1273 };
1274
1275 let selected = self.selected_index == Some(ix);
1276 const EDGE_PADDING: Pixels = px(4.);
1277 const INNER_PADDING: Pixels = px(8.);
1278
1279 let is_submenu = matches!(item, PopupMenuItem::Submenu { .. });
1280
1281 let (item_height, radius) = match self.size {
1282 Size::Small => (px(20.), options.radius.half()),
1283 _ => (px(26.), options.radius),
1284 };
1285
1286 let this = MenuItemElement::new(ix)
1287 .relative()
1288 .text_sm()
1289 .py_0()
1290 .px(INNER_PADDING)
1291 .rounded(radius)
1292 .items_center()
1293 .selected(selected)
1294 .on_hover(cx.listener(move |this, hovered, window, cx| {
1295 if *hovered {
1296 if this.selected_index != Some(ix)
1299 && this.active_submenu().is_some_and(|submenu| {
1300 submenu.focus_handle(cx).contains_focused(window, cx)
1301 })
1302 {
1303 this.focus_handle.focus(window, cx);
1304 }
1305 this.selected_index = Some(ix);
1306 } else if !is_submenu
1307 && this.selected_index == Some(ix)
1308 && !window.last_input_was_keyboard()
1311 {
1312 this.selected_index = None;
1314 }
1315
1316 cx.notify();
1317 }))
1318 .when_some(item.a11y_label(), |this, label| this.aria_label(label))
1319 .checked(
1320 (item.is_checked() || self.checkable.contains(&ix)).then(|| item.is_checked()),
1321 );
1322
1323 match item {
1324 PopupMenuItem::Separator => this
1325 .h_auto()
1326 .p_0()
1327 .my_0p5()
1328 .mx_neg_1()
1329 .border_b(px(2.))
1330 .border_color(cx.theme().border)
1331 .disabled(true),
1332 PopupMenuItem::Label(label) => this.disabled(true).cursor_default().child(
1333 h_flex()
1334 .cursor_default()
1335 .items_center()
1336 .gap_x_1()
1337 .children(Self::render_icon(has_left_icon, false, None, window, cx))
1338 .child(div().flex_1().child(label.clone())),
1339 ),
1340 PopupMenuItem::ElementItem {
1341 render,
1342 icon,
1343 disabled,
1344 ..
1345 } => this
1346 .when(!disabled, |this| {
1347 this.on_click(cx.listener(move |this, event, window, cx| {
1348 this.on_click(ix, event, window, cx)
1349 }))
1350 })
1351 .disabled(*disabled)
1352 .child(
1353 h_flex()
1354 .flex_1()
1355 .min_h(item_height)
1356 .items_center()
1357 .gap_x_1()
1358 .children(Self::render_icon(
1359 has_left_icon,
1360 is_left_check,
1361 icon.clone(),
1362 window,
1363 cx,
1364 ))
1365 .child((render)(window, cx))
1366 .children(right_check_icon.map(|icon| icon.ml_3())),
1367 ),
1368 PopupMenuItem::Item {
1369 icon,
1370 label,
1371 action,
1372 disabled,
1373 is_link,
1374 ..
1375 } => {
1376 let show_link_icon = *is_link && self.external_link_icon;
1377 let action = action.as_ref().map(|action| action.boxed_clone());
1378 let key = self.render_key_binding(action, window, cx);
1379
1380 this.when(!disabled, |this| {
1381 this.on_click(cx.listener(move |this, event, window, cx| {
1382 this.on_click(ix, event, window, cx)
1383 }))
1384 })
1385 .disabled(*disabled)
1386 .h(item_height)
1387 .gap_x_1()
1388 .children(Self::render_icon(
1389 has_left_icon,
1390 is_left_check,
1391 icon.clone(),
1392 window,
1393 cx,
1394 ))
1395 .child(
1396 h_flex()
1397 .w_full()
1398 .gap_3()
1399 .items_center()
1400 .justify_between()
1401 .when(!show_link_icon, |this| this.child(label.clone()))
1402 .children(right_check_icon)
1403 .when(show_link_icon, |this| {
1404 this.child(
1405 h_flex()
1406 .w_full()
1407 .justify_between()
1408 .gap_1p5()
1409 .child(label.clone())
1410 .child(
1411 Icon::new(IconName::ExternalLink)
1412 .xsmall()
1413 .text_color(cx.theme().muted_foreground),
1414 ),
1415 )
1416 })
1417 .children(key),
1418 )
1419 }
1420 PopupMenuItem::Submenu {
1421 icon,
1422 label,
1423 menu,
1424 disabled,
1425 } => this
1426 .when(!disabled, |this| {
1427 this.on_click(cx.listener(move |this, event, window, cx| {
1428 this.on_click(ix, event, window, cx)
1429 }))
1430 })
1431 .selected(selected)
1432 .disabled(*disabled)
1433 .items_start()
1434 .child(
1435 h_flex()
1436 .min_h(item_height)
1437 .size_full()
1438 .items_center()
1439 .gap_x_1()
1440 .children(Self::render_icon(
1441 has_left_icon,
1442 false,
1443 icon.clone(),
1444 window,
1445 cx,
1446 ))
1447 .child(
1448 h_flex()
1449 .flex_1()
1450 .gap_2()
1451 .items_center()
1452 .justify_between()
1453 .child(label.clone())
1454 .child(
1455 Icon::new(IconName::ChevronRight)
1456 .xsmall()
1457 .text_color(cx.theme().muted_foreground),
1458 ),
1459 ),
1460 )
1461 .when(selected, |this| {
1462 this.child({
1463 let (anchor, left) = self.submenu_anchor;
1464 let is_bottom_pos =
1465 matches!(anchor, Anchor::BottomLeft | Anchor::BottomRight);
1466 deferred(
1467 anchored()
1468 .anchor(anchor)
1469 .child(
1470 div()
1471 .id("submenu")
1472 .test_support()
1473 .occlude()
1474 .when(is_bottom_pos, |this| this.bottom_0())
1475 .when(!is_bottom_pos, |this| this.top_neg_1())
1476 .left(left)
1477 .child(menu.clone()),
1478 )
1479 .snap_to_window_with_margin(Edges::all(EDGE_PADDING)),
1480 )
1481 .with_priority(self.priority + 1)
1482 })
1483 }),
1484 }
1485 }
1486}
1487
1488impl FluentBuilder for PopupMenu {}
1489impl EventEmitter<DismissEvent> for PopupMenu {}
1490impl Focusable for PopupMenu {
1491 fn focus_handle(&self, _: &App) -> FocusHandle {
1492 self.focus_handle.clone()
1493 }
1494}
1495
1496#[derive(Clone, Copy)]
1497struct RenderOptions {
1498 has_left_icon: bool,
1499 check_side: Side,
1500 radius: Pixels,
1501}
1502
1503impl Render for PopupMenu {
1504 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1505 self.update_submenu_menu_anchor(window, cx);
1506
1507 let parent = cx.entity().downgrade();
1513 let parent_priority = self.priority;
1514 for item in &self.menu_items {
1515 if let PopupMenuItem::Submenu { menu, .. } = item {
1516 if menu.read(cx).parent_menu.is_none() {
1517 menu.update(cx, |menu, _| {
1518 menu.parent_menu = Some(parent.clone());
1519 menu.priority = parent_priority + 1;
1520 });
1521 }
1522 }
1523 }
1524
1525 let view = cx.entity().clone();
1526 let items_count = self.menu_items.len();
1527
1528 let max_height = self.max_height.unwrap_or_else(|| {
1529 let window_half_height = window.window_bounds().get_bounds().size.height * 0.5;
1530 window_half_height.min(px(450.))
1531 });
1532
1533 let has_left_icon = self
1534 .menu_items
1535 .iter()
1536 .any(|item| item.has_left_icon(self.check_side));
1537
1538 let max_width = self.max_width();
1539 let options = RenderOptions {
1540 has_left_icon,
1541 check_side: self.check_side,
1542 radius: cx.theme().radius.min(px(8.)),
1543 };
1544
1545 v_flex()
1546 .id("popup-menu")
1547 .test_support()
1548 .role(Role::Menu)
1549 .key_context(CONTEXT)
1550 .track_focus(&self.focus_handle)
1551 .on_action(cx.listener(Self::select_up))
1552 .on_action(cx.listener(Self::select_down))
1553 .on_action(cx.listener(Self::select_left))
1554 .on_action(cx.listener(Self::select_right))
1555 .on_action(cx.listener(Self::confirm))
1556 .on_action(cx.listener(Self::dismiss))
1557 .on_mouse_down_out(cx.listener(Self::on_mouse_down_out))
1558 .popover_style(cx)
1559 .text_color(cx.theme().popover_foreground)
1560 .relative()
1561 .occlude()
1562 .child(
1563 v_flex()
1564 .id("items")
1565 .p_1()
1566 .gap_y_0p5()
1567 .min_w(rems(8.))
1568 .when_some(self.min_width, |this, min_width| this.min_w(min_width))
1569 .max_w(max_width)
1570 .when(self.scrollable, |this| {
1571 this.max_h(max_height)
1572 .overflow_y_scroll()
1573 .track_scroll(&self.scroll_handle)
1574 })
1575 .children(
1576 self.menu_items
1577 .iter()
1578 .enumerate()
1579 .filter(|(ix, item)| !(*ix + 1 == items_count && item.is_separator()))
1581 .map(|(ix, item)| self.render_item(ix, item, options, window, cx)),
1582 )
1583 .on_prepaint(move |bounds, _, cx| {
1584 view.update(cx, |menu, cx| {
1585 let width_changed = menu.bounds.size.width != bounds.size.width;
1586 menu.bounds = bounds;
1587 if width_changed {
1588 if let Some(parent) = menu
1589 .parent_menu
1590 .as_ref()
1591 .and_then(|parent| parent.upgrade())
1592 {
1593 parent.update(cx, |_, cx| cx.notify());
1594 }
1595 }
1596 });
1597 }),
1598 )
1599 .when(self.scrollable, |this| {
1600 this.vertical_scrollbar(&self.scroll_handle)
1601 })
1602 }
1603}
1604
1605#[cfg(test)]
1606mod tests {
1607 use super::*;
1608
1609 #[gpui::test]
1610 fn custom_menu_item_preserves_mouse_event(cx: &mut gpui::TestAppContext) {
1611 use gpui::{Modifiers, MouseButton, MouseUpEvent};
1612 use std::cell::RefCell;
1613
1614 cx.update(crate::init);
1615 let events = Rc::new(RefCell::new(Vec::new()));
1616 let (_, cx) = cx.add_window_view({
1617 let events = events.clone();
1618 move |_, cx| {
1619 PopupMenu::new(cx).item(
1620 PopupMenuItem::element(|_, _| {
1621 div()
1622 .debug_selector(|| "custom-menu-content".into())
1623 .w(px(100.))
1624 .h(px(20.))
1625 .child("Custom")
1626 })
1627 .on_click(move |event, _, _| events.borrow_mut().push(event.clone())),
1628 )
1629 }
1630 });
1631 cx.update(|window, cx| window.draw(cx).clear(cx));
1632 let position = cx.debug_bounds("custom-menu-content").unwrap().center();
1633 let modifiers = Modifiers {
1634 shift: true,
1635 ..Modifiers::default()
1636 };
1637 cx.simulate_mouse_down(position, MouseButton::Left, modifiers);
1638 cx.simulate_event(MouseUpEvent {
1639 position,
1640 button: MouseButton::Left,
1641 modifiers,
1642 click_count: 2,
1643 });
1644 cx.run_until_parked();
1645 let events = events.borrow();
1646 assert_eq!(events.len(), 1);
1647 assert_eq!(events[0].mouse_position(), Some(position));
1648 assert_eq!(events[0].modifiers(), modifiers);
1649 let ClickEvent::Mouse(event) = &events[0] else {
1650 panic!("expected the original mouse click");
1651 };
1652 assert_eq!(event.up.click_count, 2);
1653 }
1654
1655 #[gpui::test]
1656 fn popup_menu_item_a11y_label_uses_visible_label(cx: &mut gpui::TestAppContext) {
1657 let submenu = cx.update(|cx| cx.new(|cx| PopupMenu::new(cx)));
1658
1659 assert_eq!(PopupMenuItem::new("Open").a11y_label(), Some("Open".into()));
1660 assert_eq!(
1661 PopupMenuItem::link("Docs", "https://example.com").a11y_label(),
1662 Some("Docs".into())
1663 );
1664 assert_eq!(
1665 PopupMenuItem::label("Recent files").a11y_label(),
1666 Some("Recent files".into())
1667 );
1668 assert_eq!(
1669 PopupMenuItem::submenu("More", submenu).a11y_label(),
1670 Some("More".into())
1671 );
1672 assert_eq!(PopupMenuItem::separator().a11y_label(), None);
1673 assert_eq!(PopupMenuItem::element(|_, _| div()).a11y_label(), None);
1674 }
1675}