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