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