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