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