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(&mut self, ix: usize, window: &mut Window, cx: &mut Context<Self>) {
855 cx.stop_propagation();
856 window.prevent_default();
857 self.selected_index = Some(ix);
858 self.confirm(&Confirm { secondary: false }, window, cx);
859 }
860
861 fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
862 match self.selected_index {
863 Some(index) => {
864 let item = self.menu_items.get(index);
865 match item {
866 Some(PopupMenuItem::Item {
867 handler, action, ..
868 }) => {
869 if let Some(handler) = handler {
870 handler(&ClickEvent::default(), window, cx);
871 } else if let Some(action) = action.as_ref() {
872 self.dispatch_confirm_action(action, window, cx);
873 }
874
875 self.dismiss(&Cancel, window, cx)
876 }
877 Some(PopupMenuItem::ElementItem {
878 handler, action, ..
879 }) => {
880 if let Some(handler) = handler {
881 handler(&ClickEvent::default(), window, cx);
882 } else if let Some(action) = action.as_ref() {
883 self.dispatch_confirm_action(action, window, cx);
884 }
885 self.dismiss(&Cancel, window, cx)
886 }
887 _ => {}
888 }
889 }
890 _ => {}
891 }
892 }
893
894 fn dispatch_confirm_action(
895 &self,
896 action: &Box<dyn Action>,
897 window: &mut Window,
898 cx: &mut Context<Self>,
899 ) {
900 if let Some(context) = self.resolved_action_context(cx) {
901 context.focus(window, cx);
902 }
903
904 window.dispatch_action(action.boxed_clone(), cx);
905 }
906
907 fn resolved_action_context(&self, cx: &App) -> Option<FocusHandle> {
908 self.action_context.clone().or_else(|| {
909 let parent = self.parent_menu.as_ref()?.upgrade()?;
910 parent.read(cx).resolved_action_context(cx)
911 })
912 }
913
914 fn set_selected_index(&mut self, ix: usize, cx: &mut Context<Self>) {
915 if self.selected_index != Some(ix) {
916 self.selected_index = Some(ix);
917 self.scroll_handle.scroll_to_item(ix);
918 cx.notify();
919 }
920 }
921
922 fn select_up(&mut self, _: &SelectUp, _: &mut Window, cx: &mut Context<Self>) {
923 cx.stop_propagation();
924 let ix = self.selected_index.unwrap_or(0);
925
926 if let Some((prev_ix, _)) = self
927 .menu_items
928 .iter()
929 .enumerate()
930 .rev()
931 .find(|(i, item)| *i < ix && item.is_clickable())
932 {
933 self.set_selected_index(prev_ix, cx);
934 return;
935 }
936
937 let last_clickable_ix = self.clickable_menu_items().last().map(|(ix, _)| ix);
938 self.set_selected_index(last_clickable_ix.unwrap_or(0), cx);
939 }
940
941 fn select_down(&mut self, _: &SelectDown, _: &mut Window, cx: &mut Context<Self>) {
942 cx.stop_propagation();
943 let Some(ix) = self.selected_index else {
944 self.set_selected_index(0, cx);
945 return;
946 };
947
948 if let Some((next_ix, _)) = self
949 .menu_items
950 .iter()
951 .enumerate()
952 .find(|(i, item)| *i > ix && item.is_clickable())
953 {
954 self.set_selected_index(next_ix, cx);
955 return;
956 }
957
958 self.set_selected_index(0, cx);
959 }
960
961 fn select_left(&mut self, _: &SelectLeft, window: &mut Window, cx: &mut Context<Self>) {
962 let handled = if matches!(self.submenu_anchor.0, Anchor::TopLeft | Anchor::BottomLeft) {
963 self._unselect_submenu(window, cx)
964 } else {
965 self._select_submenu(window, cx)
966 };
967
968 if self.parent_side(cx).is_left() {
969 self._focus_parent_menu(window, cx);
970 }
971
972 if handled {
973 return;
974 }
975
976 if self.parent_menu.is_none() {
978 cx.propagate();
979 }
980 }
981
982 fn select_right(&mut self, _: &SelectRight, window: &mut Window, cx: &mut Context<Self>) {
983 let handled = if matches!(self.submenu_anchor.0, Anchor::TopLeft | Anchor::BottomLeft) {
984 self._select_submenu(window, cx)
985 } else {
986 self._unselect_submenu(window, cx)
987 };
988
989 if self.parent_side(cx).is_right() {
990 self._focus_parent_menu(window, cx);
991 }
992
993 if handled {
994 return;
995 }
996
997 if self.parent_menu.is_none() {
999 cx.propagate();
1000 }
1001 }
1002
1003 fn _select_submenu(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
1004 if let Some(active_submenu) = self.active_submenu() {
1005 active_submenu.update(cx, |view, cx| {
1007 view.set_selected_index(0, cx);
1008 view.focus_handle.focus(window, cx);
1009 });
1010 cx.notify();
1011 return true;
1012 }
1013
1014 return false;
1015 }
1016
1017 fn _unselect_submenu(&mut self, _: &mut Window, cx: &mut Context<Self>) -> bool {
1018 if let Some(active_submenu) = self.active_submenu() {
1019 active_submenu.update(cx, |view, cx| {
1020 view.selected_index = None;
1021 cx.notify();
1022 });
1023 return true;
1024 }
1025
1026 return false;
1027 }
1028
1029 fn _focus_parent_menu(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1030 let Some(parent) = self.parent_menu.as_ref() else {
1031 return;
1032 };
1033 let Some(parent) = parent.upgrade() else {
1034 return;
1035 };
1036
1037 self.selected_index = None;
1038 parent.update(cx, |view, cx| {
1039 view.focus_handle.focus(window, cx);
1040 cx.notify();
1041 });
1042 }
1043
1044 fn parent_side(&self, cx: &App) -> Side {
1045 let Some(parent) = self.parent_menu.as_ref() else {
1046 return Side::Left;
1047 };
1048
1049 let Some(parent) = parent.upgrade() else {
1050 return Side::Left;
1051 };
1052
1053 match parent.read(cx).submenu_anchor.0 {
1054 Anchor::TopLeft | Anchor::BottomLeft => Side::Left,
1055 Anchor::TopRight | Anchor::BottomRight => Side::Right,
1056 _ => Side::Left,
1058 }
1059 }
1060
1061 fn dismiss(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context<Self>) {
1065 self.selected_index = None;
1066 cx.emit(DismissEvent);
1067
1068 let focus_moved_away =
1072 window.focused(cx).is_some() && !self.focus_handle.contains_focused(window, cx);
1073 if !focus_moved_away {
1074 if let Some(handle) = self
1075 .previous_focus_handle
1076 .as_ref()
1077 .or(self.action_context.as_ref())
1078 {
1079 window.focus(handle, cx);
1080 }
1081 }
1082
1083 let Some(parent_menu) = self.parent_menu.clone() else {
1084 return;
1085 };
1086
1087 _ = parent_menu.update(cx, |view, cx| {
1089 view.dismiss(&Cancel, window, cx);
1090 });
1091 }
1092
1093 fn handle_dismiss(
1094 &mut self,
1095 position: &Point<Pixels>,
1096 window: &mut Window,
1097 cx: &mut Context<Self>,
1098 ) {
1099 if let Some(parent) = self.parent_menu.as_ref() {
1101 if let Some(parent) = parent.upgrade() {
1102 if parent.read(cx).bounds.contains(position) {
1103 return;
1104 }
1105 }
1106 }
1107
1108 if self.active_submenu().is_some() {
1113 return;
1114 }
1115
1116 self.dismiss(&Cancel, window, cx);
1117 }
1118
1119 fn on_mouse_down_out(
1120 &mut self,
1121 e: &MouseDownEvent,
1122 window: &mut Window,
1123 cx: &mut Context<Self>,
1124 ) {
1125 self.handle_dismiss(&e.position, window, cx);
1126 }
1127
1128 fn render_key_binding(
1129 &self,
1130 action: Option<Box<dyn Action>>,
1131 window: &mut Window,
1132 cx: &mut Context<Self>,
1133 ) -> Option<Kbd> {
1134 let action = action?;
1135
1136 let action_context = self.resolved_action_context(cx);
1145 [
1146 action_context.as_ref(),
1147 self.trigger_focus_handle.as_ref(),
1148 self.previous_focus_handle.as_ref(),
1149 Some(&self.focus_handle),
1150 ]
1151 .into_iter()
1152 .flatten()
1153 .find_map(|handle| Kbd::binding_for_action_in(action.as_ref(), handle, window))
1154 .or_else(|| Kbd::global_binding_for_action(action.as_ref(), window))
1155 .map(|this| {
1156 this.p_0()
1157 .flex_nowrap()
1158 .border_0()
1159 .bg(gpui::transparent_white())
1160 })
1161 }
1162
1163 fn render_icon(
1164 has_icon: bool,
1165 checked: bool,
1166 icon: Option<Icon>,
1167 _: &mut Window,
1168 _: &mut Context<Self>,
1169 ) -> Option<impl IntoElement> {
1170 if !has_icon {
1171 return None;
1172 }
1173
1174 let icon = if let Some(icon) = icon {
1175 icon.clone()
1176 } else if checked {
1177 Icon::new(IconName::Check)
1178 } else {
1179 Icon::empty()
1180 };
1181
1182 Some(icon.xsmall())
1183 }
1184
1185 #[inline]
1186 fn max_width(&self) -> Pixels {
1187 self.max_width.unwrap_or(px(500.))
1188 }
1189
1190 fn update_submenu_menu_anchor(&mut self, window: &Window) {
1192 let bounds = self.bounds;
1193 let max_width = self.max_width();
1194 let (anchor, left) = if max_width + bounds.origin.x > window.bounds().size.width {
1195 (Anchor::TopRight, -px(16.))
1196 } else {
1197 (Anchor::TopLeft, bounds.size.width - px(8.))
1198 };
1199
1200 let is_bottom_pos = bounds.origin.y + bounds.size.height > window.bounds().size.height;
1201 self.submenu_anchor = if is_bottom_pos {
1202 (anchor.other_side_along(gpui::Axis::Vertical), left)
1203 } else {
1204 (anchor, left)
1205 };
1206 }
1207
1208 fn render_item(
1209 &self,
1210 ix: usize,
1211 item: &PopupMenuItem,
1212 options: RenderOptions,
1213 window: &mut Window,
1214 cx: &mut Context<Self>,
1215 ) -> MenuItemElement {
1216 let has_left_icon = options.has_left_icon;
1217 let is_left_check = options.check_side.is_left() && item.is_checked();
1218 let right_check_icon = if options.check_side.is_right() && item.is_checked() {
1219 Some(Icon::new(IconName::Check).xsmall())
1220 } else {
1221 None
1222 };
1223
1224 let selected = self.selected_index == Some(ix);
1225 const EDGE_PADDING: Pixels = px(4.);
1226 const INNER_PADDING: Pixels = px(8.);
1227
1228 let is_submenu = matches!(item, PopupMenuItem::Submenu { .. });
1229
1230 let (item_height, radius) = match self.size {
1231 Size::Small => (px(20.), options.radius.half()),
1232 _ => (px(26.), options.radius),
1233 };
1234
1235 let this = MenuItemElement::new(ix)
1236 .relative()
1237 .text_sm()
1238 .py_0()
1239 .px(INNER_PADDING)
1240 .rounded(radius)
1241 .items_center()
1242 .selected(selected)
1243 .on_hover(cx.listener(move |this, hovered, window, cx| {
1244 if *hovered {
1245 if this.selected_index != Some(ix)
1248 && this.active_submenu().is_some_and(|submenu| {
1249 submenu.focus_handle(cx).contains_focused(window, cx)
1250 })
1251 {
1252 this.focus_handle.focus(window, cx);
1253 }
1254 this.selected_index = Some(ix);
1255 } else if !is_submenu
1256 && this.selected_index == Some(ix)
1257 && !window.last_input_was_keyboard()
1260 {
1261 this.selected_index = None;
1263 }
1264
1265 cx.notify();
1266 }))
1267 .when_some(item.a11y_label(), |this, label| this.aria_label(label));
1268
1269 match item {
1270 PopupMenuItem::Separator => this
1271 .h_auto()
1272 .p_0()
1273 .my_0p5()
1274 .mx_neg_1()
1275 .border_b(px(2.))
1276 .border_color(cx.theme().border)
1277 .disabled(true),
1278 PopupMenuItem::Label(label) => this.disabled(true).cursor_default().child(
1279 h_flex()
1280 .cursor_default()
1281 .items_center()
1282 .gap_x_1()
1283 .children(Self::render_icon(has_left_icon, false, None, window, cx))
1284 .child(div().flex_1().child(label.clone())),
1285 ),
1286 PopupMenuItem::ElementItem {
1287 render,
1288 icon,
1289 disabled,
1290 ..
1291 } => this
1292 .when(!disabled, |this| {
1293 this.on_click(
1294 cx.listener(move |this, _, window, cx| this.on_click(ix, window, cx)),
1295 )
1296 })
1297 .disabled(*disabled)
1298 .child(
1299 h_flex()
1300 .flex_1()
1301 .min_h(item_height)
1302 .items_center()
1303 .gap_x_1()
1304 .children(Self::render_icon(
1305 has_left_icon,
1306 is_left_check,
1307 icon.clone(),
1308 window,
1309 cx,
1310 ))
1311 .child((render)(window, cx))
1312 .children(right_check_icon.map(|icon| icon.ml_3())),
1313 ),
1314 PopupMenuItem::Item {
1315 icon,
1316 label,
1317 action,
1318 disabled,
1319 is_link,
1320 ..
1321 } => {
1322 let show_link_icon = *is_link && self.external_link_icon;
1323 let action = action.as_ref().map(|action| action.boxed_clone());
1324 let key = self.render_key_binding(action, window, cx);
1325
1326 this.when(!disabled, |this| {
1327 this.on_click(
1328 cx.listener(move |this, _, window, cx| this.on_click(ix, window, cx)),
1329 )
1330 })
1331 .disabled(*disabled)
1332 .h(item_height)
1333 .gap_x_1()
1334 .children(Self::render_icon(
1335 has_left_icon,
1336 is_left_check,
1337 icon.clone(),
1338 window,
1339 cx,
1340 ))
1341 .child(
1342 h_flex()
1343 .w_full()
1344 .gap_3()
1345 .items_center()
1346 .justify_between()
1347 .when(!show_link_icon, |this| this.child(label.clone()))
1348 .children(right_check_icon)
1349 .when(show_link_icon, |this| {
1350 this.child(
1351 h_flex()
1352 .w_full()
1353 .justify_between()
1354 .gap_1p5()
1355 .child(label.clone())
1356 .child(
1357 Icon::new(IconName::ExternalLink)
1358 .xsmall()
1359 .text_color(cx.theme().muted_foreground),
1360 ),
1361 )
1362 })
1363 .children(key),
1364 )
1365 }
1366 PopupMenuItem::Submenu {
1367 icon,
1368 label,
1369 menu,
1370 disabled,
1371 } => this
1372 .selected(selected)
1373 .disabled(*disabled)
1374 .items_start()
1375 .child(
1376 h_flex()
1377 .min_h(item_height)
1378 .size_full()
1379 .items_center()
1380 .gap_x_1()
1381 .children(Self::render_icon(
1382 has_left_icon,
1383 false,
1384 icon.clone(),
1385 window,
1386 cx,
1387 ))
1388 .child(
1389 h_flex()
1390 .flex_1()
1391 .gap_2()
1392 .items_center()
1393 .justify_between()
1394 .child(label.clone())
1395 .child(
1396 Icon::new(IconName::ChevronRight)
1397 .xsmall()
1398 .text_color(cx.theme().muted_foreground),
1399 ),
1400 ),
1401 )
1402 .when(selected, |this| {
1403 this.child({
1404 let (anchor, left) = self.submenu_anchor;
1405 let is_bottom_pos =
1406 matches!(anchor, Anchor::BottomLeft | Anchor::BottomRight);
1407 deferred(
1408 anchored()
1409 .anchor(anchor)
1410 .child(
1411 div()
1412 .id("submenu")
1413 .test_support()
1414 .occlude()
1415 .when(is_bottom_pos, |this| this.bottom_0())
1416 .when(!is_bottom_pos, |this| this.top_neg_1())
1417 .left(left)
1418 .child(menu.clone()),
1419 )
1420 .snap_to_window_with_margin(Edges::all(EDGE_PADDING)),
1421 )
1422 .with_priority(self.priority + 1)
1423 })
1424 }),
1425 }
1426 }
1427}
1428
1429impl FluentBuilder for PopupMenu {}
1430impl EventEmitter<DismissEvent> for PopupMenu {}
1431impl Focusable for PopupMenu {
1432 fn focus_handle(&self, _: &App) -> FocusHandle {
1433 self.focus_handle.clone()
1434 }
1435}
1436
1437#[derive(Clone, Copy)]
1438struct RenderOptions {
1439 has_left_icon: bool,
1440 check_side: Side,
1441 radius: Pixels,
1442}
1443
1444impl Render for PopupMenu {
1445 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1446 self.update_submenu_menu_anchor(window);
1447
1448 let parent = cx.entity().downgrade();
1454 let parent_priority = self.priority;
1455 for item in &self.menu_items {
1456 if let PopupMenuItem::Submenu { menu, .. } = item {
1457 if menu.read(cx).parent_menu.is_none() {
1458 menu.update(cx, |menu, _| {
1459 menu.parent_menu = Some(parent.clone());
1460 menu.priority = parent_priority + 1;
1461 });
1462 }
1463 }
1464 }
1465
1466 let view = cx.entity().clone();
1467 let items_count = self.menu_items.len();
1468
1469 let max_height = self.max_height.unwrap_or_else(|| {
1470 let window_half_height = window.window_bounds().get_bounds().size.height * 0.5;
1471 window_half_height.min(px(450.))
1472 });
1473
1474 let has_left_icon = self
1475 .menu_items
1476 .iter()
1477 .any(|item| item.has_left_icon(self.check_side));
1478
1479 let max_width = self.max_width();
1480 let options = RenderOptions {
1481 has_left_icon,
1482 check_side: self.check_side,
1483 radius: cx.theme().radius.min(px(8.)),
1484 };
1485
1486 v_flex()
1487 .id("popup-menu")
1488 .test_support()
1489 .role(Role::Menu)
1490 .key_context(CONTEXT)
1491 .track_focus(&self.focus_handle)
1492 .on_action(cx.listener(Self::select_up))
1493 .on_action(cx.listener(Self::select_down))
1494 .on_action(cx.listener(Self::select_left))
1495 .on_action(cx.listener(Self::select_right))
1496 .on_action(cx.listener(Self::confirm))
1497 .on_action(cx.listener(Self::dismiss))
1498 .on_mouse_down_out(cx.listener(Self::on_mouse_down_out))
1499 .popover_style(cx)
1500 .text_color(cx.theme().popover_foreground)
1501 .relative()
1502 .occlude()
1503 .child(
1504 v_flex()
1505 .id("items")
1506 .p_1()
1507 .gap_y_0p5()
1508 .min_w(rems(8.))
1509 .when_some(self.min_width, |this, min_width| this.min_w(min_width))
1510 .max_w(max_width)
1511 .when(self.scrollable, |this| {
1512 this.max_h(max_height)
1513 .overflow_y_scroll()
1514 .track_scroll(&self.scroll_handle)
1515 })
1516 .children(
1517 self.menu_items
1518 .iter()
1519 .enumerate()
1520 .filter(|(ix, item)| !(*ix + 1 == items_count && item.is_separator()))
1522 .map(|(ix, item)| self.render_item(ix, item, options, window, cx)),
1523 )
1524 .on_prepaint(move |bounds, _, cx| view.update(cx, |r, _| r.bounds = bounds)),
1525 )
1526 .when(self.scrollable, |this| {
1527 this.vertical_scrollbar(&self.scroll_handle)
1528 })
1529 }
1530}
1531
1532#[cfg(test)]
1533mod tests {
1534 use super::*;
1535
1536 #[gpui::test]
1537 fn popup_menu_item_a11y_label_uses_visible_label(cx: &mut gpui::TestAppContext) {
1538 let submenu = cx.update(|cx| cx.new(|cx| PopupMenu::new(cx)));
1539
1540 assert_eq!(PopupMenuItem::new("Open").a11y_label(), Some("Open".into()));
1541 assert_eq!(
1542 PopupMenuItem::link("Docs", "https://example.com").a11y_label(),
1543 Some("Docs".into())
1544 );
1545 assert_eq!(
1546 PopupMenuItem::label("Recent files").a11y_label(),
1547 Some("Recent files".into())
1548 );
1549 assert_eq!(
1550 PopupMenuItem::submenu("More", submenu).a11y_label(),
1551 Some("More".into())
1552 );
1553 assert_eq!(PopupMenuItem::separator().a11y_label(), None);
1554 assert_eq!(PopupMenuItem::element(|_, _| div()).a11y_label(), None);
1555 }
1556}