From c5416c4774d87da1f1ca0e6b81c0f21b017b2770 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Fri, 7 Mar 2025 11:47:27 +0800 Subject: [PATCH] menu: Fix sub-menu render position. (#694) image --- crates/story/examples/popup.rs | 35 ++++ crates/story/src/popup_story.rs | 63 +++---- crates/ui/src/popup_menu.rs | 307 +++++++++++++++----------------- 3 files changed, 201 insertions(+), 204 deletions(-) create mode 100644 crates/story/examples/popup.rs diff --git a/crates/story/examples/popup.rs b/crates/story/examples/popup.rs new file mode 100644 index 00000000..93a3d6a1 --- /dev/null +++ b/crates/story/examples/popup.rs @@ -0,0 +1,35 @@ +use gpui::*; +use story::{Assets, PopupStory}; + +pub struct Example { + story: Entity, +} + +impl Example { + pub fn new(window: &mut Window, cx: &mut Context) -> Self { + let story = PopupStory::view(window, cx); + + Self { story } + } + + fn view(window: &mut Window, cx: &mut App) -> Entity { + cx.new(|cx| Self::new(window, cx)) + } +} + +impl Render for Example { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + div().p_4().size_full().child(self.story.clone()) + } +} + +fn main() { + let app = Application::new().with_assets(Assets); + + app.run(move |cx| { + story::init(cx); + cx.activate(true); + + story::create_new_window("Popup Example", Example::view, cx); + }); +} diff --git a/crates/story/src/popup_story.rs b/crates/story/src/popup_story.rs index 7df4e9e2..06800cf6 100644 --- a/crates/story/src/popup_story.rs +++ b/crates/story/src/popup_story.rs @@ -11,7 +11,6 @@ use gpui_component::{ input::TextInput, popover::{Popover, PopoverContent}, popup_menu::PopupMenuExt, - switch::Switch, v_flex, ActiveTheme as _, ContextModal, IconName, Sizable, }; use serde::Deserialize; @@ -19,10 +18,7 @@ use serde::Deserialize; #[derive(Clone, PartialEq, Deserialize)] struct Info(usize); -actions!( - popover_story, - [Copy, Paste, Cut, SearchAll, ToggleWindowMode] -); +actions!(popover_story, [Copy, Paste, Cut, SearchAll, ToggleCheck]); impl_internal_actions!(popover_story, [Info]); pub fn init(cx: &mut App) { @@ -86,8 +82,8 @@ impl Render for Form { pub struct PopupStory { focus_handle: FocusHandle, form: Entity
, + checked: bool, message: String, - window_mode: bool, } impl super::Story for PopupStory { @@ -116,9 +112,9 @@ impl PopupStory { Self { form, + checked: true, focus_handle: cx.focus_handle(), message: "".to_string(), - window_mode: false, } } @@ -126,31 +122,32 @@ impl PopupStory { self.message = "You have clicked copy".to_string(); cx.notify() } + fn on_cut(&mut self, _: &Cut, _: &mut Window, cx: &mut Context) { self.message = "You have clicked cut".to_string(); cx.notify() } + fn on_paste(&mut self, _: &Paste, _: &mut Window, cx: &mut Context) { self.message = "You have clicked paste".to_string(); cx.notify() } + fn on_search_all(&mut self, _: &SearchAll, _: &mut Window, cx: &mut Context) { self.message = "You have clicked search all".to_string(); cx.notify() } - fn on_toggle_window_mode( - &mut self, - _: &ToggleWindowMode, - _: &mut Window, - cx: &mut Context, - ) { - self.window_mode = !self.window_mode; - cx.notify() - } + fn on_action_info(&mut self, info: &Info, _: &mut Window, cx: &mut Context) { self.message = format!("You have clicked info: {}", info.0); cx.notify() } + + fn on_action_toggle_check(&mut self, _: &ToggleCheck, _: &mut Window, cx: &mut Context) { + self.checked = !self.checked; + self.message = format!("You have clicked toggle check: {}", self.checked); + cx.notify() + } } impl Focusable for PopupStory { @@ -162,7 +159,7 @@ impl Focusable for PopupStory { impl Render for PopupStory { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { let form = self.form.clone(); - let window_mode = self.window_mode; + let checked = self.checked; v_flex() .track_focus(&self.focus_handle) @@ -170,8 +167,8 @@ impl Render for PopupStory { .on_action(cx.listener(Self::on_cut)) .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)) + .on_action(cx.listener(Self::on_action_toggle_check)) .p_4() .mb_5() .size_full() @@ -183,17 +180,13 @@ impl Render for PopupStory { .menu("Copy", Box::new(Copy)) .menu("Paste", Box::new(Paste)) .separator() + .menu_with_check("Toggle Check", checked, Box::new(ToggleCheck)) .separator() .submenu("Settings", window, 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))) + menu.menu("Info 0", Box::new(Info(0))) + .separator() + .menu("Item 1", Box::new(Info(1))) + .menu("Item 2", Box::new(Info(2))) }) .separator() .menu("Search All", Box::new(SearchAll)) @@ -201,14 +194,6 @@ impl Render for PopupStory { } }) .gap_6() - .child( - Switch::new("switch-window-mode") - .checked(window_mode) - .label("Use Window Popover") - .on_click(cx.listener(|this, checked, _, _| { - this.window_mode = *checked; - })), - ) .child( h_flex() .items_center() @@ -273,13 +258,9 @@ impl Render for PopupStory { .menu("Cut", Box::new(Cut)) .menu("Paste", Box::new(Paste)) .separator() - .menu_with_icon("Search", IconName::Search, Box::new(SearchAll)) + .menu_with_check("Toggle Check", checked, Box::new(ToggleCheck)) .separator() - .menu_with_check( - "Window Mode", - window_mode, - Box::new(ToggleWindowMode), - ) + .menu_with_icon("Search", IconName::Search, Box::new(SearchAll)) .separator() .menu_with_element( |_, cx| { diff --git a/crates/ui/src/popup_menu.rs b/crates/ui/src/popup_menu.rs index 1d803a1a..14be7861 100644 --- a/crates/ui/src/popup_menu.rs +++ b/crates/ui/src/popup_menu.rs @@ -17,6 +17,8 @@ use std::rc::Rc; actions!(menu, [Confirm, Dismiss, SelectNext, SelectPrev]); +const ITEM_HEIGHT: Pixels = px(26.); + pub fn init(cx: &mut App) { let context = Some("PopupMenu"); cx.bind_keys([ @@ -476,6 +478,134 @@ impl PopupMenu { Some(icon) } + + fn render_item( + &self, + ix: usize, + item: &PopupMenuItem, + state: ItemState, + window: &mut Window, + cx: &mut Context, + ) -> impl IntoElement { + let max_width = self.max_width; + let bounds = self.bounds; + let has_icon = state.has_icon; + let hovered = self.hovered_menu_ix == Some(ix); + const EDGE_PADDING: Pixels = px(8.); + const INNER_PADDING: Pixels = px(4.); + + let this = ListItem::new(ix) + .relative() + .text_sm() + .py_0() + .px(INNER_PADDING) + .rounded(state.radius) + .items_center() + .on_mouse_enter(cx.listener(move |this, _, _, 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().muted), + ), + 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_1() + .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.selected(hovered).child( + h_flex() + .when(hovered, |this| { + this.rounded(cx.theme().radius) + .mx(-INNER_PADDING) + .px(INNER_PADDING) + .bg(cx.theme().accent) + .text_color(cx.theme().accent_foreground) + }) + .items_start() + .child( + h_flex() + .size_full() + .items_center() + .gap_x_1() + .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::ChevronRight), + ), + ) + .when(hovered, |this| { + let (anchor, left) = + if window.bounds().size.width - bounds.origin.x < max_width { + (Corner::TopRight, -px(12.)) + } else { + (Corner::TopLeft, bounds.size.width + px(4.)) + }; + + let is_bottom_pos = + bounds.origin.y + bounds.size.height > window.bounds().size.height; + + this.child( + anchored() + .anchor(anchor) + .child( + div() + .occlude() + .when(is_bottom_pos, |this| this.bottom_0()) + .when(!is_bottom_pos, |this| this.top(-px(4.))) + .left(left) + .child(menu.clone()), + ) + .snap_to_window_with_margin(Edges::all(EDGE_PADDING)), + ) + }), + ), + } + } } impl FluentBuilder for PopupMenu {} @@ -486,13 +616,17 @@ impl Focusable for PopupMenu { } } +#[derive(Clone, Copy)] +struct ItemState { + radius: Pixels, + has_icon: bool, +} + 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 max_height = self.max_height.map_or_else( || { let window_half_height = window.window_bounds().get_bounds().size.height * 0.5; @@ -501,8 +635,10 @@ impl Render for PopupMenu { |height| height, ); - const ITEM_HEIGHT: Pixels = px(26.); - let item_radius = cx.theme().radius.min(px(8.)); + let item_state = ItemState { + radius: cx.theme().radius.min(px(8.)), + has_icon: self.menu_items.iter().any(|item| item.has_icon()), + }; v_flex() .id("popup-menu") @@ -521,7 +657,7 @@ impl Render for PopupMenu { .p_1() .child( div() - .id("popup-menu-items") + .id("items") .when(self.scrollable, |this| { this.max_h(max_height) .overflow_y_scroll() @@ -543,169 +679,14 @@ impl Render for PopupMenu { }) .children( self.menu_items - .iter_mut() + .iter() .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() - .text_sm() - .py_0() - .px_1() - .rounded(item_radius) - .items_center() - .on_mouse_enter(cx.listener(move |this, _, _, 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().muted), - ) - } - 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_1() - .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_1() - .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::ChevronRight, - ), - ), - ) - .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 - } - }, - ), - ), - } + self.render_item(ix, item, item_state, window, cx) }), ), ),