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