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