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, Rems, Render, Role, ScrollHandle, SharedString,
12 StatefulInteractiveElement, Styled, WeakEntity, Window, anchored, deferred, div,
13 prelude::FluentBuilder, px, rems,
14};
15use gpui::{ClickEvent, Half, MouseDownEvent, OwnedMenuItem, Point, Subscription};
16use gpui_base::TestSupportExt as _;
17
18use std::collections::HashSet;
19use std::rc::Rc;
20
21const CONTEXT: &str = "PopupMenu";
22
23const SCROLL_TO_CHECKED_FRAMES: u8 = 4;
26
27pub fn init(cx: &mut App) {
28 cx.bind_keys([
29 KeyBinding::new("enter", Confirm { secondary: false }, Some(CONTEXT)),
30 KeyBinding::new("escape", Cancel, Some(CONTEXT)),
31 KeyBinding::new("up", SelectUp, Some(CONTEXT)),
32 KeyBinding::new("down", SelectDown, Some(CONTEXT)),
33 KeyBinding::new("left", SelectLeft, Some(CONTEXT)),
34 KeyBinding::new("right", SelectRight, Some(CONTEXT)),
35 ]);
36}
37
38pub enum PopupMenuItem {
40 Separator,
42 Label(SharedString),
44 Item {
46 icon: Option<Icon>,
47 label: SharedString,
48 disabled: bool,
49 checked: bool,
50 is_link: bool,
51 action: Option<Box<dyn Action>>,
52 handler: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
54 },
55 ElementItem {
57 icon: Option<Icon>,
58 disabled: bool,
59 checked: bool,
60 action: Option<Box<dyn Action>>,
61 render: Box<dyn Fn(&mut Window, &mut App) -> AnyElement + 'static>,
62 handler: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
63 },
64 Submenu {
66 icon: Option<Icon>,
67 label: SharedString,
68 disabled: bool,
69 menu: Entity<PopupMenu>,
70 },
71}
72
73impl FluentBuilder for PopupMenuItem {}
74impl PopupMenuItem {
75 #[inline]
77 pub fn new(label: impl Into<SharedString>) -> Self {
78 PopupMenuItem::Item {
79 icon: None,
80 label: label.into(),
81 disabled: false,
82 checked: false,
83 action: None,
84 is_link: false,
85 handler: None,
86 }
87 }
88
89 #[inline]
91 pub fn element<F, E>(builder: F) -> Self
92 where
93 F: Fn(&mut Window, &mut App) -> E + 'static,
94 E: IntoElement,
95 {
96 PopupMenuItem::ElementItem {
97 icon: None,
98 disabled: false,
99 checked: false,
100 action: None,
101 render: Box::new(move |window, cx| builder(window, cx).into_any_element()),
102 handler: None,
103 }
104 }
105
106 #[inline]
108 pub fn submenu(label: impl Into<SharedString>, menu: Entity<PopupMenu>) -> Self {
109 PopupMenuItem::Submenu {
110 icon: None,
111 label: label.into(),
112 disabled: false,
113 menu,
114 }
115 }
116
117 #[inline]
119 pub fn separator() -> Self {
120 PopupMenuItem::Separator
121 }
122
123 #[inline]
125 pub fn label(label: impl Into<SharedString>) -> Self {
126 PopupMenuItem::Label(label.into())
127 }
128
129 pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
133 match &mut self {
134 PopupMenuItem::Item { icon: i, .. } => {
135 *i = Some(icon.into());
136 }
137 PopupMenuItem::ElementItem { icon: i, .. } => {
138 *i = Some(icon.into());
139 }
140 PopupMenuItem::Submenu { icon: i, .. } => {
141 *i = Some(icon.into());
142 }
143 _ => {}
144 }
145 self
146 }
147
148 pub fn action(mut self, action: Box<dyn Action>) -> Self {
152 match &mut self {
153 PopupMenuItem::Item { action: a, .. } => {
154 *a = Some(action);
155 }
156 PopupMenuItem::ElementItem { action: a, .. } => {
157 *a = Some(action);
158 }
159 _ => {}
160 }
161 self
162 }
163
164 pub fn disabled(mut self, disabled: bool) -> Self {
168 match &mut self {
169 PopupMenuItem::Item { disabled: d, .. } => {
170 *d = disabled;
171 }
172 PopupMenuItem::ElementItem { disabled: d, .. } => {
173 *d = disabled;
174 }
175 PopupMenuItem::Submenu { disabled: d, .. } => {
176 *d = disabled;
177 }
178 _ => {}
179 }
180 self
181 }
182
183 pub fn checked(mut self, checked: bool) -> Self {
187 match &mut self {
188 PopupMenuItem::Item { checked: c, .. } => {
189 *c = checked;
190 }
191 PopupMenuItem::ElementItem { checked: c, .. } => {
192 *c = checked;
193 }
194 _ => {}
195 }
196 self
197 }
198
199 pub fn on_click<F>(mut self, handler: F) -> Self
203 where
204 F: Fn(&ClickEvent, &mut Window, &mut App) + 'static,
205 {
206 match &mut self {
207 PopupMenuItem::Item { handler: h, .. } => {
208 *h = Some(Rc::new(handler));
209 }
210 PopupMenuItem::ElementItem { handler: h, .. } => {
211 *h = Some(Rc::new(handler));
212 }
213 _ => {}
214 }
215 self
216 }
217
218 #[inline]
220 pub fn link(label: impl Into<SharedString>, href: impl Into<String>) -> Self {
221 let href = href.into();
222 PopupMenuItem::Item {
223 icon: None,
224 label: label.into(),
225 disabled: false,
226 checked: false,
227 action: None,
228 is_link: true,
229 handler: Some(Rc::new(move |_, _, cx| cx.open_url(&href))),
230 }
231 }
232
233 #[inline]
234 fn is_clickable(&self) -> bool {
235 !matches!(self, PopupMenuItem::Separator)
236 && matches!(
237 self,
238 PopupMenuItem::Item {
239 disabled: false,
240 ..
241 } | PopupMenuItem::ElementItem {
242 disabled: false,
243 ..
244 } | PopupMenuItem::Submenu {
245 disabled: false,
246 ..
247 }
248 )
249 }
250
251 #[inline]
252 fn is_separator(&self) -> bool {
253 matches!(self, PopupMenuItem::Separator)
254 }
255
256 fn has_left_icon(&self, check_side: Side) -> bool {
257 match self {
258 PopupMenuItem::Item { icon, checked, .. } => {
259 icon.is_some() || (check_side.is_left() && *checked)
260 }
261 PopupMenuItem::ElementItem { icon, checked, .. } => {
262 icon.is_some() || (check_side.is_left() && *checked)
263 }
264 PopupMenuItem::Submenu { icon, .. } => icon.is_some(),
265 _ => false,
266 }
267 }
268
269 #[inline]
270 fn is_checked(&self) -> bool {
271 match self {
272 PopupMenuItem::Item { checked, .. } => *checked,
273 PopupMenuItem::ElementItem { checked, .. } => *checked,
274 _ => false,
275 }
276 }
277
278 fn a11y_label(&self) -> Option<SharedString> {
279 match self {
280 PopupMenuItem::Item { label, .. }
281 | PopupMenuItem::Label(label)
282 | PopupMenuItem::Submenu { label, .. } => Some(label.clone()),
283 PopupMenuItem::Separator | PopupMenuItem::ElementItem { .. } => None,
284 }
285 }
286}
287
288pub struct PopupMenu {
289 pub(crate) focus_handle: FocusHandle,
290 pub(crate) menu_items: Vec<PopupMenuItem>,
291 pub(crate) action_context: Option<FocusHandle>,
293 pub(crate) previous_focus_handle: Option<FocusHandle>,
297 pub(crate) trigger_focus_handle: Option<FocusHandle>,
302 selected_index: Option<usize>,
303 min_width: Option<Pixels>,
304 max_width: Option<Pixels>,
305 max_height: Option<Pixels>,
306 bounds: Bounds<Pixels>,
307 size: Size,
308 check_side: Side,
309 checkable: HashSet<usize>,
312
313 parent_menu: Option<WeakEntity<Self>>,
315 scrollable: bool,
316 scroll_to_checked_frames: u8,
319 external_link_icon: bool,
320 scroll_handle: ScrollHandle,
321 submenu_anchor: (Anchor, Pixels),
323
324 priority: usize,
341
342 _subscriptions: Vec<Subscription>,
343}
344
345impl PopupMenu {
346 pub(crate) fn new(cx: &mut App) -> Self {
347 Self {
348 focus_handle: cx.focus_handle(),
349 action_context: None,
350 previous_focus_handle: None,
351 trigger_focus_handle: None,
352 parent_menu: None,
353 menu_items: Vec::new(),
354 selected_index: None,
355 min_width: None,
356 max_width: None,
357 max_height: None,
358 check_side: Side::Left,
359 checkable: HashSet::new(),
360 bounds: Bounds::default(),
361 scrollable: false,
362 scroll_to_checked_frames: SCROLL_TO_CHECKED_FRAMES,
363 scroll_handle: ScrollHandle::default(),
364 external_link_icon: true,
365 size: Size::default(),
366 submenu_anchor: (Anchor::TopLeft, Pixels::ZERO),
367 priority: gpui_base::POPUP_PRIORITY,
368 _subscriptions: vec![],
369 }
370 }
371
372 pub fn build(
373 window: &mut Window,
374 cx: &mut App,
375 f: impl FnOnce(Self, &mut Window, &mut Context<PopupMenu>) -> Self,
376 ) -> Entity<Self> {
377 cx.new(|cx| f(Self::new(cx), window, cx))
378 }
379
380 pub fn action_context(mut self, handle: FocusHandle) -> Self {
388 self.action_context = Some(handle);
389 self
390 }
391
392 pub(crate) fn set_action_context(
393 &mut self,
394 action_context: Option<FocusHandle>,
395 cx: &mut Context<Self>,
396 ) {
397 self.action_context = action_context.clone();
398
399 for item in &self.menu_items {
400 if let PopupMenuItem::Submenu { menu, .. } = item {
401 menu.update(cx, |menu, cx| {
402 menu.set_action_context(action_context.clone(), cx);
403 });
404 }
405 }
406 }
407
408 pub(crate) fn set_previous_focus(
411 &mut self,
412 handle: Option<FocusHandle>,
413 cx: &mut Context<Self>,
414 ) {
415 self.previous_focus_handle = handle.clone();
416
417 for item in &self.menu_items {
418 if let PopupMenuItem::Submenu { menu, .. } = item {
419 menu.update(cx, |menu, cx| {
420 menu.set_previous_focus(handle.clone(), cx);
421 });
422 }
423 }
424 }
425
426 pub(crate) fn set_trigger_focus(
429 &mut self,
430 handle: Option<FocusHandle>,
431 cx: &mut Context<Self>,
432 ) {
433 self.trigger_focus_handle = handle.clone();
434
435 for item in &self.menu_items {
436 if let PopupMenuItem::Submenu { menu, .. } = item {
437 menu.update(cx, |menu, cx| {
438 menu.set_trigger_focus(handle.clone(), cx);
439 });
440 }
441 }
442 }
443
444 pub fn min_w(mut self, width: impl Into<Pixels>) -> Self {
446 self.min_width = Some(width.into());
447 self
448 }
449
450 pub fn max_w(mut self, width: impl Into<Pixels>) -> Self {
453 self.max_width = Some(width.into());
454 self
455 }
456
457 pub fn max_h(mut self, height: impl Into<Pixels>) -> Self {
460 self.max_height = Some(height.into());
461 self
462 }
463
464 pub fn scrollable(mut self, scrollable: bool) -> Self {
466 self.scrollable = scrollable;
467 self
468 }
469
470 pub fn check_side(mut self, side: Side) -> Self {
472 self.check_side = side;
473 self
474 }
475
476 pub fn external_link_icon(mut self, visible: bool) -> Self {
478 self.external_link_icon = visible;
479 self
480 }
481
482 pub fn menu(self, label: impl Into<SharedString>, action: Box<dyn Action>) -> Self {
484 self.menu_with_disabled(label, action, false)
485 }
486
487 pub fn menu_with_enable(
489 mut self,
490 label: impl Into<SharedString>,
491 action: Box<dyn Action>,
492 enable: bool,
493 ) -> Self {
494 self.add_menu_item(label, None, action, !enable, false);
495 self
496 }
497
498 pub fn menu_with_disabled(
500 mut self,
501 label: impl Into<SharedString>,
502 action: Box<dyn Action>,
503 disabled: bool,
504 ) -> Self {
505 self.add_menu_item(label, None, action, disabled, false);
506 self
507 }
508
509 pub fn label(mut self, label: impl Into<SharedString>) -> Self {
511 self.menu_items.push(PopupMenuItem::label(label.into()));
512 self
513 }
514
515 pub fn link(self, label: impl Into<SharedString>, href: impl Into<String>) -> Self {
517 self.link_with_disabled(label, href, false)
518 }
519
520 pub fn link_with_disabled(
522 mut self,
523 label: impl Into<SharedString>,
524 href: impl Into<String>,
525 disabled: bool,
526 ) -> Self {
527 let href = href.into();
528 self.menu_items
529 .push(PopupMenuItem::link(label, href).disabled(disabled));
530 self
531 }
532
533 pub fn link_with_icon(
535 self,
536 label: impl Into<SharedString>,
537 icon: impl Into<Icon>,
538 href: impl Into<String>,
539 ) -> Self {
540 self.link_with_icon_and_disabled(label, icon, href, false)
541 }
542
543 fn link_with_icon_and_disabled(
545 mut self,
546 label: impl Into<SharedString>,
547 icon: impl Into<Icon>,
548 href: impl Into<String>,
549 disabled: bool,
550 ) -> Self {
551 let href = href.into();
552 self.menu_items.push(
553 PopupMenuItem::link(label, href)
554 .icon(icon)
555 .disabled(disabled),
556 );
557 self
558 }
559
560 pub fn menu_with_icon(
562 self,
563 label: impl Into<SharedString>,
564 icon: impl Into<Icon>,
565 action: Box<dyn Action>,
566 ) -> Self {
567 self.menu_with_icon_and_disabled(label, icon, action, false)
568 }
569
570 pub fn menu_with_icon_and_disabled(
572 mut self,
573 label: impl Into<SharedString>,
574 icon: impl Into<Icon>,
575 action: Box<dyn Action>,
576 disabled: bool,
577 ) -> Self {
578 self.add_menu_item(label, Some(icon.into()), action, disabled, false);
579 self
580 }
581
582 pub fn menu_with_check(
584 self,
585 label: impl Into<SharedString>,
586 checked: bool,
587 action: Box<dyn Action>,
588 ) -> Self {
589 self.menu_with_check_and_disabled(label, checked, action, false)
590 }
591
592 pub fn menu_with_check_and_disabled(
594 mut self,
595 label: impl Into<SharedString>,
596 checked: bool,
597 action: Box<dyn Action>,
598 disabled: bool,
599 ) -> Self {
600 self.checkable.insert(self.menu_items.len());
601 self.add_menu_item(label, None, action, disabled, checked);
602 self
603 }
604
605 pub fn menu_element<F, E>(self, action: Box<dyn Action>, builder: F) -> Self
607 where
608 F: Fn(&mut Window, &mut App) -> E + 'static,
609 E: IntoElement,
610 {
611 self.menu_element_with_check(false, action, builder)
612 }
613
614 pub fn menu_element_with_disabled<F, E>(
616 self,
617 action: Box<dyn Action>,
618 disabled: bool,
619 builder: F,
620 ) -> Self
621 where
622 F: Fn(&mut Window, &mut App) -> E + 'static,
623 E: IntoElement,
624 {
625 self.menu_element_with_check_and_disabled(false, action, disabled, builder)
626 }
627
628 pub fn menu_element_with_icon<F, E>(
630 self,
631 icon: impl Into<Icon>,
632 action: Box<dyn Action>,
633 builder: F,
634 ) -> Self
635 where
636 F: Fn(&mut Window, &mut App) -> E + 'static,
637 E: IntoElement,
638 {
639 self.menu_element_with_icon_and_disabled(icon, action, false, builder)
640 }
641
642 pub fn menu_element_with_check<F, E>(
644 self,
645 checked: bool,
646 action: Box<dyn Action>,
647 builder: F,
648 ) -> Self
649 where
650 F: Fn(&mut Window, &mut App) -> E + 'static,
651 E: IntoElement,
652 {
653 self.menu_element_with_check_and_disabled(checked, action, false, builder)
654 }
655
656 fn menu_element_with_icon_and_disabled<F, E>(
658 mut self,
659 icon: impl Into<Icon>,
660 action: Box<dyn Action>,
661 disabled: bool,
662 builder: F,
663 ) -> Self
664 where
665 F: Fn(&mut Window, &mut App) -> E + 'static,
666 E: IntoElement,
667 {
668 self.menu_items.push(
669 PopupMenuItem::element(builder)
670 .action(action)
671 .icon(icon)
672 .disabled(disabled),
673 );
674 self
675 }
676
677 fn menu_element_with_check_and_disabled<F, E>(
679 mut self,
680 checked: bool,
681 action: Box<dyn Action>,
682 disabled: bool,
683 builder: F,
684 ) -> Self
685 where
686 F: Fn(&mut Window, &mut App) -> E + 'static,
687 E: IntoElement,
688 {
689 self.checkable.insert(self.menu_items.len());
690 self.menu_items.push(
691 PopupMenuItem::element(builder)
692 .action(action)
693 .checked(checked)
694 .disabled(disabled),
695 );
696 self
697 }
698
699 pub fn separator(mut self) -> Self {
701 if self.menu_items.is_empty() {
702 return self;
703 }
704
705 if let Some(PopupMenuItem::Separator) = self.menu_items.last() {
706 return self;
707 }
708
709 self.menu_items.push(PopupMenuItem::separator());
710 self
711 }
712
713 pub fn submenu(
715 self,
716 label: impl Into<SharedString>,
717 window: &mut Window,
718 cx: &mut Context<Self>,
719 f: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
720 ) -> Self {
721 self.submenu_with_icon(None, label, window, cx, f)
722 }
723
724 pub fn submenu_with_icon(
726 mut self,
727 icon: Option<Icon>,
728 label: impl Into<SharedString>,
729 window: &mut Window,
730 cx: &mut Context<Self>,
731 f: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
732 ) -> Self {
733 let submenu = PopupMenu::build(window, cx, f);
734 let parent_menu = cx.entity().downgrade();
735 let parent_priority = self.priority;
736 submenu.update(cx, |view, _| {
737 view.parent_menu = Some(parent_menu);
738 view.priority = parent_priority + 1;
739 });
740
741 self.menu_items.push(
742 PopupMenuItem::submenu(label, submenu).when_some(icon, |this, icon| this.icon(icon)),
743 );
744 self
745 }
746
747 pub fn item(mut self, item: impl Into<PopupMenuItem>) -> Self {
749 let item: PopupMenuItem = item.into();
750 self.menu_items.push(item);
751 self
752 }
753
754 pub fn rebuild(
774 &mut self,
775 window: &mut Window,
776 cx: &mut Context<Self>,
777 f: impl FnOnce(Self, &mut Window, &mut Context<Self>) -> Self,
778 ) {
779 let mut menu = std::mem::replace(self, Self::new(cx));
780 menu.menu_items.clear();
781 menu.selected_index = None;
782 *self = f(menu, window, cx);
783 cx.notify();
784 }
785
786 fn add_menu_item(
787 &mut self,
788 label: impl Into<SharedString>,
789 icon: Option<Icon>,
790 action: Box<dyn Action>,
791 disabled: bool,
792 checked: bool,
793 ) -> &mut Self {
794 self.menu_items.push(
795 PopupMenuItem::new(label)
796 .when_some(icon, |item, icon| item.icon(icon))
797 .disabled(disabled)
798 .checked(checked)
799 .action(action),
800 );
801 self
802 }
803
804 pub(super) fn with_menu_items<I>(
805 mut self,
806 items: impl IntoIterator<Item = I>,
807 window: &mut Window,
808 cx: &mut Context<Self>,
809 ) -> Self
810 where
811 I: Into<OwnedMenuItem>,
812 {
813 for item in items {
814 match item.into() {
815 OwnedMenuItem::Action {
816 name,
817 action,
818 checked,
819 disabled,
820 ..
821 } => {
822 self = self.item(
825 PopupMenuItem::new(name)
826 .action(action.boxed_clone())
827 .disabled(disabled)
828 .checked(checked),
829 )
830 }
831 OwnedMenuItem::Separator => {
832 self = self.separator();
833 }
834 OwnedMenuItem::Submenu(submenu) => {
835 self = self.submenu(submenu.name, window, cx, move |menu, window, cx| {
836 menu.with_menu_items(submenu.items.clone(), window, cx)
837 })
838 }
839 OwnedMenuItem::SystemMenu(_) => {}
840 }
841 }
842
843 if self.menu_items.len() > 20 {
844 self.scrollable = true;
845 }
846
847 self
848 }
849
850 pub(crate) fn active_submenu(&self) -> Option<Entity<PopupMenu>> {
851 if let Some(ix) = self.selected_index {
852 if let Some(item) = self.menu_items.get(ix) {
853 return match item {
854 PopupMenuItem::Submenu { menu, .. } => Some(menu.clone()),
855 _ => None,
856 };
857 }
858 }
859
860 None
861 }
862
863 pub fn is_empty(&self) -> bool {
864 self.menu_items.is_empty()
865 }
866
867 fn clickable_menu_items(&self) -> impl Iterator<Item = (usize, &PopupMenuItem)> {
868 self.menu_items
869 .iter()
870 .enumerate()
871 .filter(|(_, item)| item.is_clickable())
872 }
873
874 fn on_click(
875 &mut self,
876 ix: usize,
877 event: &ClickEvent,
878 window: &mut Window,
879 cx: &mut Context<Self>,
880 ) {
881 cx.stop_propagation();
882 window.prevent_default();
883 self.selected_index = Some(ix);
884 self.activate(event, window, cx);
885 }
886
887 fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
888 self.activate(&ClickEvent::default(), window, cx);
889 }
890
891 fn activate(&mut self, event: &ClickEvent, window: &mut Window, cx: &mut Context<Self>) {
892 match self.selected_index {
893 Some(index) => {
894 let item = self.menu_items.get(index);
895 if !item.is_some_and(PopupMenuItem::is_clickable) {
896 return;
897 }
898 match item {
899 Some(PopupMenuItem::Item {
900 handler, action, ..
901 }) => {
902 if let Some(handler) = handler {
903 handler(event, window, cx);
904 } else if let Some(action) = action.as_ref() {
905 self.dispatch_confirm_action(action, window, cx);
906 }
907
908 self.dismiss(&Cancel, window, cx)
909 }
910 Some(PopupMenuItem::ElementItem {
911 handler, action, ..
912 }) => {
913 if let Some(handler) = handler {
914 handler(event, window, cx);
915 } else if let Some(action) = action.as_ref() {
916 self.dispatch_confirm_action(action, window, cx);
917 }
918 self.dismiss(&Cancel, window, cx)
919 }
920 Some(PopupMenuItem::Submenu {
921 disabled: false, ..
922 }) => {
923 self._select_submenu(window, cx);
924 }
925 _ => {}
926 }
927 }
928 _ => {}
929 }
930 }
931
932 fn dispatch_confirm_action(
933 &self,
934 action: &Box<dyn Action>,
935 window: &mut Window,
936 cx: &mut Context<Self>,
937 ) {
938 if let Some(context) = self.resolved_action_context(cx) {
939 context.focus(window, cx);
940 }
941
942 window.dispatch_action(action.boxed_clone(), cx);
943 }
944
945 fn resolved_action_context(&self, cx: &App) -> Option<FocusHandle> {
946 self.action_context.clone().or_else(|| {
947 let parent = self.parent_menu.as_ref()?.upgrade()?;
948 parent.read(cx).resolved_action_context(cx)
949 })
950 }
951
952 fn set_selected_index(&mut self, ix: usize, cx: &mut Context<Self>) {
953 if self.selected_index != Some(ix) {
954 self.selected_index = Some(ix);
955 self.scroll_handle.scroll_to_item(ix);
956 cx.notify();
957 }
958 }
959
960 fn scroll_to_checked_item(&mut self, cx: &mut Context<Self>) {
972 if !self.scrollable || self.scroll_to_checked_frames == 0 {
973 return;
974 }
975
976 let checked_ix = self.menu_items.iter().position(|item| item.is_checked());
979 let Some(ix) = checked_ix.filter(|ix| *ix > 0) else {
980 self.scroll_to_checked_frames = 0;
981 return;
982 };
983
984 match (
985 self.scroll_handle.bounds_for_item(0),
986 self.scroll_handle.bounds_for_item(ix),
987 ) {
988 (Some(first), Some(checked)) => {
989 self.scroll_to_checked_frames = 0;
990 let mut offset = self.scroll_handle.offset();
991 offset.y = first.top() - checked.top();
992 self.scroll_handle.set_offset(offset);
993 }
994 _ => {
995 self.scroll_to_checked_frames -= 1;
996 cx.notify();
997 }
998 }
999 }
1000
1001 fn select_up(&mut self, _: &SelectUp, _: &mut Window, cx: &mut Context<Self>) {
1002 cx.stop_propagation();
1003 let ix = self.selected_index.unwrap_or(0);
1004
1005 if let Some((prev_ix, _)) = self
1006 .menu_items
1007 .iter()
1008 .enumerate()
1009 .rev()
1010 .find(|(i, item)| *i < ix && item.is_clickable())
1011 {
1012 self.set_selected_index(prev_ix, cx);
1013 return;
1014 }
1015
1016 let last_clickable_ix = self.clickable_menu_items().last().map(|(ix, _)| ix);
1017 if let Some(ix) = last_clickable_ix {
1018 self.set_selected_index(ix, cx);
1019 }
1020 }
1021
1022 fn select_down(&mut self, _: &SelectDown, _: &mut Window, cx: &mut Context<Self>) {
1023 cx.stop_propagation();
1024 let Some(ix) = self.selected_index else {
1025 let first_clickable_ix = self.clickable_menu_items().next().map(|(ix, _)| ix);
1026 if let Some(ix) = first_clickable_ix {
1027 self.set_selected_index(ix, cx);
1028 }
1029 return;
1030 };
1031
1032 if let Some((next_ix, _)) = self
1033 .menu_items
1034 .iter()
1035 .enumerate()
1036 .find(|(i, item)| *i > ix && item.is_clickable())
1037 {
1038 self.set_selected_index(next_ix, cx);
1039 return;
1040 }
1041
1042 let first_clickable_ix = self.clickable_menu_items().next().map(|(ix, _)| ix);
1043 if let Some(ix) = first_clickable_ix {
1044 self.set_selected_index(ix, cx);
1045 }
1046 }
1047
1048 fn select_left(&mut self, _: &SelectLeft, window: &mut Window, cx: &mut Context<Self>) {
1049 let handled = if matches!(self.submenu_anchor.0, Anchor::TopLeft | Anchor::BottomLeft) {
1050 self._unselect_submenu(window, cx)
1051 } else {
1052 self._select_submenu(window, cx)
1053 };
1054
1055 if self.parent_side(cx).is_left() {
1056 self._focus_parent_menu(window, cx);
1057 }
1058
1059 if handled {
1060 return;
1061 }
1062
1063 if self.parent_menu.is_none() {
1065 cx.propagate();
1066 }
1067 }
1068
1069 fn select_right(&mut self, _: &SelectRight, window: &mut Window, cx: &mut Context<Self>) {
1070 let handled = if matches!(self.submenu_anchor.0, Anchor::TopLeft | Anchor::BottomLeft) {
1071 self._select_submenu(window, cx)
1072 } else {
1073 self._unselect_submenu(window, cx)
1074 };
1075
1076 if self.parent_side(cx).is_right() {
1077 self._focus_parent_menu(window, cx);
1078 }
1079
1080 if handled {
1081 return;
1082 }
1083
1084 if self.parent_menu.is_none() {
1086 cx.propagate();
1087 }
1088 }
1089
1090 fn _select_submenu(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
1091 if let Some(active_submenu) = self.active_submenu() {
1092 active_submenu.update(cx, |view, cx| {
1094 let first_clickable_ix = view.clickable_menu_items().next().map(|(ix, _)| ix);
1095 if let Some(ix) = first_clickable_ix {
1096 view.set_selected_index(ix, cx);
1097 } else {
1098 view.selected_index = None;
1099 cx.notify();
1100 }
1101 view.focus_handle.focus(window, cx);
1102 });
1103 cx.notify();
1104 return true;
1105 }
1106
1107 return false;
1108 }
1109
1110 fn _unselect_submenu(&mut self, _: &mut Window, cx: &mut Context<Self>) -> bool {
1111 if let Some(active_submenu) = self.active_submenu() {
1112 active_submenu.update(cx, |view, cx| {
1113 view.selected_index = None;
1114 cx.notify();
1115 });
1116 return true;
1117 }
1118
1119 return false;
1120 }
1121
1122 fn _focus_parent_menu(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1123 let Some(parent) = self.parent_menu.as_ref() else {
1124 return;
1125 };
1126 let Some(parent) = parent.upgrade() else {
1127 return;
1128 };
1129
1130 self.selected_index = None;
1131 parent.update(cx, |view, cx| {
1132 view.focus_handle.focus(window, cx);
1133 cx.notify();
1134 });
1135 }
1136
1137 fn parent_side(&self, cx: &App) -> Side {
1138 let Some(parent) = self.parent_menu.as_ref() else {
1139 return Side::Left;
1140 };
1141
1142 let Some(parent) = parent.upgrade() else {
1143 return Side::Left;
1144 };
1145
1146 match parent.read(cx).submenu_anchor.0 {
1147 Anchor::TopLeft | Anchor::BottomLeft => Side::Left,
1148 Anchor::TopRight | Anchor::BottomRight => Side::Right,
1149 _ => Side::Left,
1151 }
1152 }
1153
1154 fn dismiss(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context<Self>) {
1158 self.selected_index = None;
1159 cx.emit(DismissEvent);
1160
1161 let focus_moved_away =
1165 window.focused(cx).is_some() && !self.focus_handle.contains_focused(window, cx);
1166 if !focus_moved_away {
1167 if let Some(handle) = self
1168 .previous_focus_handle
1169 .as_ref()
1170 .or(self.action_context.as_ref())
1171 {
1172 window.focus(handle, cx);
1173 }
1174 }
1175
1176 let Some(parent_menu) = self.parent_menu.clone() else {
1177 return;
1178 };
1179
1180 _ = parent_menu.update(cx, |view, cx| {
1182 view.dismiss(&Cancel, window, cx);
1183 });
1184 }
1185
1186 fn handle_dismiss(
1187 &mut self,
1188 position: &Point<Pixels>,
1189 window: &mut Window,
1190 cx: &mut Context<Self>,
1191 ) {
1192 if let Some(parent) = self.parent_menu.as_ref() {
1194 if let Some(parent) = parent.upgrade() {
1195 if parent.read(cx).bounds.contains(position) {
1196 return;
1197 }
1198 }
1199 }
1200
1201 if self.active_submenu().is_some() {
1206 return;
1207 }
1208
1209 self.dismiss(&Cancel, window, cx);
1210 }
1211
1212 fn on_mouse_down_out(
1213 &mut self,
1214 e: &MouseDownEvent,
1215 window: &mut Window,
1216 cx: &mut Context<Self>,
1217 ) {
1218 self.handle_dismiss(&e.position, window, cx);
1219 }
1220
1221 fn render_key_binding(
1222 &self,
1223 action: Option<Box<dyn Action>>,
1224 window: &mut Window,
1225 cx: &mut Context<Self>,
1226 ) -> Option<Kbd> {
1227 let action = action?;
1228
1229 let action_context = self.resolved_action_context(cx);
1238 [
1239 action_context.as_ref(),
1240 self.trigger_focus_handle.as_ref(),
1241 self.previous_focus_handle.as_ref(),
1242 Some(&self.focus_handle),
1243 ]
1244 .into_iter()
1245 .flatten()
1246 .find_map(|handle| Kbd::binding_for_action_in(action.as_ref(), handle, window))
1247 .or_else(|| Kbd::global_binding_for_action(action.as_ref(), window))
1248 .map(|this| {
1249 this.p_0()
1250 .flex_nowrap()
1251 .border_0()
1252 .bg(gpui::transparent_white())
1253 })
1254 }
1255
1256 fn render_icon(
1257 has_icon: bool,
1258 checked: bool,
1259 icon: Option<Icon>,
1260 _: &mut Window,
1261 _: &mut Context<Self>,
1262 ) -> Option<impl IntoElement> {
1263 if !has_icon {
1264 return None;
1265 }
1266
1267 let icon = if let Some(icon) = icon {
1268 icon.clone()
1269 } else if checked {
1270 Icon::new(IconName::Check)
1271 } else {
1272 Icon::empty()
1273 };
1274
1275 Some(icon.xsmall())
1276 }
1277
1278 fn update_submenu_menu_anchor(&mut self, window: &Window, cx: &App) {
1280 let bounds = self.bounds;
1281 let submenu_width = self
1285 .selected_index
1286 .and_then(|ix| match self.menu_items.get(ix) {
1287 Some(PopupMenuItem::Submenu { menu, .. }) => Some(menu.read(cx).bounds.size.width),
1288 _ => None,
1289 })
1290 .filter(|width| *width > Pixels::ZERO)
1291 .unwrap_or(bounds.size.width);
1292 let rem_size = window.rem_size();
1293 let (anchor, left) = if bounds.right() + submenu_width > window.viewport_size().width {
1294 (Anchor::TopRight, -rems(1.).to_pixels(rem_size))
1295 } else {
1296 (
1297 Anchor::TopLeft,
1298 bounds.size.width - rems(0.5).to_pixels(rem_size),
1299 )
1300 };
1301
1302 let is_bottom_pos = bounds.origin.y + bounds.size.height > window.bounds().size.height;
1303 self.submenu_anchor = if is_bottom_pos {
1304 (anchor.other_side_along(gpui::Axis::Vertical), left)
1305 } else {
1306 (anchor, left)
1307 };
1308 }
1309
1310 fn render_item(
1311 &self,
1312 ix: usize,
1313 item: &PopupMenuItem,
1314 options: RenderOptions,
1315 window: &mut Window,
1316 cx: &mut Context<Self>,
1317 ) -> MenuItemElement {
1318 let has_left_icon = options.has_left_icon;
1319 let is_left_check = options.check_side.is_left() && item.is_checked();
1320 let right_check_icon = if options.check_side.is_right() && item.is_checked() {
1321 Some(Icon::new(IconName::Check).xsmall())
1322 } else {
1323 None
1324 };
1325
1326 let selected = self.selected_index == Some(ix);
1327 const EDGE_PADDING: Pixels = px(4.);
1328 const INNER_PADDING: Rems = rems(0.5);
1329
1330 let is_submenu = matches!(item, PopupMenuItem::Submenu { .. });
1331
1332 let (item_height, radius) = match self.size {
1333 Size::Small => (rems(1.25), options.radius.half()),
1334 _ => (rems(1.625), options.radius),
1335 };
1336
1337 let this = MenuItemElement::new(ix)
1338 .relative()
1339 .text_sm()
1340 .py_0()
1341 .px(INNER_PADDING)
1342 .rounded(radius)
1343 .items_center()
1344 .selected(selected)
1345 .on_hover(cx.listener(move |this, hovered, window, cx| {
1346 if *hovered {
1347 if this.selected_index != Some(ix)
1350 && this.active_submenu().is_some_and(|submenu| {
1351 submenu.focus_handle(cx).contains_focused(window, cx)
1352 })
1353 {
1354 this.focus_handle.focus(window, cx);
1355 }
1356 this.selected_index = Some(ix);
1357 } else if !is_submenu
1358 && this.selected_index == Some(ix)
1359 && !window.last_input_was_keyboard()
1362 {
1363 this.selected_index = None;
1365 }
1366
1367 cx.notify();
1368 }))
1369 .when_some(item.a11y_label(), |this, label| this.aria_label(label))
1370 .checked(
1371 (item.is_checked() || self.checkable.contains(&ix)).then(|| item.is_checked()),
1372 );
1373
1374 match item {
1375 PopupMenuItem::Separator => this
1376 .h_auto()
1377 .p_0()
1378 .my_0p5()
1379 .mx_neg_1()
1380 .border_b(px(2.))
1381 .border_color(cx.theme().border)
1382 .disabled(true),
1383 PopupMenuItem::Label(label) => this.disabled(true).cursor_default().child(
1384 h_flex()
1385 .cursor_default()
1386 .items_center()
1387 .gap_x_1()
1388 .children(Self::render_icon(has_left_icon, false, None, window, cx))
1389 .child(div().flex_1().child(label.clone())),
1390 ),
1391 PopupMenuItem::ElementItem {
1392 render,
1393 icon,
1394 disabled,
1395 ..
1396 } => this
1397 .when(!disabled, |this| {
1398 this.on_click(cx.listener(move |this, event, window, cx| {
1399 this.on_click(ix, event, window, cx)
1400 }))
1401 })
1402 .disabled(*disabled)
1403 .child(
1404 h_flex()
1405 .flex_1()
1406 .min_h(item_height)
1407 .items_center()
1408 .gap_x_1()
1409 .children(Self::render_icon(
1410 has_left_icon,
1411 is_left_check,
1412 icon.clone(),
1413 window,
1414 cx,
1415 ))
1416 .child((render)(window, cx))
1417 .children(right_check_icon.map(|icon| icon.ml_3())),
1418 ),
1419 PopupMenuItem::Item {
1420 icon,
1421 label,
1422 action,
1423 disabled,
1424 is_link,
1425 ..
1426 } => {
1427 let show_link_icon = *is_link && self.external_link_icon;
1428 let action = action.as_ref().map(|action| action.boxed_clone());
1429 let key = self.render_key_binding(action, window, cx);
1430
1431 this.when(!disabled, |this| {
1432 this.on_click(cx.listener(move |this, event, window, cx| {
1433 this.on_click(ix, event, window, cx)
1434 }))
1435 })
1436 .disabled(*disabled)
1437 .h(item_height)
1438 .gap_x_1()
1439 .children(Self::render_icon(
1440 has_left_icon,
1441 is_left_check,
1442 icon.clone(),
1443 window,
1444 cx,
1445 ))
1446 .child(
1447 h_flex()
1448 .w_full()
1449 .gap_3()
1450 .items_center()
1451 .justify_between()
1452 .when(!show_link_icon, |this| this.child(label.clone()))
1453 .children(right_check_icon)
1454 .when(show_link_icon, |this| {
1455 this.child(
1456 h_flex()
1457 .w_full()
1458 .justify_between()
1459 .gap_1p5()
1460 .child(label.clone())
1461 .child(
1462 Icon::new(IconName::ExternalLink)
1463 .xsmall()
1464 .text_color(cx.theme().muted_foreground),
1465 ),
1466 )
1467 })
1468 .children(key),
1469 )
1470 }
1471 PopupMenuItem::Submenu {
1472 icon,
1473 label,
1474 menu,
1475 disabled,
1476 } => this
1477 .when(!disabled, |this| {
1478 this.on_click(cx.listener(move |this, event, window, cx| {
1479 this.on_click(ix, event, window, cx)
1480 }))
1481 })
1482 .selected(selected)
1483 .disabled(*disabled)
1484 .items_start()
1485 .child(
1486 h_flex()
1487 .min_h(item_height)
1488 .size_full()
1489 .items_center()
1490 .gap_x_1()
1491 .children(Self::render_icon(
1492 has_left_icon,
1493 false,
1494 icon.clone(),
1495 window,
1496 cx,
1497 ))
1498 .child(
1499 h_flex()
1500 .flex_1()
1501 .gap_2()
1502 .items_center()
1503 .justify_between()
1504 .child(label.clone())
1505 .child(
1506 Icon::new(IconName::ChevronRight)
1507 .xsmall()
1508 .text_color(cx.theme().muted_foreground),
1509 ),
1510 ),
1511 )
1512 .when(selected, |this| {
1513 this.child({
1514 let (anchor, left) = self.submenu_anchor;
1515 let is_bottom_pos =
1516 matches!(anchor, Anchor::BottomLeft | Anchor::BottomRight);
1517 deferred(
1518 anchored()
1519 .anchor(anchor)
1520 .child(
1521 div()
1522 .id("submenu")
1523 .test_support()
1524 .occlude()
1525 .when(is_bottom_pos, |this| this.bottom_0())
1526 .when(!is_bottom_pos, |this| this.top_neg_1())
1527 .left(left)
1528 .child(menu.clone()),
1529 )
1530 .snap_to_window_with_margin(Edges::all(EDGE_PADDING)),
1531 )
1532 .with_priority(self.priority + 1)
1533 })
1534 }),
1535 }
1536 }
1537}
1538
1539impl FluentBuilder for PopupMenu {}
1540impl EventEmitter<DismissEvent> for PopupMenu {}
1541impl Focusable for PopupMenu {
1542 fn focus_handle(&self, _: &App) -> FocusHandle {
1543 self.focus_handle.clone()
1544 }
1545}
1546
1547#[derive(Clone, Copy)]
1548struct RenderOptions {
1549 has_left_icon: bool,
1550 check_side: Side,
1551 radius: Pixels,
1552}
1553
1554impl Render for PopupMenu {
1555 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1556 self.update_submenu_menu_anchor(window, cx);
1557
1558 let parent = cx.entity().downgrade();
1564 let parent_priority = self.priority;
1565 for item in &self.menu_items {
1566 if let PopupMenuItem::Submenu { menu, .. } = item {
1567 if menu.read(cx).parent_menu.is_none() {
1568 menu.update(cx, |menu, _| {
1569 menu.parent_menu = Some(parent.clone());
1570 menu.priority = parent_priority + 1;
1571 });
1572 }
1573 }
1574 }
1575
1576 self.scroll_to_checked_item(cx);
1577
1578 let view = cx.entity().clone();
1579 let items_count = self.menu_items.len();
1580
1581 let max_height = self.max_height.unwrap_or_else(|| {
1582 let window_half_height = window.window_bounds().get_bounds().size.height * 0.5;
1583 window_half_height.min(rems(28.125).to_pixels(window.rem_size()))
1584 });
1585
1586 let has_left_icon = self
1587 .menu_items
1588 .iter()
1589 .any(|item| item.has_left_icon(self.check_side));
1590
1591 let options = RenderOptions {
1592 has_left_icon,
1593 check_side: self.check_side,
1594 radius: cx.theme().radius.min(px(8.)),
1595 };
1596
1597 v_flex()
1598 .id("popup-menu")
1599 .test_support()
1600 .role(Role::Menu)
1601 .key_context(CONTEXT)
1602 .track_focus(&self.focus_handle)
1603 .on_action(cx.listener(Self::select_up))
1604 .on_action(cx.listener(Self::select_down))
1605 .on_action(cx.listener(Self::select_left))
1606 .on_action(cx.listener(Self::select_right))
1607 .on_action(cx.listener(Self::confirm))
1608 .on_action(cx.listener(Self::dismiss))
1609 .on_mouse_down_out(cx.listener(Self::on_mouse_down_out))
1610 .popover_style(cx)
1611 .text_color(cx.theme().popover_foreground)
1612 .relative()
1613 .occlude()
1614 .child(
1615 v_flex()
1616 .id("items")
1617 .p_1()
1618 .gap_y_0p5()
1619 .min_w(rems(8.))
1620 .when_some(self.min_width, |this, min_width| this.min_w(min_width))
1621 .map(|this| match self.max_width {
1622 Some(max_width) => this.max_w(max_width),
1623 None => this.max_w(rems(31.25)),
1624 })
1625 .when(self.scrollable, |this| {
1626 this.max_h(max_height)
1627 .overflow_y_scroll()
1628 .track_scroll(&self.scroll_handle)
1629 })
1630 .children(
1631 self.menu_items
1632 .iter()
1633 .enumerate()
1634 .filter(|(ix, item)| !(*ix + 1 == items_count && item.is_separator()))
1636 .map(|(ix, item)| self.render_item(ix, item, options, window, cx)),
1637 )
1638 .on_prepaint(move |bounds, _, cx| {
1639 view.update(cx, |menu, cx| {
1640 let width_changed = menu.bounds.size.width != bounds.size.width;
1641 menu.bounds = bounds;
1642 if width_changed {
1643 if let Some(parent) = menu
1644 .parent_menu
1645 .as_ref()
1646 .and_then(|parent| parent.upgrade())
1647 {
1648 parent.update(cx, |_, cx| cx.notify());
1649 }
1650 }
1651 });
1652 }),
1653 )
1654 .when(self.scrollable, |this| {
1655 this.vertical_scrollbar(&self.scroll_handle)
1656 })
1657 }
1658}
1659
1660#[cfg(test)]
1661mod tests {
1662 use super::*;
1663
1664 #[gpui::test]
1665 fn custom_menu_item_preserves_mouse_event(cx: &mut gpui::TestAppContext) {
1666 use gpui::{Modifiers, MouseButton, MouseUpEvent};
1667 use std::cell::RefCell;
1668
1669 cx.update(crate::init);
1670 let events = Rc::new(RefCell::new(Vec::new()));
1671 let (_, cx) = cx.add_window_view({
1672 let events = events.clone();
1673 move |_, cx| {
1674 PopupMenu::new(cx).item(
1675 PopupMenuItem::element(|_, _| {
1676 div()
1677 .debug_selector(|| "custom-menu-content".into())
1678 .w(px(100.))
1679 .h(px(20.))
1680 .child("Custom")
1681 })
1682 .on_click(move |event, _, _| events.borrow_mut().push(event.clone())),
1683 )
1684 }
1685 });
1686 cx.update(|window, cx| window.draw(cx).clear(cx));
1687 let position = cx.debug_bounds("custom-menu-content").unwrap().center();
1688 let modifiers = Modifiers {
1689 shift: true,
1690 ..Modifiers::default()
1691 };
1692 cx.simulate_mouse_down(position, MouseButton::Left, modifiers);
1693 cx.simulate_event(MouseUpEvent {
1694 position,
1695 button: MouseButton::Left,
1696 modifiers,
1697 click_count: 2,
1698 });
1699 cx.run_until_parked();
1700 let events = events.borrow();
1701 assert_eq!(events.len(), 1);
1702 assert_eq!(events[0].mouse_position(), Some(position));
1703 assert_eq!(events[0].modifiers(), modifiers);
1704 let ClickEvent::Mouse(event) = &events[0] else {
1705 panic!("expected the original mouse click");
1706 };
1707 assert_eq!(event.up.click_count, 2);
1708 }
1709
1710 #[gpui::test]
1711 fn popup_menu_item_a11y_label_uses_visible_label(cx: &mut gpui::TestAppContext) {
1712 let submenu = cx.update(|cx| cx.new(|cx| PopupMenu::new(cx)));
1713
1714 assert_eq!(PopupMenuItem::new("Open").a11y_label(), Some("Open".into()));
1715 assert_eq!(
1716 PopupMenuItem::link("Docs", "https://example.com").a11y_label(),
1717 Some("Docs".into())
1718 );
1719 assert_eq!(
1720 PopupMenuItem::label("Recent files").a11y_label(),
1721 Some("Recent files".into())
1722 );
1723 assert_eq!(
1724 PopupMenuItem::submenu("More", submenu).a11y_label(),
1725 Some("More".into())
1726 );
1727 assert_eq!(PopupMenuItem::separator().a11y_label(), None);
1728 assert_eq!(PopupMenuItem::element(|_, _| div()).a11y_label(), None);
1729 }
1730}