1use crate::{
2 IconButtonShape, KeyBinding, List, ListItem, ListSeparator, ListSubHeader, Tooltip, prelude::*,
3 utils::WithRemSize,
4};
5use gpui::{
6 Action, AnyElement, App, Bounds, Corner, DismissEvent, Entity, EventEmitter, FocusHandle,
7 Focusable, MouseDownEvent, MouseMoveEvent, Pixels, Point, Size, Subscription, anchored, canvas,
8 prelude::*, px,
9};
10use menu::{SelectChild, SelectFirst, SelectLast, SelectNext, SelectParent, SelectPrevious};
11use settings::Settings;
12use std::{
13 cell::Cell,
14 rc::Rc,
15 time::{Duration, Instant},
16};
17use theme::ThemeSettings;
18
19#[derive(Copy, Clone, Debug, PartialEq, Eq)]
20enum SubmenuOpenTrigger {
21 Pointer,
22 Keyboard,
23}
24
25struct OpenSubmenu {
26 item_index: usize,
27 entity: Entity<ContextMenu>,
28 _dismiss_subscription: Subscription,
29}
30
31enum SubmenuState {
32 Closed,
33 Open(OpenSubmenu),
34}
35
36struct SubmenuHoverSafetyHeuristic {
37 last_mouse_position: Option<Point<Pixels>>,
38 trigger_left_x: Option<Pixels>,
39}
40
41impl SubmenuHoverSafetyHeuristic {
42 fn new() -> Self {
43 Self {
44 last_mouse_position: None,
45 trigger_left_x: None,
46 }
47 }
48
49 fn clear(&mut self) {
50 self.last_mouse_position = None;
51 self.trigger_left_x = None;
52 }
53
54 fn update_mouse_position(&mut self, position: Point<Pixels>) {
55 self.last_mouse_position = Some(position);
56 }
57
58 fn update_trigger_left_x(&mut self, trigger_left_x: Pixels) {
59 self.trigger_left_x = Some(trigger_left_x);
60 }
61
62 fn should_allow_close_from_parent_area(&self, mouse_position: Point<Pixels>) -> bool {
63 self.trigger_left_x
64 .map(|trigger_left_x| mouse_position.x < trigger_left_x)
65 .unwrap_or(true)
66 }
67}
68
69pub enum ContextMenuItem {
70 Separator,
71 Header(SharedString),
72 /// title, link_label, link_url
73 HeaderWithLink(SharedString, SharedString, SharedString), // This could be folded into header
74 Label(SharedString),
75 Entry(ContextMenuEntry),
76 CustomEntry {
77 entry_render: Box<dyn Fn(&mut Window, &mut App) -> AnyElement>,
78 handler: Rc<dyn Fn(Option<&FocusHandle>, &mut Window, &mut App)>,
79 selectable: bool,
80 documentation_aside: Option<DocumentationAside>,
81 },
82 Submenu {
83 label: SharedString,
84 icon: Option<IconName>,
85 builder: Rc<dyn Fn(ContextMenu, &mut Window, &mut Context<ContextMenu>) -> ContextMenu>,
86 },
87}
88
89impl ContextMenuItem {
90 pub fn custom_entry(
91 entry_render: impl Fn(&mut Window, &mut App) -> AnyElement + 'static,
92 handler: impl Fn(&mut Window, &mut App) + 'static,
93 documentation_aside: Option<DocumentationAside>,
94 ) -> Self {
95 Self::CustomEntry {
96 entry_render: Box::new(entry_render),
97 handler: Rc::new(move |_, window, cx| handler(window, cx)),
98 selectable: true,
99 documentation_aside,
100 }
101 }
102}
103
104pub struct ContextMenuEntry {
105 toggle: Option<(IconPosition, bool)>,
106 label: SharedString,
107 icon: Option<IconName>,
108 custom_icon_path: Option<SharedString>,
109 custom_icon_svg: Option<SharedString>,
110 icon_position: IconPosition,
111 icon_size: IconSize,
112 icon_color: Option<Color>,
113 handler: Rc<dyn Fn(Option<&FocusHandle>, &mut Window, &mut App)>,
114 action: Option<Box<dyn Action>>,
115 disabled: bool,
116 documentation_aside: Option<DocumentationAside>,
117 end_slot_icon: Option<IconName>,
118 end_slot_title: Option<SharedString>,
119 end_slot_handler: Option<Rc<dyn Fn(Option<&FocusHandle>, &mut Window, &mut App)>>,
120 show_end_slot_on_hover: bool,
121}
122
123impl ContextMenuEntry {
124 pub fn new(label: impl Into<SharedString>) -> Self {
125 ContextMenuEntry {
126 toggle: None,
127 label: label.into(),
128 icon: None,
129 custom_icon_path: None,
130 custom_icon_svg: None,
131 icon_position: IconPosition::Start,
132 icon_size: IconSize::Small,
133 icon_color: None,
134 handler: Rc::new(|_, _, _| {}),
135 action: None,
136 disabled: false,
137 documentation_aside: None,
138 end_slot_icon: None,
139 end_slot_title: None,
140 end_slot_handler: None,
141 show_end_slot_on_hover: false,
142 }
143 }
144
145 pub fn toggleable(mut self, toggle_position: IconPosition, toggled: bool) -> Self {
146 self.toggle = Some((toggle_position, toggled));
147 self
148 }
149
150 pub fn icon(mut self, icon: IconName) -> Self {
151 self.icon = Some(icon);
152 self
153 }
154
155 pub fn custom_icon_path(mut self, path: impl Into<SharedString>) -> Self {
156 self.custom_icon_path = Some(path.into());
157 self.custom_icon_svg = None; // Clear other icon sources if custom path is set
158 self.icon = None;
159 self
160 }
161
162 pub fn custom_icon_svg(mut self, svg: impl Into<SharedString>) -> Self {
163 self.custom_icon_svg = Some(svg.into());
164 self.custom_icon_path = None; // Clear other icon sources if custom path is set
165 self.icon = None;
166 self
167 }
168
169 pub fn icon_position(mut self, position: IconPosition) -> Self {
170 self.icon_position = position;
171 self
172 }
173
174 pub fn icon_size(mut self, icon_size: IconSize) -> Self {
175 self.icon_size = icon_size;
176 self
177 }
178
179 pub fn icon_color(mut self, icon_color: Color) -> Self {
180 self.icon_color = Some(icon_color);
181 self
182 }
183
184 pub fn toggle(mut self, toggle_position: IconPosition, toggled: bool) -> Self {
185 self.toggle = Some((toggle_position, toggled));
186 self
187 }
188
189 pub fn action(mut self, action: Box<dyn Action>) -> Self {
190 self.action = Some(action);
191 self
192 }
193
194 pub fn handler(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
195 self.handler = Rc::new(move |_, window, cx| handler(window, cx));
196 self
197 }
198
199 pub fn disabled(mut self, disabled: bool) -> Self {
200 self.disabled = disabled;
201 self
202 }
203
204 pub fn documentation_aside(
205 mut self,
206 side: DocumentationSide,
207 edge: DocumentationEdge,
208 render: impl Fn(&mut App) -> AnyElement + 'static,
209 ) -> Self {
210 self.documentation_aside = Some(DocumentationAside {
211 side,
212 edge,
213 render: Rc::new(render),
214 });
215
216 self
217 }
218}
219
220impl FluentBuilder for ContextMenuEntry {}
221
222impl From<ContextMenuEntry> for ContextMenuItem {
223 fn from(entry: ContextMenuEntry) -> Self {
224 ContextMenuItem::Entry(entry)
225 }
226}
227
228pub struct ContextMenu {
229 builder: Option<Rc<dyn Fn(Self, &mut Window, &mut Context<Self>) -> Self>>,
230 items: Vec<ContextMenuItem>,
231 focus_handle: FocusHandle,
232 action_context: Option<FocusHandle>,
233 selected_index: Option<usize>,
234 delayed: bool,
235 clicked: bool,
236 end_slot_action: Option<Box<dyn Action>>,
237 key_context: SharedString,
238 _on_blur_subscription: Subscription,
239 keep_open_on_confirm: bool,
240 documentation_aside: Option<(usize, DocumentationAside)>,
241 fixed_width: Option<DefiniteLength>,
242 // Submenu-related fields
243 submenu_state: SubmenuState,
244 submenu_hover_safety_heuristic: SubmenuHoverSafetyHeuristic,
245 submenu_observed_bounds: Rc<Cell<Option<Bounds<Pixels>>>>,
246 is_submenu: bool,
247 submenu_hovered: bool,
248 submenu_generation: u64,
249 ignore_blur_cancel_until: Option<Instant>,
250 parent_menu: Option<Entity<ContextMenu>>,
251 menu_hovered: bool,
252}
253
254#[derive(Copy, Clone, PartialEq, Eq)]
255pub enum DocumentationSide {
256 Left,
257 Right,
258}
259
260#[derive(Copy, Default, Clone, PartialEq, Eq)]
261pub enum DocumentationEdge {
262 #[default]
263 Top,
264 Bottom,
265}
266
267#[derive(Clone)]
268pub struct DocumentationAside {
269 pub side: DocumentationSide,
270 pub edge: DocumentationEdge,
271 pub render: Rc<dyn Fn(&mut App) -> AnyElement>,
272}
273
274impl DocumentationAside {
275 pub fn new(
276 side: DocumentationSide,
277 edge: DocumentationEdge,
278 render: Rc<dyn Fn(&mut App) -> AnyElement>,
279 ) -> Self {
280 Self { side, edge, render }
281 }
282}
283
284impl Focusable for ContextMenu {
285 fn focus_handle(&self, _cx: &App) -> FocusHandle {
286 self.focus_handle.clone()
287 }
288}
289
290impl EventEmitter<DismissEvent> for ContextMenu {}
291
292impl FluentBuilder for ContextMenu {}
293
294impl ContextMenu {
295 pub fn new(
296 window: &mut Window,
297 cx: &mut Context<Self>,
298 f: impl FnOnce(Self, &mut Window, &mut Context<Self>) -> Self,
299 ) -> Self {
300 let focus_handle = cx.focus_handle();
301 let _on_blur_subscription = cx.on_blur(
302 &focus_handle,
303 window,
304 |this: &mut ContextMenu, window, cx| {
305 if let Some(ignore_until) = this.ignore_blur_cancel_until {
306 if Instant::now() < ignore_until {
307 return;
308 } else {
309 this.ignore_blur_cancel_until = None;
310 }
311 }
312
313 if !this.is_submenu {
314 if let SubmenuState::Open(open_submenu) = &this.submenu_state {
315 let submenu_focus = open_submenu.entity.read(cx).focus_handle.clone();
316 if submenu_focus.contains_focused(window, cx) {
317 return;
318 }
319 }
320 }
321
322 this.cancel(&menu::Cancel, window, cx)
323 },
324 );
325 window.refresh();
326
327 f(
328 Self {
329 builder: None,
330 items: Default::default(),
331 focus_handle,
332 action_context: None,
333 selected_index: None,
334 delayed: false,
335 clicked: false,
336 end_slot_action: None,
337 key_context: "menu".into(),
338 _on_blur_subscription,
339 keep_open_on_confirm: false,
340 documentation_aside: None,
341 fixed_width: None,
342 submenu_state: SubmenuState::Closed,
343 submenu_hover_safety_heuristic: SubmenuHoverSafetyHeuristic::new(),
344 submenu_observed_bounds: Rc::new(Cell::new(None)),
345 is_submenu: false,
346 submenu_hovered: false,
347 submenu_generation: 0,
348 ignore_blur_cancel_until: None,
349 parent_menu: None,
350 menu_hovered: true,
351 },
352 window,
353 cx,
354 )
355 }
356
357 pub fn build(
358 window: &mut Window,
359 cx: &mut App,
360 f: impl FnOnce(Self, &mut Window, &mut Context<Self>) -> Self,
361 ) -> Entity<Self> {
362 cx.new(|cx| Self::new(window, cx, f))
363 }
364
365 /// Builds a [`ContextMenu`] that will stay open when making changes instead of closing after each confirmation.
366 ///
367 /// The main difference from [`ContextMenu::build`] is the type of the `builder`, as we need to be able to hold onto
368 /// it to call it again.
369 pub fn build_persistent(
370 window: &mut Window,
371 cx: &mut App,
372 builder: impl Fn(Self, &mut Window, &mut Context<Self>) -> Self + 'static,
373 ) -> Entity<Self> {
374 cx.new(|cx| {
375 let builder = Rc::new(builder);
376
377 let focus_handle = cx.focus_handle();
378 let _on_blur_subscription = cx.on_blur(
379 &focus_handle,
380 window,
381 |this: &mut ContextMenu, window, cx| {
382 if let Some(ignore_until) = this.ignore_blur_cancel_until {
383 if Instant::now() < ignore_until {
384 return;
385 } else {
386 this.ignore_blur_cancel_until = None;
387 }
388 }
389
390 if !this.is_submenu {
391 if let SubmenuState::Open(open_submenu) = &this.submenu_state {
392 let submenu_focus = open_submenu.entity.read(cx).focus_handle.clone();
393 if submenu_focus.contains_focused(window, cx) {
394 return;
395 }
396 }
397 }
398
399 this.cancel(&menu::Cancel, window, cx)
400 },
401 );
402 window.refresh();
403
404 (builder.clone())(
405 Self {
406 builder: Some(builder),
407 items: Default::default(),
408 focus_handle,
409 action_context: None,
410 selected_index: None,
411 delayed: false,
412 clicked: false,
413 end_slot_action: None,
414 key_context: "menu".into(),
415 _on_blur_subscription,
416 keep_open_on_confirm: true,
417 documentation_aside: None,
418 fixed_width: None,
419 submenu_state: SubmenuState::Closed,
420 submenu_hover_safety_heuristic: SubmenuHoverSafetyHeuristic::new(),
421 submenu_observed_bounds: Rc::new(Cell::new(None)),
422 is_submenu: false,
423 submenu_hovered: false,
424 submenu_generation: 0,
425 ignore_blur_cancel_until: None,
426 parent_menu: None,
427 menu_hovered: true,
428 },
429 window,
430 cx,
431 )
432 })
433 }
434
435 /// Rebuilds the menu.
436 ///
437 /// This is used to refresh the menu entries when entries are toggled when the menu is configured with
438 /// `keep_open_on_confirm = true`.
439 ///
440 /// This only works if the [`ContextMenu`] was constructed using [`ContextMenu::build_persistent`]. Otherwise it is
441 /// a no-op.
442 pub fn rebuild(&mut self, window: &mut Window, cx: &mut Context<Self>) {
443 let Some(builder) = self.builder.clone() else {
444 return;
445 };
446
447 // The way we rebuild the menu is a bit of a hack.
448 let focus_handle = cx.focus_handle();
449 let new_menu = (builder.clone())(
450 Self {
451 builder: Some(builder),
452 items: Default::default(),
453 focus_handle: focus_handle.clone(),
454 action_context: None,
455 selected_index: None,
456 delayed: false,
457 clicked: false,
458 end_slot_action: None,
459 key_context: "menu".into(),
460 _on_blur_subscription: cx.on_blur(
461 &focus_handle,
462 window,
463 |this: &mut ContextMenu, window, cx| {
464 if let Some(ignore_until) = this.ignore_blur_cancel_until {
465 if Instant::now() < ignore_until {
466 return;
467 } else {
468 this.ignore_blur_cancel_until = None;
469 }
470 }
471
472 if !this.is_submenu {
473 if let SubmenuState::Open(open_submenu) = &this.submenu_state {
474 let submenu_focus =
475 open_submenu.entity.read(cx).focus_handle.clone();
476 if submenu_focus.contains_focused(window, cx) {
477 return;
478 }
479 }
480 }
481
482 this.cancel(&menu::Cancel, window, cx)
483 },
484 ),
485 keep_open_on_confirm: false,
486 documentation_aside: None,
487 fixed_width: None,
488 submenu_state: SubmenuState::Closed,
489 submenu_hover_safety_heuristic: SubmenuHoverSafetyHeuristic::new(),
490 submenu_observed_bounds: Rc::new(Cell::new(None)),
491 is_submenu: false,
492 submenu_hovered: false,
493 submenu_generation: 0,
494 ignore_blur_cancel_until: None,
495 parent_menu: None,
496 menu_hovered: true,
497 },
498 window,
499 cx,
500 );
501
502 self.items = new_menu.items;
503
504 cx.notify();
505 }
506
507 pub fn context(mut self, focus: FocusHandle) -> Self {
508 self.action_context = Some(focus);
509 self
510 }
511
512 pub fn header(mut self, title: impl Into<SharedString>) -> Self {
513 self.items.push(ContextMenuItem::Header(title.into()));
514 self
515 }
516
517 pub fn header_with_link(
518 mut self,
519 title: impl Into<SharedString>,
520 link_label: impl Into<SharedString>,
521 link_url: impl Into<SharedString>,
522 ) -> Self {
523 self.items.push(ContextMenuItem::HeaderWithLink(
524 title.into(),
525 link_label.into(),
526 link_url.into(),
527 ));
528 self
529 }
530
531 pub fn separator(mut self) -> Self {
532 self.items.push(ContextMenuItem::Separator);
533 self
534 }
535
536 pub fn extend<I: Into<ContextMenuItem>>(mut self, items: impl IntoIterator<Item = I>) -> Self {
537 self.items.extend(items.into_iter().map(Into::into));
538 self
539 }
540
541 pub fn item(mut self, item: impl Into<ContextMenuItem>) -> Self {
542 self.items.push(item.into());
543 self
544 }
545
546 pub fn push_item(&mut self, item: impl Into<ContextMenuItem>) {
547 self.items.push(item.into());
548 }
549
550 pub fn entry(
551 mut self,
552 label: impl Into<SharedString>,
553 action: Option<Box<dyn Action>>,
554 handler: impl Fn(&mut Window, &mut App) + 'static,
555 ) -> Self {
556 self.items.push(ContextMenuItem::Entry(ContextMenuEntry {
557 toggle: None,
558 label: label.into(),
559 handler: Rc::new(move |_, window, cx| handler(window, cx)),
560 icon: None,
561 custom_icon_path: None,
562 custom_icon_svg: None,
563 icon_position: IconPosition::End,
564 icon_size: IconSize::Small,
565 icon_color: None,
566 action,
567 disabled: false,
568 documentation_aside: None,
569 end_slot_icon: None,
570 end_slot_title: None,
571 end_slot_handler: None,
572 show_end_slot_on_hover: false,
573 }));
574 self
575 }
576
577 pub fn entry_with_end_slot(
578 mut self,
579 label: impl Into<SharedString>,
580 action: Option<Box<dyn Action>>,
581 handler: impl Fn(&mut Window, &mut App) + 'static,
582 end_slot_icon: IconName,
583 end_slot_title: SharedString,
584 end_slot_handler: impl Fn(&mut Window, &mut App) + 'static,
585 ) -> Self {
586 self.items.push(ContextMenuItem::Entry(ContextMenuEntry {
587 toggle: None,
588 label: label.into(),
589 handler: Rc::new(move |_, window, cx| handler(window, cx)),
590 icon: None,
591 custom_icon_path: None,
592 custom_icon_svg: None,
593 icon_position: IconPosition::End,
594 icon_size: IconSize::Small,
595 icon_color: None,
596 action,
597 disabled: false,
598 documentation_aside: None,
599 end_slot_icon: Some(end_slot_icon),
600 end_slot_title: Some(end_slot_title),
601 end_slot_handler: Some(Rc::new(move |_, window, cx| end_slot_handler(window, cx))),
602 show_end_slot_on_hover: false,
603 }));
604 self
605 }
606
607 pub fn entry_with_end_slot_on_hover(
608 mut self,
609 label: impl Into<SharedString>,
610 action: Option<Box<dyn Action>>,
611 handler: impl Fn(&mut Window, &mut App) + 'static,
612 end_slot_icon: IconName,
613 end_slot_title: SharedString,
614 end_slot_handler: impl Fn(&mut Window, &mut App) + 'static,
615 ) -> Self {
616 self.items.push(ContextMenuItem::Entry(ContextMenuEntry {
617 toggle: None,
618 label: label.into(),
619 handler: Rc::new(move |_, window, cx| handler(window, cx)),
620 icon: None,
621 custom_icon_path: None,
622 custom_icon_svg: None,
623 icon_position: IconPosition::End,
624 icon_size: IconSize::Small,
625 icon_color: None,
626 action,
627 disabled: false,
628 documentation_aside: None,
629 end_slot_icon: Some(end_slot_icon),
630 end_slot_title: Some(end_slot_title),
631 end_slot_handler: Some(Rc::new(move |_, window, cx| end_slot_handler(window, cx))),
632 show_end_slot_on_hover: true,
633 }));
634 self
635 }
636
637 pub fn toggleable_entry(
638 mut self,
639 label: impl Into<SharedString>,
640 toggled: bool,
641 position: IconPosition,
642 action: Option<Box<dyn Action>>,
643 handler: impl Fn(&mut Window, &mut App) + 'static,
644 ) -> Self {
645 self.items.push(ContextMenuItem::Entry(ContextMenuEntry {
646 toggle: Some((position, toggled)),
647 label: label.into(),
648 handler: Rc::new(move |_, window, cx| handler(window, cx)),
649 icon: None,
650 custom_icon_path: None,
651 custom_icon_svg: None,
652 icon_position: position,
653 icon_size: IconSize::Small,
654 icon_color: None,
655 action,
656 disabled: false,
657 documentation_aside: None,
658 end_slot_icon: None,
659 end_slot_title: None,
660 end_slot_handler: None,
661 show_end_slot_on_hover: false,
662 }));
663 self
664 }
665
666 pub fn custom_row(
667 mut self,
668 entry_render: impl Fn(&mut Window, &mut App) -> AnyElement + 'static,
669 ) -> Self {
670 self.items.push(ContextMenuItem::CustomEntry {
671 entry_render: Box::new(entry_render),
672 handler: Rc::new(|_, _, _| {}),
673 selectable: false,
674 documentation_aside: None,
675 });
676 self
677 }
678
679 pub fn custom_entry(
680 mut self,
681 entry_render: impl Fn(&mut Window, &mut App) -> AnyElement + 'static,
682 handler: impl Fn(&mut Window, &mut App) + 'static,
683 ) -> Self {
684 self.items.push(ContextMenuItem::CustomEntry {
685 entry_render: Box::new(entry_render),
686 handler: Rc::new(move |_, window, cx| handler(window, cx)),
687 selectable: true,
688 documentation_aside: None,
689 });
690 self
691 }
692
693 pub fn label(mut self, label: impl Into<SharedString>) -> Self {
694 self.items.push(ContextMenuItem::Label(label.into()));
695 self
696 }
697
698 pub fn action(self, label: impl Into<SharedString>, action: Box<dyn Action>) -> Self {
699 self.action_checked(label, action, false)
700 }
701
702 pub fn action_checked(
703 mut self,
704 label: impl Into<SharedString>,
705 action: Box<dyn Action>,
706 checked: bool,
707 ) -> Self {
708 self.items.push(ContextMenuItem::Entry(ContextMenuEntry {
709 toggle: if checked {
710 Some((IconPosition::Start, true))
711 } else {
712 None
713 },
714 label: label.into(),
715 action: Some(action.boxed_clone()),
716 handler: Rc::new(move |context, window, cx| {
717 if let Some(context) = &context {
718 window.focus(context, cx);
719 }
720 window.dispatch_action(action.boxed_clone(), cx);
721 }),
722 icon: None,
723 custom_icon_path: None,
724 custom_icon_svg: None,
725 icon_position: IconPosition::End,
726 icon_size: IconSize::Small,
727 icon_color: None,
728 disabled: false,
729 documentation_aside: None,
730 end_slot_icon: None,
731 end_slot_title: None,
732 end_slot_handler: None,
733 show_end_slot_on_hover: false,
734 }));
735 self
736 }
737
738 pub fn action_disabled_when(
739 mut self,
740 disabled: bool,
741 label: impl Into<SharedString>,
742 action: Box<dyn Action>,
743 ) -> Self {
744 self.items.push(ContextMenuItem::Entry(ContextMenuEntry {
745 toggle: None,
746 label: label.into(),
747 action: Some(action.boxed_clone()),
748 handler: Rc::new(move |context, window, cx| {
749 if let Some(context) = &context {
750 window.focus(context, cx);
751 }
752 window.dispatch_action(action.boxed_clone(), cx);
753 }),
754 icon: None,
755 custom_icon_path: None,
756 custom_icon_svg: None,
757 icon_size: IconSize::Small,
758 icon_position: IconPosition::End,
759 icon_color: None,
760 disabled,
761 documentation_aside: None,
762 end_slot_icon: None,
763 end_slot_title: None,
764 end_slot_handler: None,
765 show_end_slot_on_hover: false,
766 }));
767 self
768 }
769
770 pub fn link(mut self, label: impl Into<SharedString>, action: Box<dyn Action>) -> Self {
771 self.items.push(ContextMenuItem::Entry(ContextMenuEntry {
772 toggle: None,
773 label: label.into(),
774 action: Some(action.boxed_clone()),
775 handler: Rc::new(move |_, window, cx| window.dispatch_action(action.boxed_clone(), cx)),
776 icon: Some(IconName::ArrowUpRight),
777 custom_icon_path: None,
778 custom_icon_svg: None,
779 icon_size: IconSize::XSmall,
780 icon_position: IconPosition::End,
781 icon_color: None,
782 disabled: false,
783 documentation_aside: None,
784 end_slot_icon: None,
785 end_slot_title: None,
786 end_slot_handler: None,
787 show_end_slot_on_hover: false,
788 }));
789 self
790 }
791
792 pub fn submenu(
793 mut self,
794 label: impl Into<SharedString>,
795 builder: impl Fn(ContextMenu, &mut Window, &mut Context<ContextMenu>) -> ContextMenu + 'static,
796 ) -> Self {
797 self.items.push(ContextMenuItem::Submenu {
798 label: label.into(),
799 icon: None,
800 builder: Rc::new(builder),
801 });
802 self
803 }
804
805 pub fn submenu_with_icon(
806 mut self,
807 label: impl Into<SharedString>,
808 icon: IconName,
809 builder: impl Fn(ContextMenu, &mut Window, &mut Context<ContextMenu>) -> ContextMenu + 'static,
810 ) -> Self {
811 self.items.push(ContextMenuItem::Submenu {
812 label: label.into(),
813 icon: Some(icon),
814 builder: Rc::new(builder),
815 });
816 self
817 }
818
819 pub fn keep_open_on_confirm(mut self, keep_open: bool) -> Self {
820 self.keep_open_on_confirm = keep_open;
821 self
822 }
823
824 pub fn trigger_end_slot_handler(&mut self, window: &mut Window, cx: &mut Context<Self>) {
825 let Some(entry) = self.selected_index.and_then(|ix| self.items.get(ix)) else {
826 return;
827 };
828 let ContextMenuItem::Entry(entry) = entry else {
829 return;
830 };
831 let Some(handler) = entry.end_slot_handler.as_ref() else {
832 return;
833 };
834 handler(None, window, cx);
835 }
836
837 pub fn fixed_width(mut self, width: DefiniteLength) -> Self {
838 self.fixed_width = Some(width);
839 self
840 }
841
842 pub fn end_slot_action(mut self, action: Box<dyn Action>) -> Self {
843 self.end_slot_action = Some(action);
844 self
845 }
846
847 pub fn key_context(mut self, context: impl Into<SharedString>) -> Self {
848 self.key_context = context.into();
849 self
850 }
851
852 pub fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) {
853 let context = self.action_context.as_ref();
854 if let Some(
855 ContextMenuItem::Entry(ContextMenuEntry {
856 handler,
857 disabled: false,
858 ..
859 })
860 | ContextMenuItem::CustomEntry { handler, .. },
861 ) = self.selected_index.and_then(|ix| self.items.get(ix))
862 {
863 (handler)(context, window, cx)
864 }
865
866 if self.is_submenu && !self.keep_open_on_confirm {
867 self.clicked = true;
868 }
869
870 if self.keep_open_on_confirm {
871 self.rebuild(window, cx);
872 } else {
873 cx.emit(DismissEvent);
874 }
875 }
876
877 pub fn cancel(&mut self, _: &menu::Cancel, window: &mut Window, cx: &mut Context<Self>) {
878 if self.is_submenu {
879 cx.emit(DismissEvent);
880
881 // Restore keyboard focus to the parent menu so arrow keys / Escape / Enter work again.
882 if let Some(parent) = &self.parent_menu {
883 let parent_focus = parent.read(cx).focus_handle.clone();
884
885 parent.update(cx, |parent, _cx| {
886 parent.ignore_blur_cancel_until =
887 Some(Instant::now() + Duration::from_millis(200));
888 });
889
890 window.focus(&parent_focus, cx);
891 }
892
893 return;
894 }
895
896 cx.emit(DismissEvent);
897 }
898
899 pub fn end_slot(&mut self, _: &dyn Action, window: &mut Window, cx: &mut Context<Self>) {
900 let Some(item) = self.selected_index.and_then(|ix| self.items.get(ix)) else {
901 return;
902 };
903 let ContextMenuItem::Entry(entry) = item else {
904 return;
905 };
906 let Some(handler) = entry.end_slot_handler.as_ref() else {
907 return;
908 };
909 handler(None, window, cx);
910 self.rebuild(window, cx);
911 cx.notify();
912 }
913
914 pub fn clear_selected(&mut self) {
915 self.selected_index = None;
916 }
917
918 pub fn select_first(&mut self, _: &SelectFirst, window: &mut Window, cx: &mut Context<Self>) {
919 if let Some(ix) = self.items.iter().position(|item| item.is_selectable()) {
920 self.select_index(ix, window, cx);
921 }
922 cx.notify();
923 }
924
925 pub fn select_last(&mut self, window: &mut Window, cx: &mut Context<Self>) -> Option<usize> {
926 for (ix, item) in self.items.iter().enumerate().rev() {
927 if item.is_selectable() {
928 return self.select_index(ix, window, cx);
929 }
930 }
931 None
932 }
933
934 fn handle_select_last(&mut self, _: &SelectLast, window: &mut Window, cx: &mut Context<Self>) {
935 if self.select_last(window, cx).is_some() {
936 cx.notify();
937 }
938 }
939
940 pub fn select_next(&mut self, _: &SelectNext, window: &mut Window, cx: &mut Context<Self>) {
941 if let Some(ix) = self.selected_index {
942 let next_index = ix + 1;
943 if self.items.len() <= next_index {
944 self.select_first(&SelectFirst, window, cx);
945 return;
946 } else {
947 for (ix, item) in self.items.iter().enumerate().skip(next_index) {
948 if item.is_selectable() {
949 self.select_index(ix, window, cx);
950 cx.notify();
951 return;
952 }
953 }
954 }
955 }
956 self.select_first(&SelectFirst, window, cx);
957 }
958
959 pub fn select_previous(
960 &mut self,
961 _: &SelectPrevious,
962 window: &mut Window,
963 cx: &mut Context<Self>,
964 ) {
965 if let Some(ix) = self.selected_index {
966 for (ix, item) in self.items.iter().enumerate().take(ix).rev() {
967 if item.is_selectable() {
968 self.select_index(ix, window, cx);
969 cx.notify();
970 return;
971 }
972 }
973 }
974 self.handle_select_last(&SelectLast, window, cx);
975 }
976
977 pub fn select_submenu_child(
978 &mut self,
979 _: &SelectChild,
980 window: &mut Window,
981 cx: &mut Context<Self>,
982 ) {
983 let Some(ix) = self.selected_index else {
984 return;
985 };
986
987 let Some(ContextMenuItem::Submenu { builder, .. }) = self.items.get(ix) else {
988 return;
989 };
990
991 self.open_submenu(
992 ix,
993 builder.clone(),
994 SubmenuOpenTrigger::Keyboard,
995 window,
996 cx,
997 );
998
999 if let SubmenuState::Open(open_submenu) = &self.submenu_state {
1000 let focus_handle = open_submenu.entity.read(cx).focus_handle.clone();
1001 window.focus(&focus_handle, cx);
1002 open_submenu.entity.update(cx, |submenu, cx| {
1003 submenu.select_first(&SelectFirst, window, cx);
1004 });
1005 }
1006
1007 cx.notify();
1008 }
1009
1010 pub fn select_submenu_parent(
1011 &mut self,
1012 _: &SelectParent,
1013 window: &mut Window,
1014 cx: &mut Context<Self>,
1015 ) {
1016 if !self.is_submenu {
1017 return;
1018 }
1019
1020 if let Some(parent) = &self.parent_menu {
1021 let parent_clone = parent.clone();
1022
1023 let parent_focus = parent.read(cx).focus_handle.clone();
1024 window.focus(&parent_focus, cx);
1025
1026 cx.emit(DismissEvent);
1027
1028 parent_clone.update(cx, |parent, cx| {
1029 if let SubmenuState::Open(open_submenu) = &parent.submenu_state {
1030 let trigger_index = open_submenu.item_index;
1031 parent.close_submenu(false, cx);
1032 let _ = parent.select_index(trigger_index, window, cx);
1033 cx.notify();
1034 }
1035 });
1036
1037 return;
1038 }
1039
1040 cx.emit(DismissEvent);
1041 }
1042
1043 fn select_index(
1044 &mut self,
1045 ix: usize,
1046 _window: &mut Window,
1047 _cx: &mut Context<Self>,
1048 ) -> Option<usize> {
1049 self.documentation_aside = None;
1050 let item = self.items.get(ix)?;
1051 if item.is_selectable() {
1052 self.selected_index = Some(ix);
1053 match item {
1054 ContextMenuItem::Entry(entry) => {
1055 if let Some(callback) = &entry.documentation_aside {
1056 self.documentation_aside = Some((ix, callback.clone()));
1057 }
1058 }
1059 ContextMenuItem::CustomEntry {
1060 documentation_aside: Some(callback),
1061 ..
1062 } => {
1063 self.documentation_aside = Some((ix, callback.clone()));
1064 }
1065 ContextMenuItem::Submenu { .. } => {}
1066 _ => (),
1067 }
1068 }
1069 Some(ix)
1070 }
1071
1072 fn create_submenu(
1073 builder: Rc<dyn Fn(ContextMenu, &mut Window, &mut Context<ContextMenu>) -> ContextMenu>,
1074 parent_entity: Entity<ContextMenu>,
1075 window: &mut Window,
1076 cx: &mut Context<Self>,
1077 ) -> (Entity<ContextMenu>, Subscription) {
1078 let submenu = Self::build_submenu(builder, parent_entity, window, cx);
1079
1080 let dismiss_subscription = cx.subscribe(&submenu, |this, submenu, _: &DismissEvent, cx| {
1081 let should_dismiss_parent = submenu.read(cx).clicked;
1082
1083 this.close_submenu(false, cx);
1084
1085 if should_dismiss_parent {
1086 cx.emit(DismissEvent);
1087 }
1088 });
1089
1090 (submenu, dismiss_subscription)
1091 }
1092
1093 fn build_submenu(
1094 builder: Rc<dyn Fn(ContextMenu, &mut Window, &mut Context<ContextMenu>) -> ContextMenu>,
1095 parent_entity: Entity<ContextMenu>,
1096 window: &mut Window,
1097 cx: &mut App,
1098 ) -> Entity<ContextMenu> {
1099 cx.new(|cx| {
1100 let focus_handle = cx.focus_handle();
1101
1102 let _on_blur_subscription = cx.on_blur(
1103 &focus_handle,
1104 window,
1105 |_this: &mut ContextMenu, _window, _cx| {},
1106 );
1107
1108 let mut menu = ContextMenu {
1109 builder: None,
1110 items: Default::default(),
1111 focus_handle,
1112 action_context: None,
1113 selected_index: None,
1114 delayed: false,
1115 clicked: false,
1116 end_slot_action: None,
1117 key_context: "menu".into(),
1118 _on_blur_subscription,
1119 keep_open_on_confirm: false,
1120 documentation_aside: None,
1121 fixed_width: None,
1122 submenu_state: SubmenuState::Closed,
1123 submenu_hover_safety_heuristic: SubmenuHoverSafetyHeuristic::new(),
1124 submenu_observed_bounds: Rc::new(Cell::new(None)),
1125 is_submenu: true,
1126 submenu_hovered: false,
1127 submenu_generation: 0,
1128 ignore_blur_cancel_until: None,
1129 parent_menu: Some(parent_entity),
1130 menu_hovered: true,
1131 };
1132
1133 menu = (builder)(menu, window, cx);
1134 menu
1135 })
1136 }
1137
1138 fn close_submenu(&mut self, clear_selection: bool, cx: &mut Context<Self>) {
1139 self.submenu_generation = self.submenu_generation.wrapping_add(1);
1140 self.submenu_state = SubmenuState::Closed;
1141 self.submenu_hovered = false;
1142 self.submenu_hover_safety_heuristic.clear();
1143 self.submenu_observed_bounds.set(None);
1144
1145 if clear_selection {
1146 self.selected_index = None;
1147 }
1148
1149 cx.notify();
1150 }
1151
1152 fn open_submenu(
1153 &mut self,
1154 item_index: usize,
1155 builder: Rc<dyn Fn(ContextMenu, &mut Window, &mut Context<ContextMenu>) -> ContextMenu>,
1156 reason: SubmenuOpenTrigger,
1157 window: &mut Window,
1158 cx: &mut Context<Self>,
1159 ) {
1160 let (submenu, dismiss_subscription) =
1161 Self::create_submenu(builder, cx.entity().clone(), window, cx);
1162
1163 self.submenu_observed_bounds.set(None);
1164 self.submenu_hover_safety_heuristic.clear();
1165 self.submenu_hovered = false;
1166
1167 let _ = reason;
1168
1169 let submenu_focus = submenu.read(cx).focus_handle.clone();
1170
1171 self.submenu_state = SubmenuState::Open(OpenSubmenu {
1172 item_index,
1173 entity: submenu,
1174 _dismiss_subscription: dismiss_subscription,
1175 });
1176
1177 window.focus(&submenu_focus, cx);
1178 cx.notify();
1179 }
1180
1181 fn update_last_mouse_position(&mut self, position: Point<Pixels>) {
1182 self.submenu_hover_safety_heuristic
1183 .update_mouse_position(position);
1184 }
1185
1186 pub fn on_action_dispatch(
1187 &mut self,
1188 dispatched: &dyn Action,
1189 window: &mut Window,
1190 cx: &mut Context<Self>,
1191 ) {
1192 if self.clicked {
1193 cx.propagate();
1194 return;
1195 }
1196
1197 if let Some(ix) = self.items.iter().position(|item| {
1198 if let ContextMenuItem::Entry(ContextMenuEntry {
1199 action: Some(action),
1200 disabled: false,
1201 ..
1202 }) = item
1203 {
1204 action.partial_eq(dispatched)
1205 } else {
1206 false
1207 }
1208 }) {
1209 self.select_index(ix, window, cx);
1210 self.delayed = true;
1211 cx.notify();
1212 let action = dispatched.boxed_clone();
1213 cx.spawn_in(window, async move |this, cx| {
1214 cx.background_executor()
1215 .timer(Duration::from_millis(50))
1216 .await;
1217 cx.update(|window, cx| {
1218 this.update(cx, |this, cx| {
1219 this.cancel(&menu::Cancel, window, cx);
1220 window.dispatch_action(action, cx);
1221 })
1222 })
1223 })
1224 .detach_and_log_err(cx);
1225 } else {
1226 cx.propagate()
1227 }
1228 }
1229
1230 pub fn on_blur_subscription(mut self, new_subscription: Subscription) -> Self {
1231 self._on_blur_subscription = new_subscription;
1232 self
1233 }
1234
1235 fn render_menu_item(
1236 &self,
1237 ix: usize,
1238 item: &ContextMenuItem,
1239 window: &mut Window,
1240 cx: &mut Context<Self>,
1241 ) -> impl IntoElement + use<> {
1242 match item {
1243 ContextMenuItem::Separator => ListSeparator.into_any_element(),
1244 ContextMenuItem::Header(header) => ListSubHeader::new(header.clone())
1245 .inset(true)
1246 .into_any_element(),
1247 ContextMenuItem::HeaderWithLink(header, label, url) => {
1248 let url = url.clone();
1249 let link_id = ElementId::Name(format!("link-{}", url).into());
1250 ListSubHeader::new(header.clone())
1251 .inset(true)
1252 .end_slot(
1253 Button::new(link_id, label.clone())
1254 .color(Color::Muted)
1255 .label_size(LabelSize::Small)
1256 .size(ButtonSize::None)
1257 .style(ButtonStyle::Transparent)
1258 .on_click(move |_, _, cx| {
1259 let url = url.clone();
1260 cx.open_url(&url);
1261 })
1262 .into_any_element(),
1263 )
1264 .into_any_element()
1265 }
1266 ContextMenuItem::Label(label) => ListItem::new(ix)
1267 .inset(true)
1268 .disabled(true)
1269 .child(Label::new(label.clone()))
1270 .into_any_element(),
1271 ContextMenuItem::Entry(entry) => {
1272 self.render_menu_entry(ix, entry, cx).into_any_element()
1273 }
1274 ContextMenuItem::CustomEntry {
1275 entry_render,
1276 handler,
1277 selectable,
1278 documentation_aside,
1279 ..
1280 } => {
1281 let handler = handler.clone();
1282 let menu = cx.entity().downgrade();
1283 let selectable = *selectable;
1284
1285 div()
1286 .id(("context-menu-child", ix))
1287 .when_some(documentation_aside.clone(), |this, documentation_aside| {
1288 this.occlude()
1289 .on_hover(cx.listener(move |menu, hovered, _, cx| {
1290 if *hovered {
1291 menu.documentation_aside = Some((ix, documentation_aside.clone()));
1292 } else if matches!(menu.documentation_aside, Some((id, _)) if id == ix)
1293 {
1294 menu.documentation_aside = None;
1295 }
1296 cx.notify();
1297 }))
1298 })
1299 .child(
1300 ListItem::new(ix)
1301 .inset(true)
1302 .toggle_state(Some(ix) == self.selected_index)
1303 .selectable(selectable)
1304 .when(selectable, |item| {
1305 item.on_click({
1306 let context = self.action_context.clone();
1307 let keep_open_on_confirm = self.keep_open_on_confirm;
1308 move |_, window, cx| {
1309 handler(context.as_ref(), window, cx);
1310 menu.update(cx, |menu, cx| {
1311 menu.clicked = true;
1312
1313 if keep_open_on_confirm {
1314 menu.rebuild(window, cx);
1315 } else {
1316 cx.emit(DismissEvent);
1317 }
1318 })
1319 .ok();
1320 }
1321 })
1322 })
1323 .child(entry_render(window, cx)),
1324 )
1325 .into_any_element()
1326 }
1327 ContextMenuItem::Submenu { label, icon, .. } => self
1328 .render_submenu_item_trigger(ix, label.clone(), *icon, cx)
1329 .into_any_element(),
1330 }
1331 }
1332
1333 fn render_submenu_item_trigger(
1334 &self,
1335 ix: usize,
1336 label: SharedString,
1337 icon: Option<IconName>,
1338 cx: &mut Context<Self>,
1339 ) -> impl IntoElement {
1340 let toggle_state = Some(ix) == self.selected_index
1341 || matches!(
1342 &self.submenu_state,
1343 SubmenuState::Open(open_submenu) if open_submenu.item_index == ix
1344 );
1345
1346 div()
1347 .id(("context-menu-submenu-trigger", ix))
1348 .on_mouse_move(cx.listener(move |this, event: &MouseMoveEvent, _, cx| {
1349 this.update_last_mouse_position(event.position);
1350
1351 if matches!(&this.submenu_state, SubmenuState::Open(_))
1352 || this.selected_index == Some(ix)
1353 {
1354 this.submenu_hover_safety_heuristic
1355 .update_trigger_left_x(event.position.x - px(100.0));
1356 }
1357
1358 cx.notify();
1359 }))
1360 .child(
1361 ListItem::new(ix)
1362 .inset(true)
1363 .toggle_state(toggle_state)
1364 .on_hover(cx.listener(move |this, hovered, window, cx| {
1365 let mouse_pos = window.mouse_position();
1366
1367 if *hovered {
1368 this.clear_selected();
1369 window.focus(&this.focus_handle.clone(), cx);
1370 this.menu_hovered = true;
1371 this.submenu_hover_safety_heuristic
1372 .update_trigger_left_x(mouse_pos.x - px(50.0));
1373
1374 if let Some(ContextMenuItem::Submenu { builder, .. }) =
1375 this.items.get(ix)
1376 {
1377 this.open_submenu(
1378 ix,
1379 builder.clone(),
1380 SubmenuOpenTrigger::Pointer,
1381 window,
1382 cx,
1383 );
1384 }
1385
1386 cx.notify();
1387 } else {
1388 let is_open_for_this_item = matches!(
1389 &this.submenu_state,
1390 SubmenuState::Open(open_submenu) if open_submenu.item_index == ix
1391 );
1392
1393 if is_open_for_this_item && !this.submenu_hovered {
1394 this.close_submenu(false, cx);
1395 this.clear_selected();
1396 cx.notify();
1397 }
1398 }
1399 }))
1400 .on_click(cx.listener(move |this, _, window, cx| {
1401 if let Some(ContextMenuItem::Submenu { builder, .. }) = this.items.get(ix) {
1402 this.open_submenu(
1403 ix,
1404 builder.clone(),
1405 SubmenuOpenTrigger::Pointer,
1406 window,
1407 cx,
1408 );
1409 }
1410 }))
1411 .child(
1412 h_flex()
1413 .w_full()
1414 .justify_between()
1415 .child(
1416 h_flex()
1417 .gap_1p5()
1418 .when_some(icon, |this, icon_name| {
1419 this.child(
1420 Icon::new(icon_name)
1421 .size(IconSize::Small)
1422 .color(Color::Default),
1423 )
1424 })
1425 .child(Label::new(label).color(Color::Default)),
1426 )
1427 .child(
1428 Icon::new(IconName::ChevronRight)
1429 .size(IconSize::Small)
1430 .color(Color::Muted),
1431 ),
1432 ),
1433 )
1434 }
1435
1436 fn padded_submenu_bounds_for(&self) -> Option<Bounds<Pixels>> {
1437 let bounds = self.submenu_observed_bounds.get()?;
1438 Some(Bounds {
1439 origin: Point {
1440 x: bounds.origin.x - px(50.0),
1441 y: bounds.origin.y - px(50.0),
1442 },
1443 size: Size {
1444 width: bounds.size.width + px(100.0),
1445 height: bounds.size.height + px(100.0),
1446 },
1447 })
1448 }
1449
1450 fn calculate_submenu_offset(&self, item_index: usize) -> Pixels {
1451 let list_item_height = px(28.);
1452 let separator_height = px(9.);
1453
1454 let mut offset = px(0.0);
1455
1456 for (ix, item) in self.items.iter().enumerate() {
1457 if ix >= item_index {
1458 break;
1459 }
1460 match item {
1461 ContextMenuItem::Separator => offset += separator_height,
1462 _ => offset += list_item_height,
1463 }
1464 }
1465 offset
1466 }
1467
1468 fn render_submenu_container(
1469 &self,
1470 ix: usize,
1471 submenu: Entity<ContextMenu>,
1472 offset: Pixels,
1473 cx: &mut Context<Self>,
1474 ) -> impl IntoElement {
1475 let bounds_cell = self.submenu_observed_bounds.clone();
1476 let canvas = canvas(
1477 {
1478 let bounds_cell = bounds_cell.clone();
1479 move |bounds, _window, _cx| {
1480 bounds_cell.set(Some(bounds));
1481 }
1482 },
1483 |_bounds, _state, _window, _cx| {},
1484 )
1485 .size_full()
1486 .absolute()
1487 .top_0()
1488 .left_0();
1489
1490 div()
1491 .id(("submenu-container", ix))
1492 .on_hover(cx.listener(|this, _, _, _| {
1493 this.submenu_hovered = true;
1494 }))
1495 .absolute()
1496 .left_full()
1497 .top(offset)
1498 .child(
1499 anchored()
1500 .anchor(Corner::TopLeft)
1501 .snap_to_window_with_margin(px(8.0))
1502 .child(
1503 div()
1504 .id(("submenu-hover-zone", ix))
1505 .occlude()
1506 .child(canvas)
1507 .child(submenu),
1508 ),
1509 )
1510 }
1511
1512 fn render_menu_entry(
1513 &self,
1514 ix: usize,
1515 entry: &ContextMenuEntry,
1516 cx: &mut Context<Self>,
1517 ) -> impl IntoElement {
1518 let ContextMenuEntry {
1519 toggle,
1520 label,
1521 handler,
1522 icon,
1523 custom_icon_path,
1524 custom_icon_svg,
1525 icon_position,
1526 icon_size,
1527 icon_color,
1528 action,
1529 disabled,
1530 documentation_aside,
1531 end_slot_icon,
1532 end_slot_title,
1533 end_slot_handler,
1534 show_end_slot_on_hover,
1535 } = entry;
1536 let this = cx.weak_entity();
1537
1538 let handler = handler.clone();
1539 let menu = cx.entity().downgrade();
1540
1541 let icon_color = if *disabled {
1542 Color::Muted
1543 } else if toggle.is_some() {
1544 icon_color.unwrap_or(Color::Accent)
1545 } else {
1546 icon_color.unwrap_or(Color::Default)
1547 };
1548
1549 let label_color = if *disabled {
1550 Color::Disabled
1551 } else {
1552 Color::Default
1553 };
1554
1555 let label_element = if let Some(custom_path) = custom_icon_path {
1556 h_flex()
1557 .gap_1p5()
1558 .when(
1559 *icon_position == IconPosition::Start && toggle.is_none(),
1560 |flex| {
1561 flex.child(
1562 Icon::from_path(custom_path.clone())
1563 .size(*icon_size)
1564 .color(icon_color),
1565 )
1566 },
1567 )
1568 .child(Label::new(label.clone()).color(label_color).truncate())
1569 .when(*icon_position == IconPosition::End, |flex| {
1570 flex.child(
1571 Icon::from_path(custom_path.clone())
1572 .size(*icon_size)
1573 .color(icon_color),
1574 )
1575 })
1576 .into_any_element()
1577 } else if let Some(custom_icon_svg) = custom_icon_svg {
1578 h_flex()
1579 .gap_1p5()
1580 .when(
1581 *icon_position == IconPosition::Start && toggle.is_none(),
1582 |flex| {
1583 flex.child(
1584 Icon::from_external_svg(custom_icon_svg.clone())
1585 .size(*icon_size)
1586 .color(icon_color),
1587 )
1588 },
1589 )
1590 .child(Label::new(label.clone()).color(label_color).truncate())
1591 .when(*icon_position == IconPosition::End, |flex| {
1592 flex.child(
1593 Icon::from_external_svg(custom_icon_svg.clone())
1594 .size(*icon_size)
1595 .color(icon_color),
1596 )
1597 })
1598 .into_any_element()
1599 } else if let Some(icon_name) = icon {
1600 h_flex()
1601 .gap_1p5()
1602 .when(
1603 *icon_position == IconPosition::Start && toggle.is_none(),
1604 |flex| flex.child(Icon::new(*icon_name).size(*icon_size).color(icon_color)),
1605 )
1606 .child(Label::new(label.clone()).color(label_color).truncate())
1607 .when(*icon_position == IconPosition::End, |flex| {
1608 flex.child(Icon::new(*icon_name).size(*icon_size).color(icon_color))
1609 })
1610 .into_any_element()
1611 } else {
1612 Label::new(label.clone())
1613 .color(label_color)
1614 .truncate()
1615 .into_any_element()
1616 };
1617
1618 div()
1619 .id(("context-menu-child", ix))
1620 .when_some(documentation_aside.clone(), |this, documentation_aside| {
1621 this.occlude()
1622 .on_hover(cx.listener(move |menu, hovered, _, cx| {
1623 if *hovered {
1624 menu.documentation_aside = Some((ix, documentation_aside.clone()));
1625 } else if matches!(menu.documentation_aside, Some((id, _)) if id == ix) {
1626 menu.documentation_aside = None;
1627 }
1628 cx.notify();
1629 }))
1630 })
1631 .child(
1632 ListItem::new(ix)
1633 .group_name("label_container")
1634 .inset(true)
1635 .disabled(*disabled)
1636 .toggle_state(Some(ix) == self.selected_index)
1637 .when(!self.is_submenu && !*disabled, |item| {
1638 item.on_hover(cx.listener(move |this, hovered, window, cx| {
1639 if *hovered {
1640 this.clear_selected();
1641 window.focus(&this.focus_handle.clone(), cx);
1642
1643 if let SubmenuState::Open(open_submenu) = &this.submenu_state {
1644 if open_submenu.item_index != ix {
1645 this.close_submenu(false, cx);
1646 cx.notify();
1647 }
1648 }
1649 }
1650 }))
1651 })
1652 .when(self.is_submenu, |item| {
1653 item.on_click(cx.listener(move |this, _, window, cx| {
1654 if let Some(ContextMenuItem::Submenu { builder, .. }) =
1655 this.items.get(ix)
1656 {
1657 this.open_submenu(
1658 ix,
1659 builder.clone(),
1660 SubmenuOpenTrigger::Pointer,
1661 window,
1662 cx,
1663 );
1664 }
1665 }))
1666 .on_hover(cx.listener(
1667 move |this, hovered, window, cx| {
1668 if *hovered {
1669 this.clear_selected();
1670 cx.notify();
1671 }
1672
1673 if let Some(parent) = &this.parent_menu {
1674 let mouse_pos = window.mouse_position();
1675 let parent_clone = parent.clone();
1676
1677 if *hovered {
1678 parent.update(cx, |parent, _| {
1679 parent.clear_selected();
1680 parent.submenu_hovered = true;
1681 });
1682 } else {
1683 parent_clone.update(cx, |parent, cx| {
1684 if matches!(
1685 &parent.submenu_state,
1686 SubmenuState::Open(_)
1687 ) {
1688 let should_close = parent
1689 .submenu_hover_safety_heuristic
1690 .should_allow_close_from_parent_area(mouse_pos);
1691
1692 if should_close {
1693 parent.close_submenu(true, cx);
1694 }
1695 }
1696 });
1697 }
1698 }
1699 },
1700 ))
1701 })
1702 .when_some(*toggle, |list_item, (position, toggled)| {
1703 let contents = div()
1704 .flex_none()
1705 .child(
1706 Icon::new(icon.unwrap_or(IconName::Check))
1707 .color(icon_color)
1708 .size(*icon_size),
1709 )
1710 .when(!toggled, |contents| contents.invisible());
1711
1712 match position {
1713 IconPosition::Start => list_item.start_slot(contents),
1714 IconPosition::End => list_item.end_slot(contents),
1715 }
1716 })
1717 .child(
1718 h_flex()
1719 .w_full()
1720 .justify_between()
1721 .child(label_element)
1722 .debug_selector(|| format!("MENU_ITEM-{}", label))
1723 .children(action.as_ref().map(|action| {
1724 let binding = self
1725 .action_context
1726 .as_ref()
1727 .map(|focus| KeyBinding::for_action_in(&**action, focus, cx))
1728 .unwrap_or_else(|| KeyBinding::for_action(&**action, cx));
1729
1730 div()
1731 .ml_4()
1732 .child(binding.disabled(*disabled))
1733 .when(*disabled && documentation_aside.is_some(), |parent| {
1734 parent.invisible()
1735 })
1736 }))
1737 .when(*disabled && documentation_aside.is_some(), |parent| {
1738 parent.child(
1739 Icon::new(IconName::Info)
1740 .size(IconSize::XSmall)
1741 .color(Color::Muted),
1742 )
1743 }),
1744 )
1745 .when_some(
1746 end_slot_icon
1747 .as_ref()
1748 .zip(self.end_slot_action.as_ref())
1749 .zip(end_slot_title.as_ref())
1750 .zip(end_slot_handler.as_ref()),
1751 |el, (((icon, action), title), handler)| {
1752 el.end_slot({
1753 let icon_button = IconButton::new("end-slot-icon", *icon)
1754 .shape(IconButtonShape::Square)
1755 .tooltip({
1756 let action_context = self.action_context.clone();
1757 let title = title.clone();
1758 let action = action.boxed_clone();
1759 move |_window, cx| {
1760 action_context
1761 .as_ref()
1762 .map(|focus| {
1763 Tooltip::for_action_in(
1764 title.clone(),
1765 &*action,
1766 focus,
1767 cx,
1768 )
1769 })
1770 .unwrap_or_else(|| {
1771 Tooltip::for_action(title.clone(), &*action, cx)
1772 })
1773 }
1774 })
1775 .on_click({
1776 let handler = handler.clone();
1777 move |_, window, cx| {
1778 handler(None, window, cx);
1779 this.update(cx, |this, cx| {
1780 this.rebuild(window, cx);
1781 cx.notify();
1782 })
1783 .ok();
1784 }
1785 });
1786
1787 if *show_end_slot_on_hover {
1788 div()
1789 .visible_on_hover("label_container")
1790 .child(icon_button)
1791 .into_any_element()
1792 } else {
1793 icon_button.into_any_element()
1794 }
1795 })
1796 },
1797 )
1798 .on_click({
1799 let context = self.action_context.clone();
1800 let keep_open_on_confirm = self.keep_open_on_confirm;
1801 move |_, window, cx| {
1802 handler(context.as_ref(), window, cx);
1803 menu.update(cx, |menu, cx| {
1804 menu.clicked = true;
1805 if keep_open_on_confirm {
1806 menu.rebuild(window, cx);
1807 } else {
1808 cx.emit(DismissEvent);
1809 }
1810 })
1811 .ok();
1812 }
1813 }),
1814 )
1815 .into_any_element()
1816 }
1817}
1818
1819impl ContextMenuItem {
1820 fn is_selectable(&self) -> bool {
1821 match self {
1822 ContextMenuItem::Header(_)
1823 | ContextMenuItem::HeaderWithLink(_, _, _)
1824 | ContextMenuItem::Separator
1825 | ContextMenuItem::Label { .. } => false,
1826 ContextMenuItem::Entry(ContextMenuEntry { disabled, .. }) => !disabled,
1827 ContextMenuItem::CustomEntry { selectable, .. } => *selectable,
1828 ContextMenuItem::Submenu { .. } => true,
1829 }
1830 }
1831}
1832
1833impl Render for ContextMenu {
1834 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1835 let ui_font_size = ThemeSettings::get_global(cx).ui_font_size(cx);
1836 let window_size = window.viewport_size();
1837 let rem_size = window.rem_size();
1838 let is_wide_window = window_size.width / rem_size > rems_from_px(800.).0;
1839
1840 let submenu_container = match &self.submenu_state {
1841 SubmenuState::Open(open_submenu) => {
1842 let offset = self.calculate_submenu_offset(open_submenu.item_index);
1843 Some((open_submenu.item_index, open_submenu.entity.clone(), offset))
1844 }
1845 _ => None,
1846 };
1847
1848 let aside = self.documentation_aside.clone();
1849 let render_aside = |aside: DocumentationAside, cx: &mut Context<Self>| {
1850 WithRemSize::new(ui_font_size)
1851 .occlude()
1852 .elevation_2(cx)
1853 .w_full()
1854 .p_2()
1855 .overflow_hidden()
1856 .when(is_wide_window, |this| this.max_w_96())
1857 .when(!is_wide_window, |this| this.max_w_48())
1858 .child((aside.render)(cx))
1859 };
1860
1861 let render_menu = |cx: &mut Context<Self>, window: &mut Window| {
1862 WithRemSize::new(ui_font_size)
1863 .occlude()
1864 .elevation_2(cx)
1865 .flex()
1866 .flex_row()
1867 .flex_shrink_0()
1868 .child(
1869 v_flex()
1870 .id("context-menu")
1871 .max_h(vh(0.75, window))
1872 .flex_shrink_0()
1873 .when_some(self.fixed_width, |this, width| {
1874 this.w(width).overflow_x_hidden()
1875 })
1876 .when(self.fixed_width.is_none(), |this| {
1877 this.min_w(px(200.)).flex_1()
1878 })
1879 .overflow_y_scroll()
1880 .track_focus(&self.focus_handle(cx))
1881 .key_context(self.key_context.as_ref())
1882 .on_action(cx.listener(ContextMenu::select_first))
1883 .on_action(cx.listener(ContextMenu::handle_select_last))
1884 .on_action(cx.listener(ContextMenu::select_next))
1885 .on_action(cx.listener(ContextMenu::select_previous))
1886 .on_action(cx.listener(ContextMenu::select_submenu_child))
1887 .on_action(cx.listener(ContextMenu::select_submenu_parent))
1888 .on_action(cx.listener(ContextMenu::confirm))
1889 .on_action(cx.listener(ContextMenu::cancel))
1890 .on_hover(cx.listener(|this, hovered: &bool, _, _| {
1891 this.menu_hovered = *hovered;
1892 }))
1893 .on_mouse_down_out(cx.listener(
1894 |this, event: &MouseDownEvent, window, cx| {
1895 if matches!(&this.submenu_state, SubmenuState::Open(_)) {
1896 if let Some(padded_bounds) = this.padded_submenu_bounds_for() {
1897 if padded_bounds.contains(&event.position) {
1898 return;
1899 }
1900 }
1901 }
1902
1903 this.cancel(&menu::Cancel, window, cx)
1904 },
1905 ))
1906 .when_some(self.end_slot_action.as_ref(), |el, action| {
1907 el.on_boxed_action(&**action, cx.listener(ContextMenu::end_slot))
1908 })
1909 .when(!self.delayed, |mut el| {
1910 for item in self.items.iter() {
1911 if let ContextMenuItem::Entry(ContextMenuEntry {
1912 action: Some(action),
1913 disabled: false,
1914 ..
1915 }) = item
1916 {
1917 el = el.on_boxed_action(
1918 &**action,
1919 cx.listener(ContextMenu::on_action_dispatch),
1920 );
1921 }
1922 }
1923 el
1924 })
1925 .child(
1926 List::new().children(
1927 self.items
1928 .iter()
1929 .enumerate()
1930 .map(|(ix, item)| self.render_menu_item(ix, item, window, cx)),
1931 ),
1932 ),
1933 )
1934 };
1935
1936 if is_wide_window {
1937 div()
1938 .relative()
1939 .child(render_menu(cx, window))
1940 .children(aside.map(|(_item_index, aside)| {
1941 h_flex()
1942 .absolute()
1943 .when(aside.side == DocumentationSide::Left, |this| {
1944 this.right_full().mr_1()
1945 })
1946 .when(aside.side == DocumentationSide::Right, |this| {
1947 this.left_full().ml_1()
1948 })
1949 .when(aside.edge == DocumentationEdge::Top, |this| this.top_0())
1950 .when(aside.edge == DocumentationEdge::Bottom, |this| {
1951 this.bottom_0()
1952 })
1953 .child(render_aside(aside, cx))
1954 }))
1955 .when_some(submenu_container.clone(), |this, (ix, submenu, offset)| {
1956 this.child(self.render_submenu_container(ix, submenu, offset, cx))
1957 })
1958 } else {
1959 v_flex()
1960 .w_full()
1961 .relative()
1962 .gap_1()
1963 .justify_end()
1964 .children(aside.map(|(_, aside)| render_aside(aside, cx)))
1965 .child(render_menu(cx, window))
1966 .when_some(submenu_container, |this, (ix, submenu, offset)| {
1967 this.child(self.render_submenu_container(ix, submenu, offset, cx))
1968 })
1969 }
1970 }
1971}
1972
1973#[cfg(test)]
1974mod tests {
1975 use gpui::TestAppContext;
1976
1977 use super::*;
1978
1979 #[gpui::test]
1980 fn can_navigate_back_over_headers(cx: &mut TestAppContext) {
1981 let cx = cx.add_empty_window();
1982 let context_menu = cx.update(|window, cx| {
1983 ContextMenu::build(window, cx, |menu, _, _| {
1984 menu.header("First header")
1985 .separator()
1986 .entry("First entry", None, |_, _| {})
1987 .separator()
1988 .separator()
1989 .entry("Last entry", None, |_, _| {})
1990 .header("Last header")
1991 })
1992 });
1993
1994 context_menu.update_in(cx, |context_menu, window, cx| {
1995 assert_eq!(
1996 None, context_menu.selected_index,
1997 "No selection is in the menu initially"
1998 );
1999
2000 context_menu.select_first(&SelectFirst, window, cx);
2001 assert_eq!(
2002 Some(2),
2003 context_menu.selected_index,
2004 "Should select first selectable entry, skipping the header and the separator"
2005 );
2006
2007 context_menu.select_next(&SelectNext, window, cx);
2008 assert_eq!(
2009 Some(5),
2010 context_menu.selected_index,
2011 "Should select next selectable entry, skipping 2 separators along the way"
2012 );
2013
2014 context_menu.select_next(&SelectNext, window, cx);
2015 assert_eq!(
2016 Some(2),
2017 context_menu.selected_index,
2018 "Should wrap around to first selectable entry"
2019 );
2020 });
2021
2022 context_menu.update_in(cx, |context_menu, window, cx| {
2023 assert_eq!(
2024 Some(2),
2025 context_menu.selected_index,
2026 "Should start from the first selectable entry"
2027 );
2028
2029 context_menu.select_previous(&SelectPrevious, window, cx);
2030 assert_eq!(
2031 Some(5),
2032 context_menu.selected_index,
2033 "Should wrap around to previous selectable entry (last)"
2034 );
2035
2036 context_menu.select_previous(&SelectPrevious, window, cx);
2037 assert_eq!(
2038 Some(2),
2039 context_menu.selected_index,
2040 "Should go back to previous selectable entry (first)"
2041 );
2042 });
2043
2044 context_menu.update_in(cx, |context_menu, window, cx| {
2045 context_menu.select_first(&SelectFirst, window, cx);
2046 assert_eq!(
2047 Some(2),
2048 context_menu.selected_index,
2049 "Should start from the first selectable entry"
2050 );
2051
2052 context_menu.select_previous(&SelectPrevious, window, cx);
2053 assert_eq!(
2054 Some(5),
2055 context_menu.selected_index,
2056 "Should wrap around to last selectable entry"
2057 );
2058 context_menu.select_next(&SelectNext, window, cx);
2059 assert_eq!(
2060 Some(2),
2061 context_menu.selected_index,
2062 "Should wrap around to first selectable entry"
2063 );
2064 });
2065 }
2066}