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(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 .disabled(*disabled)
1834 .toggle_state(Some(ix) == self.selected_index)
1835 .when(self.main_menu.is_none() && !*disabled, |item| {
1836 item.on_hover(cx.listener(move |this, hovered, window, cx| {
1837 if *hovered {
1838 this.clear_selected();
1839 window.focus(&this.focus_handle.clone(), cx);
1840
1841 if let SubmenuState::Open(open_submenu) = &this.submenu_state {
1842 if open_submenu.item_index != ix {
1843 this.close_submenu(false, cx);
1844 cx.notify();
1845 }
1846 }
1847 }
1848 }))
1849 })
1850 .when(self.main_menu.is_some(), |item| {
1851 item.on_click(cx.listener(move |this, _, window, cx| {
1852 if matches!(
1853 &this.submenu_state,
1854 SubmenuState::Open(open_submenu) if open_submenu.item_index == ix
1855 ) {
1856 return;
1857 }
1858
1859 if let Some(ContextMenuItem::Submenu { builder, .. }) =
1860 this.items.get(ix)
1861 {
1862 this.open_submenu(
1863 ix,
1864 builder.clone(),
1865 SubmenuOpenTrigger::Pointer,
1866 window,
1867 cx,
1868 );
1869 }
1870 }))
1871 .on_hover(cx.listener(
1872 move |this, hovered, window, cx| {
1873 if *hovered {
1874 this.clear_selected();
1875 cx.notify();
1876 }
1877
1878 if let Some(parent) = &this.main_menu {
1879 let mouse_pos = window.mouse_position();
1880 let parent_clone = parent.clone();
1881
1882 if *hovered {
1883 parent.update(cx, |parent, _| {
1884 parent.clear_selected();
1885 parent.hover_target = HoverTarget::Submenu;
1886 });
1887 } else {
1888 parent_clone.update(cx, |parent, cx| {
1889 if matches!(
1890 &parent.submenu_state,
1891 SubmenuState::Open(_)
1892 ) {
1893 // Only close if mouse is to the left of the safety threshold
1894 // (prevents accidental close when moving diagonally toward submenu)
1895 let should_close = parent
1896 .submenu_safety_threshold_x
1897 .map(|threshold_x| mouse_pos.x < threshold_x)
1898 .unwrap_or(true);
1899
1900 if should_close {
1901 parent.close_submenu(true, cx);
1902 }
1903 }
1904 });
1905 }
1906 }
1907 },
1908 ))
1909 })
1910 .when_some(*toggle, |list_item, (position, toggled)| {
1911 let contents = div()
1912 .flex_none()
1913 .child(
1914 Icon::new(icon.unwrap_or(IconName::Check))
1915 .color(icon_color)
1916 .size(*icon_size),
1917 )
1918 .when(!toggled, |contents| contents.invisible());
1919
1920 match position {
1921 IconPosition::Start => list_item.start_slot(contents),
1922 IconPosition::End => list_item.end_slot(contents),
1923 }
1924 })
1925 .child(
1926 h_flex()
1927 .w_full()
1928 .justify_between()
1929 .child(label_element)
1930 .debug_selector(|| format!("MENU_ITEM-{}", label))
1931 .children(action.as_ref().map(|action| {
1932 let binding = self
1933 .action_context
1934 .as_ref()
1935 .map(|focus| KeyBinding::for_action_in(&**action, focus, cx))
1936 .unwrap_or_else(|| KeyBinding::for_action(&**action, cx));
1937
1938 div()
1939 .ml_4()
1940 .child(binding.disabled(*disabled))
1941 .when(*disabled && documentation_aside.is_some(), |parent| {
1942 parent.invisible()
1943 })
1944 }))
1945 .when(*disabled && documentation_aside.is_some(), |parent| {
1946 parent.child(
1947 Icon::new(IconName::Info)
1948 .size(IconSize::XSmall)
1949 .color(Color::Muted),
1950 )
1951 }),
1952 )
1953 .when_some(
1954 end_slot_icon
1955 .as_ref()
1956 .zip(self.end_slot_action.as_ref())
1957 .zip(end_slot_title.as_ref())
1958 .zip(end_slot_handler.as_ref()),
1959 |el, (((icon, action), title), handler)| {
1960 el.end_slot({
1961 let icon_button = IconButton::new("end-slot-icon", *icon)
1962 .shape(IconButtonShape::Square)
1963 .tooltip({
1964 let action_context = self.action_context.clone();
1965 let title = title.clone();
1966 let action = action.boxed_clone();
1967 move |_window, cx| {
1968 action_context
1969 .as_ref()
1970 .map(|focus| {
1971 Tooltip::for_action_in(
1972 title.clone(),
1973 &*action,
1974 focus,
1975 cx,
1976 )
1977 })
1978 .unwrap_or_else(|| {
1979 Tooltip::for_action(title.clone(), &*action, cx)
1980 })
1981 }
1982 })
1983 .on_click({
1984 let handler = handler.clone();
1985 move |_, window, cx| {
1986 handler(None, window, cx);
1987 this.update(cx, |this, cx| {
1988 this.rebuild(window, cx);
1989 cx.notify();
1990 })
1991 .ok();
1992 }
1993 });
1994
1995 if *show_end_slot_on_hover {
1996 div()
1997 .visible_on_hover("label_container")
1998 .child(icon_button)
1999 .into_any_element()
2000 } else {
2001 icon_button.into_any_element()
2002 }
2003 })
2004 },
2005 )
2006 .on_click({
2007 let context = self.action_context.clone();
2008 let keep_open_on_confirm = self.keep_open_on_confirm;
2009 move |_, window, cx| {
2010 handler(context.as_ref(), window, cx);
2011 menu.update(cx, |menu, cx| {
2012 menu.clicked = true;
2013 if keep_open_on_confirm {
2014 menu.rebuild(window, cx);
2015 } else {
2016 cx.emit(DismissEvent);
2017 }
2018 })
2019 .ok();
2020 }
2021 }),
2022 )
2023 .into_any_element()
2024 }
2025}
2026
2027impl ContextMenuItem {
2028 fn is_selectable(&self) -> bool {
2029 match self {
2030 ContextMenuItem::Header(_)
2031 | ContextMenuItem::HeaderWithLink(_, _, _)
2032 | ContextMenuItem::Separator
2033 | ContextMenuItem::Label { .. } => false,
2034 ContextMenuItem::Entry(ContextMenuEntry { disabled, .. }) => !disabled,
2035 ContextMenuItem::CustomEntry { selectable, .. } => *selectable,
2036 ContextMenuItem::Submenu { .. } => true,
2037 }
2038 }
2039}
2040
2041impl Render for ContextMenu {
2042 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
2043 let ui_font_size = ThemeSettings::get_global(cx).ui_font_size(cx);
2044 let window_size = window.viewport_size();
2045 let rem_size = window.rem_size();
2046 let is_wide_window = window_size.width / rem_size > rems_from_px(800.).0;
2047
2048 let mut focus_submenu: Option<FocusHandle> = None;
2049
2050 let submenu_container = match &mut self.submenu_state {
2051 SubmenuState::Open(open_submenu) => {
2052 let is_initializing = open_submenu.offset.is_none();
2053
2054 let computed_offset = if is_initializing {
2055 let menu_bounds = self.main_menu_observed_bounds.get();
2056 let trigger_bounds = open_submenu
2057 .trigger_bounds
2058 .or_else(|| self.submenu_trigger_bounds.get());
2059
2060 match (menu_bounds, trigger_bounds) {
2061 (Some(menu_bounds), Some(trigger_bounds)) => {
2062 Some(trigger_bounds.origin.y - menu_bounds.origin.y)
2063 }
2064 _ => None,
2065 }
2066 } else {
2067 None
2068 };
2069
2070 if let Some(offset) = open_submenu.offset.or(computed_offset) {
2071 if open_submenu.offset.is_none() {
2072 open_submenu.offset = Some(offset);
2073 }
2074
2075 focus_submenu = Some(open_submenu.entity.read(cx).focus_handle.clone());
2076 Some((open_submenu.item_index, open_submenu.entity.clone(), offset))
2077 } else {
2078 None
2079 }
2080 }
2081 _ => None,
2082 };
2083
2084 let aside = self.documentation_aside.clone();
2085 let render_aside = |aside: DocumentationAside, cx: &mut Context<Self>| {
2086 WithRemSize::new(ui_font_size)
2087 .occlude()
2088 .elevation_2(cx)
2089 .w_full()
2090 .p_2()
2091 .overflow_hidden()
2092 .when(is_wide_window, |this| this.max_w_96())
2093 .when(!is_wide_window, |this| this.max_w_48())
2094 .child((aside.render)(cx))
2095 };
2096
2097 let render_menu = |cx: &mut Context<Self>, window: &mut Window| {
2098 let bounds_cell = self.main_menu_observed_bounds.clone();
2099 let menu_bounds_measure = canvas(
2100 {
2101 move |bounds, _window, _cx| {
2102 bounds_cell.set(Some(bounds));
2103 }
2104 },
2105 |_bounds, _state, _window, _cx| {},
2106 )
2107 .size_full()
2108 .absolute()
2109 .top_0()
2110 .left_0();
2111
2112 WithRemSize::new(ui_font_size)
2113 .occlude()
2114 .elevation_2(cx)
2115 .flex()
2116 .flex_row()
2117 .flex_shrink_0()
2118 .child(
2119 v_flex()
2120 .id("context-menu")
2121 .max_h(vh(0.75, window))
2122 .flex_shrink_0()
2123 .child(menu_bounds_measure)
2124 .when_some(self.fixed_width, |this, width| {
2125 this.w(width).overflow_x_hidden()
2126 })
2127 .when(self.fixed_width.is_none(), |this| {
2128 this.min_w(px(200.)).flex_1()
2129 })
2130 .overflow_y_scroll()
2131 .track_focus(&self.focus_handle(cx))
2132 .key_context(self.key_context.as_ref())
2133 .on_action(cx.listener(ContextMenu::select_first))
2134 .on_action(cx.listener(ContextMenu::handle_select_last))
2135 .on_action(cx.listener(ContextMenu::select_next))
2136 .on_action(cx.listener(ContextMenu::select_previous))
2137 .on_action(cx.listener(ContextMenu::select_submenu_child))
2138 .on_action(cx.listener(ContextMenu::select_submenu_parent))
2139 .on_action(cx.listener(ContextMenu::confirm))
2140 .on_action(cx.listener(ContextMenu::secondary_confirm))
2141 .on_action(cx.listener(ContextMenu::cancel))
2142 .on_hover(cx.listener(|this, hovered: &bool, _, cx| {
2143 if *hovered {
2144 this.hover_target = HoverTarget::MainMenu;
2145 if let Some(parent) = &this.main_menu {
2146 parent.update(cx, |parent, _| {
2147 parent.hover_target = HoverTarget::Submenu;
2148 });
2149 }
2150 }
2151 }))
2152 .on_mouse_down_out(cx.listener(
2153 |this, event: &MouseDownEvent, window, cx| {
2154 if matches!(&this.submenu_state, SubmenuState::Open(_)) {
2155 if let Some(padded_bounds) = this.padded_submenu_bounds() {
2156 if padded_bounds.contains(&event.position) {
2157 return;
2158 }
2159 }
2160 }
2161
2162 if let Some(parent) = &this.main_menu {
2163 let overridden_by_parent_trigger = parent
2164 .read(cx)
2165 .submenu_trigger_bounds
2166 .get()
2167 .is_some_and(|bounds| bounds.contains(&event.position));
2168 if overridden_by_parent_trigger {
2169 return;
2170 }
2171 }
2172
2173 this.cancel(&menu::Cancel, window, cx)
2174 },
2175 ))
2176 .when_some(self.end_slot_action.as_ref(), |el, action| {
2177 el.on_boxed_action(&**action, cx.listener(ContextMenu::end_slot))
2178 })
2179 .when(!self.delayed, |mut el| {
2180 for item in self.items.iter() {
2181 if let ContextMenuItem::Entry(ContextMenuEntry {
2182 action: Some(action),
2183 disabled: false,
2184 ..
2185 }) = item
2186 {
2187 el = el.on_boxed_action(
2188 &**action,
2189 cx.listener(ContextMenu::on_action_dispatch),
2190 );
2191 }
2192 }
2193 el
2194 })
2195 .child(
2196 List::new().children(
2197 self.items
2198 .iter()
2199 .enumerate()
2200 .map(|(ix, item)| self.render_menu_item(ix, item, window, cx)),
2201 ),
2202 ),
2203 )
2204 };
2205
2206 if let Some(focus_handle) = focus_submenu.as_ref() {
2207 window.focus(focus_handle, cx);
2208 }
2209
2210 if is_wide_window {
2211 let menu_bounds = self.main_menu_observed_bounds.get();
2212 let trigger_bounds = self
2213 .documentation_aside
2214 .as_ref()
2215 .and_then(|(ix, _)| self.aside_trigger_bounds.borrow().get(ix).copied());
2216
2217 let trigger_position = match (menu_bounds, trigger_bounds) {
2218 (Some(menu_bounds), Some(trigger_bounds)) => {
2219 let relative_top = trigger_bounds.origin.y - menu_bounds.origin.y;
2220 let height = trigger_bounds.size.height;
2221 Some((relative_top, height))
2222 }
2223 _ => None,
2224 };
2225
2226 div()
2227 .relative()
2228 .child(render_menu(cx, window))
2229 // Only render the aside once we have trigger bounds to avoid flicker.
2230 .when_some(trigger_position, |this, (top, height)| {
2231 this.children(aside.map(|(_, aside)| {
2232 h_flex()
2233 .absolute()
2234 .when(aside.side == DocumentationSide::Left, |el| {
2235 el.right_full().mr_1()
2236 })
2237 .when(aside.side == DocumentationSide::Right, |el| {
2238 el.left_full().ml_1()
2239 })
2240 .top(top)
2241 .h(height)
2242 .child(render_aside(aside, cx))
2243 }))
2244 })
2245 .when_some(submenu_container, |this, (ix, submenu, offset)| {
2246 this.child(self.render_submenu_container(ix, submenu, offset, cx))
2247 })
2248 } else {
2249 v_flex()
2250 .w_full()
2251 .relative()
2252 .gap_1()
2253 .justify_end()
2254 .children(aside.map(|(_, aside)| render_aside(aside, cx)))
2255 .child(render_menu(cx, window))
2256 .when_some(submenu_container, |this, (ix, submenu, offset)| {
2257 this.child(self.render_submenu_container(ix, submenu, offset, cx))
2258 })
2259 }
2260 }
2261}
2262
2263#[cfg(test)]
2264mod tests {
2265 use gpui::TestAppContext;
2266
2267 use super::*;
2268
2269 #[gpui::test]
2270 fn can_navigate_back_over_headers(cx: &mut TestAppContext) {
2271 let cx = cx.add_empty_window();
2272 let context_menu = cx.update(|window, cx| {
2273 ContextMenu::build(window, cx, |menu, _, _| {
2274 menu.header("First header")
2275 .separator()
2276 .entry("First entry", None, |_, _| {})
2277 .separator()
2278 .separator()
2279 .entry("Last entry", None, |_, _| {})
2280 .header("Last header")
2281 })
2282 });
2283
2284 context_menu.update_in(cx, |context_menu, window, cx| {
2285 assert_eq!(
2286 None, context_menu.selected_index,
2287 "No selection is in the menu initially"
2288 );
2289
2290 context_menu.select_first(&SelectFirst, window, cx);
2291 assert_eq!(
2292 Some(2),
2293 context_menu.selected_index,
2294 "Should select first selectable entry, skipping the header and the separator"
2295 );
2296
2297 context_menu.select_next(&SelectNext, window, cx);
2298 assert_eq!(
2299 Some(5),
2300 context_menu.selected_index,
2301 "Should select next selectable entry, skipping 2 separators along the way"
2302 );
2303
2304 context_menu.select_next(&SelectNext, window, cx);
2305 assert_eq!(
2306 Some(2),
2307 context_menu.selected_index,
2308 "Should wrap around to first selectable entry"
2309 );
2310 });
2311
2312 context_menu.update_in(cx, |context_menu, window, cx| {
2313 assert_eq!(
2314 Some(2),
2315 context_menu.selected_index,
2316 "Should start from the first selectable entry"
2317 );
2318
2319 context_menu.select_previous(&SelectPrevious, window, cx);
2320 assert_eq!(
2321 Some(5),
2322 context_menu.selected_index,
2323 "Should wrap around to previous selectable entry (last)"
2324 );
2325
2326 context_menu.select_previous(&SelectPrevious, window, cx);
2327 assert_eq!(
2328 Some(2),
2329 context_menu.selected_index,
2330 "Should go back to previous selectable entry (first)"
2331 );
2332 });
2333
2334 context_menu.update_in(cx, |context_menu, window, cx| {
2335 context_menu.select_first(&SelectFirst, window, cx);
2336 assert_eq!(
2337 Some(2),
2338 context_menu.selected_index,
2339 "Should start from the first selectable entry"
2340 );
2341
2342 context_menu.select_previous(&SelectPrevious, window, cx);
2343 assert_eq!(
2344 Some(5),
2345 context_menu.selected_index,
2346 "Should wrap around to last selectable entry"
2347 );
2348 context_menu.select_next(&SelectNext, window, cx);
2349 assert_eq!(
2350 Some(2),
2351 context_menu.selected_index,
2352 "Should wrap around to first selectable entry"
2353 );
2354 });
2355 }
2356}