1use crate::actions::{Cancel, Confirm, SelectDown, SelectUp};
2use crate::actions::{SelectLeft, SelectRight};
3use crate::menu::menu_item::MenuItemElement;
4use crate::scroll::{Scrollbar, ScrollbarState};
5use crate::{h_flex, v_flex, ActiveTheme, Icon, IconName, Sizable as _};
6use crate::{kbd::Kbd, Side, Size, StyledExt};
7use gpui::{
8 anchored, canvas, div, prelude::FluentBuilder, px, rems, Action, AnyElement, App, AppContext,
9 Bounds, Context, Corner, DismissEvent, Edges, Entity, EventEmitter, FocusHandle, Focusable,
10 InteractiveElement, IntoElement, KeyBinding, ParentElement, Pixels, Render, ScrollHandle,
11 SharedString, StatefulInteractiveElement, Styled, WeakEntity, Window,
12};
13use gpui::{ClickEvent, Half, MouseDownEvent, OwnedMenuItem, Subscription};
14use std::rc::Rc;
15
16const CONTEXT: &str = "PopupMenu";
17
18pub fn init(cx: &mut App) {
19 cx.bind_keys([
20 KeyBinding::new("enter", Confirm { secondary: false }, Some(CONTEXT)),
21 KeyBinding::new("escape", Cancel, Some(CONTEXT)),
22 KeyBinding::new("up", SelectUp, Some(CONTEXT)),
23 KeyBinding::new("down", SelectDown, Some(CONTEXT)),
24 KeyBinding::new("left", SelectLeft, Some(CONTEXT)),
25 KeyBinding::new("right", SelectRight, Some(CONTEXT)),
26 ]);
27}
28
29pub enum PopupMenuItem {
31 Separator,
33 Label(SharedString),
35 Item {
37 icon: Option<Icon>,
38 label: SharedString,
39 disabled: bool,
40 is_link: bool,
41 action: Option<Box<dyn Action>>,
42 handler: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
44 },
45 ElementItem {
47 icon: Option<Icon>,
48 disabled: bool,
49 action: Option<Box<dyn Action>>,
50 render: Box<dyn Fn(&mut Window, &mut App) -> AnyElement + 'static>,
51 handler: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
52 },
53 Submenu {
57 icon: Option<Icon>,
58 label: SharedString,
59 disabled: bool,
60 menu: Entity<PopupMenu>,
61 },
62}
63
64impl FluentBuilder for PopupMenuItem {}
65impl PopupMenuItem {
66 #[inline]
68 pub fn new(label: impl Into<SharedString>) -> Self {
69 PopupMenuItem::Item {
70 icon: None,
71 label: label.into(),
72 disabled: false,
73 action: None,
74 is_link: false,
75 handler: None,
76 }
77 }
78
79 #[inline]
81 pub fn element<F, E>(builder: F) -> Self
82 where
83 F: Fn(&mut Window, &mut App) -> E + 'static,
84 E: IntoElement,
85 {
86 PopupMenuItem::ElementItem {
87 icon: None,
88 disabled: false,
89 action: None,
90 render: Box::new(move |window, cx| builder(window, cx).into_any_element()),
91 handler: None,
92 }
93 }
94
95 #[inline]
97 pub fn submenu(label: impl Into<SharedString>, menu: Entity<PopupMenu>) -> Self {
98 PopupMenuItem::Submenu {
99 icon: None,
100 label: label.into(),
101 disabled: false,
102 menu,
103 }
104 }
105
106 #[inline]
108 pub fn separator() -> Self {
109 PopupMenuItem::Separator
110 }
111
112 #[inline]
114 pub fn label(label: impl Into<SharedString>) -> Self {
115 PopupMenuItem::Label(label.into())
116 }
117
118 pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
122 match &mut self {
123 PopupMenuItem::Item { icon: i, .. } => {
124 *i = Some(icon.into());
125 }
126 PopupMenuItem::ElementItem { icon: i, .. } => {
127 *i = Some(icon.into());
128 }
129 PopupMenuItem::Submenu { icon: i, .. } => {
130 *i = Some(icon.into());
131 }
132 _ => {}
133 }
134 self
135 }
136
137 pub fn action(mut self, action: Box<dyn Action>) -> Self {
141 match &mut self {
142 PopupMenuItem::Item { action: a, .. } => {
143 *a = Some(action);
144 }
145 PopupMenuItem::ElementItem { action: a, .. } => {
146 *a = Some(action);
147 }
148 _ => {}
149 }
150 self
151 }
152
153 pub fn disabled(mut self, disabled: bool) -> Self {
157 match &mut self {
158 PopupMenuItem::Item { disabled: d, .. } => {
159 *d = disabled;
160 }
161 PopupMenuItem::ElementItem { disabled: d, .. } => {
162 *d = disabled;
163 }
164 PopupMenuItem::Submenu { disabled: d, .. } => {
165 *d = disabled;
166 }
167 _ => {}
168 }
169 self
170 }
171
172 pub fn checked(mut self, checked: bool) -> Self {
176 match &mut self {
177 PopupMenuItem::Item { icon: i, .. } => {
178 if checked {
179 *i = Some(IconName::Check.into());
180 } else {
181 *i = None;
182 }
183 }
184 PopupMenuItem::ElementItem { icon: i, .. } => {
185 if checked {
186 *i = Some(IconName::Check.into());
187 } else {
188 *i = None;
189 }
190 }
191 _ => {}
192 }
193 self
194 }
195
196 pub fn on_click<F>(mut self, handler: F) -> Self
200 where
201 F: Fn(&ClickEvent, &mut Window, &mut App) + 'static,
202 {
203 match &mut self {
204 PopupMenuItem::Item { handler: h, .. } => {
205 *h = Some(Rc::new(handler));
206 }
207 PopupMenuItem::ElementItem { handler: h, .. } => {
208 *h = Some(Rc::new(handler));
209 }
210 _ => {}
211 }
212 self
213 }
214
215 #[inline]
217 pub fn link(label: impl Into<SharedString>, href: impl Into<String>) -> Self {
218 let href = href.into();
219 PopupMenuItem::Item {
220 icon: None,
221 label: label.into(),
222 disabled: false,
223 action: None,
224 is_link: true,
225 handler: Some(Rc::new(move |_, _, cx| cx.open_url(&href))),
226 }
227 }
228
229 #[inline]
230 fn is_clickable(&self) -> bool {
231 !matches!(self, PopupMenuItem::Separator)
232 && matches!(
233 self,
234 PopupMenuItem::Item {
235 disabled: false,
236 ..
237 } | PopupMenuItem::ElementItem {
238 disabled: false,
239 ..
240 } | PopupMenuItem::Submenu {
241 disabled: false,
242 ..
243 }
244 )
245 }
246
247 #[inline]
248 fn is_separator(&self) -> bool {
249 matches!(self, PopupMenuItem::Separator)
250 }
251
252 fn has_icon(&self) -> bool {
253 match self {
254 PopupMenuItem::Item { icon, .. } => icon.is_some(),
255 PopupMenuItem::ElementItem { icon, .. } => icon.is_some(),
256 PopupMenuItem::Submenu { icon, .. } => icon.is_some(),
257 _ => false,
258 }
259 }
260}
261
262pub struct PopupMenu {
263 pub(crate) focus_handle: FocusHandle,
264 pub(crate) menu_items: Vec<PopupMenuItem>,
265 pub(crate) action_context: Option<FocusHandle>,
267 has_icon: bool,
268 selected_index: Option<usize>,
269 min_width: Option<Pixels>,
270 max_width: Option<Pixels>,
271 max_height: Option<Pixels>,
272 bounds: Bounds<Pixels>,
273 size: Size,
274
275 parent_menu: Option<WeakEntity<Self>>,
277 scrollable: bool,
278 external_link_icon: bool,
279 scroll_handle: ScrollHandle,
280 scroll_state: ScrollbarState,
281 submenu_anchor: (Corner, Pixels),
283
284 _subscriptions: Vec<Subscription>,
285}
286
287impl PopupMenu {
288 pub(crate) fn new(cx: &mut App) -> Self {
289 Self {
290 focus_handle: cx.focus_handle(),
291 action_context: None,
292 parent_menu: None,
293 menu_items: Vec::new(),
294 selected_index: None,
295 min_width: None,
296 max_width: None,
297 max_height: None,
298 has_icon: false,
299 bounds: Bounds::default(),
300 scrollable: false,
301 scroll_handle: ScrollHandle::default(),
302 scroll_state: ScrollbarState::default(),
303 external_link_icon: true,
304 size: Size::default(),
305 submenu_anchor: (Corner::TopLeft, Pixels::ZERO),
306 _subscriptions: vec![],
307 }
308 }
309
310 pub fn build(
311 window: &mut Window,
312 cx: &mut App,
313 f: impl FnOnce(Self, &mut Window, &mut Context<PopupMenu>) -> Self,
314 ) -> Entity<Self> {
315 cx.new(|cx| f(Self::new(cx), window, cx))
316 }
317
318 pub fn action_context(mut self, handle: FocusHandle) -> Self {
324 self.action_context = Some(handle);
325 self
326 }
327
328 pub fn min_w(mut self, width: impl Into<Pixels>) -> Self {
330 self.min_width = Some(width.into());
331 self
332 }
333
334 pub fn max_w(mut self, width: impl Into<Pixels>) -> Self {
336 self.max_width = Some(width.into());
337 self
338 }
339
340 pub fn max_h(mut self, height: impl Into<Pixels>) -> Self {
342 self.max_height = Some(height.into());
343 self
344 }
345
346 pub fn scrollable(mut self, scrollable: bool) -> Self {
350 self.scrollable = scrollable;
351 self
352 }
353
354 pub fn external_link_icon(mut self, visible: bool) -> Self {
356 self.external_link_icon = visible;
357 self
358 }
359
360 pub fn menu(self, label: impl Into<SharedString>, action: Box<dyn Action>) -> Self {
362 self.menu_with_disabled(label, action, false)
363 }
364
365 pub fn menu_with_enable(
367 mut self,
368 label: impl Into<SharedString>,
369 action: Box<dyn Action>,
370 enable: bool,
371 ) -> Self {
372 self.add_menu_item(label, None, action, !enable);
373 self
374 }
375
376 pub fn menu_with_disabled(
378 mut self,
379 label: impl Into<SharedString>,
380 action: Box<dyn Action>,
381 disabled: bool,
382 ) -> Self {
383 self.add_menu_item(label, None, action, disabled);
384 self
385 }
386
387 pub fn label(mut self, label: impl Into<SharedString>) -> Self {
389 self.menu_items.push(PopupMenuItem::label(label.into()));
390 self
391 }
392
393 pub fn link(self, label: impl Into<SharedString>, href: impl Into<String>) -> Self {
395 self.link_with_disabled(label, href, false)
396 }
397
398 pub fn link_with_disabled(
400 mut self,
401 label: impl Into<SharedString>,
402 href: impl Into<String>,
403 disabled: bool,
404 ) -> Self {
405 let href = href.into();
406 self.menu_items
407 .push(PopupMenuItem::link(label, href).disabled(disabled));
408 self
409 }
410
411 pub fn link_with_icon(
413 self,
414 label: impl Into<SharedString>,
415 icon: impl Into<Icon>,
416 href: impl Into<String>,
417 ) -> Self {
418 self.link_with_icon_and_disabled(label, icon, href, false)
419 }
420
421 fn link_with_icon_and_disabled(
423 mut self,
424 label: impl Into<SharedString>,
425 icon: impl Into<Icon>,
426 href: impl Into<String>,
427 disabled: bool,
428 ) -> Self {
429 let href = href.into();
430 self.menu_items.push(
431 PopupMenuItem::link(label, href)
432 .icon(icon)
433 .disabled(disabled),
434 );
435 self
436 }
437
438 pub fn menu_with_icon(
440 self,
441 label: impl Into<SharedString>,
442 icon: impl Into<Icon>,
443 action: Box<dyn Action>,
444 ) -> Self {
445 self.menu_with_icon_and_disabled(label, icon, action, false)
446 }
447
448 pub fn menu_with_icon_and_disabled(
450 mut self,
451 label: impl Into<SharedString>,
452 icon: impl Into<Icon>,
453 action: Box<dyn Action>,
454 disabled: bool,
455 ) -> Self {
456 self.add_menu_item(label, Some(icon.into()), action, disabled);
457 self
458 }
459
460 pub fn menu_with_check(
462 self,
463 label: impl Into<SharedString>,
464 checked: bool,
465 action: Box<dyn Action>,
466 ) -> Self {
467 self.menu_with_check_and_disabled(label, checked, action, false)
468 }
469
470 pub fn menu_with_check_and_disabled(
472 mut self,
473 label: impl Into<SharedString>,
474 checked: bool,
475 action: Box<dyn Action>,
476 disabled: bool,
477 ) -> Self {
478 if checked {
479 self.add_menu_item(label, Some(IconName::Check.into()), action, disabled);
480 } else {
481 self.add_menu_item(label, None, action, disabled);
482 }
483
484 self
485 }
486
487 pub fn menu_element<F, E>(self, action: Box<dyn Action>, builder: F) -> Self
489 where
490 F: Fn(&mut Window, &mut App) -> E + 'static,
491 E: IntoElement,
492 {
493 self.menu_element_with_check(false, action, builder)
494 }
495
496 pub fn menu_element_with_disabled<F, E>(
498 self,
499 action: Box<dyn Action>,
500 disabled: bool,
501 builder: F,
502 ) -> Self
503 where
504 F: Fn(&mut Window, &mut App) -> E + 'static,
505 E: IntoElement,
506 {
507 self.menu_element_with_check_and_disabled(false, action, disabled, builder)
508 }
509
510 pub fn menu_element_with_icon<F, E>(
512 self,
513 icon: impl Into<Icon>,
514 action: Box<dyn Action>,
515 builder: F,
516 ) -> Self
517 where
518 F: Fn(&mut Window, &mut App) -> E + 'static,
519 E: IntoElement,
520 {
521 self.menu_element_with_icon_and_disabled(icon, action, false, builder)
522 }
523
524 pub fn menu_element_with_check<F, E>(
526 self,
527 checked: bool,
528 action: Box<dyn Action>,
529 builder: F,
530 ) -> Self
531 where
532 F: Fn(&mut Window, &mut App) -> E + 'static,
533 E: IntoElement,
534 {
535 self.menu_element_with_check_and_disabled(checked, action, false, builder)
536 }
537
538 fn menu_element_with_icon_and_disabled<F, E>(
540 mut self,
541 icon: impl Into<Icon>,
542 action: Box<dyn Action>,
543 disabled: bool,
544 builder: F,
545 ) -> Self
546 where
547 F: Fn(&mut Window, &mut App) -> E + 'static,
548 E: IntoElement,
549 {
550 self.menu_items.push(
551 PopupMenuItem::element(builder)
552 .action(action)
553 .icon(icon)
554 .disabled(disabled),
555 );
556 self.has_icon = true;
557 self
558 }
559
560 fn menu_element_with_check_and_disabled<F, E>(
562 mut self,
563 checked: bool,
564 action: Box<dyn Action>,
565 disabled: bool,
566 builder: F,
567 ) -> Self
568 where
569 F: Fn(&mut Window, &mut App) -> E + 'static,
570 E: IntoElement,
571 {
572 self.menu_items.push(
573 PopupMenuItem::element(builder)
574 .action(action)
575 .when(checked, |item| item.icon(IconName::Check))
576 .disabled(disabled),
577 );
578 self.has_icon = self.has_icon || checked;
579 self
580 }
581
582 pub fn separator(mut self) -> Self {
584 if self.menu_items.is_empty() {
585 return self;
586 }
587
588 if let Some(PopupMenuItem::Separator) = self.menu_items.last() {
589 return self;
590 }
591
592 self.menu_items.push(PopupMenuItem::separator());
593 self
594 }
595
596 pub fn submenu(
598 self,
599 label: impl Into<SharedString>,
600 window: &mut Window,
601 cx: &mut Context<Self>,
602 f: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
603 ) -> Self {
604 self.submenu_with_icon(None, label, window, cx, f)
605 }
606
607 pub fn submenu_with_icon(
609 mut self,
610 icon: Option<Icon>,
611 label: impl Into<SharedString>,
612 window: &mut Window,
613 cx: &mut Context<Self>,
614 f: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
615 ) -> Self {
616 let submenu = PopupMenu::build(window, cx, f);
617 let parent_menu = cx.entity().downgrade();
618 submenu.update(cx, |view, _| {
619 view.parent_menu = Some(parent_menu);
620 });
621
622 self.menu_items.push(
623 PopupMenuItem::submenu(label, submenu).when_some(icon, |this, icon| this.icon(icon)),
624 );
625 self
626 }
627
628 pub fn item(mut self, item: impl Into<PopupMenuItem>) -> Self {
630 let item: PopupMenuItem = item.into();
631 if item.has_icon() {
632 self.has_icon = true;
633 }
634 self.menu_items.push(item);
635 self
636 }
637
638 pub(crate) fn small(mut self) -> Self {
640 self.size = Size::Small;
641 self
642 }
643
644 fn add_menu_item(
645 &mut self,
646 label: impl Into<SharedString>,
647 icon: Option<Icon>,
648 action: Box<dyn Action>,
649 disabled: bool,
650 ) -> &mut Self {
651 if icon.is_some() {
652 self.has_icon = true;
653 }
654
655 self.menu_items.push(
656 PopupMenuItem::new(label)
657 .when_some(icon, |item, icon| item.icon(icon))
658 .disabled(disabled)
659 .action(action),
660 );
661 self
662 }
663
664 pub(super) fn with_menu_items<I>(
665 mut self,
666 items: impl IntoIterator<Item = I>,
667 window: &mut Window,
668 cx: &mut Context<Self>,
669 ) -> Self
670 where
671 I: Into<OwnedMenuItem>,
672 {
673 for item in items {
674 match item.into() {
675 OwnedMenuItem::Action { name, action, .. } => {
676 self = self.menu(name, action.boxed_clone())
677 }
678 OwnedMenuItem::Separator => {
679 self = self.separator();
680 }
681 OwnedMenuItem::Submenu(submenu) => {
682 self = self.submenu(submenu.name, window, cx, move |menu, window, cx| {
683 menu.with_menu_items(submenu.items.clone(), window, cx)
684 })
685 }
686 OwnedMenuItem::SystemMenu(_) => {}
687 }
688 }
689
690 if self.menu_items.len() > 20 {
691 self.scrollable = true;
692 }
693
694 self
695 }
696
697 pub(crate) fn active_submenu(&self) -> Option<Entity<PopupMenu>> {
698 if let Some(ix) = self.selected_index {
699 if let Some(item) = self.menu_items.get(ix) {
700 return match item {
701 PopupMenuItem::Submenu { menu, .. } => Some(menu.clone()),
702 _ => None,
703 };
704 }
705 }
706
707 None
708 }
709
710 pub fn is_empty(&self) -> bool {
711 self.menu_items.is_empty()
712 }
713
714 fn clickable_menu_items(&self) -> impl Iterator<Item = (usize, &PopupMenuItem)> {
715 self.menu_items
716 .iter()
717 .enumerate()
718 .filter(|(_, item)| item.is_clickable())
719 }
720
721 fn on_click(&mut self, ix: usize, window: &mut Window, cx: &mut Context<Self>) {
722 cx.stop_propagation();
723 window.prevent_default();
724 self.selected_index = Some(ix);
725 self.confirm(&Confirm { secondary: false }, window, cx);
726 }
727
728 fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
729 match self.selected_index {
730 Some(index) => {
731 let item = self.menu_items.get(index);
732 match item {
733 Some(PopupMenuItem::Item {
734 handler, action, ..
735 }) => {
736 if let Some(handler) = handler {
737 handler(&ClickEvent::default(), window, cx);
738 } else if let Some(action) = action.as_ref() {
739 self.dispatch_confirm_action(action, window, cx);
740 }
741
742 self.dismiss(&Cancel, window, cx)
743 }
744 Some(PopupMenuItem::ElementItem {
745 handler, action, ..
746 }) => {
747 if let Some(handler) = handler {
748 handler(&ClickEvent::default(), window, cx);
749 } else if let Some(action) = action.as_ref() {
750 self.dispatch_confirm_action(action, window, cx);
751 }
752 self.dismiss(&Cancel, window, cx)
753 }
754 _ => {}
755 }
756 }
757 _ => {}
758 }
759 }
760
761 fn dispatch_confirm_action(
762 &self,
763 action: &Box<dyn Action>,
764 window: &mut Window,
765 cx: &mut Context<Self>,
766 ) {
767 if let Some(context) = self.action_context.as_ref() {
768 context.focus(window);
769 }
770
771 window.dispatch_action(action.boxed_clone(), cx);
772 }
773
774 fn set_selected_index(&mut self, ix: usize, cx: &mut Context<Self>) {
775 if self.selected_index != Some(ix) {
776 self.selected_index = Some(ix);
777 self.scroll_handle.scroll_to_item(ix);
778 cx.notify();
779 }
780 }
781
782 fn select_up(&mut self, _: &SelectUp, _: &mut Window, cx: &mut Context<Self>) {
783 cx.stop_propagation();
784 let ix = self.selected_index.unwrap_or(0);
785
786 if let Some((prev_ix, _)) = self
787 .menu_items
788 .iter()
789 .enumerate()
790 .rev()
791 .find(|(i, item)| *i < ix && item.is_clickable())
792 {
793 self.set_selected_index(prev_ix, cx);
794 return;
795 }
796
797 let last_clickable_ix = self.clickable_menu_items().last().map(|(ix, _)| ix);
798 self.set_selected_index(last_clickable_ix.unwrap_or(0), cx);
799 }
800
801 fn select_down(&mut self, _: &SelectDown, _: &mut Window, cx: &mut Context<Self>) {
802 cx.stop_propagation();
803 let Some(ix) = self.selected_index else {
804 self.set_selected_index(0, cx);
805 return;
806 };
807
808 if let Some((next_ix, _)) = self
809 .menu_items
810 .iter()
811 .enumerate()
812 .find(|(i, item)| *i > ix && item.is_clickable())
813 {
814 self.set_selected_index(next_ix, cx);
815 return;
816 }
817
818 self.set_selected_index(0, cx);
819 }
820
821 fn select_left(&mut self, _: &SelectLeft, window: &mut Window, cx: &mut Context<Self>) {
822 let handled = if matches!(self.submenu_anchor.0, Corner::TopLeft | Corner::BottomLeft) {
823 self._unselect_submenu(window, cx)
824 } else {
825 self._select_submenu(window, cx)
826 };
827
828 if self.parent_side(cx).is_left() {
829 self._focus_parent_menu(window, cx);
830 }
831
832 if handled {
833 return;
834 }
835
836 if self.parent_menu.is_none() {
838 cx.propagate();
839 }
840 }
841
842 fn select_right(&mut self, _: &SelectRight, window: &mut Window, cx: &mut Context<Self>) {
843 let handled = if matches!(self.submenu_anchor.0, Corner::TopLeft | Corner::BottomLeft) {
844 self._select_submenu(window, cx)
845 } else {
846 self._unselect_submenu(window, cx)
847 };
848
849 if self.parent_side(cx).is_right() {
850 self._focus_parent_menu(window, cx);
851 }
852
853 if handled {
854 return;
855 }
856
857 if self.parent_menu.is_none() {
859 cx.propagate();
860 }
861 }
862
863 fn _select_submenu(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
864 if let Some(active_submenu) = self.active_submenu() {
865 active_submenu.update(cx, |view, cx| {
867 view.set_selected_index(0, cx);
868 view.focus_handle.focus(window);
869 });
870 cx.notify();
871 return true;
872 }
873
874 return false;
875 }
876
877 fn _unselect_submenu(&mut self, _: &mut Window, cx: &mut Context<Self>) -> bool {
878 if let Some(active_submenu) = self.active_submenu() {
879 active_submenu.update(cx, |view, cx| {
880 view.selected_index = None;
881 cx.notify();
882 });
883 return true;
884 }
885
886 return false;
887 }
888
889 fn _focus_parent_menu(&mut self, window: &mut Window, cx: &mut Context<Self>) {
890 let Some(parent) = self.parent_menu.as_ref() else {
891 return;
892 };
893 let Some(parent) = parent.upgrade() else {
894 return;
895 };
896
897 self.selected_index = None;
898 parent.update(cx, |view, cx| {
899 view.focus_handle.focus(window);
900 cx.notify();
901 });
902 }
903
904 fn parent_side(&self, cx: &App) -> Side {
905 let Some(parent) = self.parent_menu.as_ref() else {
906 return Side::Left;
907 };
908
909 let Some(parent) = parent.upgrade() else {
910 return Side::Left;
911 };
912
913 match parent.read(cx).submenu_anchor.0 {
914 Corner::TopLeft | Corner::BottomLeft => Side::Left,
915 Corner::TopRight | Corner::BottomRight => Side::Right,
916 }
917 }
918
919 fn dismiss(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context<Self>) {
920 if self.active_submenu().is_some() {
921 return;
922 }
923
924 cx.emit(DismissEvent);
925
926 if let Some(action_context) = self.action_context.as_ref() {
928 window.focus(action_context);
929 }
930
931 let Some(parent_menu) = self.parent_menu.clone() else {
932 return;
933 };
934
935 _ = parent_menu.update(cx, |view, cx| {
937 view.selected_index = None;
938 view.dismiss(&Cancel, window, cx);
939 });
940 }
941
942 fn render_key_binding(
943 &self,
944 action: Option<Box<dyn Action>>,
945 window: &mut Window,
946 _: &mut Context<Self>,
947 ) -> Option<impl IntoElement> {
948 let action = action?;
949
950 match self
951 .action_context
952 .as_ref()
953 .and_then(|handle| Kbd::binding_for_action_in(action.as_ref(), handle, window))
954 {
955 Some(kbd) => Some(kbd),
956 None => Kbd::binding_for_action(action.as_ref(), None, window),
958 }
959 .map(|this| {
960 this.p_0()
961 .flex_nowrap()
962 .border_0()
963 .bg(gpui::transparent_white())
964 })
965 }
966
967 fn render_icon(
968 has_icon: bool,
969 icon: Option<Icon>,
970 _: &mut Window,
971 _: &mut Context<Self>,
972 ) -> Option<impl IntoElement> {
973 let icon_placeholder = if has_icon { Some(Icon::empty()) } else { None };
974
975 if !has_icon {
976 return None;
977 }
978
979 let icon = h_flex()
980 .w_3p5()
981 .h_3p5()
982 .justify_center()
983 .text_sm()
984 .map(|this| {
985 if let Some(icon) = icon {
986 this.child(icon.clone().xsmall())
987 } else {
988 this.children(icon_placeholder.clone())
989 }
990 });
991
992 Some(icon)
993 }
994
995 #[inline]
996 fn max_width(&self) -> Pixels {
997 self.max_width.unwrap_or(px(500.))
998 }
999
1000 fn update_submenu_menu_anchor(&mut self, window: &Window) {
1002 let bounds = self.bounds;
1003 let max_width = self.max_width();
1004 let (anchor, left) = if max_width + bounds.origin.x > window.bounds().size.width {
1005 (Corner::TopRight, -px(16.))
1006 } else {
1007 (Corner::TopLeft, bounds.size.width - px(8.))
1008 };
1009
1010 let is_bottom_pos = bounds.origin.y + bounds.size.height > window.bounds().size.height;
1011 self.submenu_anchor = if is_bottom_pos {
1012 (anchor.other_side_corner_along(gpui::Axis::Vertical), left)
1013 } else {
1014 (anchor, left)
1015 };
1016 }
1017
1018 fn render_item(
1019 &self,
1020 ix: usize,
1021 item: &PopupMenuItem,
1022 state: ItemState,
1023 window: &mut Window,
1024 cx: &mut Context<Self>,
1025 ) -> impl IntoElement {
1026 let has_icon = self.has_icon;
1027 let selected = self.selected_index == Some(ix);
1028 const EDGE_PADDING: Pixels = px(4.);
1029 const INNER_PADDING: Pixels = px(8.);
1030
1031 let is_submenu = matches!(item, PopupMenuItem::Submenu { .. });
1032 let group_name = format!("popup-menu-item-{}", ix);
1033
1034 let (item_height, radius) = match self.size {
1035 Size::Small => (px(20.), state.radius.half()),
1036 _ => (px(26.), state.radius),
1037 };
1038
1039 let this = MenuItemElement::new(ix, &group_name)
1040 .relative()
1041 .text_sm()
1042 .py_0()
1043 .px(INNER_PADDING)
1044 .rounded(radius)
1045 .items_center()
1046 .selected(selected)
1047 .on_hover(cx.listener(move |this, hovered, _, cx| {
1048 if *hovered {
1049 this.selected_index = Some(ix);
1050 } else if !is_submenu && this.selected_index == Some(ix) {
1051 this.selected_index = None;
1053 }
1054
1055 cx.notify();
1056 }));
1057
1058 match item {
1059 PopupMenuItem::Separator => this
1060 .h_auto()
1061 .p_0()
1062 .my_0p5()
1063 .mx_neg_1()
1064 .h(px(1.))
1065 .bg(cx.theme().border)
1066 .disabled(true),
1067 PopupMenuItem::Label(label) => this.disabled(true).cursor_default().child(
1068 h_flex()
1069 .cursor_default()
1070 .items_center()
1071 .gap_x_1()
1072 .children(Self::render_icon(has_icon, None, window, cx))
1073 .child(label.clone()),
1074 ),
1075 PopupMenuItem::ElementItem {
1076 render,
1077 icon,
1078 disabled,
1079 ..
1080 } => this
1081 .when(!disabled, |this| {
1082 this.on_click(
1083 cx.listener(move |this, _, window, cx| this.on_click(ix, window, cx)),
1084 )
1085 })
1086 .disabled(*disabled)
1087 .child(
1088 h_flex()
1089 .flex_1()
1090 .min_h(item_height)
1091 .items_center()
1092 .gap_x_1()
1093 .children(Self::render_icon(has_icon, icon.clone(), window, cx))
1094 .child((render)(window, cx)),
1095 ),
1096 PopupMenuItem::Item {
1097 icon,
1098 label,
1099 action,
1100 disabled,
1101 is_link,
1102 ..
1103 } => {
1104 let show_link_icon = *is_link && self.external_link_icon;
1105 let action = action.as_ref().map(|action| action.boxed_clone());
1106 let key = self.render_key_binding(action, window, cx);
1107
1108 this.when(!disabled, |this| {
1109 this.on_click(
1110 cx.listener(move |this, _, window, cx| this.on_click(ix, window, cx)),
1111 )
1112 })
1113 .disabled(*disabled)
1114 .h(item_height)
1115 .children(Self::render_icon(has_icon, icon.clone(), window, cx))
1116 .child(
1117 h_flex()
1118 .w_full()
1119 .gap_2()
1120 .items_center()
1121 .justify_between()
1122 .when(!show_link_icon, |this| this.child(label.clone()))
1123 .when(show_link_icon, |this| {
1124 this.child(
1125 h_flex()
1126 .w_full()
1127 .justify_between()
1128 .gap_1p5()
1129 .child(label.clone())
1130 .child(
1131 Icon::new(IconName::ExternalLink)
1132 .xsmall()
1133 .text_color(cx.theme().muted_foreground),
1134 ),
1135 )
1136 })
1137 .children(key),
1138 )
1139 }
1140 PopupMenuItem::Submenu {
1141 icon,
1142 label,
1143 menu,
1144 disabled,
1145 } => this
1146 .selected(selected)
1147 .disabled(*disabled)
1148 .items_start()
1149 .child(
1150 h_flex()
1151 .min_h(item_height)
1152 .size_full()
1153 .items_center()
1154 .gap_x_1()
1155 .children(Self::render_icon(has_icon, icon.clone(), window, cx))
1156 .child(
1157 h_flex()
1158 .flex_1()
1159 .gap_2()
1160 .items_center()
1161 .justify_between()
1162 .child(label.clone())
1163 .child(IconName::ChevronRight),
1164 ),
1165 )
1166 .when(selected, |this| {
1167 this.child({
1168 let (anchor, left) = self.submenu_anchor;
1169 let is_bottom_pos =
1170 matches!(anchor, Corner::BottomLeft | Corner::BottomRight);
1171 anchored()
1172 .anchor(anchor)
1173 .child(
1174 div()
1175 .id("submenu")
1176 .occlude()
1177 .when(is_bottom_pos, |this| this.bottom_0())
1178 .when(!is_bottom_pos, |this| this.top_neg_1())
1179 .left(left)
1180 .child(menu.clone()),
1181 )
1182 .snap_to_window_with_margin(Edges::all(EDGE_PADDING))
1183 })
1184 }),
1185 }
1186 }
1187}
1188
1189impl FluentBuilder for PopupMenu {}
1190impl EventEmitter<DismissEvent> for PopupMenu {}
1191impl Focusable for PopupMenu {
1192 fn focus_handle(&self, _: &App) -> FocusHandle {
1193 self.focus_handle.clone()
1194 }
1195}
1196
1197#[derive(Clone, Copy)]
1198struct ItemState {
1199 radius: Pixels,
1200}
1201
1202impl Render for PopupMenu {
1203 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1204 self.update_submenu_menu_anchor(window);
1205
1206 let view = cx.entity().clone();
1207 let items_count = self.menu_items.len();
1208
1209 let max_height = self.max_height.map_or_else(
1210 || {
1211 let window_half_height = window.window_bounds().get_bounds().size.height * 0.5;
1212 window_half_height.min(px(450.))
1213 },
1214 |height| height,
1215 );
1216
1217 let max_width = self.max_width();
1218 let item_state = ItemState {
1219 radius: cx.theme().radius.min(px(8.)),
1220 };
1221
1222 v_flex()
1223 .id("popup-menu")
1224 .key_context(CONTEXT)
1225 .track_focus(&self.focus_handle)
1226 .on_action(cx.listener(Self::select_up))
1227 .on_action(cx.listener(Self::select_down))
1228 .on_action(cx.listener(Self::select_left))
1229 .on_action(cx.listener(Self::select_right))
1230 .on_action(cx.listener(Self::confirm))
1231 .on_action(cx.listener(Self::dismiss))
1232 .on_mouse_down_out(cx.listener(|this, ev: &MouseDownEvent, window, cx| {
1233 if let Some(parent) = this.parent_menu.as_ref() {
1235 if let Some(parent) = parent.upgrade() {
1236 if parent.read(cx).bounds.contains(&ev.position) {
1237 return;
1238 }
1239 }
1240 }
1241
1242 this.dismiss(&Cancel, window, cx);
1243 }))
1244 .popover_style(cx)
1245 .text_color(cx.theme().popover_foreground)
1246 .relative()
1247 .child(
1248 v_flex()
1249 .id("items")
1250 .p_1()
1251 .gap_y_0p5()
1252 .min_w(rems(8.))
1253 .when_some(self.min_width, |this, min_width| this.min_w(min_width))
1254 .max_w(max_width)
1255 .when(self.scrollable, |this| {
1256 this.max_h(max_height)
1257 .overflow_y_scroll()
1258 .track_scroll(&self.scroll_handle)
1259 })
1260 .children(
1261 self.menu_items
1262 .iter()
1263 .enumerate()
1264 .filter(|(ix, item)| !(*ix + 1 == items_count && item.is_separator()))
1266 .map(|(ix, item)| self.render_item(ix, item, item_state, window, cx)),
1267 )
1268 .child({
1269 canvas(
1270 move |bounds, _, cx| view.update(cx, |r, _| r.bounds = bounds),
1271 |_, _, _, _| {},
1272 )
1273 .absolute()
1274 .size_full()
1275 }),
1276 )
1277 .when(self.scrollable, |this| {
1278 this.child(
1280 div()
1281 .absolute()
1282 .top_0()
1283 .left_0()
1284 .right_0()
1285 .bottom_0()
1286 .child(Scrollbar::vertical(&self.scroll_state, &self.scroll_handle)),
1287 )
1288 })
1289 }
1290}