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:
parent
bf1420357b
commit
c5416c4774
3 changed files with 201 additions and 204 deletions
35
crates/story/examples/popup.rs
Normal file
35
crates/story/examples/popup.rs
Normal 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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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| {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
Loading…
Reference in a new issue