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