diff --git a/crates/story/src/popup_story.rs b/crates/story/src/popup_story.rs index 2f90d84c..0a843c55 100644 --- a/crates/story/src/popup_story.rs +++ b/crates/story/src/popup_story.rs @@ -1,8 +1,8 @@ use gpui::{ actions, div, impl_actions, px, AnchorCorner, AppContext, DismissEvent, Element, EventEmitter, FocusHandle, FocusableView, InteractiveElement, IntoElement, KeyBinding, MouseButton, - MouseDownEvent, ParentElement as _, Render, Styled as _, View, ViewContext, VisualContext, - WindowContext, + MouseDownEvent, ParentElement as _, Render, SharedString, Styled as _, View, ViewContext, + VisualContext, WindowContext, }; use serde::Deserialize; use ui::{ @@ -289,6 +289,20 @@ impl Render for PopupStory { }) }), ) + .child( + Button::new("popup-menu-11112") + .label("Scrollable Menu") + .popup_menu(move |this, _| { + let mut this = this.scrollable(); + for i in 0..100 { + this = this.menu( + SharedString::from(format!("Item {}", i)), + Box::new(Info(i)), + ) + } + this + }), + ) .child(self.message.clone()), ) .child("Right click to open ContextMenu") diff --git a/crates/ui/src/button.rs b/crates/ui/src/button.rs index 6db5c000..435ccea7 100644 --- a/crates/ui/src/button.rs +++ b/crates/ui/src/button.rs @@ -273,6 +273,10 @@ impl Disableable for Button { } impl Selectable for Button { + fn element_id(&self) -> &ElementId { + &self.id + } + fn selected(mut self, selected: bool) -> Self { self.selected = selected; self @@ -305,6 +309,12 @@ impl ParentElement for Button { } } +impl InteractiveElement for Button { + fn interactivity(&mut self) -> &mut gpui::Interactivity { + self.base.interactivity() + } +} + impl RenderOnce for Button { fn render(self, cx: &mut WindowContext) -> impl IntoElement { let style: ButtonStyle = self.style; diff --git a/crates/ui/src/checkbox.rs b/crates/ui/src/checkbox.rs index 6f376fa6..2482279e 100644 --- a/crates/ui/src/checkbox.rs +++ b/crates/ui/src/checkbox.rs @@ -54,6 +54,10 @@ impl Disableable for Checkbox { } impl Selectable for Checkbox { + fn element_id(&self) -> &ElementId { + &self.id + } + fn selected(self, selected: bool) -> Self { self.checked(selected) } diff --git a/crates/ui/src/list/list_item.rs b/crates/ui/src/list/list_item.rs index be71271a..daa3cb26 100644 --- a/crates/ui/src/list/list_item.rs +++ b/crates/ui/src/list/list_item.rs @@ -9,6 +9,7 @@ use crate::{h_flex, theme::ActiveTheme, Disableable, Icon, IconName, Selectable, #[derive(IntoElement)] pub struct ListItem { + id: ElementId, base: Stateful
, disabled: bool, selected: bool, @@ -23,8 +24,10 @@ pub struct ListItem { impl ListItem { pub fn new(id: impl Into) -> Self { + let id: ElementId = id.into(); Self { - base: h_flex().id(id.into()).gap_x_1().py_1().px_2().text_base(), + id: id.clone(), + base: h_flex().id(id).gap_x_1().py_1().px_2().text_base(), disabled: false, selected: false, confirmed: false, @@ -98,6 +101,10 @@ impl Disableable for ListItem { } impl Selectable for ListItem { + fn element_id(&self) -> &ElementId { + &self.id + } + fn selected(mut self, selected: bool) -> Self { self.selected = selected; self diff --git a/crates/ui/src/popover.rs b/crates/ui/src/popover.rs index 0eccae26..2c835d0b 100644 --- a/crates/ui/src/popover.rs +++ b/crates/ui/src/popover.rs @@ -3,7 +3,7 @@ use gpui::{ AppContext, Bounds, DismissEvent, DispatchPhase, Element, ElementId, EventEmitter, FocusHandle, FocusableView, GlobalElementId, Hitbox, InteractiveElement as _, IntoElement, KeyBinding, LayoutId, ManagedView, MouseButton, MouseDownEvent, ParentElement, Pixels, Point, Render, - Style, Styled, View, ViewContext, VisualContext, WindowContext, + SharedString, Style, Styled, View, ViewContext, VisualContext, WindowContext, }; use std::{cell::RefCell, rc::Rc}; @@ -131,7 +131,7 @@ where } fn render_trigger(&mut self, is_open: bool, cx: &mut WindowContext) -> impl IntoElement { - let base = div().id("popover-trigger"); + let base = div().id(SharedString::from(format!("{}-trigger", self.id))); if self.trigger.is_none() { return base; diff --git a/crates/ui/src/popup_menu.rs b/crates/ui/src/popup_menu.rs index ffe5864b..dbba7a46 100644 --- a/crates/ui/src/popup_menu.rs +++ b/crates/ui/src/popup_menu.rs @@ -1,3 +1,4 @@ +use std::cell::Cell; use std::ops::Deref; use std::rc::Rc; @@ -6,8 +7,12 @@ use gpui::{ FocusHandle, InteractiveElement, IntoElement, KeyBinding, ParentElement, Pixels, Render, SharedString, Styled as _, View, ViewContext, VisualContext as _, WindowContext, }; -use gpui::{anchored, canvas, rems, AnchorCorner, Bounds, FocusableView, Keystroke, WeakView}; +use gpui::{ + anchored, canvas, rems, AnchorCorner, Bounds, Edges, FocusableView, Keystroke, ScrollHandle, + StatefulInteractiveElement, WeakView, +}; +use crate::scroll::{Scrollbar, ScrollbarState}; use crate::StyledExt; use crate::{ button::Button, h_flex, list::ListItem, popover::Popover, theme::ActiveTheme, v_flex, Icon, @@ -31,7 +36,8 @@ pub trait PopupMenuExt: Selectable + IntoElement + 'static { self, f: impl Fn(PopupMenu, &mut ViewContext) -> PopupMenu + 'static, ) -> Popover { - Popover::new("popup-menu") + let element_id = self.element_id(); + Popover::new(SharedString::from(format!("popup-menu:{:?}", element_id))) .no_style() .trigger(self) .content(move |cx| PopupMenu::build(cx, |menu, cx| f(menu, cx))) @@ -80,6 +86,10 @@ pub struct PopupMenu { hovered_menu_ix: Option, bounds: Bounds, + scrollable: bool, + scroll_handle: ScrollHandle, + scroll_state: Rc>, + action_focus_handle: Option, _subscriptions: [gpui::Subscription; 1], } @@ -106,6 +116,9 @@ impl PopupMenu { has_icon: false, hovered_menu_ix: None, bounds: Bounds::default(), + scrollable: false, + scroll_handle: ScrollHandle::default(), + scroll_state: Rc::new(Cell::new(ScrollbarState::default())), _subscriptions: [_on_blur_subscription], }; cx.refresh(); @@ -131,6 +144,14 @@ impl PopupMenu { self } + /// Set the menu to be scrollable to show vertical scrollbar. + /// + /// NOTE: If this is true, the sub-menus will cannot be support. + pub fn scrollable(mut self) -> Self { + self.scrollable = true; + self + } + /// Add Menu Item pub fn menu(mut self, label: impl Into, action: Box) -> Self { self.add_menu_item(label, None, action); @@ -414,20 +435,24 @@ impl PopupMenu { impl FluentBuilder for PopupMenu {} impl EventEmitter for PopupMenu {} impl FocusableView for PopupMenu { - fn focus_handle(&self, _: &gpui::AppContext) -> FocusHandle { + fn focus_handle(&self, _: &AppContext) -> FocusHandle { self.focus_handle.clone() } } impl Render for PopupMenu { - fn render(&mut self, cx: &mut gpui::ViewContext) -> impl gpui::IntoElement { + fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { let view = cx.view().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 window_haft_height = cx.window_bounds().get_bounds().size.height * 0.5; + let max_height = window_haft_height.min(px(450.)); + v_flex() + .id("popup-menu") .key_context("PopupMenu") .track_focus(&self.focus_handle) .on_action(cx.listener(Self::select_next)) @@ -435,141 +460,203 @@ impl Render for PopupMenu { .on_action(cx.listener(Self::confirm)) .on_action(cx.listener(Self::dismiss)) .on_mouse_down_out(cx.listener(|this, _, cx| this.dismiss(&Dismiss, cx))) - .max_h(self.max_width) - .min_w(self.min_width) - .p_1() - .gap_y_0p5() - .min_w(rems(8.)) .popover_style(cx) .text_color(cx.theme().popover_foreground) .relative() - .child({ - canvas( - move |bounds, cx| view.update(cx, |r, _| r.bounds = bounds), - |_, _, _| {}, - ) - .absolute() - .size_full() - }) - .children( - self.menu_items - .iter_mut() - .enumerate() - // Skip last separator - .filter(|(ix, item)| !(*ix == items_count - 1 && item.is_separator())) - .map(|(ix, item)| { - let group_id = format!("item:{}", ix); - - let this = ListItem::new(("menu-item", ix)) - .group(group_id.clone()) - .relative() - .text_sm() - .py_0() - .px_2() - .h(px(28.)) - .rounded_md() - .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::Item { - icon, - label, - action, - .. - } => { - let action = action.as_ref().map(|action| action.boxed_clone()); - let key = Self::render_keybinding(action, cx); - - this.on_click(cx.listener(move |this, _, cx| this.on_click(ix, cx))) - .child( - h_flex() + .child( + div() + .id("popup-menu-items") + .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(self.min_width) + .max_w(self.max_width) + .min_w(rems(8.)) + .child({ + canvas( + move |bounds, cx| view.update(cx, |r, _| r.bounds = bounds), + |_, _, _| {}, + ) + .absolute() + .size_full() + }) + .children( + self.menu_items + .iter_mut() + .enumerate() + // Skip last separator + .filter(|(ix, item)| { + !(*ix == items_count - 1 && item.is_separator()) + }) + .map(|(ix, item)| { + let group_id = format!("item:{}", ix); + + let this = ListItem::new(("menu-item", ix)) + .group(group_id.clone()) + .relative() + .text_sm() + .py_0() + .px_2() + .rounded_md() .items_center() - .gap_x_1p5() - .children(Self::render_icon(has_icon, icon.clone(), 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_1p5() - .children(Self::render_icon( - has_icon, - icon.clone(), - cx, - )) + .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::Item { + icon, + label, + action, + .. + } => { + let action = action + .as_ref() + .map(|action| action.boxed_clone()); + let key = Self::render_keybinding(action, cx); + + this.on_click(cx.listener(move |this, _, cx| { + this.on_click(ix, cx) + })) .child( h_flex() - .flex_1() - .gap_2() + .h(px(26.)) .items_center() - .justify_between() - .child(label.clone()) - .child(IconName::ChevronRight), - ), - ) - .when_some(self.hovered_menu_ix, |this, hovered_ix| { - let (anchor, left) = if cx.bounds().size.width - - bounds.origin.x - < max_width - { - (AnchorCorner::TopRight, -px(15.)) - } else { - (AnchorCorner::TopLeft, bounds.size.width - px(10.)) - }; - - let top = if bounds.origin.y + bounds.size.height - > cx.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()), - ), + .gap_x_1p5() + .children(Self::render_icon( + has_icon, + icon.clone(), + cx, + )) + .child( + h_flex() + .flex_1() + .gap_2() + .items_center() + .justify_between() + .child(label.clone()) + .children(key), + ), ) - } else { - this } - }), - ), - } - }), + 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_1p5() + .children(Self::render_icon( + has_icon, + icon.clone(), + 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 cx.bounds().size.width + - bounds.origin.x + < max_width + { + ( + AnchorCorner::TopRight, + -px(15.), + ) + } else { + ( + AnchorCorner::TopLeft, + bounds.size.width + - px(10.), + ) + }; + + let top = if bounds.origin.y + + bounds.size.height + > cx.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 + } + }, + ), + ), + } + }), + ), + ), ) + .when(self.scrollable, |this| { + // TODO: When the menu is limited by `overflow_y_scroll`, the sub-menu will cannot be displayed. + this.child( + div() + .absolute() + .top_1() + .left_1() + .right_1() + .bottom_1() + .child(Scrollbar::vertical( + cx.entity_id(), + self.scroll_state.clone(), + self.scroll_handle.clone(), + self.bounds.size, + )), + ) + }) } } diff --git a/crates/ui/src/scroll/scrollbar.rs b/crates/ui/src/scroll/scrollbar.rs index 7b0657ff..b9615cbd 100644 --- a/crates/ui/src/scroll/scrollbar.rs +++ b/crates/ui/src/scroll/scrollbar.rs @@ -8,7 +8,7 @@ use gpui::{ }; const MIN_THUMB_SIZE: f32 = 80.; -const THUMB_RADIUS: Pixels = Pixels(5.0); +const THUMB_RADIUS: Pixels = Pixels(4.0); const THUMB_INSET: Pixels = Pixels(2.); pub trait ScrollHandleOffsetable { diff --git a/crates/ui/src/styled.rs b/crates/ui/src/styled.rs index 90c9a624..9b16497a 100644 --- a/crates/ui/src/styled.rs +++ b/crates/ui/src/styled.rs @@ -4,7 +4,9 @@ use crate::{ scroll::{Scrollable, ScrollbarAxis}, theme::ActiveTheme, }; -use gpui::{div, px, Axis, Div, Element, EntityId, FocusHandle, Pixels, Styled, WindowContext}; +use gpui::{ + div, px, Axis, Div, Element, ElementId, EntityId, FocusHandle, Pixels, Styled, WindowContext, +}; use serde::{Deserialize, Serialize}; /// Returns a `Div` as horizontal flex layout. @@ -152,6 +154,7 @@ impl From for Size { /// A trait for defining element that can be selected. pub trait Selectable: Sized { + fn element_id(&self) -> &ElementId; /// Set the selected state of the element. fn selected(self, selected: bool) -> Self; } diff --git a/crates/ui/src/tab/tab.rs b/crates/ui/src/tab/tab.rs index 03f1bf54..df3521d4 100644 --- a/crates/ui/src/tab/tab.rs +++ b/crates/ui/src/tab/tab.rs @@ -8,6 +8,7 @@ use gpui::{ #[derive(IntoElement)] pub struct Tab { + id: ElementId, base: Stateful
, label: AnyElement, prefix: Option, @@ -18,8 +19,10 @@ pub struct Tab { impl Tab { pub fn new(id: impl Into, label: impl IntoElement) -> Self { + let id: ElementId = id.into(); Self { - base: div().id(id.into()).gap_1().py_1p5().px_3().h(px(30.)), + id: id.clone(), + base: div().id(id).gap_1().py_1p5().px_3().h(px(30.)), label: label.into_any_element(), disabled: false, selected: false, @@ -42,6 +45,10 @@ impl Tab { } impl Selectable for Tab { + fn element_id(&self) -> &ElementId { + &self.id + } + fn selected(mut self, selected: bool) -> Self { self.selected = selected; self