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