Skip to main content

ui/components/
context_menu.rs

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