use std::ops::Deref; use std::rc::Rc; use gpui::prelude::FluentBuilder; use gpui::{ actions, anchored, canvas, div, px, rems, Action, AnyElement, App, AppContext, Bounds, Context, Corner, DismissEvent, Edges, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, KeyBinding, Keystroke, ParentElement, Pixels, Render, ScrollHandle, SharedString, StatefulInteractiveElement, Styled, Subscription, WeakEntity, Window, }; use theme::ActiveTheme; use crate::button::Button; use crate::list::ListItem; use crate::popover::Popover; use crate::scroll::{Scrollbar, ScrollbarState}; use crate::{h_flex, v_flex, Icon, IconName, Selectable, Sizable as _, StyledExt}; actions!( menu, [ /// Trigger confirm action when user presses enter button Confirm, /// Trigger dismiss action when user presses escape button Dismiss, /// Select the next item when user presses up button SelectNext, /// Select the previous item when user preses down button SelectPrev ] ); const ITEM_HEIGHT: Pixels = px(26.); pub fn init(cx: &mut App) { let context = Some("PopupMenu"); cx.bind_keys([ KeyBinding::new("enter", Confirm, context), KeyBinding::new("escape", Dismiss, context), KeyBinding::new("up", SelectPrev, context), KeyBinding::new("down", SelectNext, context), ]); } pub trait PopupMenuExt: Styled + Selectable + IntoElement + 'static { /// Create a popup menu with the given items, anchored to the TopLeft corner fn popup_menu( self, f: impl Fn(PopupMenu, &mut Window, &mut Context) -> PopupMenu + 'static, ) -> Popover { self.popup_menu_with_anchor(Corner::TopLeft, f) } /// Create a popup menu with the given items, anchored to the given corner fn popup_menu_with_anchor( mut self, anchor: impl Into, f: impl Fn(PopupMenu, &mut Window, &mut Context) -> PopupMenu + 'static, ) -> Popover { let style = self.style().clone(); let element_id = self.element_id(); Popover::new(SharedString::from(format!("popup-menu:{element_id:?}"))) .no_style() .trigger(self) .trigger_style(style) .anchor(anchor.into()) .content(move |window, cx| { PopupMenu::build(window, cx, |menu, window, cx| f(menu, window, cx)) }) } } impl PopupMenuExt for Button {} enum PopupMenuItem { Separator, Item { icon: Option, label: SharedString, action: Option>, #[allow(clippy::type_complexity)] handler: Rc, }, ElementItem { #[allow(clippy::type_complexity)] render: Box AnyElement + 'static>, #[allow(clippy::type_complexity)] handler: Rc, }, Submenu { icon: Option, label: SharedString, menu: Entity, }, } impl PopupMenuItem { fn is_clickable(&self) -> bool { !matches!(self, PopupMenuItem::Separator) } fn is_separator(&self) -> bool { matches!(self, PopupMenuItem::Separator) } fn has_icon(&self) -> bool { matches!(self, PopupMenuItem::Item { icon: Some(_), .. }) } } pub struct PopupMenu { /// The parent menu of this menu, if this is a submenu parent_menu: Option>, focus_handle: FocusHandle, menu_items: Vec, has_icon: bool, selected_index: Option, min_width: Pixels, max_width: Pixels, hovered_menu_ix: Option, bounds: Bounds, scrollable: bool, scroll_handle: ScrollHandle, scroll_state: ScrollbarState, action_focus_handle: Option, #[allow(dead_code)] subscriptions: Vec, } impl PopupMenu { pub fn build( window: &mut Window, cx: &mut App, f: impl FnOnce(Self, &mut Window, &mut Context) -> Self, ) -> Entity { cx.new(|cx| { let focus_handle = cx.focus_handle(); let subscriptions = vec![ cx.on_blur(&focus_handle, window, |this: &mut PopupMenu, window, cx| { this.dismiss(&Dismiss, window, cx) }), ]; let menu = Self { focus_handle, action_focus_handle: None, parent_menu: None, menu_items: Vec::new(), selected_index: None, min_width: px(120.), max_width: px(500.), has_icon: false, hovered_menu_ix: None, bounds: Bounds::default(), scrollable: false, scroll_handle: ScrollHandle::default(), scroll_state: ScrollbarState::default(), subscriptions, }; f(menu, window, cx) }) } /// Bind the focus handle of the menu, when clicked, it will focus back to this handle and then dispatch the action pub fn track_focus(mut self, focus_handle: &FocusHandle) -> Self { self.action_focus_handle = Some(focus_handle.clone()); self } /// Set min width of the popup menu, default is 120px pub fn min_w(mut self, width: impl Into) -> Self { self.min_width = width.into(); self } /// Set max width of the popup menu, default is 500px pub fn max_w(mut self, width: impl Into) -> Self { self.max_width = width.into(); self } /// Set the menu to be scrollable to show vertical scrollbar. /// /// NOTE: If this is true, the sub-menus will cannot be support. pub fn scrollable(mut self) -> Self { self.scrollable = true; self } /// Add Menu Item pub fn menu(mut self, label: impl Into, action: Box) -> Self { self.add_menu_item(label, None, action); self } /// Add Menu to open link pub fn link(mut self, label: impl Into, href: impl Into) -> Self { let href = href.into(); self.menu_items.push(PopupMenuItem::Item { icon: None, label: label.into(), action: None, handler: Rc::new(move |_window, cx| cx.open_url(&href)), }); self } /// Add Menu to open link pub fn link_with_icon( mut self, label: impl Into, icon: impl Into, href: impl Into, ) -> Self { let href = href.into(); self.menu_items.push(PopupMenuItem::Item { icon: Some(icon.into()), label: label.into(), action: None, handler: Rc::new(move |_window, cx| cx.open_url(&href)), }); self } /// Add Menu Item with Icon pub fn menu_with_icon( mut self, label: impl Into, icon: impl Into, action: Box, ) -> Self { self.add_menu_item(label, Some(icon.into()), action); self } /// Add Menu Item with check icon pub fn menu_with_check( mut self, label: impl Into, checked: bool, action: Box, ) -> Self { if checked { self.add_menu_item(label, Some(IconName::Check.into()), action); } else { self.add_menu_item(label, None, action); } self } /// Add Menu Item with custom element render. pub fn menu_with_element(mut self, builder: F, action: Box) -> Self where F: Fn(&mut Window, &mut App) -> E + 'static, E: IntoElement, { self.menu_items.push(PopupMenuItem::ElementItem { render: Box::new(move |window, cx| builder(window, cx).into_any_element()), handler: self.wrap_handler(action), }); self } #[allow(clippy::type_complexity)] fn wrap_handler(&self, action: Box) -> Rc { let action_focus_handle = self.action_focus_handle.clone(); Rc::new(move |window, cx| { window.activate_window(); // Focus back to the user expected focus handle // Then the actions listened on that focus handle can be received // // For example: // // TabPanel // |- PopupMenu // |- PanelContent (actions are listened here) // // The `PopupMenu` and `PanelContent` are at the same level in the TabPanel // If the actions are listened on the `PanelContent`, // it can't receive the actions from the `PopupMenu`, unless we focus on `PanelContent`. if let Some(handle) = action_focus_handle.as_ref() { window.focus(handle); } window.dispatch_action(action.boxed_clone(), cx); }) } fn add_menu_item( &mut self, label: impl Into, icon: Option, action: Box, ) -> &mut Self { if icon.is_some() { self.has_icon = true; } self.menu_items.push(PopupMenuItem::Item { icon, label: label.into(), action: Some(action.boxed_clone()), handler: self.wrap_handler(action), }); self } /// Add a separator Menu Item pub fn separator(mut self) -> Self { if self.menu_items.is_empty() { return self; } if let Some(PopupMenuItem::Separator) = self.menu_items.last() { return self; } self.menu_items.push(PopupMenuItem::Separator); self } pub fn submenu( self, label: impl Into, window: &mut Window, cx: &mut Context, f: impl Fn(PopupMenu, &mut Window, &mut Context) -> PopupMenu + 'static, ) -> Self { self.submenu_with_icon(None, label, window, cx, f) } /// Add a Submenu item with icon pub fn submenu_with_icon( mut self, icon: Option, label: impl Into, window: &mut Window, cx: &mut Context, f: impl Fn(PopupMenu, &mut Window, &mut Context) -> PopupMenu + 'static, ) -> Self { let submenu = PopupMenu::build(window, cx, f); let parent_menu = cx.entity().downgrade(); submenu.update(cx, |view, _| { view.parent_menu = Some(parent_menu); }); self.menu_items.push(PopupMenuItem::Submenu { icon, label: label.into(), menu: submenu, }); self } pub(crate) fn active_submenu(&self) -> Option> { if let Some(ix) = self.hovered_menu_ix { if let Some(item) = self.menu_items.get(ix) { return match item { PopupMenuItem::Submenu { menu, .. } => Some(menu.clone()), _ => None, }; } } None } pub fn is_empty(&self) -> bool { self.menu_items.is_empty() } fn clickable_menu_items(&self) -> impl Iterator { self.menu_items .iter() .enumerate() .filter(|(_, item)| item.is_clickable()) } fn on_click(&mut self, ix: usize, window: &mut Window, cx: &mut Context) { cx.stop_propagation(); window.prevent_default(); self.selected_index = Some(ix); self.confirm(&Confirm, window, cx); } fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context) { if let Some(index) = self.selected_index { let item = self.menu_items.get(index); match item { Some(PopupMenuItem::Item { handler, .. }) => { handler(window, cx); self.dismiss(&Dismiss, window, cx) } Some(PopupMenuItem::ElementItem { handler, .. }) => { handler(window, cx); self.dismiss(&Dismiss, window, cx) } _ => {} } } } fn select_next(&mut self, _: &SelectNext, _window: &mut Window, cx: &mut Context) { let count = self.clickable_menu_items().count(); if count > 0 { let last_ix = count.saturating_sub(1); let ix = self .selected_index .map(|index| if index == last_ix { 0 } else { index + 1 }) .unwrap_or(0); self.selected_index = Some(ix); cx.notify(); } } fn select_prev(&mut self, _: &SelectPrev, _window: &mut Window, cx: &mut Context) { let count = self.clickable_menu_items().count(); if count > 0 { let last_ix = count.saturating_sub(1); let ix = self .selected_index .map(|index| { if index == last_ix { 0 } else { index.saturating_sub(1) } }) .unwrap_or(last_ix); self.selected_index = Some(ix); cx.notify(); } } // TODO: fix this #[allow(clippy::only_used_in_recursion)] fn dismiss(&mut self, _: &Dismiss, window: &mut Window, cx: &mut Context) { if self.active_submenu().is_some() { return; } cx.emit(DismissEvent); // Dismiss parent menu, when this menu is dismissed if let Some(parent_menu) = self.parent_menu.clone().and_then(|menu| menu.upgrade()) { parent_menu.update(cx, |view, cx| { view.hovered_menu_ix = None; view.dismiss(&Dismiss, window, cx); }) } } fn render_keybinding( action: Option>, window: &mut Window, cx: &mut Context, ) -> Option { if let Some(action) = action { if let Some(keybinding) = window.bindings_for_action(action.deref()).first() { let el = div().text_color(cx.theme().text_muted).children( keybinding .keystrokes() .iter() .map(|key| key_shortcut(key.clone())), ); return Some(el); } } None } fn render_icon( has_icon: bool, icon: Option, _window: &Window, _cx: &Context, ) -> Option { let icon_placeholder = if has_icon { Some(Icon::empty()) } else { None }; if !has_icon { return None; } let icon = h_flex() .w_3p5() .h_3p5() .items_center() .justify_center() .text_sm() .map(|this| { if let Some(icon) = icon { this.child(icon.clone().small()) } else { this.children(icon_placeholder.clone()) } }); Some(icon) } } impl FluentBuilder for PopupMenu {} impl EventEmitter for PopupMenu {} impl Focusable for PopupMenu { fn focus_handle(&self, _: &App) -> FocusHandle { self.focus_handle.clone() } } impl Render for PopupMenu { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let view = cx.entity().clone(); let has_icon = self.menu_items.iter().any(|item| item.has_icon()); let items_count = self.menu_items.len(); let max_width = self.max_width; let bounds = self.bounds; let window_haft_height = window.window_bounds().get_bounds().size.height * 0.5; let max_height = window_haft_height.min(px(450.)); v_flex() .id("popup-menu") .key_context("PopupMenu") .track_focus(&self.focus_handle) .on_action(cx.listener(Self::select_next)) .on_action(cx.listener(Self::select_prev)) .on_action(cx.listener(Self::confirm)) .on_action(cx.listener(Self::dismiss)) .on_mouse_down_out(cx.listener(|this, _, window, cx| this.dismiss(&Dismiss, window, cx))) .popover_style(cx) .relative() .p_1() .child( div() .id("popup-menu-items") .when(self.scrollable, |this| { this.max_h(max_height) .overflow_y_scroll() .track_scroll(&self.scroll_handle) }) .child( v_flex() .gap_y_0p5() .min_w(self.min_width) .max_w(self.max_width) .min_w(rems(8.)) .child({ canvas( move |bounds, _, cx| view.update(cx, |r, _| r.bounds = bounds), |_, _, _, _| {}, ) .absolute() .size_full() }) .children( self.menu_items .iter_mut() .enumerate() // Skip last separator .filter(|(ix, item)| !(*ix == items_count - 1 && item.is_separator())) .map(|(ix, item)| { let this = ListItem::new(("menu-item", ix)) .relative() .items_center() .py_0() .px_2() .rounded_md() .text_sm() .on_mouse_enter(cx.listener(move |this, _, _window, cx| { this.hovered_menu_ix = Some(ix); cx.notify(); })); match item { PopupMenuItem::Separator => this.h_auto().p_0().disabled(true).child( div() .rounded_none() .h(px(1.)) .mx_neg_1() .my_0p5() .bg(cx.theme().border_disabled), ), PopupMenuItem::ElementItem { render, .. } => this .on_click( cx.listener(move |this, _, window, cx| { this.on_click(ix, window, cx) }), ) .child( h_flex() .min_h(ITEM_HEIGHT) .items_center() .gap_x_1() .children(Self::render_icon(has_icon, None, window, cx)) .child((render)(window, cx)), ), PopupMenuItem::Item { icon, label, action, .. } => { let action = action.as_ref().map(|action| action.boxed_clone()); let key = Self::render_keybinding(action, window, cx); this.on_click( cx.listener(move |this, _, window, cx| { this.on_click(ix, window, cx) }), ) .child( h_flex() .h(ITEM_HEIGHT) .items_center() .gap_x_1p5() .children(Self::render_icon(has_icon, icon.clone(), window, cx)) .child( h_flex() .flex_1() .gap_2() .items_center() .justify_between() .child(label.clone()) .children(key), ), ) } PopupMenuItem::Submenu { icon, label, menu } => this .when(self.hovered_menu_ix == Some(ix), |this| this.selected(true)) .child( h_flex() .items_start() .child( h_flex() .size_full() .items_center() .gap_x_1p5() .children(Self::render_icon( has_icon, icon.clone(), window, cx, )) .child( h_flex() .flex_1() .gap_2() .items_center() .justify_between() .child(label.clone()) .child(IconName::CaretRight), ), ) .when_some(self.hovered_menu_ix, |this, hovered_ix| { let (anchor, left) = if window.bounds().size.width - bounds.origin.x < max_width { (Corner::TopRight, -px(15.)) } else { (Corner::TopLeft, bounds.size.width - px(10.)) }; let top = if bounds.origin.y + bounds.size.height > window.bounds().size.height { px(32.) } else { -px(10.) }; if hovered_ix == ix { this.child( anchored() .anchor(anchor) .child( div() .occlude() .top(top) .left(left) .child(menu.clone()), ) .snap_to_window_with_margin(Edges::all(px(8.))), ) } else { this } }), ), } }), ), ), ) .when(self.scrollable, |this| { // TODO: When the menu is limited by `overflow_y_scroll`, the sub-menu will cannot be displayed. this.child( div() .absolute() .top_0() .left_0() .right_0p5() .bottom_0() .child(Scrollbar::vertical(&self.scroll_state, &self.scroll_handle)), ) }) } } /// Return the Platform specific keybinding string by KeyStroke pub fn key_shortcut(key: Keystroke) -> String { if cfg!(target_os = "macos") { return format!("{key}"); } let mut parts = vec![]; if key.modifiers.control { parts.push("Ctrl"); } if key.modifiers.alt { parts.push("Alt"); } if key.modifiers.platform { parts.push("Win"); } if key.modifiers.shift { parts.push("Shift"); } // Capitalize the first letter let key = if let Some(first_c) = key.key.chars().next() { format!("{}{}", first_c.to_uppercase(), &key.key[1..]) } else { key.key.to_string() }; parts.push(&key); parts.join("+") }