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