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