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