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