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