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 {
372 self.action_context = Some(handle);
373 self
374 }
375
376 pub(crate) fn set_action_context(
377 &mut self,
378 action_context: Option<FocusHandle>,
379 cx: &mut Context<Self>,
380 ) {
381 self.action_context = action_context.clone();
382
383 for item in &self.menu_items {
384 if let PopupMenuItem::Submenu { menu, .. } = item {
385 menu.update(cx, |menu, cx| {
386 menu.set_action_context(action_context.clone(), cx);
387 });
388 }
389 }
390 }
391
392 pub(crate) fn set_previous_focus(
395 &mut self,
396 handle: Option<FocusHandle>,
397 cx: &mut Context<Self>,
398 ) {
399 self.previous_focus_handle = handle.clone();
400
401 for item in &self.menu_items {
402 if let PopupMenuItem::Submenu { menu, .. } = item {
403 menu.update(cx, |menu, cx| {
404 menu.set_previous_focus(handle.clone(), cx);
405 });
406 }
407 }
408 }
409
410 pub(crate) fn set_trigger_focus(
413 &mut self,
414 handle: Option<FocusHandle>,
415 cx: &mut Context<Self>,
416 ) {
417 self.trigger_focus_handle = handle.clone();
418
419 for item in &self.menu_items {
420 if let PopupMenuItem::Submenu { menu, .. } = item {
421 menu.update(cx, |menu, cx| {
422 menu.set_trigger_focus(handle.clone(), cx);
423 });
424 }
425 }
426 }
427
428 pub fn min_w(mut self, width: impl Into<Pixels>) -> Self {
430 self.min_width = Some(width.into());
431 self
432 }
433
434 pub fn max_w(mut self, width: impl Into<Pixels>) -> Self {
436 self.max_width = Some(width.into());
437 self
438 }
439
440 pub fn max_h(mut self, height: impl Into<Pixels>) -> Self {
442 self.max_height = Some(height.into());
443 self
444 }
445
446 pub fn scrollable(mut self, scrollable: bool) -> Self {
448 self.scrollable = scrollable;
449 self
450 }
451
452 pub fn check_side(mut self, side: Side) -> Self {
454 self.check_side = side;
455 self
456 }
457
458 pub fn external_link_icon(mut self, visible: bool) -> Self {
460 self.external_link_icon = visible;
461 self
462 }
463
464 pub fn menu(self, label: impl Into<SharedString>, action: Box<dyn Action>) -> Self {
466 self.menu_with_disabled(label, action, false)
467 }
468
469 pub fn menu_with_enable(
471 mut self,
472 label: impl Into<SharedString>,
473 action: Box<dyn Action>,
474 enable: bool,
475 ) -> Self {
476 self.add_menu_item(label, None, action, !enable, false);
477 self
478 }
479
480 pub fn menu_with_disabled(
482 mut self,
483 label: impl Into<SharedString>,
484 action: Box<dyn Action>,
485 disabled: bool,
486 ) -> Self {
487 self.add_menu_item(label, None, action, disabled, false);
488 self
489 }
490
491 pub fn label(mut self, label: impl Into<SharedString>) -> Self {
493 self.menu_items.push(PopupMenuItem::label(label.into()));
494 self
495 }
496
497 pub fn link(self, label: impl Into<SharedString>, href: impl Into<String>) -> Self {
499 self.link_with_disabled(label, href, false)
500 }
501
502 pub fn link_with_disabled(
504 mut self,
505 label: impl Into<SharedString>,
506 href: impl Into<String>,
507 disabled: bool,
508 ) -> Self {
509 let href = href.into();
510 self.menu_items
511 .push(PopupMenuItem::link(label, href).disabled(disabled));
512 self
513 }
514
515 pub fn link_with_icon(
517 self,
518 label: impl Into<SharedString>,
519 icon: impl Into<Icon>,
520 href: impl Into<String>,
521 ) -> Self {
522 self.link_with_icon_and_disabled(label, icon, href, false)
523 }
524
525 fn link_with_icon_and_disabled(
527 mut self,
528 label: impl Into<SharedString>,
529 icon: impl Into<Icon>,
530 href: impl Into<String>,
531 disabled: bool,
532 ) -> Self {
533 let href = href.into();
534 self.menu_items.push(
535 PopupMenuItem::link(label, href)
536 .icon(icon)
537 .disabled(disabled),
538 );
539 self
540 }
541
542 pub fn menu_with_icon(
544 self,
545 label: impl Into<SharedString>,
546 icon: impl Into<Icon>,
547 action: Box<dyn Action>,
548 ) -> Self {
549 self.menu_with_icon_and_disabled(label, icon, action, false)
550 }
551
552 pub fn menu_with_icon_and_disabled(
554 mut self,
555 label: impl Into<SharedString>,
556 icon: impl Into<Icon>,
557 action: Box<dyn Action>,
558 disabled: bool,
559 ) -> Self {
560 self.add_menu_item(label, Some(icon.into()), action, disabled, false);
561 self
562 }
563
564 pub fn menu_with_check(
566 self,
567 label: impl Into<SharedString>,
568 checked: bool,
569 action: Box<dyn Action>,
570 ) -> Self {
571 self.menu_with_check_and_disabled(label, checked, action, false)
572 }
573
574 pub fn menu_with_check_and_disabled(
576 mut self,
577 label: impl Into<SharedString>,
578 checked: bool,
579 action: Box<dyn Action>,
580 disabled: bool,
581 ) -> Self {
582 self.add_menu_item(label, None, action, disabled, checked);
583 self
584 }
585
586 pub fn menu_element<F, E>(self, action: Box<dyn Action>, builder: F) -> Self
588 where
589 F: Fn(&mut Window, &mut App) -> E + 'static,
590 E: IntoElement,
591 {
592 self.menu_element_with_check(false, action, builder)
593 }
594
595 pub fn menu_element_with_disabled<F, E>(
597 self,
598 action: Box<dyn Action>,
599 disabled: bool,
600 builder: F,
601 ) -> Self
602 where
603 F: Fn(&mut Window, &mut App) -> E + 'static,
604 E: IntoElement,
605 {
606 self.menu_element_with_check_and_disabled(false, action, disabled, builder)
607 }
608
609 pub fn menu_element_with_icon<F, E>(
611 self,
612 icon: impl Into<Icon>,
613 action: Box<dyn Action>,
614 builder: F,
615 ) -> Self
616 where
617 F: Fn(&mut Window, &mut App) -> E + 'static,
618 E: IntoElement,
619 {
620 self.menu_element_with_icon_and_disabled(icon, action, false, builder)
621 }
622
623 pub fn menu_element_with_check<F, E>(
625 self,
626 checked: bool,
627 action: Box<dyn Action>,
628 builder: F,
629 ) -> Self
630 where
631 F: Fn(&mut Window, &mut App) -> E + 'static,
632 E: IntoElement,
633 {
634 self.menu_element_with_check_and_disabled(checked, action, false, builder)
635 }
636
637 fn menu_element_with_icon_and_disabled<F, E>(
639 mut self,
640 icon: impl Into<Icon>,
641 action: Box<dyn Action>,
642 disabled: bool,
643 builder: F,
644 ) -> Self
645 where
646 F: Fn(&mut Window, &mut App) -> E + 'static,
647 E: IntoElement,
648 {
649 self.menu_items.push(
650 PopupMenuItem::element(builder)
651 .action(action)
652 .icon(icon)
653 .disabled(disabled),
654 );
655 self
656 }
657
658 fn menu_element_with_check_and_disabled<F, E>(
660 mut self,
661 checked: bool,
662 action: Box<dyn Action>,
663 disabled: bool,
664 builder: F,
665 ) -> Self
666 where
667 F: Fn(&mut Window, &mut App) -> E + 'static,
668 E: IntoElement,
669 {
670 self.menu_items.push(
671 PopupMenuItem::element(builder)
672 .action(action)
673 .checked(checked)
674 .disabled(disabled),
675 );
676 self
677 }
678
679 pub fn separator(mut self) -> Self {
681 if self.menu_items.is_empty() {
682 return self;
683 }
684
685 if let Some(PopupMenuItem::Separator) = self.menu_items.last() {
686 return self;
687 }
688
689 self.menu_items.push(PopupMenuItem::separator());
690 self
691 }
692
693 pub fn submenu(
695 self,
696 label: impl Into<SharedString>,
697 window: &mut Window,
698 cx: &mut Context<Self>,
699 f: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
700 ) -> Self {
701 self.submenu_with_icon(None, label, window, cx, f)
702 }
703
704 pub fn submenu_with_icon(
706 mut self,
707 icon: Option<Icon>,
708 label: impl Into<SharedString>,
709 window: &mut Window,
710 cx: &mut Context<Self>,
711 f: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
712 ) -> Self {
713 let submenu = PopupMenu::build(window, cx, f);
714 let parent_menu = cx.entity().downgrade();
715 let parent_priority = self.priority;
716 submenu.update(cx, |view, _| {
717 view.parent_menu = Some(parent_menu);
718 view.priority = parent_priority + 1;
719 });
720
721 self.menu_items.push(
722 PopupMenuItem::submenu(label, submenu).when_some(icon, |this, icon| this.icon(icon)),
723 );
724 self
725 }
726
727 pub fn item(mut self, item: impl Into<PopupMenuItem>) -> Self {
729 let item: PopupMenuItem = item.into();
730 self.menu_items.push(item);
731 self
732 }
733
734 pub fn rebuild(
754 &mut self,
755 window: &mut Window,
756 cx: &mut Context<Self>,
757 f: impl FnOnce(Self, &mut Window, &mut Context<Self>) -> Self,
758 ) {
759 let mut menu = std::mem::replace(self, Self::new(cx));
760 menu.menu_items.clear();
761 menu.selected_index = None;
762 *self = f(menu, window, cx);
763 cx.notify();
764 }
765
766 fn add_menu_item(
767 &mut self,
768 label: impl Into<SharedString>,
769 icon: Option<Icon>,
770 action: Box<dyn Action>,
771 disabled: bool,
772 checked: bool,
773 ) -> &mut Self {
774 self.menu_items.push(
775 PopupMenuItem::new(label)
776 .when_some(icon, |item, icon| item.icon(icon))
777 .disabled(disabled)
778 .checked(checked)
779 .action(action),
780 );
781 self
782 }
783
784 pub(super) fn with_menu_items<I>(
785 mut self,
786 items: impl IntoIterator<Item = I>,
787 window: &mut Window,
788 cx: &mut Context<Self>,
789 ) -> Self
790 where
791 I: Into<OwnedMenuItem>,
792 {
793 for item in items {
794 match item.into() {
795 OwnedMenuItem::Action {
796 name,
797 action,
798 checked,
799 disabled,
800 ..
801 } => {
802 self = self.menu_with_check_and_disabled(
803 name,
804 checked,
805 action.boxed_clone(),
806 disabled,
807 )
808 }
809 OwnedMenuItem::Separator => {
810 self = self.separator();
811 }
812 OwnedMenuItem::Submenu(submenu) => {
813 self = self.submenu(submenu.name, window, cx, move |menu, window, cx| {
814 menu.with_menu_items(submenu.items.clone(), window, cx)
815 })
816 }
817 OwnedMenuItem::SystemMenu(_) => {}
818 }
819 }
820
821 if self.menu_items.len() > 20 {
822 self.scrollable = true;
823 }
824
825 self
826 }
827
828 pub(crate) fn active_submenu(&self) -> Option<Entity<PopupMenu>> {
829 if let Some(ix) = self.selected_index {
830 if let Some(item) = self.menu_items.get(ix) {
831 return match item {
832 PopupMenuItem::Submenu { menu, .. } => Some(menu.clone()),
833 _ => None,
834 };
835 }
836 }
837
838 None
839 }
840
841 pub fn is_empty(&self) -> bool {
842 self.menu_items.is_empty()
843 }
844
845 fn clickable_menu_items(&self) -> impl Iterator<Item = (usize, &PopupMenuItem)> {
846 self.menu_items
847 .iter()
848 .enumerate()
849 .filter(|(_, item)| item.is_clickable())
850 }
851
852 fn on_click(&mut self, ix: usize, window: &mut Window, cx: &mut Context<Self>) {
853 cx.stop_propagation();
854 window.prevent_default();
855 self.selected_index = Some(ix);
856 self.confirm(&Confirm { secondary: false }, window, cx);
857 }
858
859 fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
860 match self.selected_index {
861 Some(index) => {
862 let item = self.menu_items.get(index);
863 match item {
864 Some(PopupMenuItem::Item {
865 handler, action, ..
866 }) => {
867 if let Some(handler) = handler {
868 handler(&ClickEvent::default(), window, cx);
869 } else if let Some(action) = action.as_ref() {
870 self.dispatch_confirm_action(action, window, cx);
871 }
872
873 self.dismiss(&Cancel, window, cx)
874 }
875 Some(PopupMenuItem::ElementItem {
876 handler, action, ..
877 }) => {
878 if let Some(handler) = handler {
879 handler(&ClickEvent::default(), window, cx);
880 } else if let Some(action) = action.as_ref() {
881 self.dispatch_confirm_action(action, window, cx);
882 }
883 self.dismiss(&Cancel, window, cx)
884 }
885 _ => {}
886 }
887 }
888 _ => {}
889 }
890 }
891
892 fn dispatch_confirm_action(
893 &self,
894 action: &Box<dyn Action>,
895 window: &mut Window,
896 cx: &mut Context<Self>,
897 ) {
898 if let Some(context) = self.action_context.as_ref() {
899 context.focus(window, cx);
900 }
901
902 window.dispatch_action(action.boxed_clone(), cx);
903 }
904
905 fn set_selected_index(&mut self, ix: usize, cx: &mut Context<Self>) {
906 if self.selected_index != Some(ix) {
907 self.selected_index = Some(ix);
908 self.scroll_handle.scroll_to_item(ix);
909 cx.notify();
910 }
911 }
912
913 fn select_up(&mut self, _: &SelectUp, _: &mut Window, cx: &mut Context<Self>) {
914 cx.stop_propagation();
915 let ix = self.selected_index.unwrap_or(0);
916
917 if let Some((prev_ix, _)) = self
918 .menu_items
919 .iter()
920 .enumerate()
921 .rev()
922 .find(|(i, item)| *i < ix && item.is_clickable())
923 {
924 self.set_selected_index(prev_ix, cx);
925 return;
926 }
927
928 let last_clickable_ix = self.clickable_menu_items().last().map(|(ix, _)| ix);
929 self.set_selected_index(last_clickable_ix.unwrap_or(0), cx);
930 }
931
932 fn select_down(&mut self, _: &SelectDown, _: &mut Window, cx: &mut Context<Self>) {
933 cx.stop_propagation();
934 let Some(ix) = self.selected_index else {
935 self.set_selected_index(0, cx);
936 return;
937 };
938
939 if let Some((next_ix, _)) = self
940 .menu_items
941 .iter()
942 .enumerate()
943 .find(|(i, item)| *i > ix && item.is_clickable())
944 {
945 self.set_selected_index(next_ix, cx);
946 return;
947 }
948
949 self.set_selected_index(0, cx);
950 }
951
952 fn select_left(&mut self, _: &SelectLeft, window: &mut Window, cx: &mut Context<Self>) {
953 let handled = if matches!(self.submenu_anchor.0, Anchor::TopLeft | Anchor::BottomLeft) {
954 self._unselect_submenu(window, cx)
955 } else {
956 self._select_submenu(window, cx)
957 };
958
959 if self.parent_side(cx).is_left() {
960 self._focus_parent_menu(window, cx);
961 }
962
963 if handled {
964 return;
965 }
966
967 if self.parent_menu.is_none() {
969 cx.propagate();
970 }
971 }
972
973 fn select_right(&mut self, _: &SelectRight, window: &mut Window, cx: &mut Context<Self>) {
974 let handled = if matches!(self.submenu_anchor.0, Anchor::TopLeft | Anchor::BottomLeft) {
975 self._select_submenu(window, cx)
976 } else {
977 self._unselect_submenu(window, cx)
978 };
979
980 if self.parent_side(cx).is_right() {
981 self._focus_parent_menu(window, cx);
982 }
983
984 if handled {
985 return;
986 }
987
988 if self.parent_menu.is_none() {
990 cx.propagate();
991 }
992 }
993
994 fn _select_submenu(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
995 if let Some(active_submenu) = self.active_submenu() {
996 active_submenu.update(cx, |view, cx| {
998 view.set_selected_index(0, cx);
999 view.focus_handle.focus(window, cx);
1000 });
1001 cx.notify();
1002 return true;
1003 }
1004
1005 return false;
1006 }
1007
1008 fn _unselect_submenu(&mut self, _: &mut Window, cx: &mut Context<Self>) -> bool {
1009 if let Some(active_submenu) = self.active_submenu() {
1010 active_submenu.update(cx, |view, cx| {
1011 view.selected_index = None;
1012 cx.notify();
1013 });
1014 return true;
1015 }
1016
1017 return false;
1018 }
1019
1020 fn _focus_parent_menu(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1021 let Some(parent) = self.parent_menu.as_ref() else {
1022 return;
1023 };
1024 let Some(parent) = parent.upgrade() else {
1025 return;
1026 };
1027
1028 self.selected_index = None;
1029 parent.update(cx, |view, cx| {
1030 view.focus_handle.focus(window, cx);
1031 cx.notify();
1032 });
1033 }
1034
1035 fn parent_side(&self, cx: &App) -> Side {
1036 let Some(parent) = self.parent_menu.as_ref() else {
1037 return Side::Left;
1038 };
1039
1040 let Some(parent) = parent.upgrade() else {
1041 return Side::Left;
1042 };
1043
1044 match parent.read(cx).submenu_anchor.0 {
1045 Anchor::TopLeft | Anchor::BottomLeft => Side::Left,
1046 Anchor::TopRight | Anchor::BottomRight => Side::Right,
1047 _ => Side::Left,
1049 }
1050 }
1051
1052 fn dismiss(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context<Self>) {
1056 self.selected_index = None;
1057 cx.emit(DismissEvent);
1058
1059 let focus_moved_away =
1063 window.focused(cx).is_some() && !self.focus_handle.contains_focused(window, cx);
1064 if !focus_moved_away {
1065 if let Some(handle) = self
1066 .previous_focus_handle
1067 .as_ref()
1068 .or(self.action_context.as_ref())
1069 {
1070 window.focus(handle, cx);
1071 }
1072 }
1073
1074 let Some(parent_menu) = self.parent_menu.clone() else {
1075 return;
1076 };
1077
1078 _ = parent_menu.update(cx, |view, cx| {
1080 view.dismiss(&Cancel, window, cx);
1081 });
1082 }
1083
1084 fn handle_dismiss(
1085 &mut self,
1086 position: &Point<Pixels>,
1087 window: &mut Window,
1088 cx: &mut Context<Self>,
1089 ) {
1090 if let Some(parent) = self.parent_menu.as_ref() {
1092 if let Some(parent) = parent.upgrade() {
1093 if parent.read(cx).bounds.contains(position) {
1094 return;
1095 }
1096 }
1097 }
1098
1099 if self.active_submenu().is_some() {
1104 return;
1105 }
1106
1107 self.dismiss(&Cancel, window, cx);
1108 }
1109
1110 fn on_mouse_down_out(
1111 &mut self,
1112 e: &MouseDownEvent,
1113 window: &mut Window,
1114 cx: &mut Context<Self>,
1115 ) {
1116 self.handle_dismiss(&e.position, window, cx);
1117 }
1118
1119 fn render_key_binding(
1120 &self,
1121 action: Option<Box<dyn Action>>,
1122 window: &mut Window,
1123 _: &mut Context<Self>,
1124 ) -> Option<Kbd> {
1125 let action = action?;
1126
1127 [
1136 self.action_context.as_ref(),
1137 self.trigger_focus_handle.as_ref(),
1138 self.previous_focus_handle.as_ref(),
1139 Some(&self.focus_handle),
1140 ]
1141 .into_iter()
1142 .flatten()
1143 .find_map(|handle| Kbd::binding_for_action_in(action.as_ref(), handle, window))
1144 .or_else(|| Kbd::global_binding_for_action(action.as_ref(), window))
1145 .map(|this| {
1146 this.p_0()
1147 .flex_nowrap()
1148 .border_0()
1149 .bg(gpui::transparent_white())
1150 })
1151 }
1152
1153 fn render_icon(
1154 has_icon: bool,
1155 checked: bool,
1156 icon: Option<Icon>,
1157 _: &mut Window,
1158 _: &mut Context<Self>,
1159 ) -> Option<impl IntoElement> {
1160 if !has_icon {
1161 return None;
1162 }
1163
1164 let icon = if let Some(icon) = icon {
1165 icon.clone()
1166 } else if checked {
1167 Icon::new(IconName::Check)
1168 } else {
1169 Icon::empty()
1170 };
1171
1172 Some(icon.xsmall())
1173 }
1174
1175 #[inline]
1176 fn max_width(&self) -> Pixels {
1177 self.max_width.unwrap_or(px(500.))
1178 }
1179
1180 fn update_submenu_menu_anchor(&mut self, window: &Window) {
1182 let bounds = self.bounds;
1183 let max_width = self.max_width();
1184 let (anchor, left) = if max_width + bounds.origin.x > window.bounds().size.width {
1185 (Anchor::TopRight, -px(16.))
1186 } else {
1187 (Anchor::TopLeft, bounds.size.width - px(8.))
1188 };
1189
1190 let is_bottom_pos = bounds.origin.y + bounds.size.height > window.bounds().size.height;
1191 self.submenu_anchor = if is_bottom_pos {
1192 (anchor.other_side_along(gpui::Axis::Vertical), left)
1193 } else {
1194 (anchor, left)
1195 };
1196 }
1197
1198 fn render_item(
1199 &self,
1200 ix: usize,
1201 item: &PopupMenuItem,
1202 options: RenderOptions,
1203 window: &mut Window,
1204 cx: &mut Context<Self>,
1205 ) -> MenuItemElement {
1206 let has_left_icon = options.has_left_icon;
1207 let is_left_check = options.check_side.is_left() && item.is_checked();
1208 let right_check_icon = if options.check_side.is_right() && item.is_checked() {
1209 Some(Icon::new(IconName::Check).xsmall())
1210 } else {
1211 None
1212 };
1213
1214 let selected = self.selected_index == Some(ix);
1215 const EDGE_PADDING: Pixels = px(4.);
1216 const INNER_PADDING: Pixels = px(8.);
1217
1218 let is_submenu = matches!(item, PopupMenuItem::Submenu { .. });
1219 let group_name = format!("{}:item-{}", cx.entity().entity_id(), ix);
1220
1221 let (item_height, radius) = match self.size {
1222 Size::Small => (px(20.), options.radius.half()),
1223 _ => (px(26.), options.radius),
1224 };
1225
1226 let this = MenuItemElement::new(ix, &group_name)
1227 .relative()
1228 .text_sm()
1229 .py_0()
1230 .px(INNER_PADDING)
1231 .rounded(radius)
1232 .items_center()
1233 .selected(selected)
1234 .on_hover(cx.listener(move |this, hovered, _, cx| {
1235 if *hovered {
1236 this.selected_index = Some(ix);
1237 } else if !is_submenu && this.selected_index == Some(ix) {
1238 this.selected_index = None;
1240 }
1241
1242 cx.notify();
1243 }))
1244 .when_some(item.a11y_label(), |this, label| this.aria_label(label));
1245
1246 match item {
1247 PopupMenuItem::Separator => this
1248 .h_auto()
1249 .p_0()
1250 .my_0p5()
1251 .mx_neg_1()
1252 .border_b(px(2.))
1253 .border_color(cx.theme().border)
1254 .disabled(true),
1255 PopupMenuItem::Label(label) => this.disabled(true).cursor_default().child(
1256 h_flex()
1257 .cursor_default()
1258 .items_center()
1259 .gap_x_1()
1260 .children(Self::render_icon(has_left_icon, false, None, window, cx))
1261 .child(div().flex_1().child(label.clone())),
1262 ),
1263 PopupMenuItem::ElementItem {
1264 render,
1265 icon,
1266 disabled,
1267 ..
1268 } => this
1269 .when(!disabled, |this| {
1270 this.on_click(
1271 cx.listener(move |this, _, window, cx| this.on_click(ix, window, cx)),
1272 )
1273 })
1274 .disabled(*disabled)
1275 .child(
1276 h_flex()
1277 .flex_1()
1278 .min_h(item_height)
1279 .items_center()
1280 .gap_x_1()
1281 .children(Self::render_icon(
1282 has_left_icon,
1283 is_left_check,
1284 icon.clone(),
1285 window,
1286 cx,
1287 ))
1288 .child((render)(window, cx))
1289 .children(right_check_icon.map(|icon| icon.ml_3())),
1290 ),
1291 PopupMenuItem::Item {
1292 icon,
1293 label,
1294 action,
1295 disabled,
1296 is_link,
1297 ..
1298 } => {
1299 let show_link_icon = *is_link && self.external_link_icon;
1300 let action = action.as_ref().map(|action| action.boxed_clone());
1301 let key = self.render_key_binding(action, window, cx);
1302
1303 this.when(!disabled, |this| {
1304 this.on_click(
1305 cx.listener(move |this, _, window, cx| this.on_click(ix, window, cx)),
1306 )
1307 })
1308 .disabled(*disabled)
1309 .h(item_height)
1310 .gap_x_1()
1311 .children(Self::render_icon(
1312 has_left_icon,
1313 is_left_check,
1314 icon.clone(),
1315 window,
1316 cx,
1317 ))
1318 .child(
1319 h_flex()
1320 .w_full()
1321 .gap_3()
1322 .items_center()
1323 .justify_between()
1324 .when(!show_link_icon, |this| this.child(label.clone()))
1325 .children(right_check_icon)
1326 .when(show_link_icon, |this| {
1327 this.child(
1328 h_flex()
1329 .w_full()
1330 .justify_between()
1331 .gap_1p5()
1332 .child(label.clone())
1333 .child(
1334 Icon::new(IconName::ExternalLink)
1335 .xsmall()
1336 .text_color(cx.theme().muted_foreground),
1337 ),
1338 )
1339 })
1340 .children(key),
1341 )
1342 }
1343 PopupMenuItem::Submenu {
1344 icon,
1345 label,
1346 menu,
1347 disabled,
1348 } => this
1349 .selected(selected)
1350 .disabled(*disabled)
1351 .items_start()
1352 .child(
1353 h_flex()
1354 .min_h(item_height)
1355 .size_full()
1356 .items_center()
1357 .gap_x_1()
1358 .children(Self::render_icon(
1359 has_left_icon,
1360 false,
1361 icon.clone(),
1362 window,
1363 cx,
1364 ))
1365 .child(
1366 h_flex()
1367 .flex_1()
1368 .gap_2()
1369 .items_center()
1370 .justify_between()
1371 .child(label.clone())
1372 .child(
1373 Icon::new(IconName::ChevronRight)
1374 .xsmall()
1375 .text_color(cx.theme().muted_foreground),
1376 ),
1377 ),
1378 )
1379 .when(selected, |this| {
1380 this.child({
1381 let (anchor, left) = self.submenu_anchor;
1382 let is_bottom_pos =
1383 matches!(anchor, Anchor::BottomLeft | Anchor::BottomRight);
1384 deferred(
1385 anchored()
1386 .anchor(anchor)
1387 .child(
1388 div()
1389 .id("submenu")
1390 .test_support()
1391 .occlude()
1392 .when(is_bottom_pos, |this| this.bottom_0())
1393 .when(!is_bottom_pos, |this| this.top_neg_1())
1394 .left(left)
1395 .child(menu.clone()),
1396 )
1397 .snap_to_window_with_margin(Edges::all(EDGE_PADDING)),
1398 )
1399 .with_priority(self.priority + 1)
1400 })
1401 }),
1402 }
1403 }
1404}
1405
1406impl FluentBuilder for PopupMenu {}
1407impl EventEmitter<DismissEvent> for PopupMenu {}
1408impl Focusable for PopupMenu {
1409 fn focus_handle(&self, _: &App) -> FocusHandle {
1410 self.focus_handle.clone()
1411 }
1412}
1413
1414#[derive(Clone, Copy)]
1415struct RenderOptions {
1416 has_left_icon: bool,
1417 check_side: Side,
1418 radius: Pixels,
1419}
1420
1421impl Render for PopupMenu {
1422 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1423 self.update_submenu_menu_anchor(window);
1424
1425 let parent = cx.entity().downgrade();
1431 let parent_priority = self.priority;
1432 for item in &self.menu_items {
1433 if let PopupMenuItem::Submenu { menu, .. } = item {
1434 if menu.read(cx).parent_menu.is_none() {
1435 menu.update(cx, |menu, _| {
1436 menu.parent_menu = Some(parent.clone());
1437 menu.priority = parent_priority + 1;
1438 });
1439 }
1440 }
1441 }
1442
1443 let view = cx.entity().clone();
1444 let items_count = self.menu_items.len();
1445
1446 let max_height = self.max_height.unwrap_or_else(|| {
1447 let window_half_height = window.window_bounds().get_bounds().size.height * 0.5;
1448 window_half_height.min(px(450.))
1449 });
1450
1451 let has_left_icon = self
1452 .menu_items
1453 .iter()
1454 .any(|item| item.has_left_icon(self.check_side));
1455
1456 let max_width = self.max_width();
1457 let options = RenderOptions {
1458 has_left_icon,
1459 check_side: self.check_side,
1460 radius: cx.theme().radius.min(px(8.)),
1461 };
1462
1463 v_flex()
1464 .id("popup-menu")
1465 .test_support()
1466 .role(Role::Menu)
1467 .key_context(CONTEXT)
1468 .track_focus(&self.focus_handle)
1469 .on_action(cx.listener(Self::select_up))
1470 .on_action(cx.listener(Self::select_down))
1471 .on_action(cx.listener(Self::select_left))
1472 .on_action(cx.listener(Self::select_right))
1473 .on_action(cx.listener(Self::confirm))
1474 .on_action(cx.listener(Self::dismiss))
1475 .on_mouse_down_out(cx.listener(Self::on_mouse_down_out))
1476 .popover_style(cx)
1477 .text_color(cx.theme().popover_foreground)
1478 .relative()
1479 .occlude()
1480 .child(
1481 v_flex()
1482 .id("items")
1483 .p_1()
1484 .gap_y_0p5()
1485 .min_w(rems(8.))
1486 .when_some(self.min_width, |this, min_width| this.min_w(min_width))
1487 .max_w(max_width)
1488 .when(self.scrollable, |this| {
1489 this.max_h(max_height)
1490 .overflow_y_scroll()
1491 .track_scroll(&self.scroll_handle)
1492 })
1493 .children(
1494 self.menu_items
1495 .iter()
1496 .enumerate()
1497 .filter(|(ix, item)| !(*ix + 1 == items_count && item.is_separator()))
1499 .map(|(ix, item)| self.render_item(ix, item, options, window, cx)),
1500 )
1501 .on_prepaint(move |bounds, _, cx| view.update(cx, |r, _| r.bounds = bounds)),
1502 )
1503 .when(self.scrollable, |this| {
1504 this.vertical_scrollbar(&self.scroll_handle)
1505 })
1506 }
1507}
1508
1509#[cfg(test)]
1510mod tests {
1511 use super::*;
1512
1513 #[gpui::test]
1514 fn popup_menu_item_a11y_label_uses_visible_label(cx: &mut gpui::TestAppContext) {
1515 let submenu = cx.update(|cx| cx.new(|cx| PopupMenu::new(cx)));
1516
1517 assert_eq!(PopupMenuItem::new("Open").a11y_label(), Some("Open".into()));
1518 assert_eq!(
1519 PopupMenuItem::link("Docs", "https://example.com").a11y_label(),
1520 Some("Docs".into())
1521 );
1522 assert_eq!(
1523 PopupMenuItem::label("Recent files").a11y_label(),
1524 Some("Recent files".into())
1525 );
1526 assert_eq!(
1527 PopupMenuItem::submenu("More", submenu).a11y_label(),
1528 Some("More".into())
1529 );
1530 assert_eq!(PopupMenuItem::separator().a11y_label(), None);
1531 assert_eq!(PopupMenuItem::element(|_, _| div()).a11y_label(), None);
1532 }
1533}