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
|