menu: Fix sub-menu render position. (#694)

<img width="385" alt="image"
src="https://github.com/user-attachments/assets/2e8527af-08af-4176-b4cf-931f79b6d45c"
/>
This commit is contained in:
Jason Lee 2025-03-07 11:47:27 +08:00 committed by GitHub
parent bf1420357b
commit c5416c4774
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 201 additions and 204 deletions

View file

@ -0,0 +1,35 @@
use gpui::*;
use story::{Assets, PopupStory};
pub struct Example {
story: Entity<PopupStory>,
}
impl Example {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let story = PopupStory::view(window, cx);
Self { story }
}
fn view(window: &mut Window, cx: &mut App) -> Entity<Self> {
cx.new(|cx| Self::new(window, cx))
}
}
impl Render for Example {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> 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);
});
}

View file

@ -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<Form>,
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>) {
self.message = "You have clicked cut".to_string();
cx.notify()
}
fn on_paste(&mut self, _: &Paste, _: &mut Window, cx: &mut Context<Self>) {
self.message = "You have clicked paste".to_string();
cx.notify()
}
fn on_search_all(&mut self, _: &SearchAll, _: &mut Window, cx: &mut Context<Self>) {
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>,
) {
self.window_mode = !self.window_mode;
cx.notify()
}
fn on_action_info(&mut self, info: &Info, _: &mut Window, cx: &mut Context<Self>) {
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>) {
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<Self>) -> 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| {

View file

@ -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<Self>,
) -> 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<Self>) -> 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)
}),
),
),