From 455605d64b1c90336596193fcfae2e795e6d4a16 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Mon, 22 Sep 2025 21:47:30 +0800 Subject: [PATCH] menu: Fix to support keyboard to select menu items. (#1263) - And add to support scroll to selected item. --------- Co-authored-by: Floyd Wang --- Cargo.lock | 53 ++-- crates/ui/src/actions.rs | 5 +- crates/ui/src/input/search.rs | 2 +- crates/ui/src/menu/context_menu.rs | 4 +- crates/ui/src/menu/menu_item.rs | 32 ++- crates/ui/src/menu/popup_menu.rs | 410 ++++++++++++++++++----------- 6 files changed, 320 insertions(+), 186 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 83562a52..af835cdb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -757,7 +757,7 @@ dependencies = [ "bitflags 2.9.1", "cexpr", "clang-sys", - "itertools 0.11.0", + "itertools 0.12.1", "lazy_static", "lazycell", "log", @@ -780,7 +780,7 @@ dependencies = [ "bitflags 2.9.1", "cexpr", "clang-sys", - "itertools 0.11.0", + "itertools 0.13.0", "log", "prettyplease", "proc-macro2", @@ -1449,7 +1449,7 @@ dependencies = [ [[package]] name = "collections" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed.git#53b2f37452189870c93d4514604f903d5ed885d9" +source = "git+https://github.com/zed-industries/zed.git#fbe06238e45c2fec6955c70b86eb26fd6a864266" dependencies = [ "indexmap 2.10.0", "rustc-hash 2.1.1", @@ -1918,7 +1918,7 @@ dependencies = [ [[package]] name = "derive_refineable" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed.git#53b2f37452189870c93d4514604f903d5ed885d9" +source = "git+https://github.com/zed-industries/zed.git#fbe06238e45c2fec6955c70b86eb26fd6a864266" dependencies = [ "proc-macro2", "quote", @@ -3112,7 +3112,7 @@ dependencies = [ [[package]] name = "gpui" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed.git#53b2f37452189870c93d4514604f903d5ed885d9" +source = "git+https://github.com/zed-industries/zed.git#fbe06238e45c2fec6955c70b86eb26fd6a864266" dependencies = [ "anyhow", "as-raw-xcb-connection", @@ -3184,6 +3184,7 @@ dependencies = [ "thiserror 2.0.14", "usvg", "util", + "util_macros", "uuid", "waker-fn", "wayland-backend", @@ -3296,7 +3297,7 @@ dependencies = [ [[package]] name = "gpui_macros" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed.git#53b2f37452189870c93d4514604f903d5ed885d9" +source = "git+https://github.com/zed-industries/zed.git#fbe06238e45c2fec6955c70b86eb26fd6a864266" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -3565,7 +3566,7 @@ dependencies = [ [[package]] name = "http_client" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed.git#53b2f37452189870c93d4514604f903d5ed885d9" +source = "git+https://github.com/zed-industries/zed.git#fbe06238e45c2fec6955c70b86eb26fd6a864266" dependencies = [ "anyhow", "bytes", @@ -3585,7 +3586,7 @@ dependencies = [ [[package]] name = "http_client_tls" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed.git#53b2f37452189870c93d4514604f903d5ed885d9" +source = "git+https://github.com/zed-industries/zed.git#fbe06238e45c2fec6955c70b86eb26fd6a864266" dependencies = [ "rustls", "rustls-platform-verifier", @@ -4543,7 +4544,7 @@ dependencies = [ [[package]] name = "media" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed.git#53b2f37452189870c93d4514604f903d5ed885d9" +source = "git+https://github.com/zed-industries/zed.git#fbe06238e45c2fec6955c70b86eb26fd6a864266" dependencies = [ "anyhow", "bindgen 0.71.1", @@ -5598,6 +5599,17 @@ version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" +[[package]] +name = "perf" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git#fbe06238e45c2fec6955c70b86eb26fd6a864266" +dependencies = [ + "collections", + "serde", + "serde_json", + "workspace-hack", +] + [[package]] name = "pest" version = "2.8.1" @@ -6507,7 +6519,7 @@ dependencies = [ [[package]] name = "refineable" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed.git#53b2f37452189870c93d4514604f903d5ed885d9" +source = "git+https://github.com/zed-industries/zed.git#fbe06238e45c2fec6955c70b86eb26fd6a864266" dependencies = [ "derive_refineable", "workspace-hack", @@ -6660,7 +6672,7 @@ dependencies = [ [[package]] name = "reqwest_client" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed.git#53b2f37452189870c93d4514604f903d5ed885d9" +source = "git+https://github.com/zed-industries/zed.git#fbe06238e45c2fec6955c70b86eb26fd6a864266" dependencies = [ "anyhow", "bytes", @@ -6744,7 +6756,7 @@ dependencies = [ [[package]] name = "rope" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed.git#53b2f37452189870c93d4514604f903d5ed885d9" +source = "git+https://github.com/zed-industries/zed.git#fbe06238e45c2fec6955c70b86eb26fd6a864266" dependencies = [ "arrayvec", "log", @@ -7251,7 +7263,7 @@ checksum = "0f7d95a54511e0c7be3f51e8867aa8cf35148d7b9445d44de2f943e2b206e749" [[package]] name = "semantic_version" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed.git#53b2f37452189870c93d4514604f903d5ed885d9" +source = "git+https://github.com/zed-industries/zed.git#fbe06238e45c2fec6955c70b86eb26fd6a864266" dependencies = [ "anyhow", "serde", @@ -7888,7 +7900,7 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "sum_tree" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed.git#53b2f37452189870c93d4514604f903d5ed885d9" +source = "git+https://github.com/zed-industries/zed.git#fbe06238e45c2fec6955c70b86eb26fd6a864266" dependencies = [ "arrayvec", "log", @@ -9204,7 +9216,7 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "util" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed.git#53b2f37452189870c93d4514604f903d5ed885d9" +source = "git+https://github.com/zed-industries/zed.git#fbe06238e45c2fec6955c70b86eb26fd6a864266" dependencies = [ "anyhow", "async-fs 2.1.3", @@ -9236,6 +9248,17 @@ dependencies = [ "workspace-hack", ] +[[package]] +name = "util_macros" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git#fbe06238e45c2fec6955c70b86eb26fd6a864266" +dependencies = [ + "perf", + "quote", + "syn 2.0.105", + "workspace-hack", +] + [[package]] name = "uuid" version = "1.18.0" diff --git a/crates/ui/src/actions.rs b/crates/ui/src/actions.rs index 9be2fa1d..af32e223 100644 --- a/crates/ui/src/actions.rs +++ b/crates/ui/src/actions.rs @@ -8,4 +8,7 @@ pub struct Confirm { pub secondary: bool, } -actions!(list, [Cancel, SelectPrev, SelectNext]); +actions!( + list, + [Cancel, SelectPrev, SelectNext, SelectLeft, SelectRight] +); diff --git a/crates/ui/src/input/search.rs b/crates/ui/src/input/search.rs index 9f85d914..72ab6bb8 100644 --- a/crates/ui/src/input/search.rs +++ b/crates/ui/src/input/search.rs @@ -568,7 +568,7 @@ mod tests { matcher.update_query("Is", true); assert_eq!(matcher.len(), 3); - let mut matches = matcher.clone().into_iter(); + let mut matches = matcher.clone(); assert_eq!(matches.current_match_ix, 0); assert_eq!(matches.next(), Some(18..20)); assert_eq!(matches.next(), Some(23..25)); diff --git a/crates/ui/src/menu/context_menu.rs b/crates/ui/src/menu/context_menu.rs index aa6cc036..d4e85129 100644 --- a/crates/ui/src/menu/context_menu.rs +++ b/crates/ui/src/menu/context_menu.rs @@ -143,7 +143,9 @@ impl Element for ContextMenu { .anchor(anchor) .when_some(menu_view, |this, menu| { // Focus the menu, so that can be handle the action. - menu.focus_handle(cx).focus(window); + if !menu.focus_handle(cx).contains_focused(window, cx) { + menu.focus_handle(cx).focus(window); + } this.child(div().occlude().child(menu.clone())) }), diff --git a/crates/ui/src/menu/menu_item.rs b/crates/ui/src/menu/menu_item.rs index 656bca82..dfac9bfc 100644 --- a/crates/ui/src/menu/menu_item.rs +++ b/crates/ui/src/menu/menu_item.rs @@ -1,6 +1,6 @@ use crate::{h_flex, ActiveTheme, Disableable, Selectable, StyledExt}; use gpui::{ - div, prelude::FluentBuilder as _, AnyElement, App, ClickEvent, ElementId, InteractiveElement, + prelude::FluentBuilder as _, AnyElement, App, ClickEvent, ElementId, InteractiveElement, IntoElement, MouseButton, MouseMoveEvent, ParentElement, RenderOnce, StatefulInteractiveElement as _, StyleRefinement, Styled, Window, }; @@ -12,6 +12,7 @@ pub struct MenuItem { style: StyleRefinement, disabled: bool, selected: bool, + hovered: bool, on_click: Option>, on_mouse_enter: Option>, children: SmallVec<[AnyElement; 2]>, @@ -25,6 +26,7 @@ impl MenuItem { style: StyleRefinement::default(), disabled: false, selected: false, + hovered: false, on_click: None, on_mouse_enter: None, children: SmallVec::new(), @@ -42,6 +44,11 @@ impl MenuItem { self } + pub fn hovered(mut self, hovered: bool) -> Self { + self.hovered = hovered; + self + } + pub fn on_click( mut self, handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, @@ -50,6 +57,8 @@ impl MenuItem { self } + /// Set a handler for when the mouse enters the MenuItem. + #[allow(unused)] pub fn on_mouse_enter( mut self, handler: impl Fn(&MouseMoveEvent, &mut Window, &mut App) + 'static, @@ -103,11 +112,13 @@ impl RenderOnce for MenuItem { .justify_between() .refine_style(&self.style) .when(!self.disabled, |this| { - this.when(!self.selected, |this| { - this.hover(|this| { - this.bg(cx.theme().accent) - .text_color(cx.theme().accent_foreground) - }) + this.when(self.hovered, |this| { + this.bg(cx.theme().accent) + .text_color(cx.theme().accent_foreground) + }) + .hover(|this| { + this.bg(cx.theme().accent) + .text_color(cx.theme().accent_foreground) }) .when_some(self.on_mouse_enter, |this, on_mouse_enter| { this.on_mouse_move(move |ev, window, cx| (on_mouse_enter)(ev, window, cx)) @@ -122,13 +133,6 @@ impl RenderOnce for MenuItem { .when(self.disabled, |this| { this.text_color(cx.theme().muted_foreground) }) - .child( - h_flex() - .w_full() - .items_center() - .justify_between() - .gap_x_1() - .child(div().w_full().children(self.children)), - ) + .children(self.children) } } diff --git a/crates/ui/src/menu/popup_menu.rs b/crates/ui/src/menu/popup_menu.rs index f6798afb..6da01f95 100644 --- a/crates/ui/src/menu/popup_menu.rs +++ b/crates/ui/src/menu/popup_menu.rs @@ -1,11 +1,12 @@ use crate::actions::{Cancel, Confirm, SelectNext, SelectPrev}; +use crate::input::{SelectLeft, SelectRight}; use crate::menu::menu_item::MenuItem; use crate::scroll::{Scrollbar, ScrollbarState}; use crate::{ button::Button, h_flex, popover::Popover, v_flex, ActiveTheme, Icon, IconName, Selectable, Sizable as _, }; -use crate::{Kbd, StyledExt}; +use crate::{Kbd, Side, StyledExt}; use gpui::{ anchored, canvas, div, prelude::FluentBuilder, px, rems, Action, AnyElement, App, AppContext, Bounds, Context, Corner, DismissEvent, Edges, Entity, EventEmitter, FocusHandle, Focusable, @@ -17,14 +18,16 @@ use std::ops::Deref; use std::rc::Rc; const ITEM_HEIGHT: Pixels = px(26.); +const CONTEXT: &str = "PopupMenu"; pub fn init(cx: &mut App) { - let context = Some("PopupMenu"); cx.bind_keys([ - KeyBinding::new("enter", Confirm { secondary: false }, context), - KeyBinding::new("escape", Cancel, context), - KeyBinding::new("up", SelectPrev, context), - KeyBinding::new("down", SelectNext, context), + KeyBinding::new("enter", Confirm { secondary: false }, Some(CONTEXT)), + KeyBinding::new("escape", Cancel, Some(CONTEXT)), + KeyBinding::new("up", SelectPrev, Some(CONTEXT)), + KeyBinding::new("down", SelectNext, Some(CONTEXT)), + KeyBinding::new("left", SelectLeft, Some(CONTEXT)), + KeyBinding::new("right", SelectRight, Some(CONTEXT)), ]); } @@ -86,6 +89,19 @@ enum PopupMenuItem { impl PopupMenuItem { fn is_clickable(&self) -> bool { !matches!(self, PopupMenuItem::Separator) + && matches!( + self, + PopupMenuItem::Item { + disabled: false, + .. + } | PopupMenuItem::ElementItem { + disabled: false, + .. + } | PopupMenuItem::Submenu { + disabled: false, + .. + } + ) } fn is_separator(&self) -> bool { @@ -103,7 +119,6 @@ pub struct PopupMenu { min_width: Option, max_width: Option, max_height: Option, - hovered_menu_ix: Option, bounds: Bounds, scrollable: bool, @@ -123,12 +138,7 @@ impl PopupMenu { ) -> Entity { cx.new(|cx| { let focus_handle = cx.focus_handle(); - let _subscriptions = - vec![ - cx.on_blur(&focus_handle, window, |this: &mut PopupMenu, window, cx| { - this.dismiss(&Cancel, window, cx) - }), - ]; + let _subscriptions = vec![]; let menu = Self { focus_handle, @@ -140,7 +150,6 @@ impl PopupMenu { max_width: None, max_height: None, has_icon: false, - hovered_menu_ix: None, bounds: Bounds::default(), scrollable: false, scroll_handle: ScrollHandle::default(), @@ -516,7 +525,7 @@ impl PopupMenu { } pub(crate) fn active_submenu(&self) -> Option> { - if let Some(ix) = self.hovered_menu_ix { + if let Some(ix) = self.selected_index { if let Some(item) = self.menu_items.get(ix) { return match item { PopupMenuItem::Submenu { menu, .. } => Some(menu.clone()), @@ -566,40 +575,128 @@ impl PopupMenu { } } - fn select_next(&mut self, _: &SelectNext, _: &mut Window, cx: &mut Context) { - let count = self.clickable_menu_items().count(); - if count > 0 { - let last_ix = count.saturating_sub(1); - let ix = self - .selected_index - .map(|index| if index == last_ix { 0 } else { index + 1 }) - .unwrap_or(0); - + fn set_selected_index(&mut self, ix: usize, cx: &mut Context) { + if self.selected_index != Some(ix) { self.selected_index = Some(ix); + self.scroll_handle.scroll_to_item(ix); cx.notify(); } } - fn select_prev(&mut self, _: &SelectPrev, _: &mut Window, cx: &mut Context) { - let count = self.clickable_menu_items().count(); - if count > 0 { - let last_ix = count.saturating_sub(1); + fn select_next(&mut self, _: &SelectNext, _: &mut Window, cx: &mut Context) { + cx.stop_propagation(); + let Some(ix) = self.selected_index else { + self.set_selected_index(0, cx); + return; + }; - let ix = self - .selected_index - .map(|index| { - if index == last_ix { - 0 - } else { - index.saturating_sub(1) - } - }) - .unwrap_or(last_ix); - self.selected_index = Some(ix); + if let Some((next_ix, _)) = self + .menu_items + .iter() + .enumerate() + .find(|(i, item)| *i > ix && item.is_clickable()) + { + self.set_selected_index(next_ix, cx); + return; + } + + self.set_selected_index(0, cx); + } + + fn select_prev(&mut self, _: &SelectPrev, _: &mut Window, cx: &mut Context) { + cx.stop_propagation(); + let ix = self.selected_index.unwrap_or(0); + + if let Some((prev_ix, _)) = self + .menu_items + .iter() + .enumerate() + .rev() + .find(|(i, item)| *i < ix && item.is_clickable()) + { + self.set_selected_index(prev_ix, cx); + return; + } + + let last_clickable_ix = self.clickable_menu_items().last().map(|(ix, _)| ix); + self.set_selected_index(last_clickable_ix.unwrap_or(0), cx); + } + + fn select_left(&mut self, _: &SelectLeft, window: &mut Window, cx: &mut Context) { + let (anchor, _) = self.child_menu_anchor(window); + if matches!(anchor, Corner::TopLeft | Corner::BottomLeft) { + self._unselect_submenu(window, cx); + } else { + self._select_submenu(window, cx); + } + + if self.parent_side(cx).is_left() { + self._focus_parent_menu(window, cx); + } + } + + fn select_right(&mut self, _: &SelectRight, window: &mut Window, cx: &mut Context) { + let (anchor, _) = self.child_menu_anchor(window); + if matches!(anchor, Corner::TopLeft | Corner::BottomLeft) { + self._select_submenu(window, cx); + } else { + self._unselect_submenu(window, cx); + } + + if self.parent_side(cx).is_right() { + self._focus_parent_menu(window, cx); + } + } + + fn _select_submenu(&mut self, window: &mut Window, cx: &mut Context) { + if let Some(active_submenu) = self.active_submenu() { + // Focus the submenu, so that can be handle the action. + active_submenu.update(cx, |view, cx| { + view.focus_handle(cx).focus(window); + view.set_selected_index(0, cx); + }); cx.notify(); } } + fn _unselect_submenu(&mut self, _: &mut Window, cx: &mut Context) { + if let Some(active_submenu) = self.active_submenu() { + active_submenu.update(cx, |view, cx| { + view.selected_index = None; + cx.notify(); + }); + } + } + + fn _focus_parent_menu(&mut self, window: &mut Window, cx: &mut Context) { + if let Some(parent) = self.parent_menu.as_ref() { + self.selected_index = None; + if let Some(parent) = parent.upgrade() { + parent.update(cx, |view, cx| { + view.focus_handle.focus(window); + cx.notify(); + }); + } + } + } + + fn parent_side(&self, cx: &App) -> Side { + let Some(parent) = self.parent_menu.as_ref() else { + return Side::Left; + }; + + let Some(parent) = parent.upgrade() else { + return Side::Left; + }; + + let parent_x = parent.read(cx).bounds.origin.x; + if parent_x < self.bounds.origin.x { + Side::Left + } else { + Side::Right + } + } + fn dismiss(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context) { if self.active_submenu().is_some() { return; @@ -618,7 +715,7 @@ impl PopupMenu { // Dismiss parent menu, when this menu is dismissed _ = parent_menu.update(cx, |view, cx| { - view.hovered_menu_ix = None; + view.selected_index = None; view.dismiss(&Cancel, window, cx); }); } @@ -672,6 +769,29 @@ impl PopupMenu { Some(icon) } + #[inline] + fn max_width(&self) -> Pixels { + self.max_width.unwrap_or(px(500.)) + } + + /// Calculate the anchor corner and left offset for child submenu + fn child_menu_anchor(&self, window: &Window) -> (Corner, Pixels) { + let bounds = self.bounds; + let max_width = self.max_width(); + let (anchor, left) = if max_width + bounds.origin.x > window.bounds().size.width { + (Corner::TopRight, -px(14.)) + } else { + (Corner::TopLeft, bounds.size.width) + }; + + let is_bottom_pos = bounds.origin.y + bounds.size.height > window.bounds().size.height; + if is_bottom_pos { + (anchor.other_side_corner_along(gpui::Axis::Vertical), left) + } else { + (anchor, left) + } + } + fn render_item( &self, ix: usize, @@ -680,10 +800,8 @@ impl PopupMenu { window: &mut Window, cx: &mut Context, ) -> impl IntoElement { - let bounds = self.bounds; - let max_width = state.max_width; let has_icon = self.has_icon; - let hovered = self.hovered_menu_ix == Some(ix); + let selected = self.selected_index == Some(ix); const EDGE_PADDING: Pixels = px(8.); const INNER_PADDING: Pixels = px(4.); @@ -694,20 +812,25 @@ impl PopupMenu { .px(INNER_PADDING) .rounded(state.radius) .items_center() + .hovered(selected) .on_mouse_enter(cx.listener(move |this, _, _, cx| { - this.hovered_menu_ix = Some(ix); + if this.selected_index == Some(ix) { + return; + } + + this.selected_index = 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().border), - ), + PopupMenuItem::Separator => this + .h_auto() + .p_0() + .my_0p5() + .mx_neg_1() + .h(px(1.)) + .bg(cx.theme().border) + .disabled(true), PopupMenuItem::Label(label) => this.disabled(true).cursor_default().child( h_flex() .cursor_default() @@ -754,9 +877,45 @@ impl PopupMenu { ) }) .disabled(*disabled) + .h(ITEM_HEIGHT) + .children(Self::render_icon(has_icon, icon.clone(), window, cx)) .child( h_flex() - .h(ITEM_HEIGHT) + .w_full() + .gap_2() + .items_center() + .justify_between() + .when(!show_link_icon, |this| this.child(label.clone())) + .when(show_link_icon, |this| { + this.child( + h_flex() + .w_full() + .justify_between() + .gap_1p5() + .child(label.clone()) + .child( + Icon::new(IconName::ExternalLink) + .xsmall() + .text_color(cx.theme().muted_foreground), + ), + ) + }) + .children(key), + ) + } + PopupMenuItem::Submenu { + icon, + label, + menu, + disabled, + } => this + .selected(selected) + .disabled(*disabled) + .items_start() + .child( + h_flex() + .min_h(ITEM_HEIGHT) + .size_full() .items_center() .gap_x_1() .children(Self::render_icon(has_icon, icon.clone(), window, cx)) @@ -766,78 +925,28 @@ impl PopupMenu { .gap_2() .items_center() .justify_between() - .when(!show_link_icon, |this| this.child(label.clone())) - .when(show_link_icon, |this| { - this.child( - h_flex().gap_1p5().child(label.clone()).child( - Icon::new(IconName::ExternalLink) - .xsmall() - .text_color(cx.theme().muted_foreground), - ), - ) - }) - .children(key), + .child(label.clone()) + .child(IconName::ChevronRight), ), ) - } - PopupMenuItem::Submenu { - icon, - label, - menu, - disabled, - } => this.selected(hovered).disabled(*disabled).child( - h_flex() - .items_start() - .when(hovered, |this| { - this.rounded(cx.theme().radius) - .mx(-INNER_PADDING) - .px(INNER_PADDING) - .bg(cx.theme().accent) - .text_color(cx.theme().accent_foreground) - }) - .child( - h_flex() - .min_h(ITEM_HEIGHT) - .size_full() - .items_center() - .gap_x_1() - .children(Self::render_icon(has_icon, icon.clone(), window, cx)) + .when(selected, |this| { + let (anchor, left) = self.child_menu_anchor(window); + let is_bottom_pos = matches!(anchor, Corner::BottomLeft | Corner::BottomRight); + + this.child( + anchored() + .anchor(anchor) .child( - h_flex() - .flex_1() - .gap_2() - .items_center() - .justify_between() - .child(label.clone()) - .child(IconName::ChevronRight), - ), + div() + .occlude() + .when(is_bottom_pos, |this| this.bottom_0()) + .when(!is_bottom_pos, |this| this.top_neg_1()) + .left(left) + .child(menu.clone()), + ) + .snap_to_window_with_margin(Edges::all(EDGE_PADDING)), ) - .when(hovered, |this| { - let (anchor, left) = - if max_width + bounds.origin.x > window.bounds().size.width { - (Corner::TopRight, -px(14.)) - } else { - (Corner::TopLeft, bounds.size.width) - }; - - 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_neg_1()) - .left(left) - .child(menu.clone()), - ) - .snap_to_window_with_margin(Edges::all(EDGE_PADDING)), - ) - }), - ), + }), } } } @@ -852,7 +961,6 @@ impl Focusable for PopupMenu { #[derive(Clone, Copy)] struct ItemState { - max_width: Pixels, radius: Pixels, } @@ -868,19 +976,20 @@ impl Render for PopupMenu { }, |height| height, ); - let max_width = self.max_width.unwrap_or(px(500.)); + let max_width = self.max_width(); let item_state = ItemState { - max_width, radius: cx.theme().radius.min(px(8.)), }; v_flex() .id("popup-menu") - .key_context("PopupMenu") + .key_context(CONTEXT) .track_focus(&self.focus_handle) .on_action(cx.listener(Self::select_next)) .on_action(cx.listener(Self::select_prev)) + .on_action(cx.listener(Self::select_left)) + .on_action(cx.listener(Self::select_right)) .on_action(cx.listener(Self::confirm)) .on_action(cx.listener(Self::dismiss)) .on_mouse_down_out(cx.listener(|this, ev: &MouseDownEvent, window, cx| { @@ -899,41 +1008,34 @@ impl Render for PopupMenu { .text_color(cx.theme().popover_foreground) .relative() .child( - div() + v_flex() .id("items") + .p_1() + .gap_y_0p5() + .min_w(rems(8.)) + .when_some(self.min_width, |this, min_width| this.min_w(min_width)) + .max_w(max_width) .when(self.scrollable, |this| { this.max_h(max_height) .overflow_y_scroll() .track_scroll(&self.scroll_handle) }) - .child( - v_flex() - .p_1() - .gap_y_0p5() - .min_w(rems(8.)) - .when_some(self.min_width, |this, min_width| this.min_w(min_width)) - .max_w(max_width) - .child({ - canvas( - move |bounds, _, cx| view.update(cx, |r, _| r.bounds = bounds), - |_, _, _, _| {}, - ) - .absolute() - .size_full() - }) - .children( - self.menu_items - .iter() - .enumerate() - // Ignore last separator - .filter(|(ix, item)| { - !(*ix + 1 == items_count && item.is_separator()) - }) - .map(|(ix, item)| { - self.render_item(ix, item, item_state, window, cx) - }), - ), - ), + .children( + self.menu_items + .iter() + .enumerate() + // Ignore last separator + .filter(|(ix, item)| !(*ix + 1 == items_count && item.is_separator())) + .map(|(ix, item)| self.render_item(ix, item, item_state, window, cx)), + ) + .child({ + canvas( + move |bounds, _, cx| view.update(cx, |r, _| r.bounds = bounds), + |_, _, _, _| {}, + ) + .absolute() + .size_full() + }), ) .when(self.scrollable, |this| { // TODO: When the menu is limited by `overflow_y_scroll`, the sub-menu will cannot be displayed.