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    // Capture the submenu's vertical offset once and keep it stable while the submenu is open.
  31    offset: Option<Pixels>,
  32    _dismiss_subscription: Subscription,
  33}
  34
  35enum SubmenuState {
  36    Closed,
  37    Open(OpenSubmenu),
  38}
  39
  40#[derive(Clone, Copy, PartialEq, Eq, Default)]
  41enum HoverTarget {
  42    #[default]
  43    None,
  44    MainMenu,
  45    Submenu,
  46}
  47
  48pub enum ContextMenuItem {
  49    Separator,
  50    Header(SharedString),
  51    /// title, link_label, link_url
  52    HeaderWithLink(SharedString, SharedString, SharedString), // This could be folded into header
  53    Label(SharedString),
  54    Entry(ContextMenuEntry),
  55    CustomEntry {
  56        entry_render: Box<dyn Fn(&mut Window, &mut App) -> AnyElement>,
  57        handler: Rc<dyn Fn(Option<&FocusHandle>, &mut Window, &mut App)>,
  58        selectable: bool,
  59        documentation_aside: Option<DocumentationAside>,
  60    },
  61    Submenu {
  62        label: SharedString,
  63        icon: Option<IconName>,
  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            builder: Rc::new(builder),
 767        });
 768        self
 769    }
 770
 771    pub fn submenu_with_icon(
 772        mut self,
 773        label: impl Into<SharedString>,
 774        icon: IconName,
 775        builder: impl Fn(ContextMenu, &mut Window, &mut Context<ContextMenu>) -> ContextMenu + 'static,
 776    ) -> Self {
 777        self.items.push(ContextMenuItem::Submenu {
 778            label: label.into(),
 779            icon: Some(icon),
 780            builder: Rc::new(builder),
 781        });
 782        self
 783    }
 784
 785    pub fn keep_open_on_confirm(mut self, keep_open: bool) -> Self {
 786        self.keep_open_on_confirm = keep_open;
 787        self
 788    }
 789
 790    pub fn trigger_end_slot_handler(&mut self, window: &mut Window, cx: &mut Context<Self>) {
 791        let Some(entry) = self.selected_index.and_then(|ix| self.items.get(ix)) else {
 792            return;
 793        };
 794        let ContextMenuItem::Entry(entry) = entry else {
 795            return;
 796        };
 797        let Some(handler) = entry.end_slot_handler.as_ref() else {
 798            return;
 799        };
 800        handler(None, window, cx);
 801    }
 802
 803    pub fn fixed_width(mut self, width: DefiniteLength) -> Self {
 804        self.fixed_width = Some(width);
 805        self
 806    }
 807
 808    pub fn end_slot_action(mut self, action: Box<dyn Action>) -> Self {
 809        self.end_slot_action = Some(action);
 810        self
 811    }
 812
 813    pub fn key_context(mut self, context: impl Into<SharedString>) -> Self {
 814        self.key_context = context.into();
 815        self
 816    }
 817
 818    pub fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) {
 819        let context = self.action_context.as_ref();
 820        if let Some(
 821            ContextMenuItem::Entry(ContextMenuEntry {
 822                handler,
 823                disabled: false,
 824                ..
 825            })
 826            | ContextMenuItem::CustomEntry { handler, .. },
 827        ) = self.selected_index.and_then(|ix| self.items.get(ix))
 828        {
 829            (handler)(context, window, cx)
 830        }
 831
 832        if self.main_menu.is_some() && !self.keep_open_on_confirm {
 833            self.clicked = true;
 834        }
 835
 836        if self.keep_open_on_confirm {
 837            self.rebuild(window, cx);
 838        } else {
 839            cx.emit(DismissEvent);
 840        }
 841    }
 842
 843    pub fn cancel(&mut self, _: &menu::Cancel, window: &mut Window, cx: &mut Context<Self>) {
 844        if self.main_menu.is_some() {
 845            cx.emit(DismissEvent);
 846
 847            // Restore keyboard focus to the parent menu so arrow keys / Escape / Enter work again.
 848            if let Some(parent) = &self.main_menu {
 849                let parent_focus = parent.read(cx).focus_handle.clone();
 850
 851                parent.update(cx, |parent, _cx| {
 852                    parent.ignore_blur_until = Some(Instant::now() + Duration::from_millis(200));
 853                });
 854
 855                window.focus(&parent_focus, cx);
 856            }
 857
 858            return;
 859        }
 860
 861        cx.emit(DismissEvent);
 862    }
 863
 864    pub fn end_slot(&mut self, _: &dyn Action, window: &mut Window, cx: &mut Context<Self>) {
 865        let Some(item) = self.selected_index.and_then(|ix| self.items.get(ix)) else {
 866            return;
 867        };
 868        let ContextMenuItem::Entry(entry) = item else {
 869            return;
 870        };
 871        let Some(handler) = entry.end_slot_handler.as_ref() else {
 872            return;
 873        };
 874        handler(None, window, cx);
 875        self.rebuild(window, cx);
 876        cx.notify();
 877    }
 878
 879    pub fn clear_selected(&mut self) {
 880        self.selected_index = None;
 881    }
 882
 883    pub fn select_first(&mut self, _: &SelectFirst, window: &mut Window, cx: &mut Context<Self>) {
 884        if let Some(ix) = self.items.iter().position(|item| item.is_selectable()) {
 885            self.select_index(ix, window, cx);
 886        }
 887        cx.notify();
 888    }
 889
 890    pub fn select_last(&mut self, window: &mut Window, cx: &mut Context<Self>) -> Option<usize> {
 891        for (ix, item) in self.items.iter().enumerate().rev() {
 892            if item.is_selectable() {
 893                return self.select_index(ix, window, cx);
 894            }
 895        }
 896        None
 897    }
 898
 899    fn handle_select_last(&mut self, _: &SelectLast, window: &mut Window, cx: &mut Context<Self>) {
 900        if self.select_last(window, cx).is_some() {
 901            cx.notify();
 902        }
 903    }
 904
 905    pub fn select_next(&mut self, _: &SelectNext, window: &mut Window, cx: &mut Context<Self>) {
 906        if let Some(ix) = self.selected_index {
 907            let next_index = ix + 1;
 908            if self.items.len() <= next_index {
 909                self.select_first(&SelectFirst, window, cx);
 910                return;
 911            } else {
 912                for (ix, item) in self.items.iter().enumerate().skip(next_index) {
 913                    if item.is_selectable() {
 914                        self.select_index(ix, window, cx);
 915                        cx.notify();
 916                        return;
 917                    }
 918                }
 919            }
 920        }
 921        self.select_first(&SelectFirst, window, cx);
 922    }
 923
 924    pub fn select_previous(
 925        &mut self,
 926        _: &SelectPrevious,
 927        window: &mut Window,
 928        cx: &mut Context<Self>,
 929    ) {
 930        if let Some(ix) = self.selected_index {
 931            for (ix, item) in self.items.iter().enumerate().take(ix).rev() {
 932                if item.is_selectable() {
 933                    self.select_index(ix, window, cx);
 934                    cx.notify();
 935                    return;
 936                }
 937            }
 938        }
 939        self.handle_select_last(&SelectLast, window, cx);
 940    }
 941
 942    pub fn select_submenu_child(
 943        &mut self,
 944        _: &SelectChild,
 945        window: &mut Window,
 946        cx: &mut Context<Self>,
 947    ) {
 948        let Some(ix) = self.selected_index else {
 949            return;
 950        };
 951
 952        let Some(ContextMenuItem::Submenu { builder, .. }) = self.items.get(ix) else {
 953            return;
 954        };
 955
 956        self.open_submenu(
 957            ix,
 958            builder.clone(),
 959            SubmenuOpenTrigger::Keyboard,
 960            window,
 961            cx,
 962        );
 963
 964        if let SubmenuState::Open(open_submenu) = &self.submenu_state {
 965            let focus_handle = open_submenu.entity.read(cx).focus_handle.clone();
 966            window.focus(&focus_handle, cx);
 967            open_submenu.entity.update(cx, |submenu, cx| {
 968                submenu.select_first(&SelectFirst, window, cx);
 969            });
 970        }
 971
 972        cx.notify();
 973    }
 974
 975    pub fn select_submenu_parent(
 976        &mut self,
 977        _: &SelectParent,
 978        window: &mut Window,
 979        cx: &mut Context<Self>,
 980    ) {
 981        if self.main_menu.is_none() {
 982            return;
 983        }
 984
 985        if let Some(parent) = &self.main_menu {
 986            let parent_clone = parent.clone();
 987
 988            let parent_focus = parent.read(cx).focus_handle.clone();
 989            window.focus(&parent_focus, cx);
 990
 991            cx.emit(DismissEvent);
 992
 993            parent_clone.update(cx, |parent, cx| {
 994                if let SubmenuState::Open(open_submenu) = &parent.submenu_state {
 995                    let trigger_index = open_submenu.item_index;
 996                    parent.close_submenu(false, cx);
 997                    let _ = parent.select_index(trigger_index, window, cx);
 998                    cx.notify();
 999                }
1000            });
1001
1002            return;
1003        }
1004
1005        cx.emit(DismissEvent);
1006    }
1007
1008    fn select_index(
1009        &mut self,
1010        ix: usize,
1011        _window: &mut Window,
1012        _cx: &mut Context<Self>,
1013    ) -> Option<usize> {
1014        self.documentation_aside = None;
1015        let item = self.items.get(ix)?;
1016        if item.is_selectable() {
1017            self.selected_index = Some(ix);
1018            match item {
1019                ContextMenuItem::Entry(entry) => {
1020                    if let Some(callback) = &entry.documentation_aside {
1021                        self.documentation_aside = Some((ix, callback.clone()));
1022                    }
1023                }
1024                ContextMenuItem::CustomEntry {
1025                    documentation_aside: Some(callback),
1026                    ..
1027                } => {
1028                    self.documentation_aside = Some((ix, callback.clone()));
1029                }
1030                ContextMenuItem::Submenu { .. } => {}
1031                _ => (),
1032            }
1033        }
1034        Some(ix)
1035    }
1036
1037    fn create_submenu(
1038        builder: Rc<dyn Fn(ContextMenu, &mut Window, &mut Context<ContextMenu>) -> ContextMenu>,
1039        parent_entity: Entity<ContextMenu>,
1040        window: &mut Window,
1041        cx: &mut Context<Self>,
1042    ) -> (Entity<ContextMenu>, Subscription) {
1043        let submenu = Self::build_submenu(builder, parent_entity, window, cx);
1044
1045        let dismiss_subscription = cx.subscribe(&submenu, |this, submenu, _: &DismissEvent, cx| {
1046            let should_dismiss_parent = submenu.read(cx).clicked;
1047
1048            this.close_submenu(false, cx);
1049
1050            if should_dismiss_parent {
1051                cx.emit(DismissEvent);
1052            }
1053        });
1054
1055        (submenu, dismiss_subscription)
1056    }
1057
1058    fn build_submenu(
1059        builder: Rc<dyn Fn(ContextMenu, &mut Window, &mut Context<ContextMenu>) -> ContextMenu>,
1060        parent_entity: Entity<ContextMenu>,
1061        window: &mut Window,
1062        cx: &mut App,
1063    ) -> Entity<ContextMenu> {
1064        cx.new(|cx| {
1065            let focus_handle = cx.focus_handle();
1066
1067            let _on_blur_subscription = cx.on_blur(
1068                &focus_handle,
1069                window,
1070                |_this: &mut ContextMenu, _window, _cx| {},
1071            );
1072
1073            let mut menu = ContextMenu {
1074                builder: None,
1075                items: Default::default(),
1076                focus_handle,
1077                action_context: None,
1078                selected_index: None,
1079                delayed: false,
1080                clicked: false,
1081                end_slot_action: None,
1082                key_context: "menu".into(),
1083                _on_blur_subscription,
1084                keep_open_on_confirm: false,
1085                fixed_width: None,
1086                documentation_aside: None,
1087                aside_trigger_bounds: Rc::new(RefCell::new(HashMap::default())),
1088                main_menu: Some(parent_entity),
1089                main_menu_observed_bounds: Rc::new(Cell::new(None)),
1090                submenu_state: SubmenuState::Closed,
1091                hover_target: HoverTarget::MainMenu,
1092                submenu_safety_threshold_x: None,
1093                submenu_trigger_bounds: Rc::new(Cell::new(None)),
1094                submenu_trigger_mouse_down: false,
1095                ignore_blur_until: None,
1096            };
1097
1098            menu = (builder)(menu, window, cx);
1099            menu
1100        })
1101    }
1102
1103    fn close_submenu(&mut self, clear_selection: bool, cx: &mut Context<Self>) {
1104        self.submenu_state = SubmenuState::Closed;
1105        self.hover_target = HoverTarget::MainMenu;
1106        self.submenu_safety_threshold_x = None;
1107        self.main_menu_observed_bounds.set(None);
1108        self.submenu_trigger_bounds.set(None);
1109
1110        if clear_selection {
1111            self.selected_index = None;
1112        }
1113
1114        cx.notify();
1115    }
1116
1117    fn open_submenu(
1118        &mut self,
1119        item_index: usize,
1120        builder: Rc<dyn Fn(ContextMenu, &mut Window, &mut Context<ContextMenu>) -> ContextMenu>,
1121        reason: SubmenuOpenTrigger,
1122        window: &mut Window,
1123        cx: &mut Context<Self>,
1124    ) {
1125        // If the submenu is already open for this item, don't recreate it.
1126        if matches!(
1127            &self.submenu_state,
1128            SubmenuState::Open(open_submenu) if open_submenu.item_index == item_index
1129        ) {
1130            return;
1131        }
1132
1133        let (submenu, dismiss_subscription) =
1134            Self::create_submenu(builder, cx.entity(), window, cx);
1135
1136        // If we're switching from one submenu item to another, throw away any previously-captured
1137        // offset so we don't reuse a stale position.
1138        self.main_menu_observed_bounds.set(None);
1139        self.submenu_trigger_bounds.set(None);
1140
1141        self.submenu_safety_threshold_x = None;
1142        self.hover_target = HoverTarget::MainMenu;
1143
1144        // When opening a submenu via keyboard, there is a brief moment where focus/hover can
1145        // transition in a way that triggers the parent menu's `on_blur` dismissal.
1146        if matches!(reason, SubmenuOpenTrigger::Keyboard) {
1147            self.ignore_blur_until = Some(Instant::now() + Duration::from_millis(150));
1148        }
1149
1150        let trigger_bounds = self.submenu_trigger_bounds.get();
1151
1152        self.submenu_state = SubmenuState::Open(OpenSubmenu {
1153            item_index,
1154            entity: submenu,
1155            trigger_bounds,
1156            offset: None,
1157            _dismiss_subscription: dismiss_subscription,
1158        });
1159
1160        cx.notify();
1161    }
1162
1163    pub fn on_action_dispatch(
1164        &mut self,
1165        dispatched: &dyn Action,
1166        window: &mut Window,
1167        cx: &mut Context<Self>,
1168    ) {
1169        if self.clicked {
1170            cx.propagate();
1171            return;
1172        }
1173
1174        if let Some(ix) = self.items.iter().position(|item| {
1175            if let ContextMenuItem::Entry(ContextMenuEntry {
1176                action: Some(action),
1177                disabled: false,
1178                ..
1179            }) = item
1180            {
1181                action.partial_eq(dispatched)
1182            } else {
1183                false
1184            }
1185        }) {
1186            self.select_index(ix, window, cx);
1187            self.delayed = true;
1188            cx.notify();
1189            let action = dispatched.boxed_clone();
1190            cx.spawn_in(window, async move |this, cx| {
1191                cx.background_executor()
1192                    .timer(Duration::from_millis(50))
1193                    .await;
1194                cx.update(|window, cx| {
1195                    this.update(cx, |this, cx| {
1196                        this.cancel(&menu::Cancel, window, cx);
1197                        window.dispatch_action(action, cx);
1198                    })
1199                })
1200            })
1201            .detach_and_log_err(cx);
1202        } else {
1203            cx.propagate()
1204        }
1205    }
1206
1207    pub fn on_blur_subscription(mut self, new_subscription: Subscription) -> Self {
1208        self._on_blur_subscription = new_subscription;
1209        self
1210    }
1211
1212    fn render_menu_item(
1213        &self,
1214        ix: usize,
1215        item: &ContextMenuItem,
1216        window: &mut Window,
1217        cx: &mut Context<Self>,
1218    ) -> impl IntoElement + use<> {
1219        match item {
1220            ContextMenuItem::Separator => ListSeparator.into_any_element(),
1221            ContextMenuItem::Header(header) => ListSubHeader::new(header.clone())
1222                .inset(true)
1223                .into_any_element(),
1224            ContextMenuItem::HeaderWithLink(header, label, url) => {
1225                let url = url.clone();
1226                let link_id = ElementId::Name(format!("link-{}", url).into());
1227                ListSubHeader::new(header.clone())
1228                    .inset(true)
1229                    .end_slot(
1230                        Button::new(link_id, label.clone())
1231                            .color(Color::Muted)
1232                            .label_size(LabelSize::Small)
1233                            .size(ButtonSize::None)
1234                            .style(ButtonStyle::Transparent)
1235                            .on_click(move |_, _, cx| {
1236                                let url = url.clone();
1237                                cx.open_url(&url);
1238                            })
1239                            .into_any_element(),
1240                    )
1241                    .into_any_element()
1242            }
1243            ContextMenuItem::Label(label) => ListItem::new(ix)
1244                .inset(true)
1245                .disabled(true)
1246                .child(Label::new(label.clone()))
1247                .into_any_element(),
1248            ContextMenuItem::Entry(entry) => {
1249                self.render_menu_entry(ix, entry, cx).into_any_element()
1250            }
1251            ContextMenuItem::CustomEntry {
1252                entry_render,
1253                handler,
1254                selectable,
1255                documentation_aside,
1256                ..
1257            } => {
1258                let handler = handler.clone();
1259                let menu = cx.entity().downgrade();
1260                let selectable = *selectable;
1261                let aside_trigger_bounds = self.aside_trigger_bounds.clone();
1262
1263                div()
1264                    .id(("context-menu-child", ix))
1265                    .when_some(documentation_aside.clone(), |this, documentation_aside| {
1266                        this.occlude()
1267                            .on_hover(cx.listener(move |menu, hovered, _, cx| {
1268                            if *hovered {
1269                                menu.documentation_aside = Some((ix, documentation_aside.clone()));
1270                            } else if matches!(menu.documentation_aside, Some((id, _)) if id == ix)
1271                            {
1272                                menu.documentation_aside = None;
1273                            }
1274                            cx.notify();
1275                        }))
1276                    })
1277                    .when(documentation_aside.is_some(), |this| {
1278                        this.child(
1279                            canvas(
1280                                {
1281                                    let aside_trigger_bounds = aside_trigger_bounds.clone();
1282                                    move |bounds, _window, _cx| {
1283                                        aside_trigger_bounds.borrow_mut().insert(ix, bounds);
1284                                    }
1285                                },
1286                                |_bounds, _state, _window, _cx| {},
1287                            )
1288                            .size_full()
1289                            .absolute()
1290                            .top_0()
1291                            .left_0(),
1292                        )
1293                    })
1294                    .child(
1295                        ListItem::new(ix)
1296                            .inset(true)
1297                            .toggle_state(Some(ix) == self.selected_index)
1298                            .selectable(selectable)
1299                            .when(selectable, |item| {
1300                                item.on_click({
1301                                    let context = self.action_context.clone();
1302                                    let keep_open_on_confirm = self.keep_open_on_confirm;
1303                                    move |_, window, cx| {
1304                                        handler(context.as_ref(), window, cx);
1305                                        menu.update(cx, |menu, cx| {
1306                                            menu.clicked = true;
1307
1308                                            if keep_open_on_confirm {
1309                                                menu.rebuild(window, cx);
1310                                            } else {
1311                                                cx.emit(DismissEvent);
1312                                            }
1313                                        })
1314                                        .ok();
1315                                    }
1316                                })
1317                            })
1318                            .child(entry_render(window, cx)),
1319                    )
1320                    .into_any_element()
1321            }
1322            ContextMenuItem::Submenu { label, icon, .. } => self
1323                .render_submenu_item_trigger(ix, label.clone(), *icon, cx)
1324                .into_any_element(),
1325        }
1326    }
1327
1328    fn render_submenu_item_trigger(
1329        &self,
1330        ix: usize,
1331        label: SharedString,
1332        icon: Option<IconName>,
1333        cx: &mut Context<Self>,
1334    ) -> impl IntoElement {
1335        let toggle_state = Some(ix) == self.selected_index
1336            || matches!(
1337                &self.submenu_state,
1338                SubmenuState::Open(open_submenu) if open_submenu.item_index == ix
1339            );
1340
1341        div()
1342            .id(("context-menu-submenu-trigger", ix))
1343            .capture_any_mouse_down(cx.listener(move |this, event: &MouseDownEvent, _, _| {
1344                // This prevents on_hover(false) from closing the submenu during a click.
1345                if event.button == MouseButton::Left {
1346                    this.submenu_trigger_mouse_down = true;
1347                }
1348            }))
1349            .capture_any_mouse_up(cx.listener(move |this, event: &MouseUpEvent, _, _| {
1350                if event.button == MouseButton::Left {
1351                    this.submenu_trigger_mouse_down = false;
1352                }
1353            }))
1354            .on_mouse_move(cx.listener(move |this, event: &MouseMoveEvent, _, cx| {
1355                if matches!(&this.submenu_state, SubmenuState::Open(_))
1356                    || this.selected_index == Some(ix)
1357                {
1358                    this.submenu_safety_threshold_x = Some(event.position.x - px(100.0));
1359                }
1360
1361                cx.notify();
1362            }))
1363            .child(
1364                ListItem::new(ix)
1365                    .inset(true)
1366                    .toggle_state(toggle_state)
1367                    .child(
1368                        canvas(
1369                            {
1370                                let trigger_bounds_cell = self.submenu_trigger_bounds.clone();
1371                                move |bounds, _window, _cx| {
1372                                    if toggle_state {
1373                                        trigger_bounds_cell.set(Some(bounds));
1374                                    }
1375                                }
1376                            },
1377                            |_bounds, _state, _window, _cx| {},
1378                        )
1379                        .size_full()
1380                        .absolute()
1381                        .top_0()
1382                        .left_0(),
1383                    )
1384                    .on_hover(cx.listener(move |this, hovered, window, cx| {
1385                        let mouse_pos = window.mouse_position();
1386
1387                        if *hovered {
1388                            this.clear_selected();
1389                            window.focus(&this.focus_handle.clone(), cx);
1390                            this.hover_target = HoverTarget::MainMenu;
1391                            this.submenu_safety_threshold_x = Some(mouse_pos.x - px(50.0));
1392
1393                            if let Some(ContextMenuItem::Submenu { builder, .. }) =
1394                                this.items.get(ix)
1395                            {
1396                                this.open_submenu(
1397                                    ix,
1398                                    builder.clone(),
1399                                    SubmenuOpenTrigger::Pointer,
1400                                    window,
1401                                    cx,
1402                                );
1403                            }
1404
1405                            cx.notify();
1406                        } else {
1407                            if this.submenu_trigger_mouse_down {
1408                                return;
1409                            }
1410
1411                            let is_open_for_this_item = matches!(
1412                                &this.submenu_state,
1413                                SubmenuState::Open(open_submenu) if open_submenu.item_index == ix
1414                            );
1415
1416                            if is_open_for_this_item && this.hover_target != HoverTarget::Submenu {
1417                                this.close_submenu(false, cx);
1418                                this.clear_selected();
1419                                cx.notify();
1420                            }
1421                        }
1422                    }))
1423                    .on_click(cx.listener(move |this, _, window, cx| {
1424                        if matches!(
1425                            &this.submenu_state,
1426                            SubmenuState::Open(open_submenu) if open_submenu.item_index == ix
1427                        ) {
1428                            return;
1429                        }
1430
1431                        if let Some(ContextMenuItem::Submenu { builder, .. }) = this.items.get(ix) {
1432                            this.open_submenu(
1433                                ix,
1434                                builder.clone(),
1435                                SubmenuOpenTrigger::Pointer,
1436                                window,
1437                                cx,
1438                            );
1439                        }
1440                    }))
1441                    .child(
1442                        h_flex()
1443                            .w_full()
1444                            .justify_between()
1445                            .child(
1446                                h_flex()
1447                                    .gap_1p5()
1448                                    .when_some(icon, |this, icon_name| {
1449                                        this.child(
1450                                            Icon::new(icon_name)
1451                                                .size(IconSize::Small)
1452                                                .color(Color::Default),
1453                                        )
1454                                    })
1455                                    .child(Label::new(label).color(Color::Default)),
1456                            )
1457                            .child(
1458                                Icon::new(IconName::ChevronRight)
1459                                    .size(IconSize::Small)
1460                                    .color(Color::Muted),
1461                            ),
1462                    ),
1463            )
1464    }
1465
1466    fn padded_submenu_bounds(&self) -> Option<Bounds<Pixels>> {
1467        let bounds = self.main_menu_observed_bounds.get()?;
1468        Some(Bounds {
1469            origin: Point {
1470                x: bounds.origin.x - px(50.0),
1471                y: bounds.origin.y - px(50.0),
1472            },
1473            size: Size {
1474                width: bounds.size.width + px(100.0),
1475                height: bounds.size.height + px(100.0),
1476            },
1477        })
1478    }
1479
1480    fn render_submenu_container(
1481        &self,
1482        ix: usize,
1483        submenu: Entity<ContextMenu>,
1484        offset: Pixels,
1485        cx: &mut Context<Self>,
1486    ) -> impl IntoElement {
1487        let bounds_cell = self.main_menu_observed_bounds.clone();
1488        let canvas = canvas(
1489            {
1490                move |bounds, _window, _cx| {
1491                    bounds_cell.set(Some(bounds));
1492                }
1493            },
1494            |_bounds, _state, _window, _cx| {},
1495        )
1496        .size_full()
1497        .absolute()
1498        .top_0()
1499        .left_0();
1500
1501        div()
1502            .id(("submenu-container", ix))
1503            .absolute()
1504            .left_full()
1505            .ml_neg_0p5()
1506            .top(offset)
1507            .on_hover(cx.listener(|this, hovered, _, _| {
1508                if *hovered {
1509                    this.hover_target = HoverTarget::Submenu;
1510                }
1511            }))
1512            .child(
1513                anchored()
1514                    .anchor(Corner::TopLeft)
1515                    .snap_to_window_with_margin(px(8.0))
1516                    .child(
1517                        div()
1518                            .id(("submenu-hover-zone", ix))
1519                            .occlude()
1520                            .child(canvas)
1521                            .child(submenu),
1522                    ),
1523            )
1524    }
1525
1526    fn render_menu_entry(
1527        &self,
1528        ix: usize,
1529        entry: &ContextMenuEntry,
1530        cx: &mut Context<Self>,
1531    ) -> impl IntoElement {
1532        let ContextMenuEntry {
1533            toggle,
1534            label,
1535            handler,
1536            icon,
1537            custom_icon_path,
1538            custom_icon_svg,
1539            icon_position,
1540            icon_size,
1541            icon_color,
1542            action,
1543            disabled,
1544            documentation_aside,
1545            end_slot_icon,
1546            end_slot_title,
1547            end_slot_handler,
1548            show_end_slot_on_hover,
1549        } = entry;
1550        let this = cx.weak_entity();
1551
1552        let handler = handler.clone();
1553        let menu = cx.entity().downgrade();
1554
1555        let icon_color = if *disabled {
1556            Color::Muted
1557        } else if toggle.is_some() {
1558            icon_color.unwrap_or(Color::Accent)
1559        } else {
1560            icon_color.unwrap_or(Color::Default)
1561        };
1562
1563        let label_color = if *disabled {
1564            Color::Disabled
1565        } else {
1566            Color::Default
1567        };
1568
1569        let label_element = if let Some(custom_path) = custom_icon_path {
1570            h_flex()
1571                .gap_1p5()
1572                .when(
1573                    *icon_position == IconPosition::Start && toggle.is_none(),
1574                    |flex| {
1575                        flex.child(
1576                            Icon::from_path(custom_path.clone())
1577                                .size(*icon_size)
1578                                .color(icon_color),
1579                        )
1580                    },
1581                )
1582                .child(Label::new(label.clone()).color(label_color).truncate())
1583                .when(*icon_position == IconPosition::End, |flex| {
1584                    flex.child(
1585                        Icon::from_path(custom_path.clone())
1586                            .size(*icon_size)
1587                            .color(icon_color),
1588                    )
1589                })
1590                .into_any_element()
1591        } else if let Some(custom_icon_svg) = custom_icon_svg {
1592            h_flex()
1593                .gap_1p5()
1594                .when(
1595                    *icon_position == IconPosition::Start && toggle.is_none(),
1596                    |flex| {
1597                        flex.child(
1598                            Icon::from_external_svg(custom_icon_svg.clone())
1599                                .size(*icon_size)
1600                                .color(icon_color),
1601                        )
1602                    },
1603                )
1604                .child(Label::new(label.clone()).color(label_color).truncate())
1605                .when(*icon_position == IconPosition::End, |flex| {
1606                    flex.child(
1607                        Icon::from_external_svg(custom_icon_svg.clone())
1608                            .size(*icon_size)
1609                            .color(icon_color),
1610                    )
1611                })
1612                .into_any_element()
1613        } else if let Some(icon_name) = icon {
1614            h_flex()
1615                .gap_1p5()
1616                .when(
1617                    *icon_position == IconPosition::Start && toggle.is_none(),
1618                    |flex| flex.child(Icon::new(*icon_name).size(*icon_size).color(icon_color)),
1619                )
1620                .child(Label::new(label.clone()).color(label_color).truncate())
1621                .when(*icon_position == IconPosition::End, |flex| {
1622                    flex.child(Icon::new(*icon_name).size(*icon_size).color(icon_color))
1623                })
1624                .into_any_element()
1625        } else {
1626            Label::new(label.clone())
1627                .color(label_color)
1628                .truncate()
1629                .into_any_element()
1630        };
1631
1632        let aside_trigger_bounds = self.aside_trigger_bounds.clone();
1633
1634        div()
1635            .id(("context-menu-child", ix))
1636            .when_some(documentation_aside.clone(), |this, documentation_aside| {
1637                this.occlude()
1638                    .on_hover(cx.listener(move |menu, hovered, _, cx| {
1639                        if *hovered {
1640                            menu.documentation_aside = Some((ix, documentation_aside.clone()));
1641                        } else if matches!(menu.documentation_aside, Some((id, _)) if id == ix) {
1642                            menu.documentation_aside = None;
1643                        }
1644                        cx.notify();
1645                    }))
1646            })
1647            .when(documentation_aside.is_some(), |this| {
1648                this.child(
1649                    canvas(
1650                        {
1651                            let aside_trigger_bounds = aside_trigger_bounds.clone();
1652                            move |bounds, _window, _cx| {
1653                                aside_trigger_bounds.borrow_mut().insert(ix, bounds);
1654                            }
1655                        },
1656                        |_bounds, _state, _window, _cx| {},
1657                    )
1658                    .size_full()
1659                    .absolute()
1660                    .top_0()
1661                    .left_0(),
1662                )
1663            })
1664            .child(
1665                ListItem::new(ix)
1666                    .group_name("label_container")
1667                    .inset(true)
1668                    .disabled(*disabled)
1669                    .toggle_state(Some(ix) == self.selected_index)
1670                    .when(self.main_menu.is_none() && !*disabled, |item| {
1671                        item.on_hover(cx.listener(move |this, hovered, window, cx| {
1672                            if *hovered {
1673                                this.clear_selected();
1674                                window.focus(&this.focus_handle.clone(), cx);
1675
1676                                if let SubmenuState::Open(open_submenu) = &this.submenu_state {
1677                                    if open_submenu.item_index != ix {
1678                                        this.close_submenu(false, cx);
1679                                        cx.notify();
1680                                    }
1681                                }
1682                            }
1683                        }))
1684                    })
1685                    .when(self.main_menu.is_some(), |item| {
1686                        item.on_click(cx.listener(move |this, _, window, cx| {
1687                            if matches!(
1688                                &this.submenu_state,
1689                                SubmenuState::Open(open_submenu) if open_submenu.item_index == ix
1690                            ) {
1691                                return;
1692                            }
1693
1694                            if let Some(ContextMenuItem::Submenu { builder, .. }) =
1695                                this.items.get(ix)
1696                            {
1697                                this.open_submenu(
1698                                    ix,
1699                                    builder.clone(),
1700                                    SubmenuOpenTrigger::Pointer,
1701                                    window,
1702                                    cx,
1703                                );
1704                            }
1705                        }))
1706                        .on_hover(cx.listener(
1707                            move |this, hovered, window, cx| {
1708                                if *hovered {
1709                                    this.clear_selected();
1710                                    cx.notify();
1711                                }
1712
1713                                if let Some(parent) = &this.main_menu {
1714                                    let mouse_pos = window.mouse_position();
1715                                    let parent_clone = parent.clone();
1716
1717                                    if *hovered {
1718                                        parent.update(cx, |parent, _| {
1719                                            parent.clear_selected();
1720                                            parent.hover_target = HoverTarget::Submenu;
1721                                        });
1722                                    } else {
1723                                        parent_clone.update(cx, |parent, cx| {
1724                                            if matches!(
1725                                                &parent.submenu_state,
1726                                                SubmenuState::Open(_)
1727                                            ) {
1728                                                // Only close if mouse is to the left of the safety threshold
1729                                                // (prevents accidental close when moving diagonally toward submenu)
1730                                                let should_close = parent
1731                                                    .submenu_safety_threshold_x
1732                                                    .map(|threshold_x| mouse_pos.x < threshold_x)
1733                                                    .unwrap_or(true);
1734
1735                                                if should_close {
1736                                                    parent.close_submenu(true, cx);
1737                                                }
1738                                            }
1739                                        });
1740                                    }
1741                                }
1742                            },
1743                        ))
1744                    })
1745                    .when_some(*toggle, |list_item, (position, toggled)| {
1746                        let contents = div()
1747                            .flex_none()
1748                            .child(
1749                                Icon::new(icon.unwrap_or(IconName::Check))
1750                                    .color(icon_color)
1751                                    .size(*icon_size),
1752                            )
1753                            .when(!toggled, |contents| contents.invisible());
1754
1755                        match position {
1756                            IconPosition::Start => list_item.start_slot(contents),
1757                            IconPosition::End => list_item.end_slot(contents),
1758                        }
1759                    })
1760                    .child(
1761                        h_flex()
1762                            .w_full()
1763                            .justify_between()
1764                            .child(label_element)
1765                            .debug_selector(|| format!("MENU_ITEM-{}", label))
1766                            .children(action.as_ref().map(|action| {
1767                                let binding = self
1768                                    .action_context
1769                                    .as_ref()
1770                                    .map(|focus| KeyBinding::for_action_in(&**action, focus, cx))
1771                                    .unwrap_or_else(|| KeyBinding::for_action(&**action, cx));
1772
1773                                div()
1774                                    .ml_4()
1775                                    .child(binding.disabled(*disabled))
1776                                    .when(*disabled && documentation_aside.is_some(), |parent| {
1777                                        parent.invisible()
1778                                    })
1779                            }))
1780                            .when(*disabled && documentation_aside.is_some(), |parent| {
1781                                parent.child(
1782                                    Icon::new(IconName::Info)
1783                                        .size(IconSize::XSmall)
1784                                        .color(Color::Muted),
1785                                )
1786                            }),
1787                    )
1788                    .when_some(
1789                        end_slot_icon
1790                            .as_ref()
1791                            .zip(self.end_slot_action.as_ref())
1792                            .zip(end_slot_title.as_ref())
1793                            .zip(end_slot_handler.as_ref()),
1794                        |el, (((icon, action), title), handler)| {
1795                            el.end_slot({
1796                                let icon_button = IconButton::new("end-slot-icon", *icon)
1797                                    .shape(IconButtonShape::Square)
1798                                    .tooltip({
1799                                        let action_context = self.action_context.clone();
1800                                        let title = title.clone();
1801                                        let action = action.boxed_clone();
1802                                        move |_window, cx| {
1803                                            action_context
1804                                                .as_ref()
1805                                                .map(|focus| {
1806                                                    Tooltip::for_action_in(
1807                                                        title.clone(),
1808                                                        &*action,
1809                                                        focus,
1810                                                        cx,
1811                                                    )
1812                                                })
1813                                                .unwrap_or_else(|| {
1814                                                    Tooltip::for_action(title.clone(), &*action, cx)
1815                                                })
1816                                        }
1817                                    })
1818                                    .on_click({
1819                                        let handler = handler.clone();
1820                                        move |_, window, cx| {
1821                                            handler(None, window, cx);
1822                                            this.update(cx, |this, cx| {
1823                                                this.rebuild(window, cx);
1824                                                cx.notify();
1825                                            })
1826                                            .ok();
1827                                        }
1828                                    });
1829
1830                                if *show_end_slot_on_hover {
1831                                    div()
1832                                        .visible_on_hover("label_container")
1833                                        .child(icon_button)
1834                                        .into_any_element()
1835                                } else {
1836                                    icon_button.into_any_element()
1837                                }
1838                            })
1839                        },
1840                    )
1841                    .on_click({
1842                        let context = self.action_context.clone();
1843                        let keep_open_on_confirm = self.keep_open_on_confirm;
1844                        move |_, window, cx| {
1845                            handler(context.as_ref(), window, cx);
1846                            menu.update(cx, |menu, cx| {
1847                                menu.clicked = true;
1848                                if keep_open_on_confirm {
1849                                    menu.rebuild(window, cx);
1850                                } else {
1851                                    cx.emit(DismissEvent);
1852                                }
1853                            })
1854                            .ok();
1855                        }
1856                    }),
1857            )
1858            .into_any_element()
1859    }
1860}
1861
1862impl ContextMenuItem {
1863    fn is_selectable(&self) -> bool {
1864        match self {
1865            ContextMenuItem::Header(_)
1866            | ContextMenuItem::HeaderWithLink(_, _, _)
1867            | ContextMenuItem::Separator
1868            | ContextMenuItem::Label { .. } => false,
1869            ContextMenuItem::Entry(ContextMenuEntry { disabled, .. }) => !disabled,
1870            ContextMenuItem::CustomEntry { selectable, .. } => *selectable,
1871            ContextMenuItem::Submenu { .. } => true,
1872        }
1873    }
1874}
1875
1876impl Render for ContextMenu {
1877    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1878        let ui_font_size = ThemeSettings::get_global(cx).ui_font_size(cx);
1879        let window_size = window.viewport_size();
1880        let rem_size = window.rem_size();
1881        let is_wide_window = window_size.width / rem_size > rems_from_px(800.).0;
1882
1883        let mut focus_submenu: Option<FocusHandle> = None;
1884
1885        let submenu_container = match &mut self.submenu_state {
1886            SubmenuState::Open(open_submenu) => {
1887                let is_initializing = open_submenu.offset.is_none();
1888
1889                let computed_offset = if is_initializing {
1890                    let menu_bounds = self.main_menu_observed_bounds.get();
1891                    let trigger_bounds = open_submenu
1892                        .trigger_bounds
1893                        .or_else(|| self.submenu_trigger_bounds.get());
1894
1895                    match (menu_bounds, trigger_bounds) {
1896                        (Some(menu_bounds), Some(trigger_bounds)) => {
1897                            Some(trigger_bounds.origin.y - menu_bounds.origin.y)
1898                        }
1899                        _ => None,
1900                    }
1901                } else {
1902                    None
1903                };
1904
1905                if let Some(offset) = open_submenu.offset.or(computed_offset) {
1906                    if open_submenu.offset.is_none() {
1907                        open_submenu.offset = Some(offset);
1908                    }
1909
1910                    focus_submenu = Some(open_submenu.entity.read(cx).focus_handle.clone());
1911                    Some((open_submenu.item_index, open_submenu.entity.clone(), offset))
1912                } else {
1913                    None
1914                }
1915            }
1916            _ => None,
1917        };
1918
1919        let aside = self.documentation_aside.clone();
1920        let render_aside = |aside: DocumentationAside, cx: &mut Context<Self>| {
1921            WithRemSize::new(ui_font_size)
1922                .occlude()
1923                .elevation_2(cx)
1924                .w_full()
1925                .p_2()
1926                .overflow_hidden()
1927                .when(is_wide_window, |this| this.max_w_96())
1928                .when(!is_wide_window, |this| this.max_w_48())
1929                .child((aside.render)(cx))
1930        };
1931
1932        let render_menu = |cx: &mut Context<Self>, window: &mut Window| {
1933            let bounds_cell = self.main_menu_observed_bounds.clone();
1934            let menu_bounds_measure = canvas(
1935                {
1936                    move |bounds, _window, _cx| {
1937                        bounds_cell.set(Some(bounds));
1938                    }
1939                },
1940                |_bounds, _state, _window, _cx| {},
1941            )
1942            .size_full()
1943            .absolute()
1944            .top_0()
1945            .left_0();
1946
1947            WithRemSize::new(ui_font_size)
1948                .occlude()
1949                .elevation_2(cx)
1950                .flex()
1951                .flex_row()
1952                .flex_shrink_0()
1953                .child(
1954                    v_flex()
1955                        .id("context-menu")
1956                        .max_h(vh(0.75, window))
1957                        .flex_shrink_0()
1958                        .child(menu_bounds_measure)
1959                        .when_some(self.fixed_width, |this, width| {
1960                            this.w(width).overflow_x_hidden()
1961                        })
1962                        .when(self.fixed_width.is_none(), |this| {
1963                            this.min_w(px(200.)).flex_1()
1964                        })
1965                        .overflow_y_scroll()
1966                        .track_focus(&self.focus_handle(cx))
1967                        .key_context(self.key_context.as_ref())
1968                        .on_action(cx.listener(ContextMenu::select_first))
1969                        .on_action(cx.listener(ContextMenu::handle_select_last))
1970                        .on_action(cx.listener(ContextMenu::select_next))
1971                        .on_action(cx.listener(ContextMenu::select_previous))
1972                        .on_action(cx.listener(ContextMenu::select_submenu_child))
1973                        .on_action(cx.listener(ContextMenu::select_submenu_parent))
1974                        .on_action(cx.listener(ContextMenu::confirm))
1975                        .on_action(cx.listener(ContextMenu::cancel))
1976                        .on_hover(cx.listener(|this, hovered: &bool, _, _| {
1977                            if *hovered {
1978                                this.hover_target = HoverTarget::MainMenu;
1979                            }
1980                        }))
1981                        .on_mouse_down_out(cx.listener(
1982                            |this, event: &MouseDownEvent, window, cx| {
1983                                if matches!(&this.submenu_state, SubmenuState::Open(_)) {
1984                                    if let Some(padded_bounds) = this.padded_submenu_bounds() {
1985                                        if padded_bounds.contains(&event.position) {
1986                                            return;
1987                                        }
1988                                    }
1989                                }
1990
1991                                if let Some(parent) = &this.main_menu {
1992                                    let overridden_by_parent_trigger = parent
1993                                        .read(cx)
1994                                        .submenu_trigger_bounds
1995                                        .get()
1996                                        .is_some_and(|bounds| bounds.contains(&event.position));
1997                                    if overridden_by_parent_trigger {
1998                                        return;
1999                                    }
2000                                }
2001
2002                                this.cancel(&menu::Cancel, window, cx)
2003                            },
2004                        ))
2005                        .when_some(self.end_slot_action.as_ref(), |el, action| {
2006                            el.on_boxed_action(&**action, cx.listener(ContextMenu::end_slot))
2007                        })
2008                        .when(!self.delayed, |mut el| {
2009                            for item in self.items.iter() {
2010                                if let ContextMenuItem::Entry(ContextMenuEntry {
2011                                    action: Some(action),
2012                                    disabled: false,
2013                                    ..
2014                                }) = item
2015                                {
2016                                    el = el.on_boxed_action(
2017                                        &**action,
2018                                        cx.listener(ContextMenu::on_action_dispatch),
2019                                    );
2020                                }
2021                            }
2022                            el
2023                        })
2024                        .child(
2025                            List::new().children(
2026                                self.items
2027                                    .iter()
2028                                    .enumerate()
2029                                    .map(|(ix, item)| self.render_menu_item(ix, item, window, cx)),
2030                            ),
2031                        ),
2032                )
2033        };
2034
2035        if let Some(focus_handle) = focus_submenu.as_ref() {
2036            window.focus(focus_handle, cx);
2037        }
2038
2039        if is_wide_window {
2040            let menu_bounds = self.main_menu_observed_bounds.get();
2041            let trigger_bounds = self
2042                .documentation_aside
2043                .as_ref()
2044                .and_then(|(ix, _)| self.aside_trigger_bounds.borrow().get(ix).copied());
2045
2046            let trigger_position = match (menu_bounds, trigger_bounds) {
2047                (Some(menu_bounds), Some(trigger_bounds)) => {
2048                    let relative_top = trigger_bounds.origin.y - menu_bounds.origin.y;
2049                    let height = trigger_bounds.size.height;
2050                    Some((relative_top, height))
2051                }
2052                _ => None,
2053            };
2054
2055            div()
2056                .relative()
2057                .child(render_menu(cx, window))
2058                // Only render the aside once we have trigger bounds to avoid flicker.
2059                .when_some(trigger_position, |this, (top, height)| {
2060                    this.children(aside.map(|(_, aside)| {
2061                        h_flex()
2062                            .absolute()
2063                            .when(aside.side == DocumentationSide::Left, |el| {
2064                                el.right_full().mr_1()
2065                            })
2066                            .when(aside.side == DocumentationSide::Right, |el| {
2067                                el.left_full().ml_1()
2068                            })
2069                            .top(top)
2070                            .h(height)
2071                            .child(render_aside(aside, cx))
2072                    }))
2073                })
2074                .when_some(submenu_container, |this, (ix, submenu, offset)| {
2075                    this.child(self.render_submenu_container(ix, submenu, offset, cx))
2076                })
2077        } else {
2078            v_flex()
2079                .w_full()
2080                .relative()
2081                .gap_1()
2082                .justify_end()
2083                .children(aside.map(|(_, aside)| render_aside(aside, cx)))
2084                .child(render_menu(cx, window))
2085                .when_some(submenu_container, |this, (ix, submenu, offset)| {
2086                    this.child(self.render_submenu_container(ix, submenu, offset, cx))
2087                })
2088        }
2089    }
2090}
2091
2092#[cfg(test)]
2093mod tests {
2094    use gpui::TestAppContext;
2095
2096    use super::*;
2097
2098    #[gpui::test]
2099    fn can_navigate_back_over_headers(cx: &mut TestAppContext) {
2100        let cx = cx.add_empty_window();
2101        let context_menu = cx.update(|window, cx| {
2102            ContextMenu::build(window, cx, |menu, _, _| {
2103                menu.header("First header")
2104                    .separator()
2105                    .entry("First entry", None, |_, _| {})
2106                    .separator()
2107                    .separator()
2108                    .entry("Last entry", None, |_, _| {})
2109                    .header("Last header")
2110            })
2111        });
2112
2113        context_menu.update_in(cx, |context_menu, window, cx| {
2114            assert_eq!(
2115                None, context_menu.selected_index,
2116                "No selection is in the menu initially"
2117            );
2118
2119            context_menu.select_first(&SelectFirst, window, cx);
2120            assert_eq!(
2121                Some(2),
2122                context_menu.selected_index,
2123                "Should select first selectable entry, skipping the header and the separator"
2124            );
2125
2126            context_menu.select_next(&SelectNext, window, cx);
2127            assert_eq!(
2128                Some(5),
2129                context_menu.selected_index,
2130                "Should select next selectable entry, skipping 2 separators along the way"
2131            );
2132
2133            context_menu.select_next(&SelectNext, window, cx);
2134            assert_eq!(
2135                Some(2),
2136                context_menu.selected_index,
2137                "Should wrap around to first selectable entry"
2138            );
2139        });
2140
2141        context_menu.update_in(cx, |context_menu, window, cx| {
2142            assert_eq!(
2143                Some(2),
2144                context_menu.selected_index,
2145                "Should start from the first selectable entry"
2146            );
2147
2148            context_menu.select_previous(&SelectPrevious, window, cx);
2149            assert_eq!(
2150                Some(5),
2151                context_menu.selected_index,
2152                "Should wrap around to previous selectable entry (last)"
2153            );
2154
2155            context_menu.select_previous(&SelectPrevious, window, cx);
2156            assert_eq!(
2157                Some(2),
2158                context_menu.selected_index,
2159                "Should go back to previous selectable entry (first)"
2160            );
2161        });
2162
2163        context_menu.update_in(cx, |context_menu, window, cx| {
2164            context_menu.select_first(&SelectFirst, window, cx);
2165            assert_eq!(
2166                Some(2),
2167                context_menu.selected_index,
2168                "Should start from the first selectable entry"
2169            );
2170
2171            context_menu.select_previous(&SelectPrevious, window, cx);
2172            assert_eq!(
2173                Some(5),
2174                context_menu.selected_index,
2175                "Should wrap around to last selectable entry"
2176            );
2177            context_menu.select_next(&SelectNext, window, cx);
2178            assert_eq!(
2179                Some(2),
2180                context_menu.selected_index,
2181                "Should wrap around to first selectable entry"
2182            );
2183        });
2184    }
2185}