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