diff --git a/Cargo.lock b/Cargo.lock index ed74561c..e91a1681 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4913,6 +4913,7 @@ dependencies = [ "fake", "gpui", "regex", + "serde", "ui", "workspace", ] diff --git a/crates/story/Cargo.toml b/crates/story/Cargo.toml index 3bf1e984..191cdab5 100644 --- a/crates/story/Cargo.toml +++ b/crates/story/Cargo.toml @@ -12,6 +12,7 @@ workspace.workspace = true charts-rs = "0.3" regex = "1" chrono = "0.4" +serde = "1" [lints] workspace = true diff --git a/crates/story/src/popup_story.rs b/crates/story/src/popup_story.rs index 02fd415d..2640ab65 100644 --- a/crates/story/src/popup_story.rs +++ b/crates/story/src/popup_story.rs @@ -1,8 +1,10 @@ use gpui::{ - actions, div, px, AnchorCorner, AppContext, DismissEvent, Element, EventEmitter, FocusHandle, - FocusableView, InteractiveElement, IntoElement, KeyBinding, MouseButton, MouseDownEvent, - ParentElement as _, Render, Styled as _, View, ViewContext, VisualContext, WindowContext, + actions, div, impl_actions, px, AnchorCorner, AppContext, DismissEvent, Element, EventEmitter, + FocusHandle, FocusableView, InteractiveElement, IntoElement, KeyBinding, MouseButton, + MouseDownEvent, ParentElement as _, Render, Styled as _, View, ViewContext, VisualContext, + WindowContext, }; +use serde::Deserialize; use ui::{ button::Button, context_menu::ContextMenuExt, @@ -11,15 +13,18 @@ use ui::{ input::TextInput, popover::{Popover, PopoverContent}, popup_menu::PopupMenuExt, - prelude::FluentBuilder, switch::Switch, v_flex, IconName, Sizable, }; +#[derive(Clone, PartialEq, Deserialize)] +struct Info(usize); + actions!( popover_story, [Copy, Paste, Cut, SearchAll, ToggleWindowMode] ); +impl_actions!(popover_story, [Info]); pub fn init(cx: &mut AppContext) { cx.bind_keys([ @@ -109,6 +114,10 @@ impl PopupStory { self.window_mode = !self.window_mode; cx.notify() } + fn on_action_info(&mut self, info: &Info, cx: &mut ViewContext) { + self.message = format!("You have clicked info: {}", info.0); + cx.notify() + } } impl FocusableView for PopupStory { @@ -129,6 +138,7 @@ impl Render for PopupStory { .on_action(cx.listener(Self::on_paste)) .on_action(cx.listener(Self::on_search_all)) .on_action(cx.listener(Self::on_toggle_window_mode)) + .on_action(cx.listener(Self::on_action_info)) .p_4() .mb_5() .size_full() @@ -137,12 +147,24 @@ impl Render for PopupStory { cx.focus(&this.focus_handle); })) .context_menu({ - move |this, _cx| { + move |this, cx| { this.menu("Cut", Box::new(Cut)) .menu("Copy", Box::new(Copy)) .menu("Paste", Box::new(Paste)) .separator() - .menu("About", Box::new(SearchAll)) + .submenu("Settings", cx, move |menu, _| { + menu.menu_with_check( + "Toggle Window Mode", + window_mode, + Box::new(ToggleWindowMode), + ) + .separator() + .menu("Info 0", Box::new(Info(0))) + .menu("Item 1", Box::new(Info(1))) + .menu("Item 2", Box::new(Info(2))) + }) + .separator() + .menu("Search All", Box::new(SearchAll)) } }) .gap_6() @@ -161,7 +183,6 @@ impl Render for PopupStory { .child( v_flex().gap_4().child( Popover::new("info-top-left") - .when(window_mode, |this| this.window_mode()) .trigger(Button::new("info-top-left", cx).label("Top Left")) .content(|cx| { PopoverContent::new(cx, |cx| { @@ -182,7 +203,6 @@ impl Render for PopupStory { ) .child( Popover::new("info-top-right") - .when(window_mode, |this| this.window_mode()) .anchor(AnchorCorner::TopRight) .trigger(Button::new("info-top-right", cx).label("Top Right")) .content(|cx| { @@ -209,7 +229,7 @@ impl Render for PopupStory { .child( Button::new("popup-menu-1", cx) .icon(IconName::Ellipsis) - .popup_menu(move |this, _| { + .popup_menu(move |this, cx| { this.menu("Copy", Box::new(Copy)) .menu("Cut", Box::new(Cut)) .menu("Paste", Box::new(Paste)) @@ -222,11 +242,16 @@ impl Render for PopupStory { Box::new(ToggleWindowMode), ) .separator() - .link_with_icon( - "GitHub Repository", - IconName::GitHub, - "https://github.com/huacnlee/gpui-component", - ) + .submenu("Links", cx, |menu, _| { + menu.link_with_icon( + "GitHub Repository", + IconName::GitHub, + "https://github.com/huacnlee/gpui-component", + ) + .separator() + .link("GPUI", "https://gpui.rs") + .link("Zed", "https://zed.dev") + }) }), ) .child(self.message.clone()), @@ -239,7 +264,6 @@ impl Render for PopupStory { .justify_between() .child( Popover::new("info-bottom-left") - .when(window_mode, |this| this.window_mode()) .anchor(AnchorCorner::BottomLeft) .trigger( Button::new("pop", cx).label("Popup with Form").w(px(300.)), @@ -248,7 +272,6 @@ impl Render for PopupStory { ) .child( Popover::new("info-bottom-right") - .when(window_mode, |this| this.window_mode()) .anchor(AnchorCorner::BottomRight) .mouse_button(MouseButton::Right) .trigger( diff --git a/crates/ui/src/context_menu.rs b/crates/ui/src/context_menu.rs index ff168685..8706f2c2 100644 --- a/crates/ui/src/context_menu.rs +++ b/crates/ui/src/context_menu.rs @@ -3,18 +3,18 @@ use std::{cell::RefCell, rc::Rc}; use gpui::{ anchored, deferred, div, prelude::FluentBuilder, relative, AnchorCorner, AnyElement, AppContext, DismissEvent, DispatchPhase, Element, ElementId, Focusable, GlobalElementId, - InteractiveElement, IntoElement, MouseButton, MouseDownEvent, ParentElement, Pixels, Point, - Position, Stateful, Style, Styled as _, View, WindowContext, + IntoElement, MouseButton, MouseDownEvent, ParentElement, Pixels, Point, Position, Stateful, + Style, View, ViewContext, WindowContext, }; -use crate::{popup_menu::PopupMenu, theme::ActiveTheme}; +use crate::popup_menu::PopupMenu; pub fn init(_cx: &mut AppContext) {} pub trait ContextMenuExt: ParentElement + Sized { fn context_menu( self, - f: impl Fn(PopupMenu, &mut WindowContext) -> PopupMenu + 'static, + f: impl Fn(PopupMenu, &mut ViewContext) -> PopupMenu + 'static, ) -> Self { self.child(ContextMenu::new("context_menu").menu(f)) } @@ -25,7 +25,7 @@ impl ContextMenuExt for Focusable where E: ParentElement {} pub struct ContextMenu { id: ElementId, - menu: Option PopupMenu + 'static>>, + menu: Option) -> PopupMenu + 'static>>, anchor: AnchorCorner, } @@ -41,7 +41,7 @@ impl ContextMenu { #[must_use] pub fn menu(mut self, builder: F) -> Self where - F: Fn(PopupMenu, &mut WindowContext) -> PopupMenu + 'static, + F: Fn(PopupMenu, &mut ViewContext) -> PopupMenu + 'static, { self.menu = Some(Box::new(builder)); self @@ -126,19 +126,7 @@ impl Element for ContextMenu { // Focus the menu, so that can be handle the action. menu.focus_handle(cx).focus(cx); - this.child( - div() - .bg(cx.theme().popover) - .border_1() - .border_color(cx.theme().border) - .shadow_lg() - .rounded_lg() - .child(menu) - .on_mouse_down_out(move |_, cx| { - *open.borrow_mut() = false; - cx.refresh(); - }), - ) + this.child(div().child(menu.clone())) }), ) .with_priority(1) diff --git a/crates/ui/src/list/list_item.rs b/crates/ui/src/list/list_item.rs index ade220e1..2a903668 100644 --- a/crates/ui/src/list/list_item.rs +++ b/crates/ui/src/list/list_item.rs @@ -1,7 +1,7 @@ use gpui::{ div, prelude::FluentBuilder as _, AnyElement, ClickEvent, Div, ElementId, InteractiveElement, - IntoElement, MouseButton, MouseDownEvent, ParentElement, RenderOnce, Stateful, - StatefulInteractiveElement as _, Styled, WindowContext, + IntoElement, MouseButton, MouseDownEvent, MouseMoveEvent, ParentElement, RenderOnce, + SharedString, Stateful, StatefulInteractiveElement as _, Styled, WindowContext, }; use smallvec::SmallVec; @@ -14,7 +14,9 @@ pub struct ListItem { selected: bool, confirmed: bool, check_icon: Option, + group_id: Option, on_click: Option>, + on_mouse_enter: Option>, on_secondary_mouse_down: Option>, suffix: Option AnyElement + 'static>>, children: SmallVec<[AnyElement; 2]>, @@ -29,12 +31,20 @@ impl ListItem { confirmed: false, on_click: None, on_secondary_mouse_down: None, + on_mouse_enter: None, check_icon: None, suffix: None, + group_id: None, children: SmallVec::new(), } } + /// Set group_id + pub fn group(mut self, group_id: impl Into) -> Self { + self.group_id = Some(group_id.into()); + self + } + /// Set to show check icon, default is None. pub fn check_icon(mut self, icon: IconName) -> Self { self.check_icon = Some(Icon::new(icon)); @@ -80,6 +90,14 @@ impl ListItem { self.on_secondary_mouse_down = Some(Box::new(handler)); self } + + pub fn on_mouse_enter( + mut self, + handler: impl Fn(&MouseMoveEvent, &mut WindowContext) + 'static, + ) -> Self { + self.on_mouse_enter = Some(Box::new(handler)); + self + } } impl Disableable for ListItem { @@ -113,6 +131,7 @@ impl RenderOnce for ListItem { let is_active = self.selected || self.confirmed; self.base + .when_some(self.group_id, |this, group_id| this.group(group_id)) .text_color(cx.theme().foreground) .relative() .items_center() @@ -136,13 +155,21 @@ impl RenderOnce for ListItem { this } }) + // Mouse enter + .when_some(self.on_mouse_enter, |this, on_mouse_enter| { + if !self.disabled { + this.on_mouse_move(move |ev, cx| (on_mouse_enter)(ev, cx)) + } else { + this + } + }) .child( h_flex() .w_full() .items_center() .justify_between() .gap_x_1() - .child(div().w_full().overflow_hidden().children(self.children)) + .child(div().w_full().children(self.children)) .when_some(self.check_icon, |this, icon| { this.child( div().w_5().items_center().justify_center().when( diff --git a/crates/ui/src/popover.rs b/crates/ui/src/popover.rs index 6c5537e4..da68fa9f 100644 --- a/crates/ui/src/popover.rs +++ b/crates/ui/src/popover.rs @@ -1,21 +1,17 @@ -use anyhow::Result; use gpui::{ - actions, anchored, deferred, div, point, prelude::FluentBuilder as _, px, size, AnchorCorner, - AnyElement, AppContext, Bounds, Context, DismissEvent, DispatchPhase, Element, ElementId, - EventEmitter, FocusHandle, FocusableView, Global, GlobalElementId, Hitbox, - InteractiveElement as _, IntoElement, LayoutId, ManagedView, MouseButton, MouseDownEvent, - ParentElement, Pixels, Point, Render, Style, Styled, Subscription, View, ViewContext, - VisualContext, WindowBackgroundAppearance, WindowContext, WindowId, WindowOptions, + actions, anchored, deferred, div, prelude::FluentBuilder as _, AnchorCorner, AnyElement, + AppContext, Bounds, DismissEvent, DispatchPhase, Element, ElementId, EventEmitter, FocusHandle, + FocusableView, GlobalElementId, Hitbox, InteractiveElement as _, IntoElement, LayoutId, + ManagedView, MouseButton, MouseDownEvent, ParentElement, Pixels, Point, Render, Style, Styled, + View, ViewContext, VisualContext, WindowContext, }; use std::{cell::RefCell, rc::Rc}; -use crate::{theme::ActiveTheme, Selectable}; +use crate::{Selectable, StyledExt as _}; actions!(popover, [Open, Dismiss]); -pub fn init(cx: &mut AppContext) { - cx.set_global(PopoverWindowState { window_id: None }); -} +pub fn init(_cx: &mut AppContext) {} pub struct PopoverContent { focus_handle: FocusHandle, @@ -51,19 +47,13 @@ impl Render for PopoverContent { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum PopupMode { - View, - Window, -} - pub struct Popover { id: ElementId, anchor: AnchorCorner, trigger: Option AnyElement + 'static>>, content: Option View + 'static>>, mouse_button: MouseButton, - mode: PopupMode, + no_style: bool, } impl Popover @@ -78,16 +68,10 @@ where trigger: None, content: None, mouse_button: MouseButton::Left, - mode: PopupMode::View, + no_style: false, } } - /// Set Popover to use Window mode - pub fn window_mode(mut self) -> Self { - self.mode = PopupMode::Window; - self - } - pub fn anchor(mut self, anchor: AnchorCorner) -> Self { self.anchor = anchor; self @@ -120,6 +104,17 @@ where self } + /// Set whether the popover no style, default is `false`. + /// + /// If no style: + /// + /// - The popover will not have a bg, border, shadow, or padding. + /// - The click out of the popover will not dismiss it. + pub fn no_style(mut self) -> Self { + self.no_style = true; + self + } + fn render_trigger(&mut self, is_open: bool, cx: &mut WindowContext) -> impl IntoElement { let base = div().id("popover-trigger"); @@ -197,7 +192,6 @@ pub struct PrepaintState { hitbox: Hitbox, /// Trigger bounds for limit a rect to handle mouse click. trigger_bounds: Option>, - popover_bounds: Option>, } impl Element for Popover { @@ -213,8 +207,6 @@ impl Element for Popover { id: Option<&gpui::GlobalElementId>, cx: &mut WindowContext, ) -> (gpui::LayoutId, Self::RequestLayoutState) { - let mode = self.mode; - self.with_element_state(id.unwrap(), cx, |view, element_state, cx| { let mut popover_layout_id = None; let mut popover_element = None; @@ -228,30 +220,16 @@ impl Element for Popover { anchored = anchored.position(view.resolved_corner(trigger_bounds)); } - let mut element = if mode == PopupMode::Window { - // layout the content view, to let the popover know the size of the content for window size. - anchored - .child( - div() - .border_1() - .border_color(cx.theme().border) - .child(content_view.clone()), - ) - .into_any() - } else { + let mut element = { let content_view_mut = element_state.content_view.clone(); - let bg_color = cx.theme().popover; let anchor = view.anchor; + let no_style = view.no_style; deferred( anchored.child( div() .size_full() .occlude() - .border_1() - .border_color(cx.theme().border) - .shadow_lg() - .rounded_lg() - .bg(bg_color) + .when(!no_style, |this| this.popover_style(cx)) .map(|this| match anchor { AnchorCorner::TopLeft | AnchorCorner::TopRight => this.top_2(), AnchorCorner::BottomLeft | AnchorCorner::BottomRight => { @@ -259,11 +237,13 @@ impl Element for Popover { } }) .child(content_view.clone()) - .on_mouse_down_out(move |_, cx| { - // Update the element_state.content_view to `None`, - // so that the `paint`` method will not paint it. - *content_view_mut.borrow_mut() = None; - cx.refresh(); + .when(!no_style, |this| { + this.on_mouse_down_out(move |_, cx| { + // Update the element_state.content_view to `None`, + // so that the `paint`` method will not paint it. + *content_view_mut.borrow_mut() = None; + cx.refresh(); + }) }), ), ) @@ -315,7 +295,7 @@ impl Element for Popover { .map(|id| cx.layout_bounds(id)); // Prepare the popover, for get the bounds of it for open window size. - let popover_bounds = request_layout + let _ = request_layout .popover_layout_id .map(|id| cx.layout_bounds(id)); @@ -323,7 +303,6 @@ impl Element for Popover { PrepaintState { trigger_bounds, - popover_bounds, hitbox, } } @@ -336,8 +315,6 @@ impl Element for Popover { prepaint: &mut Self::PrepaintState, cx: &mut WindowContext, ) { - let anchor = self.anchor; - let mode = self.mode; self.with_element_state(id.unwrap(), cx, |this, element_state, cx| { element_state.trigger_bounds = prepaint.trigger_bounds; @@ -345,27 +322,9 @@ impl Element for Popover { element.paint(cx); } - if mode == PopupMode::Window { - if let Some(content_view) = element_state.content_view.take() { - let popover_bounds = prepaint.popover_bounds.unwrap(); - let trigger_bounds = prepaint.trigger_bounds.unwrap(); - - PopoverWindow::open_popover( - content_view, - trigger_bounds, - popover_bounds, - anchor, - cx, - ) - .expect("BUG: failed to open popover window."); - - return; - } - } else { - if let Some(mut element) = request_layout.popover_element.take() { - element.paint(cx); - return; - } + if let Some(mut element) = request_layout.popover_element.take() { + element.paint(cx); + return; } // When mouse click down in the trigger bounds, open the popover. @@ -387,6 +346,7 @@ impl Element for Popover { let old_content_view1 = old_content_view.clone(); let previous_focus_handle = cx.focused(); + cx.subscribe(&new_content_view, move |modal, _: &DismissEvent, cx| { if modal.focus_handle(cx).contains_focused(cx) { if let Some(previous_focus_handle) = previous_focus_handle.as_ref() { @@ -394,7 +354,6 @@ impl Element for Popover { } } *old_content_view1.borrow_mut() = None; - close_popover(cx); cx.refresh(); }) @@ -405,242 +364,6 @@ impl Element for Popover { cx.refresh(); } }); - - // Click parent window to dimiss popover - if mode == PopupMode::Window { - let content_view = element_state.content_view.clone(); - cx.on_mouse_event(move |_: &MouseDownEvent, _, cx| { - *content_view.borrow_mut() = None; - close_popover(cx); - }); - } }); } } - -struct PopoverWindowState { - window_id: Option, -} - -impl Global for PopoverWindowState {} - -impl PopoverWindowState { - fn window_id(cx: &AppContext) -> Option { - cx.try_global::().and_then(|state| state.window_id) - } - - fn set_window_id(window_id: WindowId, cx: &mut WindowContext) { - cx.set_global(PopoverWindowState { - window_id: Some(window_id), - }); - } - - fn close_window(cx: &mut AppContext) { - if let Some(window) = cx - .windows() - .into_iter() - .find(|window| Some(window.window_id()) == PopoverWindowState::window_id(cx)) - { - cx.update_window(window, |_, cx| { - cx.remove_window(); - }) - .ok(); - } - } -} - -pub struct PopoverWindow { - view: View, - anchor: AnchorCorner, - close_when_deactivate: bool, - _subscriptions: Vec, -} - -pub fn close_popover(cx: &mut AppContext) { - PopoverWindowState::close_window(cx); -} - -impl PopoverWindow -where - M: ManagedView, -{ - pub fn open_popover( - view: View, - trigger_bounds: Bounds, - bounds: Bounds, - anchor: AnchorCorner, - cx: &mut WindowContext, - ) -> Result<()> { - // Every open_popover will close the existing one - PopoverWindowState::close_window(cx); - - let display = cx.display(); - let window_bounds = cx.bounds(); - - // TODO: avoid out of the screen bounds - - let border_bounds = if cfg!(target_os = "windows") { - Bounds { - origin: point(px(-8.0), px(5.0)), - size: size(px(16.0), px(8.0)), - } - } else { - Bounds { - origin: point(px(-8.0), px(0.0)), - size: size(px(20.0), px(20.0)), - } - }; - - let trigger_screen_bounds = Bounds { - origin: window_bounds.origin + trigger_bounds.origin + border_bounds.origin, - size: trigger_bounds.size, - }; - - let popover_offset = px(2.); - let popover_origin = match anchor { - AnchorCorner::TopLeft => { - trigger_screen_bounds.lower_left() + point(px(0.), popover_offset) - } - AnchorCorner::TopRight => { - trigger_screen_bounds.lower_right() + point(-bounds.size.width, popover_offset) - } - AnchorCorner::BottomLeft => { - trigger_screen_bounds.origin - - point( - px(0.0), - bounds.size.height + border_bounds.size.height + popover_offset, - ) - } - AnchorCorner::BottomRight => { - trigger_screen_bounds.upper_right() - - point( - bounds.size.width, - bounds.size.height + border_bounds.size.height + popover_offset, - ) - } - }; - - let bounds = Bounds { - origin: popover_origin, - size: size( - bounds.size.width + border_bounds.size.width, - bounds.size.height + border_bounds.size.height, - ), - }; - - let view = view.clone(); - cx.spawn(|mut cx| async move { - let window = cx - .open_window( - WindowOptions { - titlebar: None, - window_bounds: Some(gpui::WindowBounds::Windowed(bounds)), - window_background: WindowBackgroundAppearance::Transparent, - // NOTE: on Windows in currently must use PopUp kind, otherwise the window will be sizeable. - // And the PopUp kind can fast open. - kind: gpui::WindowKind::PopUp, - is_movable: false, - focus: true, - show: true, - display_id: display.map(|d| d.id()), - ..Default::default() - }, - |cx| { - let mut _subscriptions = Vec::new(); - - let view = cx.new_view(|cx| { - // Listen to window diactivation to close window - _subscriptions.push( - cx.observe_window_activation(Self::window_activation_changed), - ); - - PopoverWindow { - view, - anchor, - close_when_deactivate: true, - _subscriptions, - } - }); - - view - }, - ) - .expect("BUG: faild to create a new window."); - - cx.update(|cx| { - PopoverWindowState::set_window_id(window.window_id(), cx); - }) - .expect("BUG: failed to set window id.") - }) - .detach(); - - Ok(()) - } -} - -impl PopoverWindow -where - M: ManagedView, -{ - fn window_activation_changed(&mut self, cx: &mut ViewContext) { - if self.close_when_deactivate { - if !cx.is_window_active() { - self.dismiss(cx); - } - } - } - - fn dismiss(&mut self, cx: &mut ViewContext) { - cx.remove_window(); - } -} - -impl FocusableView for PopoverWindow -where - M: ManagedView, -{ - fn focus_handle(&self, cx: &AppContext) -> FocusHandle { - self.view.focus_handle(cx) - } -} - -impl Render for PopoverWindow -where - M: ManagedView, -{ - fn render(&mut self, cx: &mut gpui::ViewContext) -> impl IntoElement { - let is_windows = cfg!(target_os = "windows"); - - div() - .id("PopoverWindow") - .size_full() - .when(!is_windows, |this| this.p_2()) - .when(is_windows, |this| this.bg(cx.theme().popover)) - .text_color(cx.theme().popover_foreground) - // Leave margin for show window shadow - .map(|d| match self.anchor { - AnchorCorner::TopLeft | AnchorCorner::TopRight => d.mt_8(), - AnchorCorner::BottomLeft | AnchorCorner::BottomRight => d.mb_8(), - }) - .child( - div() - .when(!is_windows, |this| { - this.bg(cx.theme().popover) - .border_1() - .border_color(cx.theme().border) - .shadow_lg() - .rounded_lg() - }) - .bg(cx.theme().popover) - .child(self.view.clone()) - .on_mouse_down( - gpui::MouseButton::Left, - cx.listener(|_, _, cx| { - cx.stop_propagation(); - - PopoverWindowState::close_window(cx); - }), - ), - ) - } -} diff --git a/crates/ui/src/popup_menu.rs b/crates/ui/src/popup_menu.rs index 7e1ddd3e..6f9ce9de 100644 --- a/crates/ui/src/popup_menu.rs +++ b/crates/ui/src/popup_menu.rs @@ -6,8 +6,9 @@ use gpui::{ FocusHandle, InteractiveElement, IntoElement, KeyBinding, ParentElement, Pixels, Render, SharedString, Styled as _, View, ViewContext, VisualContext as _, WindowContext, }; -use gpui::{rems, FocusableView}; +use gpui::{anchored, canvas, rems, AnchorCorner, Bounds, FocusableView, WeakView}; +use crate::StyledExt; use crate::{ button::Button, h_flex, list::ListItem, popover::Popover, theme::ActiveTheme, v_flex, Icon, IconName, Selectable, Sizable as _, @@ -28,9 +29,10 @@ pub fn init(cx: &mut AppContext) { pub trait PopupMenuExt: Selectable + IntoElement + 'static { fn popup_menu( self, - f: impl Fn(PopupMenu, &mut WindowContext) -> PopupMenu + 'static, + f: impl Fn(PopupMenu, &mut ViewContext) -> PopupMenu + 'static, ) -> Popover { Popover::new("popup-menu") + .no_style() .trigger(self) .content(move |cx| PopupMenu::build(cx, |menu, cx| f(menu, cx))) } @@ -45,6 +47,11 @@ enum PopupMenuItem { action: Option>, handler: Rc, }, + Submenu { + icon: Option, + label: SharedString, + menu: View, + }, } impl PopupMenuItem { @@ -58,19 +65,23 @@ impl PopupMenuItem { } 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, _subscriptions: [gpui::Subscription; 1], } impl PopupMenu { pub fn build( cx: &mut WindowContext, - f: impl FnOnce(Self, &mut WindowContext) -> Self, + f: impl FnOnce(Self, &mut ViewContext) -> Self, ) -> View { cx.new_view(|cx| { let focus_handle = cx.focus_handle(); @@ -80,11 +91,14 @@ impl PopupMenu { let menu = Self { focus_handle, + 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(), _subscriptions: [_on_blur_subscription], }; cx.refresh(); @@ -138,6 +152,7 @@ impl PopupMenu { }); self } + /// Add Menu Item with Icon pub fn menu_with_icon( mut self, @@ -193,6 +208,50 @@ impl PopupMenu { self } + pub fn submenu( + self, + label: impl Into, + cx: &mut ViewContext, + f: impl Fn(PopupMenu, &mut ViewContext) -> PopupMenu + 'static, + ) -> Self { + self.submenu_with_icon(None, label, cx, f) + } + + /// Add a Submenu item with icon + pub fn submenu_with_icon( + mut self, + icon: Option, + label: impl Into, + cx: &mut ViewContext, + f: impl Fn(PopupMenu, &mut ViewContext) -> PopupMenu + 'static, + ) -> Self { + let submenu = PopupMenu::build(cx, f); + let parent_menu = cx.view().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 + } + fn clickable_menu_items(&self) -> impl Iterator { self.menu_items .iter() @@ -204,7 +263,7 @@ impl PopupMenu { cx.stop_propagation(); cx.prevent_default(); self.selected_index = Some(ix); - self.confirm(&Confirm, cx) + self.confirm(&Confirm, cx); } fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext) { @@ -249,7 +308,18 @@ impl PopupMenu { } fn dismiss(&mut self, _: &Dismiss, cx: &mut ViewContext) { + 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, cx); + }) + } } fn render_keybinding( @@ -271,25 +341,50 @@ impl PopupMenu { return None; } + + fn render_icon( + has_icon: bool, + icon: Option, + _: &ViewContext, + ) -> 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().clone()) + } else { + this.children(icon_placeholder.clone()) + } + }); + + Some(icon) + } } impl FluentBuilder for PopupMenu {} impl EventEmitter for PopupMenu {} impl FocusableView for PopupMenu { - fn focus_handle(&self, _cx: &gpui::AppContext) -> FocusHandle { + fn focus_handle(&self, _: &gpui::AppContext) -> FocusHandle { self.focus_handle.clone() } } impl Render for PopupMenu { fn render(&mut self, cx: &mut gpui::ViewContext) -> impl gpui::IntoElement { - let icon_placeholder = if self.has_icon { - Some(Icon::empty()) - } else { - None - }; - + let view = cx.view().clone(); let has_icon = self.menu_items.iter().any(|item| item.has_icon()); + let max_width = self.max_width; + let bounds = self.bounds; v_flex() .key_context("PopupMenu") @@ -304,14 +399,38 @@ impl Render for PopupMenu { .p_1() .gap_y_0p5() .min_w(rems(8.)) + .popover_style(cx) .text_color(cx.theme().popover_foreground) + .relative() + .child({ + canvas( + move |bounds, cx| view.update(cx, |r, _| r.bounds = bounds), + |_, _, _| {}, + ) + .absolute() + .size_full() + }) .children(self.menu_items.iter_mut().enumerate().map(|(ix, item)| { + let group_id = format!("item:{}", ix); let this = ListItem::new(("menu-item", ix)) + .group(group_id.clone()) .p_0() - .on_click(cx.listener(move |this, _, cx| this.on_click(ix, cx))); + .relative() + .py_1p5() + .px_2() + .rounded_md() + .text_sm() + .line_height(rems(1.25)) + .items_center() + .on_mouse_enter(cx.listener(move |this, _, cx| { + this.hovered_menu_ix = Some(ix); + cx.notify(); + })); match item { PopupMenuItem::Separator => this.disabled(true).child( div() + .p_0() + .rounded_none() .h(px(1.)) .mx_neg_1() .my_px() @@ -327,37 +446,16 @@ impl Render for PopupMenu { let action = action.as_ref().map(|action| action.boxed_clone()); let key = Self::render_keybinding(action, cx); - this.relative() - .py_1p5() - .px_2() - .rounded_md() - .text_sm() - .line_height(rems(1.25)) - .items_center() + this.on_click(cx.listener(move |this, _, cx| this.on_click(ix, cx))) .child( h_flex() .items_center() .gap_x_1p5() - .when(has_icon, |this| { - this.child( - h_flex() - .w_3p5() - .h_3p5() - .items_center() - .justify_center() - .text_sm() - .map(|this| { - if let Some(icon) = icon { - this.child(icon.clone().small().clone()) - } else { - this.children(icon_placeholder.clone()) - } - }), - ) - }) + .children(Self::render_icon(has_icon, icon.clone(), cx)) .child( h_flex() .flex_1() + .gap_2() .items_center() .justify_between() .child(label.clone()) @@ -365,6 +463,52 @@ impl Render for PopupMenu { ), ) } + 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(), cx)) + .child( + h_flex() + .flex_1() + .gap_2() + .items_center() + .justify_between() + .child(label.clone()) + .child(IconName::ChevronRight), + ), + ) + .when_some(self.hovered_menu_ix, |this, hovered_ix| { + let (anchor, left) = + if cx.bounds().size.width - bounds.origin.x < max_width { + (AnchorCorner::TopRight, -px(15.)) + } else { + (AnchorCorner::TopLeft, bounds.size.width - px(10.)) + }; + + let top = if bounds.origin.y + bounds.size.height + > cx.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()), + )) + } else { + this + } + }), + ), } })) } diff --git a/crates/ui/src/styled.rs b/crates/ui/src/styled.rs index b3ae86f9..2be8c025 100644 --- a/crates/ui/src/styled.rs +++ b/crates/ui/src/styled.rs @@ -151,6 +151,15 @@ pub trait StyledExt: Styled + Sized { this } + + /// Set as Popover style + fn popover_style(self, cx: &mut WindowContext) -> Self { + self.bg(cx.theme().popover) + .border_1() + .border_color(cx.theme().border) + .shadow_lg() + .rounded_lg() + } } impl StyledExt for E {}