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