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