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