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 move |bounds, _window, _cx| {
1548 bounds_cell.set(Some(bounds));
1549 }
1550 },
1551 |_bounds, _state, _window, _cx| {},
1552 )
1553 .size_full()
1554 .absolute()
1555 .top_0()
1556 .left_0();
1557
1558 div()
1559 .id(("submenu-container", ix))
1560 .on_hover(cx.listener(|this, _, _, _| {
1561 this.submenu_hovered = true;
1562 }))
1563 .absolute()
1564 .left_full()
1565 .top(offset)
1566 .child(
1567 anchored()
1568 .anchor(Corner::TopLeft)
1569 .snap_to_window_with_margin(px(8.0))
1570 .child(
1571 div()
1572 .id(("submenu-hover-zone", ix))
1573 .occlude()
1574 .child(canvas)
1575 .child(submenu),
1576 ),
1577 )
1578 }
1579
1580 fn render_menu_entry(
1581 &self,
1582 ix: usize,
1583 entry: &ContextMenuEntry,
1584 cx: &mut Context<Self>,
1585 ) -> impl IntoElement {
1586 let ContextMenuEntry {
1587 toggle,
1588 label,
1589 handler,
1590 icon,
1591 custom_icon_path,
1592 custom_icon_svg,
1593 icon_position,
1594 icon_size,
1595 icon_color,
1596 action,
1597 disabled,
1598 documentation_aside,
1599 end_slot_icon,
1600 end_slot_title,
1601 end_slot_handler,
1602 show_end_slot_on_hover,
1603 } = entry;
1604 let this = cx.weak_entity();
1605
1606 let handler = handler.clone();
1607 let menu = cx.entity().downgrade();
1608
1609 let icon_color = if *disabled {
1610 Color::Muted
1611 } else if toggle.is_some() {
1612 icon_color.unwrap_or(Color::Accent)
1613 } else {
1614 icon_color.unwrap_or(Color::Default)
1615 };
1616
1617 let label_color = if *disabled {
1618 Color::Disabled
1619 } else {
1620 Color::Default
1621 };
1622
1623 let label_element = if let Some(custom_path) = custom_icon_path {
1624 h_flex()
1625 .gap_1p5()
1626 .when(
1627 *icon_position == IconPosition::Start && toggle.is_none(),
1628 |flex| {
1629 flex.child(
1630 Icon::from_path(custom_path.clone())
1631 .size(*icon_size)
1632 .color(icon_color),
1633 )
1634 },
1635 )
1636 .child(Label::new(label.clone()).color(label_color).truncate())
1637 .when(*icon_position == IconPosition::End, |flex| {
1638 flex.child(
1639 Icon::from_path(custom_path.clone())
1640 .size(*icon_size)
1641 .color(icon_color),
1642 )
1643 })
1644 .into_any_element()
1645 } else if let Some(custom_icon_svg) = custom_icon_svg {
1646 h_flex()
1647 .gap_1p5()
1648 .when(
1649 *icon_position == IconPosition::Start && toggle.is_none(),
1650 |flex| {
1651 flex.child(
1652 Icon::from_external_svg(custom_icon_svg.clone())
1653 .size(*icon_size)
1654 .color(icon_color),
1655 )
1656 },
1657 )
1658 .child(Label::new(label.clone()).color(label_color).truncate())
1659 .when(*icon_position == IconPosition::End, |flex| {
1660 flex.child(
1661 Icon::from_external_svg(custom_icon_svg.clone())
1662 .size(*icon_size)
1663 .color(icon_color),
1664 )
1665 })
1666 .into_any_element()
1667 } else if let Some(icon_name) = icon {
1668 h_flex()
1669 .gap_1p5()
1670 .when(
1671 *icon_position == IconPosition::Start && toggle.is_none(),
1672 |flex| flex.child(Icon::new(*icon_name).size(*icon_size).color(icon_color)),
1673 )
1674 .child(Label::new(label.clone()).color(label_color).truncate())
1675 .when(*icon_position == IconPosition::End, |flex| {
1676 flex.child(Icon::new(*icon_name).size(*icon_size).color(icon_color))
1677 })
1678 .into_any_element()
1679 } else {
1680 Label::new(label.clone())
1681 .color(label_color)
1682 .truncate()
1683 .into_any_element()
1684 };
1685
1686 div()
1687 .id(("context-menu-child", ix))
1688 .when_some(documentation_aside.clone(), |this, documentation_aside| {
1689 this.occlude()
1690 .on_hover(cx.listener(move |menu, hovered, _, cx| {
1691 if *hovered {
1692 menu.documentation_aside = Some((ix, documentation_aside.clone()));
1693 } else if matches!(menu.documentation_aside, Some((id, _)) if id == ix) {
1694 menu.documentation_aside = None;
1695 }
1696 cx.notify();
1697 }))
1698 })
1699 .child(
1700 ListItem::new(ix)
1701 .group_name("label_container")
1702 .inset(true)
1703 .disabled(*disabled)
1704 .toggle_state(Some(ix) == self.selected_index)
1705 .when(!self.is_submenu && !*disabled, |item| {
1706 item.on_hover(cx.listener(move |this, hovered, window, cx| {
1707 if *hovered {
1708 this.clear_selected();
1709 window.focus(&this.focus_handle.clone(), cx);
1710
1711 if let SubmenuState::Open(open_submenu) = &this.submenu_state {
1712 if open_submenu.item_index != ix {
1713 this.close_submenu(false, cx);
1714 cx.notify();
1715 }
1716 }
1717 }
1718 }))
1719 })
1720 .when(self.is_submenu, |item| {
1721 item.on_click(cx.listener(move |this, _, window, cx| {
1722 if matches!(
1723 &this.submenu_state,
1724 SubmenuState::Open(open_submenu) if open_submenu.item_index == ix
1725 ) {
1726 return;
1727 }
1728
1729 if let Some(ContextMenuItem::Submenu { builder, .. }) =
1730 this.items.get(ix)
1731 {
1732 this.open_submenu(
1733 ix,
1734 builder.clone(),
1735 SubmenuOpenTrigger::Pointer,
1736 window,
1737 cx,
1738 );
1739 }
1740 }))
1741 .on_hover(cx.listener(
1742 move |this, hovered, window, cx| {
1743 if *hovered {
1744 this.clear_selected();
1745 cx.notify();
1746 }
1747
1748 if let Some(parent) = &this.parent_menu {
1749 let mouse_pos = window.mouse_position();
1750 let parent_clone = parent.clone();
1751
1752 if *hovered {
1753 parent.update(cx, |parent, _| {
1754 parent.clear_selected();
1755 parent.submenu_hovered = true;
1756 });
1757 } else {
1758 parent_clone.update(cx, |parent, cx| {
1759 if matches!(
1760 &parent.submenu_state,
1761 SubmenuState::Open(_)
1762 ) {
1763 let should_close = parent
1764 .submenu_hover_safety_heuristic
1765 .should_allow_close_from_parent_area(mouse_pos);
1766
1767 if should_close {
1768 parent.close_submenu(true, cx);
1769 }
1770 }
1771 });
1772 }
1773 }
1774 },
1775 ))
1776 })
1777 .when_some(*toggle, |list_item, (position, toggled)| {
1778 let contents = div()
1779 .flex_none()
1780 .child(
1781 Icon::new(icon.unwrap_or(IconName::Check))
1782 .color(icon_color)
1783 .size(*icon_size),
1784 )
1785 .when(!toggled, |contents| contents.invisible());
1786
1787 match position {
1788 IconPosition::Start => list_item.start_slot(contents),
1789 IconPosition::End => list_item.end_slot(contents),
1790 }
1791 })
1792 .child(
1793 h_flex()
1794 .w_full()
1795 .justify_between()
1796 .child(label_element)
1797 .debug_selector(|| format!("MENU_ITEM-{}", label))
1798 .children(action.as_ref().map(|action| {
1799 let binding = self
1800 .action_context
1801 .as_ref()
1802 .map(|focus| KeyBinding::for_action_in(&**action, focus, cx))
1803 .unwrap_or_else(|| KeyBinding::for_action(&**action, cx));
1804
1805 div()
1806 .ml_4()
1807 .child(binding.disabled(*disabled))
1808 .when(*disabled && documentation_aside.is_some(), |parent| {
1809 parent.invisible()
1810 })
1811 }))
1812 .when(*disabled && documentation_aside.is_some(), |parent| {
1813 parent.child(
1814 Icon::new(IconName::Info)
1815 .size(IconSize::XSmall)
1816 .color(Color::Muted),
1817 )
1818 }),
1819 )
1820 .when_some(
1821 end_slot_icon
1822 .as_ref()
1823 .zip(self.end_slot_action.as_ref())
1824 .zip(end_slot_title.as_ref())
1825 .zip(end_slot_handler.as_ref()),
1826 |el, (((icon, action), title), handler)| {
1827 el.end_slot({
1828 let icon_button = IconButton::new("end-slot-icon", *icon)
1829 .shape(IconButtonShape::Square)
1830 .tooltip({
1831 let action_context = self.action_context.clone();
1832 let title = title.clone();
1833 let action = action.boxed_clone();
1834 move |_window, cx| {
1835 action_context
1836 .as_ref()
1837 .map(|focus| {
1838 Tooltip::for_action_in(
1839 title.clone(),
1840 &*action,
1841 focus,
1842 cx,
1843 )
1844 })
1845 .unwrap_or_else(|| {
1846 Tooltip::for_action(title.clone(), &*action, cx)
1847 })
1848 }
1849 })
1850 .on_click({
1851 let handler = handler.clone();
1852 move |_, window, cx| {
1853 handler(None, window, cx);
1854 this.update(cx, |this, cx| {
1855 this.rebuild(window, cx);
1856 cx.notify();
1857 })
1858 .ok();
1859 }
1860 });
1861
1862 if *show_end_slot_on_hover {
1863 div()
1864 .visible_on_hover("label_container")
1865 .child(icon_button)
1866 .into_any_element()
1867 } else {
1868 icon_button.into_any_element()
1869 }
1870 })
1871 },
1872 )
1873 .on_click({
1874 let context = self.action_context.clone();
1875 let keep_open_on_confirm = self.keep_open_on_confirm;
1876 move |_, window, cx| {
1877 handler(context.as_ref(), window, cx);
1878 menu.update(cx, |menu, cx| {
1879 menu.clicked = true;
1880 if keep_open_on_confirm {
1881 menu.rebuild(window, cx);
1882 } else {
1883 cx.emit(DismissEvent);
1884 }
1885 })
1886 .ok();
1887 }
1888 }),
1889 )
1890 .into_any_element()
1891 }
1892}
1893
1894impl ContextMenuItem {
1895 fn is_selectable(&self) -> bool {
1896 match self {
1897 ContextMenuItem::Header(_)
1898 | ContextMenuItem::HeaderWithLink(_, _, _)
1899 | ContextMenuItem::Separator
1900 | ContextMenuItem::Label { .. } => false,
1901 ContextMenuItem::Entry(ContextMenuEntry { disabled, .. }) => !disabled,
1902 ContextMenuItem::CustomEntry { selectable, .. } => *selectable,
1903 ContextMenuItem::Submenu { .. } => true,
1904 }
1905 }
1906}
1907
1908impl Render for ContextMenu {
1909 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1910 let ui_font_size = ThemeSettings::get_global(cx).ui_font_size(cx);
1911 let window_size = window.viewport_size();
1912 let rem_size = window.rem_size();
1913 let is_wide_window = window_size.width / rem_size > rems_from_px(800.).0;
1914
1915 let mut focus_submenu: Option<FocusHandle> = None;
1916
1917 let submenu_container = match &mut self.submenu_state {
1918 SubmenuState::Open(open_submenu) => {
1919 let is_initializing = open_submenu.offset.is_none();
1920
1921 let computed_offset = if is_initializing {
1922 let menu_bounds = self.submenu_observed_bounds.get();
1923 let trigger_bounds = open_submenu.trigger_bounds.or_else(|| {
1924 self.submenu_trigger_observed_bounds_by_item
1925 .borrow()
1926 .get(&open_submenu.item_index)
1927 .copied()
1928 });
1929
1930 match (menu_bounds, trigger_bounds) {
1931 (Some(menu_bounds), Some(trigger_bounds)) => {
1932 Some(trigger_bounds.origin.y - menu_bounds.origin.y)
1933 }
1934 _ => None,
1935 }
1936 } else {
1937 None
1938 };
1939
1940 if let Some(offset) = open_submenu.offset.or(computed_offset) {
1941 if open_submenu.offset.is_none() {
1942 open_submenu.offset = Some(offset);
1943 }
1944
1945 focus_submenu = Some(open_submenu.entity.read(cx).focus_handle.clone());
1946 Some((open_submenu.item_index, open_submenu.entity.clone(), offset))
1947 } else {
1948 None
1949 }
1950 }
1951 _ => None,
1952 };
1953
1954 let aside = self.documentation_aside.clone();
1955 let render_aside = |aside: DocumentationAside, cx: &mut Context<Self>| {
1956 WithRemSize::new(ui_font_size)
1957 .occlude()
1958 .elevation_2(cx)
1959 .w_full()
1960 .p_2()
1961 .overflow_hidden()
1962 .when(is_wide_window, |this| this.max_w_96())
1963 .when(!is_wide_window, |this| this.max_w_48())
1964 .child((aside.render)(cx))
1965 };
1966
1967 let render_menu = |cx: &mut Context<Self>, window: &mut Window| {
1968 let bounds_cell = self.submenu_observed_bounds.clone();
1969 let menu_bounds_measure = canvas(
1970 {
1971 move |bounds, _window, _cx| {
1972 bounds_cell.set(Some(bounds));
1973 }
1974 },
1975 |_bounds, _state, _window, _cx| {},
1976 )
1977 .size_full()
1978 .absolute()
1979 .top_0()
1980 .left_0();
1981
1982 WithRemSize::new(ui_font_size)
1983 .occlude()
1984 .elevation_2(cx)
1985 .flex()
1986 .flex_row()
1987 .flex_shrink_0()
1988 .child(
1989 v_flex()
1990 .id("context-menu")
1991 .max_h(vh(0.75, window))
1992 .flex_shrink_0()
1993 .child(menu_bounds_measure)
1994 .when_some(self.fixed_width, |this, width| {
1995 this.w(width).overflow_x_hidden()
1996 })
1997 .when(self.fixed_width.is_none(), |this| {
1998 this.min_w(px(200.)).flex_1()
1999 })
2000 .overflow_y_scroll()
2001 .track_focus(&self.focus_handle(cx))
2002 .key_context(self.key_context.as_ref())
2003 .on_action(cx.listener(ContextMenu::select_first))
2004 .on_action(cx.listener(ContextMenu::handle_select_last))
2005 .on_action(cx.listener(ContextMenu::select_next))
2006 .on_action(cx.listener(ContextMenu::select_previous))
2007 .on_action(cx.listener(ContextMenu::select_submenu_child))
2008 .on_action(cx.listener(ContextMenu::select_submenu_parent))
2009 .on_action(cx.listener(ContextMenu::confirm))
2010 .on_action(cx.listener(ContextMenu::cancel))
2011 .on_hover(cx.listener(|this, hovered: &bool, _, _| {
2012 this.menu_hovered = *hovered;
2013 }))
2014 .on_mouse_down_out(cx.listener(
2015 |this, event: &MouseDownEvent, window, cx| {
2016 if matches!(&this.submenu_state, SubmenuState::Open(_)) {
2017 if let Some(padded_bounds) = this.padded_submenu_bounds() {
2018 if padded_bounds.contains(&event.position) {
2019 return;
2020 }
2021 }
2022 }
2023
2024 if this.is_submenu {
2025 if let Some(parent) = &this.parent_menu {
2026 let overriden_by_parent_trigger = parent
2027 .read(cx)
2028 .submenu_trigger_observed_bounds_by_item
2029 .borrow()
2030 .values()
2031 .any(|bounds| bounds.contains(&event.position));
2032 if overriden_by_parent_trigger {
2033 return;
2034 }
2035 }
2036 }
2037
2038 this.cancel(&menu::Cancel, window, cx)
2039 },
2040 ))
2041 .when_some(self.end_slot_action.as_ref(), |el, action| {
2042 el.on_boxed_action(&**action, cx.listener(ContextMenu::end_slot))
2043 })
2044 .when(!self.delayed, |mut el| {
2045 for item in self.items.iter() {
2046 if let ContextMenuItem::Entry(ContextMenuEntry {
2047 action: Some(action),
2048 disabled: false,
2049 ..
2050 }) = item
2051 {
2052 el = el.on_boxed_action(
2053 &**action,
2054 cx.listener(ContextMenu::on_action_dispatch),
2055 );
2056 }
2057 }
2058 el
2059 })
2060 .child(
2061 List::new().children(
2062 self.items
2063 .iter()
2064 .enumerate()
2065 .map(|(ix, item)| self.render_menu_item(ix, item, window, cx)),
2066 ),
2067 ),
2068 )
2069 };
2070
2071 if let Some(focus_handle) = focus_submenu.as_ref() {
2072 window.focus(focus_handle, cx);
2073 }
2074
2075 if is_wide_window {
2076 div()
2077 .relative()
2078 .child(render_menu(cx, window))
2079 .children(aside.map(|(_item_index, aside)| {
2080 h_flex()
2081 .absolute()
2082 .when(aside.side == DocumentationSide::Left, |this| {
2083 this.right_full().mr_1()
2084 })
2085 .when(aside.side == DocumentationSide::Right, |this| {
2086 this.left_full().ml_1()
2087 })
2088 .when(aside.edge == DocumentationEdge::Top, |this| this.top_0())
2089 .when(aside.edge == DocumentationEdge::Bottom, |this| {
2090 this.bottom_0()
2091 })
2092 .child(render_aside(aside, cx))
2093 }))
2094 .when_some(submenu_container, |this, (ix, submenu, offset)| {
2095 this.child(self.render_submenu_container(ix, submenu, offset, cx))
2096 })
2097 } else {
2098 v_flex()
2099 .w_full()
2100 .relative()
2101 .gap_1()
2102 .justify_end()
2103 .children(aside.map(|(_, aside)| render_aside(aside, cx)))
2104 .child(render_menu(cx, window))
2105 .when_some(submenu_container, |this, (ix, submenu, offset)| {
2106 this.child(self.render_submenu_container(ix, submenu, offset, cx))
2107 })
2108 }
2109 }
2110}
2111
2112#[cfg(test)]
2113mod tests {
2114 use gpui::TestAppContext;
2115
2116 use super::*;
2117
2118 #[gpui::test]
2119 fn can_navigate_back_over_headers(cx: &mut TestAppContext) {
2120 let cx = cx.add_empty_window();
2121 let context_menu = cx.update(|window, cx| {
2122 ContextMenu::build(window, cx, |menu, _, _| {
2123 menu.header("First header")
2124 .separator()
2125 .entry("First entry", None, |_, _| {})
2126 .separator()
2127 .separator()
2128 .entry("Last entry", None, |_, _| {})
2129 .header("Last header")
2130 })
2131 });
2132
2133 context_menu.update_in(cx, |context_menu, window, cx| {
2134 assert_eq!(
2135 None, context_menu.selected_index,
2136 "No selection is in the menu initially"
2137 );
2138
2139 context_menu.select_first(&SelectFirst, window, cx);
2140 assert_eq!(
2141 Some(2),
2142 context_menu.selected_index,
2143 "Should select first selectable entry, skipping the header and the separator"
2144 );
2145
2146 context_menu.select_next(&SelectNext, window, cx);
2147 assert_eq!(
2148 Some(5),
2149 context_menu.selected_index,
2150 "Should select next selectable entry, skipping 2 separators along the way"
2151 );
2152
2153 context_menu.select_next(&SelectNext, window, cx);
2154 assert_eq!(
2155 Some(2),
2156 context_menu.selected_index,
2157 "Should wrap around to first selectable entry"
2158 );
2159 });
2160
2161 context_menu.update_in(cx, |context_menu, window, cx| {
2162 assert_eq!(
2163 Some(2),
2164 context_menu.selected_index,
2165 "Should start from the first selectable entry"
2166 );
2167
2168 context_menu.select_previous(&SelectPrevious, window, cx);
2169 assert_eq!(
2170 Some(5),
2171 context_menu.selected_index,
2172 "Should wrap around to previous selectable entry (last)"
2173 );
2174
2175 context_menu.select_previous(&SelectPrevious, window, cx);
2176 assert_eq!(
2177 Some(2),
2178 context_menu.selected_index,
2179 "Should go back to previous selectable entry (first)"
2180 );
2181 });
2182
2183 context_menu.update_in(cx, |context_menu, window, cx| {
2184 context_menu.select_first(&SelectFirst, window, cx);
2185 assert_eq!(
2186 Some(2),
2187 context_menu.selected_index,
2188 "Should start from the first selectable entry"
2189 );
2190
2191 context_menu.select_previous(&SelectPrevious, window, cx);
2192 assert_eq!(
2193 Some(5),
2194 context_menu.selected_index,
2195 "Should wrap around to last selectable entry"
2196 );
2197 context_menu.select_next(&SelectNext, window, cx);
2198 assert_eq!(
2199 Some(2),
2200 context_menu.selected_index,
2201 "Should wrap around to first selectable entry"
2202 );
2203 });
2204 }
2205}