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