context_menu.rs

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