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