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