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