From 4b61c3c25ab4fd9c8a88d7ab8da2833340ba1803 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Mon, 5 Aug 2024 20:54:07 +0800 Subject: [PATCH] Add ContextMenu (#107) https://github.com/user-attachments/assets/1534bb31-1d32-40b8-8871-f41fb182c8e9 --- README.md | 3 + crates/app/src/story_workspace.rs | 8 +- crates/story/src/lib.rs | 4 +- .../src/{popover_story.rs => popup_story.rs} | 30 ++- crates/ui/src/context_menu.rs | 236 ++++++++++++++++++ crates/ui/src/lib.rs | 2 + crates/ui/src/popup_menu.rs | 35 +-- crates/ui/src/theme.rs | 7 +- 8 files changed, 294 insertions(+), 31 deletions(-) rename crates/story/src/{popover_story.rs => popup_story.rs} (91%) create mode 100644 crates/ui/src/context_menu.rs diff --git a/README.md b/README.md index ed97beb1..ae7d92dc 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,9 @@ A UI components for building desktop application by using [GPUI](https://gpui.rs - [x] Column resizing - [x] Column ordering - [x] Column sorting +- [x] Menu + - [x] Popup Menu + - [x] Context Menu - [ ] Drawer - [ ] Modal diff --git a/crates/app/src/story_workspace.rs b/crates/app/src/story_workspace.rs index 557b200d..8472d7b0 100644 --- a/crates/app/src/story_workspace.rs +++ b/crates/app/src/story_workspace.rs @@ -2,7 +2,7 @@ use gpui::*; use prelude::FluentBuilder as _; use story::{ ButtonStory, DropdownStory, IconStory, ImageStory, InputStory, ListStory, PickerStory, - PopoverStory, ProgressStory, ResizableStory, ScrollableStory, StoryContainer, SwitchStory, + PopupStory, ProgressStory, ResizableStory, ScrollableStory, StoryContainer, SwitchStory, TableStory, TextStory, TooltipStory, }; use workspace::{TitleBar, Workspace}; @@ -96,9 +96,9 @@ impl StoryWorkspace { .detach(); StoryContainer::add_pane( - "Popover", - "Displays rich content in a portal, triggered by a button.", - PopoverStory::view(cx).into(), + "Popup", + "A popup displays content on top of the main page.", + PopupStory::view(cx).into(), workspace.clone(), cx, ) diff --git a/crates/story/src/lib.rs b/crates/story/src/lib.rs index 26224a9a..eeff914a 100644 --- a/crates/story/src/lib.rs +++ b/crates/story/src/lib.rs @@ -5,7 +5,7 @@ mod image_story; mod input_story; mod list_story; mod picker_story; -mod popover_story; +mod popup_story; mod progress_story; mod resizable_story; mod scrollable_story; @@ -22,7 +22,7 @@ pub use image_story::ImageStory; pub use input_story::InputStory; pub use list_story::ListStory; pub use picker_story::PickerStory; -pub use popover_story::PopoverStory; +pub use popup_story::PopupStory; pub use progress_story::ProgressStory; pub use resizable_story::ResizableStory; pub use scrollable_story::ScrollableStory; diff --git a/crates/story/src/popover_story.rs b/crates/story/src/popup_story.rs similarity index 91% rename from crates/story/src/popover_story.rs rename to crates/story/src/popup_story.rs index aac40fa7..e163c6c9 100644 --- a/crates/story/src/popover_story.rs +++ b/crates/story/src/popup_story.rs @@ -5,6 +5,7 @@ use gpui::{ }; use ui::{ button::Button, + context_menu::ContextMenuExt, divider::Divider, h_flex, input::TextInput, @@ -50,20 +51,21 @@ impl Render for Form { .child(self.input1.clone()) .child( Button::new("submit", cx) + .label("Submit") .primary() .on_click(cx.listener(|_, _, cx| cx.emit(DismissEvent))), ) } } -pub struct PopoverStory { +pub struct PopupStory { focus_handle: FocusHandle, form: View
, message: String, window_mode: bool, } -impl PopoverStory { +impl PopupStory { pub fn view(cx: &mut WindowContext) -> View { cx.new_view(Self::new) } @@ -92,13 +94,13 @@ impl PopoverStory { } } -impl FocusableView for PopoverStory { +impl FocusableView for PopupStory { fn focus_handle(&self, _cx: &AppContext) -> FocusHandle { self.focus_handle.clone() } } -impl Render for PopoverStory { +impl Render for PopupStory { fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { let form = self.form.clone(); let focus_handle = self.focus_handle.clone(); @@ -117,6 +119,17 @@ impl Render for PopoverStory { .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, cx| { cx.focus(&this.focus_handle); })) + .context_menu({ + let focus_handle = focus_handle.clone(); + move |menu, _cx| { + menu.track_focus(focus_handle.clone()) + .menu("Cut", Box::new(Cut)) + .menu("Copy", Box::new(Copy)) + .menu("Paste", Box::new(Paste)) + .separator() + .menu("About", Box::new(SearchAll)) + } + }) .gap_6() .child( Switch::new("switch-window-mode") @@ -184,8 +197,8 @@ impl Render for PopoverStory { .trigger(Button::new("popup-menu-1", cx).icon(IconName::Ellipsis)) .content(move |cx| { let focus_handle = focus_handle.clone(); - PopupMenu::build(cx, |mut this, _cx| { - this.content(focus_handle) + PopupMenu::build(cx, |menu, _cx| { + menu.track_focus(focus_handle) .menu("Copy", Box::new(Copy)) .menu("Cut", Box::new(Cut)) .menu("Paste", Box::new(Paste)) @@ -196,14 +209,13 @@ impl Render for PopoverStory { Box::new(SearchAll), ) .separator() - .menu_with_check("Check Menu", true, Box::new(SearchAll)); - - this + .menu_with_check("Check Menu", true, Box::new(SearchAll)) }) }), ) .child(self.message.clone()), ) + .child("Right click to open ContextMenu") .child( div().absolute().bottom_4().left_0().w_full().h_10().child( h_flex() diff --git a/crates/ui/src/context_menu.rs b/crates/ui/src/context_menu.rs new file mode 100644 index 00000000..44d4dcac --- /dev/null +++ b/crates/ui/src/context_menu.rs @@ -0,0 +1,236 @@ +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, +}; + +use crate::{popup_menu::PopupMenu, theme::ActiveTheme as _, StyledExt as _}; + +pub fn init(_cx: &mut AppContext) {} + +pub trait ContextMenuExt: ParentElement + Sized { + fn context_menu( + self, + f: impl Fn(PopupMenu, &mut WindowContext) -> PopupMenu + 'static, + ) -> Self { + self.child(ContextMenu::new("context_menu").menu(f)) + } +} + +impl ContextMenuExt for Stateful where E: ParentElement {} +impl ContextMenuExt for Focusable where E: ParentElement {} + +pub struct ContextMenu { + id: ElementId, + menu: Option PopupMenu + 'static>>, + anchor: AnchorCorner, +} + +impl ContextMenu { + pub fn new(id: impl Into) -> Self { + Self { + id: id.into(), + menu: None, + anchor: AnchorCorner::TopLeft, + } + } + + #[must_use] + pub fn menu(mut self, builder: F) -> Self + where + F: Fn(PopupMenu, &mut WindowContext) -> PopupMenu + 'static, + { + self.menu = Some(Box::new(builder)); + self + } + + fn with_element_state( + &mut self, + id: &GlobalElementId, + cx: &mut WindowContext, + f: impl FnOnce(&mut Self, &mut ContextMenuState, &mut WindowContext) -> R, + ) -> R { + cx.with_optional_element_state::(Some(id), |element_state, cx| { + let mut element_state = element_state.unwrap().unwrap_or_default(); + let result = f(self, &mut element_state, cx); + (result, Some(element_state)) + }) + } +} + +impl IntoElement for ContextMenu { + type Element = Self; + + fn into_element(self) -> Self::Element { + self + } +} + +pub struct ContextMenuState { + menu_view: Rc>>>, + menu_element: Option, + open: Rc>, + position: Rc>>, +} + +impl Default for ContextMenuState { + fn default() -> Self { + Self { + menu_view: Rc::new(RefCell::new(None)), + menu_element: None, + open: Rc::new(RefCell::new(false)), + position: Default::default(), + } + } +} + +impl Element for ContextMenu { + type RequestLayoutState = ContextMenuState; + type PrepaintState = (); + + fn id(&self) -> Option { + Some(self.id.clone()) + } + + fn request_layout( + &mut self, + id: Option<&gpui::GlobalElementId>, + cx: &mut WindowContext, + ) -> (gpui::LayoutId, Self::RequestLayoutState) { + let mut style = Style::default(); + // Set the layout style relative to the table view to get same size. + style.position = Position::Absolute; + style.flex_grow = 1.0; + style.flex_shrink = 1.0; + style.size.width = relative(1.).into(); + style.size.height = relative(1.).into(); + + let anchor = self.anchor; + + self.with_element_state(id.unwrap(), cx, |_, state: &mut ContextMenuState, cx| { + let position = state.position.clone(); + let position = position.borrow(); + let open = state.open.clone(); + let menu_view = state.menu_view.borrow().clone(); + + let (menu_element, menu_layout_id) = if *open.borrow() { + let mut menu_element = deferred( + anchored() + .position(*position) + .snap_to_window() + .anchor(anchor) + .when_some(menu_view, |this, menu| { + // Focus the menu, so that can be handle the action. + menu.focus_handle(cx).focus(cx); + + this.child( + div() + .elevation_2(cx) + .bg(cx.theme().popover) + .border_1() + .border_color(cx.theme().border) + .child(menu) + .on_mouse_down_out(move |_, cx| { + *open.borrow_mut() = false; + cx.refresh(); + }), + ) + }), + ) + .with_priority(1) + .into_any(); + + let menu_layout_id = menu_element.request_layout(cx); + (Some(menu_element), Some(menu_layout_id)) + } else { + (None, None) + }; + + let mut layout_ids = vec![]; + if let Some(menu_layout_id) = menu_layout_id { + layout_ids.push(menu_layout_id); + } + + let layout_id = cx.request_layout(style, layout_ids); + + ( + layout_id, + ContextMenuState { + menu_element, + + ..Default::default() + }, + ) + }) + } + + fn prepaint( + &mut self, + _: Option<&gpui::GlobalElementId>, + _: gpui::Bounds, + request_layout: &mut Self::RequestLayoutState, + cx: &mut WindowContext, + ) -> Self::PrepaintState { + if let Some(menu_element) = &mut request_layout.menu_element { + menu_element.prepaint(cx); + } + } + + fn paint( + &mut self, + id: Option<&gpui::GlobalElementId>, + bounds: gpui::Bounds, + request_layout: &mut Self::RequestLayoutState, + _: &mut Self::PrepaintState, + cx: &mut WindowContext, + ) { + if let Some(menu_element) = &mut request_layout.menu_element { + menu_element.paint(cx); + } + + let Some(builder) = self.menu.take() else { + return; + }; + + self.with_element_state( + id.unwrap(), + cx, + |_view, state: &mut ContextMenuState, cx| { + let position = state.position.clone(); + let open = state.open.clone(); + let menu_view = state.menu_view.clone(); + + // When right mouse click, to build content menu, and show it at the mouse position. + cx.on_mouse_event(move |event: &MouseDownEvent, phase, cx| { + if phase == DispatchPhase::Bubble + && event.button == MouseButton::Right + && bounds.contains(&event.position) + { + cx.prevent_default(); + cx.stop_propagation(); + + *position.borrow_mut() = event.position; + *open.borrow_mut() = true; + + let menu = + PopupMenu::build(cx, |menu, cx| (builder)(menu, cx)).into_element(); + + let open = open.clone(); + cx.subscribe(&menu, move |_, _: &DismissEvent, cx| { + *open.borrow_mut() = false; + cx.refresh(); + }) + .detach(); + + *menu_view.borrow_mut() = Some(menu); + + cx.refresh(); + } + }); + }, + ); + } +} diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index 25d7549e..c5f0dc14 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -13,6 +13,7 @@ mod svg_img; pub mod button; pub mod checkbox; pub mod clipboard; +pub mod context_menu; pub mod divider; pub mod dropdown; pub mod indicator; @@ -57,6 +58,7 @@ pub fn init(cx: &mut gpui::AppContext) { dropdown::init(cx); popover::init(cx); popup_menu::init(cx); + context_menu::init(cx); table::init(cx); webview::init(cx) } diff --git a/crates/ui/src/popup_menu.rs b/crates/ui/src/popup_menu.rs index 9e2af380..4617dac6 100644 --- a/crates/ui/src/popup_menu.rs +++ b/crates/ui/src/popup_menu.rs @@ -77,51 +77,56 @@ impl PopupMenu { } /// Set min width of the popup menu, default is 120px - pub fn min_w(&mut self, width: impl Into) -> &mut Self { + 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, height: impl Into) -> &mut Self { + pub fn max_w(mut self, height: impl Into) -> Self { self.max_width = height.into(); self } /// You must set content (FocusHandle) with the parent view, if the menu action is listening on the parent view. /// When the Menu Item confirmed, the parent view will be focused again to ensure to receive the action. - pub fn content(&mut self, focus_handle: FocusHandle) -> &mut Self { + #[must_use] + pub fn track_focus(mut self, focus_handle: FocusHandle) -> Self { self.action_context = Some(focus_handle); self } /// Add Menu Item - pub fn menu(&mut self, label: impl Into, action: Box) -> &mut Self { - self.add_menu_item(None, label, action) + pub fn menu(mut self, label: impl Into, action: Box) -> Self { + self.add_menu_item(None, label, action); + self } /// Add Menu Item with Icon pub fn menu_with_icon( - &mut self, + mut self, icon: impl Into, label: impl Into, action: Box, - ) -> &mut Self { - self.add_menu_item(Some(icon.into()), label, action) + ) -> Self { + self.add_menu_item(Some(icon.into()), label, action); + self } /// Add Menu Item with check icon pub fn menu_with_check( - &mut self, + mut self, label: impl Into, checked: bool, action: Box, - ) -> &mut Self { + ) -> Self { if checked { - self.add_menu_item(Some(IconName::Check.into()), label, action) + self.add_menu_item(Some(IconName::Check.into()), label, action); } else { - self.add_menu_item(None, label, action) + self.add_menu_item(None, label, action); } + + self } fn add_menu_item( @@ -153,9 +158,8 @@ impl PopupMenu { } /// Add a separator Menu Item - pub fn separator(&mut self) -> &mut Self { + pub fn separator(mut self) -> Self { self.menu_items.push(PopupMenuItem::Separator); - self } @@ -248,6 +252,7 @@ impl Render for PopupMenu { .min_w(self.min_width) .p_0p5() .gap_y_0p5() + .bg(cx.theme().menu) .children(self.menu_items.iter_mut().enumerate().map(|(ix, item)| { let this = ListItem::new(("menu-item", ix)) .p_0() @@ -262,7 +267,7 @@ impl Render for PopupMenu { .bg(cx.theme().border), ), PopupMenuItem::Item { icon, label, .. } => { - this.py(px(3.)).px_3().rounded_md().text_sm().child( + this.py(px(2.)).px_2().rounded_md().text_sm().child( h_flex() .size_full() .items_center() diff --git a/crates/ui/src/theme.rs b/crates/ui/src/theme.rs index 4206ca7a..9d2fe973 100644 --- a/crates/ui/src/theme.rs +++ b/crates/ui/src/theme.rs @@ -162,6 +162,7 @@ struct Colors { pub list_active: Hsla, pub list_head: Hsla, pub link: Hsla, + pub menu: Hsla, } impl Colors { @@ -203,6 +204,7 @@ impl Colors { list_active: hsl(240.0, 7., 88.0), list_head: hsl(240.0, 0., 94.), link: hsl(221.0, 83.0, 53.0), + menu: hsl(0.0, 0.0, 97.0), } } @@ -244,6 +246,7 @@ impl Colors { list_active: hsl(240.0, 3.7, 15.0), list_head: hsl(240.0, 3.7, 10.9), link: hsl(221.0, 83.0, 53.0), + menu: hsl(300.0, 2.0, 12.), } } } @@ -309,6 +312,7 @@ pub struct Theme { pub link: Hsla, pub link_hover: Hsla, pub link_active: Hsla, + pub menu: Hsla, } impl Global for Theme {} @@ -382,10 +386,11 @@ impl From for Theme { table: colors.list, table_even: colors.list_even, table_active: colors.list_active, - table_hover: colors.list_active.opacity(0.6), + table_hover: colors.list_active.opacity(0.8), link: colors.link, link_hover: colors.link.lighten(0.2), link_active: colors.link.darken(0.2), + menu: colors.menu, } } }