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