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