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