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