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