context_menu.rs

   1use crate::{
   2    Icon, IconButtonShape, IconName, IconSize, KeyBinding, Label, List, ListItem, ListSeparator,
   3    ListSubHeader, h_flex, prelude::*, utils::WithRemSize, v_flex,
   4};
   5use gpui::{
   6    Action, AnyElement, App, AppContext as _, DismissEvent, Entity, EventEmitter, FocusHandle,
   7    Focusable, IntoElement, Render, Subscription, px,
   8};
   9use menu::{SelectFirst, SelectLast, SelectNext, SelectPrevious};
  10use settings::Settings;
  11use std::{rc::Rc, time::Duration};
  12use theme::ThemeSettings;
  13
  14use super::Tooltip;
  15
  16pub enum ContextMenuItem {
  17    Separator,
  18    Header(SharedString),
  19    /// title, link_label, link_url
  20    HeaderWithLink(SharedString, SharedString, SharedString), // This could be folded into header
  21    Label(SharedString),
  22    Entry(ContextMenuEntry),
  23    CustomEntry {
  24        entry_render: Box<dyn Fn(&mut Window, &mut App) -> AnyElement>,
  25        handler: Rc<dyn Fn(Option<&FocusHandle>, &mut Window, &mut App)>,
  26        selectable: bool,
  27        documentation_aside: Option<DocumentationAside>,
  28    },
  29}
  30
  31impl ContextMenuItem {
  32    pub fn custom_entry(
  33        entry_render: impl Fn(&mut Window, &mut App) -> AnyElement + 'static,
  34        handler: impl Fn(&mut Window, &mut App) + 'static,
  35        documentation_aside: Option<DocumentationAside>,
  36    ) -> Self {
  37        Self::CustomEntry {
  38            entry_render: Box::new(entry_render),
  39            handler: Rc::new(move |_, window, cx| handler(window, cx)),
  40            selectable: true,
  41            documentation_aside,
  42        }
  43    }
  44}
  45
  46pub struct ContextMenuEntry {
  47    toggle: Option<(IconPosition, bool)>,
  48    label: SharedString,
  49    icon: Option<IconName>,
  50    icon_position: IconPosition,
  51    icon_size: IconSize,
  52    icon_color: Option<Color>,
  53    handler: Rc<dyn Fn(Option<&FocusHandle>, &mut Window, &mut App)>,
  54    action: Option<Box<dyn Action>>,
  55    disabled: bool,
  56    documentation_aside: Option<DocumentationAside>,
  57    end_slot_icon: Option<IconName>,
  58    end_slot_title: Option<SharedString>,
  59    end_slot_handler: Option<Rc<dyn Fn(Option<&FocusHandle>, &mut Window, &mut App)>>,
  60    show_end_slot_on_hover: bool,
  61}
  62
  63impl ContextMenuEntry {
  64    pub fn new(label: impl Into<SharedString>) -> Self {
  65        ContextMenuEntry {
  66            toggle: None,
  67            label: label.into(),
  68            icon: None,
  69            icon_position: IconPosition::Start,
  70            icon_size: IconSize::Small,
  71            icon_color: None,
  72            handler: Rc::new(|_, _, _| {}),
  73            action: None,
  74            disabled: false,
  75            documentation_aside: None,
  76            end_slot_icon: None,
  77            end_slot_title: None,
  78            end_slot_handler: None,
  79            show_end_slot_on_hover: false,
  80        }
  81    }
  82
  83    pub fn toggleable(mut self, toggle_position: IconPosition, toggled: bool) -> Self {
  84        self.toggle = Some((toggle_position, toggled));
  85        self
  86    }
  87
  88    pub fn icon(mut self, icon: IconName) -> Self {
  89        self.icon = Some(icon);
  90        self
  91    }
  92
  93    pub fn icon_position(mut self, position: IconPosition) -> Self {
  94        self.icon_position = position;
  95        self
  96    }
  97
  98    pub fn icon_size(mut self, icon_size: IconSize) -> Self {
  99        self.icon_size = icon_size;
 100        self
 101    }
 102
 103    pub fn icon_color(mut self, icon_color: Color) -> Self {
 104        self.icon_color = Some(icon_color);
 105        self
 106    }
 107
 108    pub fn toggle(mut self, toggle_position: IconPosition, toggled: bool) -> Self {
 109        self.toggle = Some((toggle_position, toggled));
 110        self
 111    }
 112
 113    pub fn action(mut self, action: Box<dyn Action>) -> Self {
 114        self.action = Some(action);
 115        self
 116    }
 117
 118    pub fn handler(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
 119        self.handler = Rc::new(move |_, window, cx| handler(window, cx));
 120        self
 121    }
 122
 123    pub fn disabled(mut self, disabled: bool) -> Self {
 124        self.disabled = disabled;
 125        self
 126    }
 127
 128    pub fn documentation_aside(
 129        mut self,
 130        side: DocumentationSide,
 131        edge: DocumentationEdge,
 132        render: impl Fn(&mut App) -> AnyElement + 'static,
 133    ) -> Self {
 134        self.documentation_aside = Some(DocumentationAside {
 135            side,
 136            edge,
 137            render: Rc::new(render),
 138        });
 139
 140        self
 141    }
 142}
 143
 144impl FluentBuilder for ContextMenuEntry {}
 145
 146impl From<ContextMenuEntry> for ContextMenuItem {
 147    fn from(entry: ContextMenuEntry) -> Self {
 148        ContextMenuItem::Entry(entry)
 149    }
 150}
 151
 152pub struct ContextMenu {
 153    builder: Option<Rc<dyn Fn(Self, &mut Window, &mut Context<Self>) -> Self>>,
 154    items: Vec<ContextMenuItem>,
 155    focus_handle: FocusHandle,
 156    action_context: Option<FocusHandle>,
 157    selected_index: Option<usize>,
 158    delayed: bool,
 159    clicked: bool,
 160    end_slot_action: Option<Box<dyn Action>>,
 161    key_context: SharedString,
 162    _on_blur_subscription: Subscription,
 163    keep_open_on_confirm: bool,
 164    documentation_aside: Option<(usize, DocumentationAside)>,
 165    fixed_width: Option<DefiniteLength>,
 166}
 167
 168#[derive(Copy, Clone, PartialEq, Eq)]
 169pub enum DocumentationSide {
 170    Left,
 171    Right,
 172}
 173
 174#[derive(Copy, Default, Clone, PartialEq, Eq)]
 175pub enum DocumentationEdge {
 176    #[default]
 177    Top,
 178    Bottom,
 179}
 180
 181#[derive(Clone)]
 182pub struct DocumentationAside {
 183    pub side: DocumentationSide,
 184    pub edge: DocumentationEdge,
 185    pub render: Rc<dyn Fn(&mut App) -> AnyElement>,
 186}
 187
 188impl DocumentationAside {
 189    pub fn new(
 190        side: DocumentationSide,
 191        edge: DocumentationEdge,
 192        render: Rc<dyn Fn(&mut App) -> AnyElement>,
 193    ) -> Self {
 194        Self { side, edge, render }
 195    }
 196}
 197
 198impl Focusable for ContextMenu {
 199    fn focus_handle(&self, _cx: &App) -> FocusHandle {
 200        self.focus_handle.clone()
 201    }
 202}
 203
 204impl EventEmitter<DismissEvent> for ContextMenu {}
 205
 206impl FluentBuilder for ContextMenu {}
 207
 208impl ContextMenu {
 209    pub fn build(
 210        window: &mut Window,
 211        cx: &mut App,
 212        f: impl FnOnce(Self, &mut Window, &mut Context<Self>) -> Self,
 213    ) -> Entity<Self> {
 214        cx.new(|cx| {
 215            let focus_handle = cx.focus_handle();
 216            let _on_blur_subscription = cx.on_blur(
 217                &focus_handle,
 218                window,
 219                |this: &mut ContextMenu, window, cx| this.cancel(&menu::Cancel, window, cx),
 220            );
 221            window.refresh();
 222            f(
 223                Self {
 224                    builder: None,
 225                    items: Default::default(),
 226                    focus_handle,
 227                    action_context: None,
 228                    selected_index: None,
 229                    delayed: false,
 230                    clicked: false,
 231                    key_context: "menu".into(),
 232                    _on_blur_subscription,
 233                    keep_open_on_confirm: false,
 234                    documentation_aside: None,
 235                    fixed_width: None,
 236                    end_slot_action: None,
 237                },
 238                window,
 239                cx,
 240            )
 241        })
 242    }
 243
 244    /// Builds a [`ContextMenu`] that will stay open when making changes instead of closing after each confirmation.
 245    ///
 246    /// The main difference from [`ContextMenu::build`] is the type of the `builder`, as we need to be able to hold onto
 247    /// it to call it again.
 248    pub fn build_persistent(
 249        window: &mut Window,
 250        cx: &mut App,
 251        builder: impl Fn(Self, &mut Window, &mut Context<Self>) -> Self + 'static,
 252    ) -> Entity<Self> {
 253        cx.new(|cx| {
 254            let builder = Rc::new(builder);
 255
 256            let focus_handle = cx.focus_handle();
 257            let _on_blur_subscription = cx.on_blur(
 258                &focus_handle,
 259                window,
 260                |this: &mut ContextMenu, window, cx| this.cancel(&menu::Cancel, window, cx),
 261            );
 262            window.refresh();
 263
 264            (builder.clone())(
 265                Self {
 266                    builder: Some(builder),
 267                    items: Default::default(),
 268                    focus_handle,
 269                    action_context: None,
 270                    selected_index: None,
 271                    delayed: false,
 272                    clicked: false,
 273                    key_context: "menu".into(),
 274                    _on_blur_subscription,
 275                    keep_open_on_confirm: true,
 276                    documentation_aside: None,
 277                    fixed_width: None,
 278                    end_slot_action: None,
 279                },
 280                window,
 281                cx,
 282            )
 283        })
 284    }
 285
 286    /// Rebuilds the menu.
 287    ///
 288    /// This is used to refresh the menu entries when entries are toggled when the menu is configured with
 289    /// `keep_open_on_confirm = true`.
 290    ///
 291    /// This only works if the [`ContextMenu`] was constructed using [`ContextMenu::build_persistent`]. Otherwise it is
 292    /// a no-op.
 293    pub fn rebuild(&mut self, window: &mut Window, cx: &mut Context<Self>) {
 294        let Some(builder) = self.builder.clone() else {
 295            return;
 296        };
 297
 298        // The way we rebuild the menu is a bit of a hack.
 299        let focus_handle = cx.focus_handle();
 300        let new_menu = (builder.clone())(
 301            Self {
 302                builder: Some(builder),
 303                items: Default::default(),
 304                focus_handle: focus_handle.clone(),
 305                action_context: None,
 306                selected_index: None,
 307                delayed: false,
 308                clicked: false,
 309                key_context: "menu".into(),
 310                _on_blur_subscription: cx.on_blur(
 311                    &focus_handle,
 312                    window,
 313                    |this: &mut ContextMenu, window, cx| this.cancel(&menu::Cancel, window, cx),
 314                ),
 315                keep_open_on_confirm: false,
 316                documentation_aside: None,
 317                fixed_width: None,
 318                end_slot_action: None,
 319            },
 320            window,
 321            cx,
 322        );
 323
 324        self.items = new_menu.items;
 325
 326        cx.notify();
 327    }
 328
 329    pub fn context(mut self, focus: FocusHandle) -> Self {
 330        self.action_context = Some(focus);
 331        self
 332    }
 333
 334    pub fn header(mut self, title: impl Into<SharedString>) -> Self {
 335        self.items.push(ContextMenuItem::Header(title.into()));
 336        self
 337    }
 338
 339    pub fn header_with_link(
 340        mut self,
 341        title: impl Into<SharedString>,
 342        link_label: impl Into<SharedString>,
 343        link_url: impl Into<SharedString>,
 344    ) -> Self {
 345        self.items.push(ContextMenuItem::HeaderWithLink(
 346            title.into(),
 347            link_label.into(),
 348            link_url.into(),
 349        ));
 350        self
 351    }
 352
 353    pub fn separator(mut self) -> Self {
 354        self.items.push(ContextMenuItem::Separator);
 355        self
 356    }
 357
 358    pub fn extend<I: Into<ContextMenuItem>>(mut self, items: impl IntoIterator<Item = I>) -> Self {
 359        self.items.extend(items.into_iter().map(Into::into));
 360        self
 361    }
 362
 363    pub fn item(mut self, item: impl Into<ContextMenuItem>) -> Self {
 364        self.items.push(item.into());
 365        self
 366    }
 367
 368    pub fn push_item(&mut self, item: impl Into<ContextMenuItem>) {
 369        self.items.push(item.into());
 370    }
 371
 372    pub fn entry(
 373        mut self,
 374        label: impl Into<SharedString>,
 375        action: Option<Box<dyn Action>>,
 376        handler: impl Fn(&mut Window, &mut App) + 'static,
 377    ) -> Self {
 378        self.items.push(ContextMenuItem::Entry(ContextMenuEntry {
 379            toggle: None,
 380            label: label.into(),
 381            handler: Rc::new(move |_, window, cx| handler(window, cx)),
 382            icon: None,
 383            icon_position: IconPosition::End,
 384            icon_size: IconSize::Small,
 385            icon_color: None,
 386            action,
 387            disabled: false,
 388            documentation_aside: None,
 389            end_slot_icon: None,
 390            end_slot_title: None,
 391            end_slot_handler: None,
 392            show_end_slot_on_hover: false,
 393        }));
 394        self
 395    }
 396
 397    pub fn entry_with_end_slot(
 398        mut self,
 399        label: impl Into<SharedString>,
 400        action: Option<Box<dyn Action>>,
 401        handler: impl Fn(&mut Window, &mut App) + 'static,
 402        end_slot_icon: IconName,
 403        end_slot_title: SharedString,
 404        end_slot_handler: impl Fn(&mut Window, &mut App) + 'static,
 405    ) -> Self {
 406        self.items.push(ContextMenuItem::Entry(ContextMenuEntry {
 407            toggle: None,
 408            label: label.into(),
 409            handler: Rc::new(move |_, window, cx| handler(window, cx)),
 410            icon: None,
 411            icon_position: IconPosition::End,
 412            icon_size: IconSize::Small,
 413            icon_color: None,
 414            action,
 415            disabled: false,
 416            documentation_aside: None,
 417            end_slot_icon: Some(end_slot_icon),
 418            end_slot_title: Some(end_slot_title),
 419            end_slot_handler: Some(Rc::new(move |_, window, cx| end_slot_handler(window, cx))),
 420            show_end_slot_on_hover: false,
 421        }));
 422        self
 423    }
 424
 425    pub fn entry_with_end_slot_on_hover(
 426        mut self,
 427        label: impl Into<SharedString>,
 428        action: Option<Box<dyn Action>>,
 429        handler: impl Fn(&mut Window, &mut App) + 'static,
 430        end_slot_icon: IconName,
 431        end_slot_title: SharedString,
 432        end_slot_handler: impl Fn(&mut Window, &mut App) + 'static,
 433    ) -> Self {
 434        self.items.push(ContextMenuItem::Entry(ContextMenuEntry {
 435            toggle: None,
 436            label: label.into(),
 437            handler: Rc::new(move |_, window, cx| handler(window, cx)),
 438            icon: None,
 439            icon_position: IconPosition::End,
 440            icon_size: IconSize::Small,
 441            icon_color: None,
 442            action,
 443            disabled: false,
 444            documentation_aside: None,
 445            end_slot_icon: Some(end_slot_icon),
 446            end_slot_title: Some(end_slot_title),
 447            end_slot_handler: Some(Rc::new(move |_, window, cx| end_slot_handler(window, cx))),
 448            show_end_slot_on_hover: true,
 449        }));
 450        self
 451    }
 452
 453    pub fn toggleable_entry(
 454        mut self,
 455        label: impl Into<SharedString>,
 456        toggled: bool,
 457        position: IconPosition,
 458        action: Option<Box<dyn Action>>,
 459        handler: impl Fn(&mut Window, &mut App) + 'static,
 460    ) -> Self {
 461        self.items.push(ContextMenuItem::Entry(ContextMenuEntry {
 462            toggle: Some((position, toggled)),
 463            label: label.into(),
 464            handler: Rc::new(move |_, window, cx| handler(window, cx)),
 465            icon: None,
 466            icon_position: position,
 467            icon_size: IconSize::Small,
 468            icon_color: None,
 469            action,
 470            disabled: false,
 471            documentation_aside: None,
 472            end_slot_icon: None,
 473            end_slot_title: None,
 474            end_slot_handler: None,
 475            show_end_slot_on_hover: false,
 476        }));
 477        self
 478    }
 479
 480    pub fn custom_row(
 481        mut self,
 482        entry_render: impl Fn(&mut Window, &mut App) -> AnyElement + 'static,
 483    ) -> Self {
 484        self.items.push(ContextMenuItem::CustomEntry {
 485            entry_render: Box::new(entry_render),
 486            handler: Rc::new(|_, _, _| {}),
 487            selectable: false,
 488            documentation_aside: None,
 489        });
 490        self
 491    }
 492
 493    pub fn custom_entry(
 494        mut self,
 495        entry_render: impl Fn(&mut Window, &mut App) -> AnyElement + 'static,
 496        handler: impl Fn(&mut Window, &mut App) + 'static,
 497    ) -> Self {
 498        self.items.push(ContextMenuItem::CustomEntry {
 499            entry_render: Box::new(entry_render),
 500            handler: Rc::new(move |_, window, cx| handler(window, cx)),
 501            selectable: true,
 502            documentation_aside: None,
 503        });
 504        self
 505    }
 506
 507    pub fn label(mut self, label: impl Into<SharedString>) -> Self {
 508        self.items.push(ContextMenuItem::Label(label.into()));
 509        self
 510    }
 511
 512    pub fn action(mut self, label: impl Into<SharedString>, action: Box<dyn Action>) -> Self {
 513        self.items.push(ContextMenuItem::Entry(ContextMenuEntry {
 514            toggle: None,
 515            label: label.into(),
 516            action: Some(action.boxed_clone()),
 517            handler: Rc::new(move |context, window, cx| {
 518                if let Some(context) = &context {
 519                    window.focus(context);
 520                }
 521                window.dispatch_action(action.boxed_clone(), cx);
 522            }),
 523            icon: None,
 524            icon_position: IconPosition::End,
 525            icon_size: IconSize::Small,
 526            icon_color: None,
 527            disabled: false,
 528            documentation_aside: None,
 529            end_slot_icon: None,
 530            end_slot_title: None,
 531            end_slot_handler: None,
 532            show_end_slot_on_hover: false,
 533        }));
 534        self
 535    }
 536
 537    pub fn action_disabled_when(
 538        mut self,
 539        disabled: bool,
 540        label: impl Into<SharedString>,
 541        action: Box<dyn Action>,
 542    ) -> Self {
 543        self.items.push(ContextMenuItem::Entry(ContextMenuEntry {
 544            toggle: None,
 545            label: label.into(),
 546            action: Some(action.boxed_clone()),
 547            handler: Rc::new(move |context, window, cx| {
 548                if let Some(context) = &context {
 549                    window.focus(context);
 550                }
 551                window.dispatch_action(action.boxed_clone(), cx);
 552            }),
 553            icon: None,
 554            icon_size: IconSize::Small,
 555            icon_position: IconPosition::End,
 556            icon_color: None,
 557            disabled,
 558            documentation_aside: None,
 559            end_slot_icon: None,
 560            end_slot_title: None,
 561            end_slot_handler: None,
 562            show_end_slot_on_hover: false,
 563        }));
 564        self
 565    }
 566
 567    pub fn link(mut self, label: impl Into<SharedString>, action: Box<dyn Action>) -> Self {
 568        self.items.push(ContextMenuItem::Entry(ContextMenuEntry {
 569            toggle: None,
 570            label: label.into(),
 571            action: Some(action.boxed_clone()),
 572            handler: Rc::new(move |_, window, cx| window.dispatch_action(action.boxed_clone(), cx)),
 573            icon: Some(IconName::ArrowUpRight),
 574            icon_size: IconSize::XSmall,
 575            icon_position: IconPosition::End,
 576            icon_color: None,
 577            disabled: false,
 578            documentation_aside: None,
 579            end_slot_icon: None,
 580            end_slot_title: None,
 581            end_slot_handler: None,
 582            show_end_slot_on_hover: false,
 583        }));
 584        self
 585    }
 586
 587    pub fn keep_open_on_confirm(mut self, keep_open: bool) -> Self {
 588        self.keep_open_on_confirm = keep_open;
 589        self
 590    }
 591
 592    pub fn trigger_end_slot_handler(&mut self, window: &mut Window, cx: &mut Context<Self>) {
 593        let Some(entry) = self.selected_index.and_then(|ix| self.items.get(ix)) else {
 594            return;
 595        };
 596        let ContextMenuItem::Entry(entry) = entry else {
 597            return;
 598        };
 599        let Some(handler) = entry.end_slot_handler.as_ref() else {
 600            return;
 601        };
 602        handler(None, window, cx);
 603    }
 604
 605    pub fn fixed_width(mut self, width: DefiniteLength) -> Self {
 606        self.fixed_width = Some(width);
 607        self
 608    }
 609
 610    pub fn end_slot_action(mut self, action: Box<dyn Action>) -> Self {
 611        self.end_slot_action = Some(action);
 612        self
 613    }
 614
 615    pub fn key_context(mut self, context: impl Into<SharedString>) -> Self {
 616        self.key_context = context.into();
 617        self
 618    }
 619
 620    pub fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) {
 621        let context = self.action_context.as_ref();
 622        if let Some(
 623            ContextMenuItem::Entry(ContextMenuEntry {
 624                handler,
 625                disabled: false,
 626                ..
 627            })
 628            | ContextMenuItem::CustomEntry { handler, .. },
 629        ) = self.selected_index.and_then(|ix| self.items.get(ix))
 630        {
 631            (handler)(context, window, cx)
 632        }
 633
 634        if self.keep_open_on_confirm {
 635            self.rebuild(window, cx);
 636        } else {
 637            cx.emit(DismissEvent);
 638        }
 639    }
 640
 641    pub fn cancel(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context<Self>) {
 642        cx.emit(DismissEvent);
 643        cx.emit(DismissEvent);
 644    }
 645
 646    pub fn end_slot(&mut self, _: &dyn Action, window: &mut Window, cx: &mut Context<Self>) {
 647        let Some(item) = self.selected_index.and_then(|ix| self.items.get(ix)) else {
 648            return;
 649        };
 650        let ContextMenuItem::Entry(entry) = item else {
 651            return;
 652        };
 653        let Some(handler) = entry.end_slot_handler.as_ref() else {
 654            return;
 655        };
 656        handler(None, window, cx);
 657        self.rebuild(window, cx);
 658        cx.notify();
 659    }
 660
 661    pub fn clear_selected(&mut self) {
 662        self.selected_index = None;
 663    }
 664
 665    pub fn select_first(&mut self, _: &SelectFirst, window: &mut Window, cx: &mut Context<Self>) {
 666        if let Some(ix) = self.items.iter().position(|item| item.is_selectable()) {
 667            self.select_index(ix, window, cx);
 668        }
 669        cx.notify();
 670    }
 671
 672    pub fn select_last(&mut self, window: &mut Window, cx: &mut Context<Self>) -> Option<usize> {
 673        for (ix, item) in self.items.iter().enumerate().rev() {
 674            if item.is_selectable() {
 675                return self.select_index(ix, window, cx);
 676            }
 677        }
 678        None
 679    }
 680
 681    fn handle_select_last(&mut self, _: &SelectLast, window: &mut Window, cx: &mut Context<Self>) {
 682        if self.select_last(window, cx).is_some() {
 683            cx.notify();
 684        }
 685    }
 686
 687    pub fn select_next(&mut self, _: &SelectNext, window: &mut Window, cx: &mut Context<Self>) {
 688        if let Some(ix) = self.selected_index {
 689            let next_index = ix + 1;
 690            if self.items.len() <= next_index {
 691                self.select_first(&SelectFirst, window, cx);
 692                return;
 693            } else {
 694                for (ix, item) in self.items.iter().enumerate().skip(next_index) {
 695                    if item.is_selectable() {
 696                        self.select_index(ix, window, cx);
 697                        cx.notify();
 698                        return;
 699                    }
 700                }
 701            }
 702        }
 703        self.select_first(&SelectFirst, window, cx);
 704    }
 705
 706    pub fn select_previous(
 707        &mut self,
 708        _: &SelectPrevious,
 709        window: &mut Window,
 710        cx: &mut Context<Self>,
 711    ) {
 712        if let Some(ix) = self.selected_index {
 713            for (ix, item) in self.items.iter().enumerate().take(ix).rev() {
 714                if item.is_selectable() {
 715                    self.select_index(ix, window, cx);
 716                    cx.notify();
 717                    return;
 718                }
 719            }
 720        }
 721        self.handle_select_last(&SelectLast, window, cx);
 722    }
 723
 724    fn select_index(
 725        &mut self,
 726        ix: usize,
 727        _window: &mut Window,
 728        _cx: &mut Context<Self>,
 729    ) -> Option<usize> {
 730        self.documentation_aside = None;
 731        let item = self.items.get(ix)?;
 732        if item.is_selectable() {
 733            self.selected_index = Some(ix);
 734            match item {
 735                ContextMenuItem::Entry(entry) => {
 736                    if let Some(callback) = &entry.documentation_aside {
 737                        self.documentation_aside = Some((ix, callback.clone()));
 738                    }
 739                }
 740                ContextMenuItem::CustomEntry {
 741                    documentation_aside: Some(callback),
 742                    ..
 743                } => {
 744                    self.documentation_aside = Some((ix, callback.clone()));
 745                }
 746                _ => (),
 747            }
 748        }
 749        Some(ix)
 750    }
 751
 752    pub fn on_action_dispatch(
 753        &mut self,
 754        dispatched: &dyn Action,
 755        window: &mut Window,
 756        cx: &mut Context<Self>,
 757    ) {
 758        if self.clicked {
 759            cx.propagate();
 760            return;
 761        }
 762
 763        if let Some(ix) = self.items.iter().position(|item| {
 764            if let ContextMenuItem::Entry(ContextMenuEntry {
 765                action: Some(action),
 766                disabled: false,
 767                ..
 768            }) = item
 769            {
 770                action.partial_eq(dispatched)
 771            } else {
 772                false
 773            }
 774        }) {
 775            self.select_index(ix, window, cx);
 776            self.delayed = true;
 777            cx.notify();
 778            let action = dispatched.boxed_clone();
 779            cx.spawn_in(window, async move |this, cx| {
 780                cx.background_executor()
 781                    .timer(Duration::from_millis(50))
 782                    .await;
 783                cx.update(|window, cx| {
 784                    this.update(cx, |this, cx| {
 785                        this.cancel(&menu::Cancel, window, cx);
 786                        window.dispatch_action(action, cx);
 787                    })
 788                })
 789            })
 790            .detach_and_log_err(cx);
 791        } else {
 792            cx.propagate()
 793        }
 794    }
 795
 796    pub fn on_blur_subscription(mut self, new_subscription: Subscription) -> Self {
 797        self._on_blur_subscription = new_subscription;
 798        self
 799    }
 800
 801    fn render_menu_item(
 802        &self,
 803        ix: usize,
 804        item: &ContextMenuItem,
 805        window: &mut Window,
 806        cx: &mut Context<Self>,
 807    ) -> impl IntoElement + use<> {
 808        match item {
 809            ContextMenuItem::Separator => ListSeparator.into_any_element(),
 810            ContextMenuItem::Header(header) => ListSubHeader::new(header.clone())
 811                .inset(true)
 812                .into_any_element(),
 813            ContextMenuItem::HeaderWithLink(header, label, url) => {
 814                let url = url.clone();
 815                let link_id = ElementId::Name(format!("link-{}", url).into());
 816                ListSubHeader::new(header.clone())
 817                    .inset(true)
 818                    .end_slot(
 819                        Button::new(link_id, label.clone())
 820                            .color(Color::Muted)
 821                            .label_size(LabelSize::Small)
 822                            .size(ButtonSize::None)
 823                            .style(ButtonStyle::Transparent)
 824                            .on_click(move |_, _, cx| {
 825                                let url = url.clone();
 826                                cx.open_url(&url);
 827                            })
 828                            .into_any_element(),
 829                    )
 830                    .into_any_element()
 831            }
 832            ContextMenuItem::Label(label) => ListItem::new(ix)
 833                .inset(true)
 834                .disabled(true)
 835                .child(Label::new(label.clone()))
 836                .into_any_element(),
 837            ContextMenuItem::Entry(entry) => {
 838                self.render_menu_entry(ix, entry, cx).into_any_element()
 839            }
 840            ContextMenuItem::CustomEntry {
 841                entry_render,
 842                handler,
 843                selectable,
 844                ..
 845            } => {
 846                let handler = handler.clone();
 847                let menu = cx.entity().downgrade();
 848                let selectable = *selectable;
 849                ListItem::new(ix)
 850                    .inset(true)
 851                    .toggle_state(if selectable {
 852                        Some(ix) == self.selected_index
 853                    } else {
 854                        false
 855                    })
 856                    .selectable(selectable)
 857                    .when(selectable, |item| {
 858                        item.on_click({
 859                            let context = self.action_context.clone();
 860                            let keep_open_on_confirm = self.keep_open_on_confirm;
 861                            move |_, window, cx| {
 862                                handler(context.as_ref(), window, cx);
 863                                menu.update(cx, |menu, cx| {
 864                                    menu.clicked = true;
 865
 866                                    if keep_open_on_confirm {
 867                                        menu.rebuild(window, cx);
 868                                    } else {
 869                                        cx.emit(DismissEvent);
 870                                    }
 871                                })
 872                                .ok();
 873                            }
 874                        })
 875                    })
 876                    .child(entry_render(window, cx))
 877                    .into_any_element()
 878            }
 879        }
 880    }
 881
 882    fn render_menu_entry(
 883        &self,
 884        ix: usize,
 885        entry: &ContextMenuEntry,
 886        cx: &mut Context<Self>,
 887    ) -> impl IntoElement {
 888        let ContextMenuEntry {
 889            toggle,
 890            label,
 891            handler,
 892            icon,
 893            icon_position,
 894            icon_size,
 895            icon_color,
 896            action,
 897            disabled,
 898            documentation_aside,
 899            end_slot_icon,
 900            end_slot_title,
 901            end_slot_handler,
 902            show_end_slot_on_hover,
 903        } = entry;
 904        let this = cx.weak_entity();
 905
 906        let handler = handler.clone();
 907        let menu = cx.entity().downgrade();
 908
 909        let icon_color = if *disabled {
 910            Color::Muted
 911        } else if toggle.is_some() {
 912            icon_color.unwrap_or(Color::Accent)
 913        } else {
 914            icon_color.unwrap_or(Color::Default)
 915        };
 916
 917        let label_color = if *disabled {
 918            Color::Disabled
 919        } else {
 920            Color::Default
 921        };
 922
 923        let label_element = if let Some(icon_name) = icon {
 924            h_flex()
 925                .gap_1p5()
 926                .when(
 927                    *icon_position == IconPosition::Start && toggle.is_none(),
 928                    |flex| flex.child(Icon::new(*icon_name).size(*icon_size).color(icon_color)),
 929                )
 930                .child(Label::new(label.clone()).color(label_color).truncate())
 931                .when(*icon_position == IconPosition::End, |flex| {
 932                    flex.child(Icon::new(*icon_name).size(*icon_size).color(icon_color))
 933                })
 934                .into_any_element()
 935        } else {
 936            Label::new(label.clone())
 937                .color(label_color)
 938                .truncate()
 939                .into_any_element()
 940        };
 941
 942        div()
 943            .id(("context-menu-child", ix))
 944            .when_some(documentation_aside.clone(), |this, documentation_aside| {
 945                this.occlude()
 946                    .on_hover(cx.listener(move |menu, hovered, _, cx| {
 947                        if *hovered {
 948                            menu.documentation_aside = Some((ix, documentation_aside.clone()));
 949                        } else if matches!(menu.documentation_aside, Some((id, _)) if id == ix) {
 950                            menu.documentation_aside = None;
 951                        }
 952                        cx.notify();
 953                    }))
 954            })
 955            .child(
 956                ListItem::new(ix)
 957                    .group_name("label_container")
 958                    .inset(true)
 959                    .disabled(*disabled)
 960                    .toggle_state(Some(ix) == self.selected_index)
 961                    .when_some(*toggle, |list_item, (position, toggled)| {
 962                        let contents = div()
 963                            .flex_none()
 964                            .child(
 965                                Icon::new(icon.unwrap_or(IconName::Check))
 966                                    .color(icon_color)
 967                                    .size(*icon_size),
 968                            )
 969                            .when(!toggled, |contents| contents.invisible());
 970
 971                        match position {
 972                            IconPosition::Start => list_item.start_slot(contents),
 973                            IconPosition::End => list_item.end_slot(contents),
 974                        }
 975                    })
 976                    .child(
 977                        h_flex()
 978                            .w_full()
 979                            .justify_between()
 980                            .child(label_element)
 981                            .debug_selector(|| format!("MENU_ITEM-{}", label))
 982                            .children(action.as_ref().map(|action| {
 983                                let binding = self
 984                                    .action_context
 985                                    .as_ref()
 986                                    .map(|focus| KeyBinding::for_action_in(&**action, focus, cx))
 987                                    .unwrap_or_else(|| KeyBinding::for_action(&**action, cx));
 988
 989                                div()
 990                                    .ml_4()
 991                                    .child(binding.disabled(*disabled))
 992                                    .when(*disabled && documentation_aside.is_some(), |parent| {
 993                                        parent.invisible()
 994                                    })
 995                            }))
 996                            .when(*disabled && documentation_aside.is_some(), |parent| {
 997                                parent.child(
 998                                    Icon::new(IconName::Info)
 999                                        .size(IconSize::XSmall)
1000                                        .color(Color::Muted),
1001                                )
1002                            }),
1003                    )
1004                    .when_some(
1005                        end_slot_icon
1006                            .as_ref()
1007                            .zip(self.end_slot_action.as_ref())
1008                            .zip(end_slot_title.as_ref())
1009                            .zip(end_slot_handler.as_ref()),
1010                        |el, (((icon, action), title), handler)| {
1011                            el.end_slot({
1012                                let icon_button = IconButton::new("end-slot-icon", *icon)
1013                                    .shape(IconButtonShape::Square)
1014                                    .tooltip({
1015                                        let action_context = self.action_context.clone();
1016                                        let title = title.clone();
1017                                        let action = action.boxed_clone();
1018                                        move |_window, cx| {
1019                                            action_context
1020                                                .as_ref()
1021                                                .map(|focus| {
1022                                                    Tooltip::for_action_in(
1023                                                        title.clone(),
1024                                                        &*action,
1025                                                        focus,
1026                                                        cx,
1027                                                    )
1028                                                })
1029                                                .unwrap_or_else(|| {
1030                                                    Tooltip::for_action(title.clone(), &*action, cx)
1031                                                })
1032                                        }
1033                                    })
1034                                    .on_click({
1035                                        let handler = handler.clone();
1036                                        move |_, window, cx| {
1037                                            handler(None, window, cx);
1038                                            this.update(cx, |this, cx| {
1039                                                this.rebuild(window, cx);
1040                                                cx.notify();
1041                                            })
1042                                            .ok();
1043                                        }
1044                                    });
1045
1046                                if *show_end_slot_on_hover {
1047                                    div()
1048                                        .visible_on_hover("label_container")
1049                                        .child(icon_button)
1050                                        .into_any_element()
1051                                } else {
1052                                    icon_button.into_any_element()
1053                                }
1054                            })
1055                        },
1056                    )
1057                    .on_click({
1058                        let context = self.action_context.clone();
1059                        let keep_open_on_confirm = self.keep_open_on_confirm;
1060                        move |_, window, cx| {
1061                            handler(context.as_ref(), window, cx);
1062                            menu.update(cx, |menu, cx| {
1063                                menu.clicked = true;
1064                                if keep_open_on_confirm {
1065                                    menu.rebuild(window, cx);
1066                                } else {
1067                                    cx.emit(DismissEvent);
1068                                }
1069                            })
1070                            .ok();
1071                        }
1072                    }),
1073            )
1074            .into_any_element()
1075    }
1076}
1077
1078impl ContextMenuItem {
1079    fn is_selectable(&self) -> bool {
1080        match self {
1081            ContextMenuItem::Header(_)
1082            | ContextMenuItem::HeaderWithLink(_, _, _)
1083            | ContextMenuItem::Separator
1084            | ContextMenuItem::Label { .. } => false,
1085            ContextMenuItem::Entry(ContextMenuEntry { disabled, .. }) => !disabled,
1086            ContextMenuItem::CustomEntry { selectable, .. } => *selectable,
1087        }
1088    }
1089}
1090
1091impl Render for ContextMenu {
1092    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1093        let ui_font_size = ThemeSettings::get_global(cx).ui_font_size(cx);
1094        let window_size = window.viewport_size();
1095        let rem_size = window.rem_size();
1096        let is_wide_window = window_size.width / rem_size > rems_from_px(800.).0;
1097
1098        let aside = self.documentation_aside.clone();
1099        let render_aside = |aside: DocumentationAside, cx: &mut Context<Self>| {
1100            WithRemSize::new(ui_font_size)
1101                .occlude()
1102                .elevation_2(cx)
1103                .w_full()
1104                .p_2()
1105                .overflow_hidden()
1106                .when(is_wide_window, |this| this.max_w_96())
1107                .when(!is_wide_window, |this| this.max_w_48())
1108                .child((aside.render)(cx))
1109        };
1110
1111        let render_menu =
1112            |cx: &mut Context<Self>, window: &mut Window| {
1113                WithRemSize::new(ui_font_size)
1114                    .occlude()
1115                    .elevation_2(cx)
1116                    .flex()
1117                    .flex_row()
1118                    .flex_shrink_0()
1119                    .child(
1120                        v_flex()
1121                            .id("context-menu")
1122                            .max_h(vh(0.75, window))
1123                            .flex_shrink_0()
1124                            .when_some(self.fixed_width, |this, width| {
1125                                this.w(width).overflow_x_hidden()
1126                            })
1127                            .when(self.fixed_width.is_none(), |this| {
1128                                this.min_w(px(200.)).flex_1()
1129                            })
1130                            .overflow_y_scroll()
1131                            .track_focus(&self.focus_handle(cx))
1132                            .on_mouse_down_out(cx.listener(|this, _, window, cx| {
1133                                this.cancel(&menu::Cancel, window, cx)
1134                            }))
1135                            .key_context(self.key_context.as_ref())
1136                            .on_action(cx.listener(ContextMenu::select_first))
1137                            .on_action(cx.listener(ContextMenu::handle_select_last))
1138                            .on_action(cx.listener(ContextMenu::select_next))
1139                            .on_action(cx.listener(ContextMenu::select_previous))
1140                            .on_action(cx.listener(ContextMenu::confirm))
1141                            .on_action(cx.listener(ContextMenu::cancel))
1142                            .when_some(self.end_slot_action.as_ref(), |el, action| {
1143                                el.on_boxed_action(&**action, cx.listener(ContextMenu::end_slot))
1144                            })
1145                            .when(!self.delayed, |mut el| {
1146                                for item in self.items.iter() {
1147                                    if let ContextMenuItem::Entry(ContextMenuEntry {
1148                                        action: Some(action),
1149                                        disabled: false,
1150                                        ..
1151                                    }) = item
1152                                    {
1153                                        el = el.on_boxed_action(
1154                                            &**action,
1155                                            cx.listener(ContextMenu::on_action_dispatch),
1156                                        );
1157                                    }
1158                                }
1159                                el
1160                            })
1161                            .child(
1162                                List::new().children(
1163                                    self.items.iter().enumerate().map(|(ix, item)| {
1164                                        self.render_menu_item(ix, item, window, cx)
1165                                    }),
1166                                ),
1167                            ),
1168                    )
1169            };
1170
1171        if is_wide_window {
1172            div()
1173                .relative()
1174                .child(render_menu(cx, window))
1175                .children(aside.map(|(_item_index, aside)| {
1176                    h_flex()
1177                        .absolute()
1178                        .when(aside.side == DocumentationSide::Left, |this| {
1179                            this.right_full().mr_1()
1180                        })
1181                        .when(aside.side == DocumentationSide::Right, |this| {
1182                            this.left_full().ml_1()
1183                        })
1184                        .when(aside.edge == DocumentationEdge::Top, |this| this.top_0())
1185                        .when(aside.edge == DocumentationEdge::Bottom, |this| {
1186                            this.bottom_0()
1187                        })
1188                        .child(render_aside(aside, cx))
1189                }))
1190        } else {
1191            v_flex()
1192                .w_full()
1193                .gap_1()
1194                .justify_end()
1195                .children(aside.map(|(_, aside)| render_aside(aside, cx)))
1196                .child(render_menu(cx, window))
1197        }
1198    }
1199}
1200
1201#[cfg(test)]
1202mod tests {
1203    use gpui::TestAppContext;
1204
1205    use super::*;
1206
1207    #[gpui::test]
1208    fn can_navigate_back_over_headers(cx: &mut TestAppContext) {
1209        let cx = cx.add_empty_window();
1210        let context_menu = cx.update(|window, cx| {
1211            ContextMenu::build(window, cx, |menu, _, _| {
1212                menu.header("First header")
1213                    .separator()
1214                    .entry("First entry", None, |_, _| {})
1215                    .separator()
1216                    .separator()
1217                    .entry("Last entry", None, |_, _| {})
1218                    .header("Last header")
1219            })
1220        });
1221
1222        context_menu.update_in(cx, |context_menu, window, cx| {
1223            assert_eq!(
1224                None, context_menu.selected_index,
1225                "No selection is in the menu initially"
1226            );
1227
1228            context_menu.select_first(&SelectFirst, window, cx);
1229            assert_eq!(
1230                Some(2),
1231                context_menu.selected_index,
1232                "Should select first selectable entry, skipping the header and the separator"
1233            );
1234
1235            context_menu.select_next(&SelectNext, window, cx);
1236            assert_eq!(
1237                Some(5),
1238                context_menu.selected_index,
1239                "Should select next selectable entry, skipping 2 separators along the way"
1240            );
1241
1242            context_menu.select_next(&SelectNext, window, cx);
1243            assert_eq!(
1244                Some(2),
1245                context_menu.selected_index,
1246                "Should wrap around to first selectable entry"
1247            );
1248        });
1249
1250        context_menu.update_in(cx, |context_menu, window, cx| {
1251            assert_eq!(
1252                Some(2),
1253                context_menu.selected_index,
1254                "Should start from the first selectable entry"
1255            );
1256
1257            context_menu.select_previous(&SelectPrevious, window, cx);
1258            assert_eq!(
1259                Some(5),
1260                context_menu.selected_index,
1261                "Should wrap around to previous selectable entry (last)"
1262            );
1263
1264            context_menu.select_previous(&SelectPrevious, window, cx);
1265            assert_eq!(
1266                Some(2),
1267                context_menu.selected_index,
1268                "Should go back to previous selectable entry (first)"
1269            );
1270        });
1271
1272        context_menu.update_in(cx, |context_menu, window, cx| {
1273            context_menu.select_first(&SelectFirst, window, cx);
1274            assert_eq!(
1275                Some(2),
1276                context_menu.selected_index,
1277                "Should start from the first selectable entry"
1278            );
1279
1280            context_menu.select_previous(&SelectPrevious, window, cx);
1281            assert_eq!(
1282                Some(5),
1283                context_menu.selected_index,
1284                "Should wrap around to last selectable entry"
1285            );
1286            context_menu.select_next(&SelectNext, window, cx);
1287            assert_eq!(
1288                Some(2),
1289                context_menu.selected_index,
1290                "Should wrap around to first selectable entry"
1291            );
1292        });
1293    }
1294}