menu: Add check_side to PopupMenu to support display check at right side. (#1677)

<img width="507" height="511" alt="image"
src="https://github.com/user-attachments/assets/2d89381a-2acb-49d8-be69-8cb6ab211f41"
/>
This commit is contained in:
Jason Lee 2025-11-25 15:30:36 +08:00 committed by GitHub
parent 5ae9311202
commit e70d072b88
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 247 additions and 176 deletions

View file

@ -3,7 +3,7 @@ use gpui::{
ParentElement as _, Render, SharedString, Styled as _, Window, actions, div, px,
};
use gpui_component::{
ActiveTheme as _, IconName, StyledExt,
ActiveTheme as _, IconName, Side, StyledExt,
button::Button,
h_flex,
menu::{ContextMenuExt, DropdownMenu as _, PopupMenuItem},
@ -38,11 +38,12 @@ pub fn init(cx: &mut App) {
KeyBinding::new("cmd-shift-f", SearchAll, Some(CONTEXT)),
#[cfg(not(target_os = "macos"))]
KeyBinding::new("ctrl-shift-f", SearchAll, Some(CONTEXT)),
KeyBinding::new("ctrl-shift-alt-t", ToggleCheck, Some(CONTEXT)),
])
}
pub struct MenuStory {
checked: bool,
check_side: Option<Side>,
message: String,
}
@ -67,7 +68,7 @@ impl MenuStory {
fn new(_: &mut Window, _: &mut Context<Self>) -> Self {
Self {
checked: true,
check_side: None,
message: "".to_string(),
}
}
@ -98,15 +99,22 @@ impl MenuStory {
}
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);
self.check_side = if self.check_side == Some(Side::Left) {
Some(Side::Right)
} else if self.check_side == Some(Side::Right) {
None
} else {
Some(Side::Left)
};
self.message = format!("You have used check at side: {:?}", self.check_side);
cx.notify()
}
}
impl Render for MenuStory {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let checked = self.checked;
let check_side = self.check_side;
let view = cx.entity();
v_flex()
@ -128,6 +136,7 @@ impl Render for MenuStory {
.label("Edit")
.dropdown_menu(move |this, window, cx| {
this.link("About", "https://github.com/longbridge/gpui-component")
.check_side(check_side.unwrap_or(Side::Left))
.separator()
.item(PopupMenuItem::new("Handle Click").on_click(
window.listener_for(&view, |this, _, _, cx| {
@ -141,7 +150,11 @@ impl Render for MenuStory {
.menu("Cut", Box::new(Cut))
.menu("Paste", Box::new(Paste))
.separator()
.menu_with_check("Toggle Check", checked, Box::new(ToggleCheck))
.menu_with_check(
format!("Check Side {:?}", check_side),
check_side.is_some(),
Box::new(ToggleCheck),
)
.separator()
.menu_with_icon("Search", IconName::Search, Box::new(SearchAll))
.separator()
@ -162,14 +175,18 @@ impl Render for MenuStory {
}),
),
)
.menu_element_with_check(checked, Box::new(Info(0)), |_, cx| {
h_flex().gap_1().child("Custom Element").child(
div()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child("checked"),
)
})
.menu_element_with_check(
check_side.is_some(),
Box::new(ToggleCheck),
|_, cx| {
h_flex().gap_1().child("Custom Element").child(
div()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child("checked"),
)
},
)
.menu_element_with_icon(
IconName::Info,
Box::new(Info(0)),
@ -222,7 +239,8 @@ impl Render for MenuStory {
.child("Right click to open ContextMenu")
.context_menu({
move |this, window, cx| {
this.external_link_icon(false)
this.check_side(check_side.unwrap_or(Side::Left))
.external_link_icon(false)
.link(
"About",
"https://github.com/longbridge/gpui-component",
@ -234,8 +252,8 @@ impl Render for MenuStory {
.separator()
.label("This is a label")
.menu_with_check(
"Toggle Check",
checked,
format!("Check Side {:?}", check_side),
check_side.is_some(),
Box::new(ToggleCheck),
)
.separator()

View file

@ -6,11 +6,10 @@ use gpui::{
Window, div, px,
};
use gpui_component::{
ActiveTheme as _, IconName, PixelsExt, Sizable as _, Theme, TitleBar, WindowExt as _,
ActiveTheme as _, IconName, PixelsExt, Side, Sizable as _, Theme, TitleBar, WindowExt as _,
badge::Badge,
button::{Button, ButtonVariants as _},
menu::AppMenuBar,
menu::DropdownMenu as _,
menu::{AppMenuBar, DropdownMenu as _},
scroll::ScrollbarShow,
};
@ -155,6 +154,7 @@ impl Render for FontSizeSelector {
.icon(IconName::Settings2)
.dropdown_menu(move |this, _, _| {
this.scrollable(true)
.check_side(Side::Right)
.max_h(px(480.))
.label("Font Size")
.menu_with_check("Large", font_size == 18, Box::new(SelectFont(18)))

View file

@ -37,6 +37,7 @@ pub enum PopupMenuItem {
icon: Option<Icon>,
label: SharedString,
disabled: bool,
checked: bool,
is_link: bool,
action: Option<Box<dyn Action>>,
// For link item
@ -46,6 +47,7 @@ pub enum PopupMenuItem {
ElementItem {
icon: Option<Icon>,
disabled: bool,
checked: bool,
action: Option<Box<dyn Action>>,
render: Box<dyn Fn(&mut Window, &mut App) -> AnyElement + 'static>,
handler: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
@ -70,6 +72,7 @@ impl PopupMenuItem {
icon: None,
label: label.into(),
disabled: false,
checked: false,
action: None,
is_link: false,
handler: None,
@ -86,6 +89,7 @@ impl PopupMenuItem {
PopupMenuItem::ElementItem {
icon: None,
disabled: false,
checked: false,
action: None,
render: Box::new(move |window, cx| builder(window, cx).into_any_element()),
handler: None,
@ -169,24 +173,16 @@ impl PopupMenuItem {
self
}
/// Set checked state for the menu item by adding or removing check icon.
/// Set checked state for the menu item.
///
/// If true, will set the icon to check icon, otherwise remove the icon.
/// NOTE: If `check_side` is [`Side::Left`], the icon will replace with a check icon.
pub fn checked(mut self, checked: bool) -> Self {
match &mut self {
PopupMenuItem::Item { icon: i, .. } => {
if checked {
*i = Some(IconName::Check.into());
} else {
*i = None;
}
PopupMenuItem::Item { checked: c, .. } => {
*c = checked;
}
PopupMenuItem::ElementItem { icon: i, .. } => {
if checked {
*i = Some(IconName::Check.into());
} else {
*i = None;
}
PopupMenuItem::ElementItem { checked: c, .. } => {
*c = checked;
}
_ => {}
}
@ -220,6 +216,7 @@ impl PopupMenuItem {
icon: None,
label: label.into(),
disabled: false,
checked: false,
action: None,
is_link: true,
handler: Some(Rc::new(move |_, _, cx| cx.open_url(&href))),
@ -249,14 +246,27 @@ impl PopupMenuItem {
matches!(self, PopupMenuItem::Separator)
}
fn has_icon(&self) -> bool {
fn has_left_icon(&self, check_side: Side) -> bool {
match self {
PopupMenuItem::Item { icon, .. } => icon.is_some(),
PopupMenuItem::ElementItem { icon, .. } => icon.is_some(),
PopupMenuItem::Item { icon, checked, .. } => {
icon.is_some() || (check_side.is_left() && *checked)
}
PopupMenuItem::ElementItem { icon, checked, .. } => {
icon.is_some() || (check_side.is_left() && *checked)
}
PopupMenuItem::Submenu { icon, .. } => icon.is_some(),
_ => false,
}
}
#[inline]
fn is_checked(&self) -> bool {
match self {
PopupMenuItem::Item { checked, .. } => *checked,
PopupMenuItem::ElementItem { checked, .. } => *checked,
_ => false,
}
}
}
pub struct PopupMenu {
@ -264,13 +274,13 @@ pub struct PopupMenu {
pub(crate) menu_items: Vec<PopupMenuItem>,
/// The focus handle of Entity to handle actions.
pub(crate) action_context: Option<FocusHandle>,
has_icon: bool,
selected_index: Option<usize>,
min_width: Option<Pixels>,
max_width: Option<Pixels>,
max_height: Option<Pixels>,
bounds: Bounds<Pixels>,
size: Size,
check_side: Side,
/// The parent menu of this menu, if this is a submenu
parent_menu: Option<WeakEntity<Self>>,
@ -295,7 +305,7 @@ impl PopupMenu {
min_width: None,
max_width: None,
max_height: None,
has_icon: false,
check_side: Side::Left,
bounds: Bounds::default(),
scrollable: false,
scroll_handle: ScrollHandle::default(),
@ -351,6 +361,12 @@ impl PopupMenu {
self
}
/// Set the side to show check icon, default is `Side::Left`.
pub fn check_side(mut self, side: Side) -> Self {
self.check_side = side;
self
}
/// Set the menu to show external link icon, default is true.
pub fn external_link_icon(mut self, visible: bool) -> Self {
self.external_link_icon = visible;
@ -369,7 +385,7 @@ impl PopupMenu {
action: Box<dyn Action>,
enable: bool,
) -> Self {
self.add_menu_item(label, None, action, !enable);
self.add_menu_item(label, None, action, !enable, false);
self
}
@ -380,7 +396,7 @@ impl PopupMenu {
action: Box<dyn Action>,
disabled: bool,
) -> Self {
self.add_menu_item(label, None, action, disabled);
self.add_menu_item(label, None, action, disabled, false);
self
}
@ -453,7 +469,7 @@ impl PopupMenu {
action: Box<dyn Action>,
disabled: bool,
) -> Self {
self.add_menu_item(label, Some(icon.into()), action, disabled);
self.add_menu_item(label, Some(icon.into()), action, disabled, false);
self
}
@ -475,12 +491,7 @@ impl PopupMenu {
action: Box<dyn Action>,
disabled: bool,
) -> Self {
if checked {
self.add_menu_item(label, Some(IconName::Check.into()), action, disabled);
} else {
self.add_menu_item(label, None, action, disabled);
}
self.add_menu_item(label, None, action, disabled, checked);
self
}
@ -553,7 +564,6 @@ impl PopupMenu {
.icon(icon)
.disabled(disabled),
);
self.has_icon = true;
self
}
@ -572,10 +582,9 @@ impl PopupMenu {
self.menu_items.push(
PopupMenuItem::element(builder)
.action(action)
.when(checked, |item| item.icon(IconName::Check))
.checked(checked)
.disabled(disabled),
);
self.has_icon = self.has_icon || checked;
self
}
@ -628,9 +637,6 @@ impl PopupMenu {
/// Add menu item.
pub fn item(mut self, item: impl Into<PopupMenuItem>) -> Self {
let item: PopupMenuItem = item.into();
if item.has_icon() {
self.has_icon = true;
}
self.menu_items.push(item);
self
}
@ -647,15 +653,13 @@ impl PopupMenu {
icon: Option<Icon>,
action: Box<dyn Action>,
disabled: bool,
checked: bool,
) -> &mut Self {
if icon.is_some() {
self.has_icon = true;
}
self.menu_items.push(
PopupMenuItem::new(label)
.when_some(icon, |item, icon| item.icon(icon))
.disabled(disabled)
.checked(checked)
.action(action),
);
self
@ -966,6 +970,7 @@ impl PopupMenu {
fn render_icon(
has_icon: bool,
checked: bool,
icon: Option<Icon>,
_: &mut Window,
_: &mut Context<Self>,
@ -976,6 +981,8 @@ impl PopupMenu {
let icon = if let Some(icon) = icon {
icon.clone()
} else if checked {
Icon::new(IconName::Check)
} else {
Icon::empty()
};
@ -1010,11 +1017,18 @@ impl PopupMenu {
&self,
ix: usize,
item: &PopupMenuItem,
state: ItemState,
options: RenderOptions,
window: &mut Window,
cx: &mut Context<Self>,
) -> MenuItemElement {
let has_icon = self.has_icon;
let has_left_icon = options.has_left_icon;
let is_left_check = options.check_side.is_left() && item.is_checked();
let right_check_icon = if options.check_side.is_right() && item.is_checked() {
Some(Icon::new(IconName::Check).xsmall())
} else {
None
};
let selected = self.selected_index == Some(ix);
const EDGE_PADDING: Pixels = px(4.);
const INNER_PADDING: Pixels = px(8.);
@ -1023,8 +1037,8 @@ impl PopupMenu {
let group_name = format!("popup-menu-item-{}", ix);
let (item_height, radius) = match self.size {
Size::Small => (px(20.), state.radius.half()),
_ => (px(26.), state.radius),
Size::Small => (px(20.), options.radius.half()),
_ => (px(26.), options.radius),
};
let this = MenuItemElement::new(ix, &group_name)
@ -1060,7 +1074,7 @@ impl PopupMenu {
.cursor_default()
.items_center()
.gap_x_1()
.children(Self::render_icon(has_icon, None, window, cx))
.children(Self::render_icon(has_left_icon, false, None, window, cx))
.child(div().flex_1().child(label.clone())),
),
PopupMenuItem::ElementItem {
@ -1081,8 +1095,15 @@ impl PopupMenu {
.min_h(item_height)
.items_center()
.gap_x_1()
.children(Self::render_icon(has_icon, icon.clone(), window, cx))
.child((render)(window, cx)),
.children(Self::render_icon(
has_left_icon,
is_left_check,
icon.clone(),
window,
cx,
))
.child((render)(window, cx))
.children(right_check_icon.map(|icon| icon.ml_3())),
),
PopupMenuItem::Item {
icon,
@ -1103,14 +1124,22 @@ impl PopupMenu {
})
.disabled(*disabled)
.h(item_height)
.children(Self::render_icon(has_icon, icon.clone(), window, cx))
.gap_x_1()
.children(Self::render_icon(
has_left_icon,
is_left_check,
icon.clone(),
window,
cx,
))
.child(
h_flex()
.w_full()
.gap_2()
.gap_3()
.items_center()
.justify_between()
.when(!show_link_icon, |this| this.child(label.clone()))
.children(right_check_icon)
.when(show_link_icon, |this| {
this.child(
h_flex()
@ -1143,7 +1172,13 @@ impl PopupMenu {
.size_full()
.items_center()
.gap_x_1()
.children(Self::render_icon(has_icon, icon.clone(), window, cx))
.children(Self::render_icon(
has_left_icon,
false,
icon.clone(),
window,
cx,
))
.child(
h_flex()
.flex_1()
@ -1186,7 +1221,9 @@ impl Focusable for PopupMenu {
}
#[derive(Clone, Copy)]
struct ItemState {
struct RenderOptions {
has_left_icon: bool,
check_side: Side,
radius: Pixels,
}
@ -1205,8 +1242,15 @@ impl Render for PopupMenu {
|height| height,
);
let has_left_icon = self
.menu_items
.iter()
.any(|item| item.has_left_icon(self.check_side));
let max_width = self.max_width();
let item_state = ItemState {
let options = RenderOptions {
has_left_icon,
check_side: self.check_side,
radius: cx.theme().radius.min(px(8.)),
};
@ -1254,7 +1298,7 @@ impl Render for PopupMenu {
.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)),
.map(|(ix, item)| self.render_item(ix, item, options, window, cx)),
)
.child({
canvas(

View file

@ -1,12 +1,12 @@
use gpui::{
anchored, canvas, deferred, div, prelude::FluentBuilder as _, px, AnyElement, App, Bounds,
Context, Corner, DismissEvent, ElementId, EventEmitter, FocusHandle, Focusable,
InteractiveElement as _, IntoElement, KeyBinding, MouseButton, ParentElement, Pixels, Point,
Render, RenderOnce, StyleRefinement, Styled, Subscription, Window,
AnyElement, App, Bounds, Context, Corner, DismissEvent, ElementId, EventEmitter, FocusHandle,
Focusable, InteractiveElement as _, IntoElement, KeyBinding, MouseButton, ParentElement,
Pixels, Point, Render, RenderOnce, StyleRefinement, Styled, Subscription, Window, anchored,
canvas, deferred, div, prelude::FluentBuilder as _, px,
};
use std::rc::Rc;
use crate::{actions::Cancel, v_flex, Selectable, StyledExt as _};
use crate::{Selectable, StyledExt as _, actions::Cancel, v_flex};
const CONTEXT: &str = "Popover";
pub(crate) fn init(cx: &mut App) {

View file

@ -1,7 +1,7 @@
use crate::{ActiveTheme, StyledExt};
use gpui::{
div, prelude::FluentBuilder, px, relative, App, Hsla, IntoElement, ParentElement, RenderOnce,
StyleRefinement, Styled, Window,
App, Hsla, IntoElement, ParentElement, RenderOnce, StyleRefinement, Styled, Window, div,
prelude::FluentBuilder, px, relative,
};
/// A Progress bar element.

View file

@ -73,7 +73,7 @@ However, if you prefer not to use [Action]s, you can create custom menu items us
There have a `on_click` callback to handle the click event directly.
:::
### Menu with Anchor Position
### Anchor Position
Control where the dropdown menu appears relative to the trigger:
@ -88,7 +88,7 @@ Button::new("menu-btn")
})
```
### Menu Items with Icons
### Icons
Add icons to menu items for better visual clarity:
@ -101,7 +101,22 @@ menu.menu_with_icon("Search", IconName::Search, Box::new(Search))
.menu_with_icon("Help", IconName::Help, Box::new(ShowHelp))
```
### Checkable Menu Items
### Disabled State
Create disabled menu items that cannot be activated:
```rust
menu.menu("Available Action", Box::new(Action1))
.menu_with_disabled("Disabled Action", Box::new(Action2), true)
.menu_with_icon_and_disabled(
"Unavailable",
IconName::Lock,
Box::new(Action3),
true
)
```
### Check state
Create menu items that show a check state:
@ -112,7 +127,94 @@ menu.menu_with_check("Enable Feature", is_enabled, Box::new(ToggleFeature))
.menu_with_check("Show Sidebar", sidebar_visible, Box::new(ToggleSidebar))
```
### Menu Items with Keyboard Shortcuts
By default, the check icon will be shown on the left side of the menu item, if this menu item has an icon, the check icon will replace the icon on the left side.
There also have a `check_side` option for you to config the check icon to be shown on the right side:
```rust
menu.check_size(Side::Right)
.menu_with_check("Enable Feature", is_enabled, Box::new(ToggleFeature))
```
### Separators
Use separators to group related menu items:
```rust
menu.menu("New", Box::new(NewFile))
.menu("Open", Box::new(OpenFile))
.separator() // Groups file operations
.menu("Copy", Box::new(Copy))
.menu("Paste", Box::new(Paste))
.separator() // Groups edit operations
.menu("Exit", Box::new(Exit))
```
### Labels
Add non-interactive labels to organize menu sections:
```rust
menu.label("File Operations")
.menu("New", Box::new(NewFile))
.menu("Open", Box::new(OpenFile))
.separator()
.label("Edit Operations")
.menu("Copy", Box::new(Copy))
.menu("Paste", Box::new(Paste))
```
### Link MenuItem
Create menu items that open external links:
```rust
menu.link("Documentation", "https://docs.example.com")
.link_with_icon(
"GitHub",
IconName::GitHub,
"https://github.com/example/repo"
)
.separator()
.external_link_icon(false) // Hide external link icons
.link("Support", "https://support.example.com")
```
### Custom Element
Create custom menu items with complex content:
```rust
use gpui_component::{h_flex, v_flex};
menu.menu_element(Box::new(CustomAction), |window, cx| {
v_flex()
.child("Custom Element")
.child(
div()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child("This is a subtitle")
)
})
.menu_element_with_icon(
IconName::Info,
Box::new(InfoAction),
|window, cx| {
h_flex()
.gap_1()
.child("Status")
.child(
div()
.text_sm()
.text_color(cx.theme().success)
.child("✓ Connected")
)
}
)
```
### Keyboard Shortcuts
Menu items automatically display keyboard shortcuts if they're bound to actions:
@ -168,99 +270,6 @@ menu.submenu_with_icon(
)
```
### Disabled Menu Items
Create disabled menu items that cannot be activated:
```rust
menu.menu("Available Action", Box::new(Action1))
.menu_with_disabled("Disabled Action", Box::new(Action2), true)
.menu_with_icon_and_disabled(
"Unavailable",
IconName::Lock,
Box::new(Action3),
true
)
```
### Menu Separators
Use separators to group related menu items:
```rust
menu.menu("New", Box::new(NewFile))
.menu("Open", Box::new(OpenFile))
.separator() // Groups file operations
.menu("Copy", Box::new(Copy))
.menu("Paste", Box::new(Paste))
.separator() // Groups edit operations
.menu("Exit", Box::new(Exit))
```
### Menu Labels
Add non-interactive labels to organize menu sections:
```rust
menu.label("File Operations")
.menu("New", Box::new(NewFile))
.menu("Open", Box::new(OpenFile))
.separator()
.label("Edit Operations")
.menu("Copy", Box::new(Copy))
.menu("Paste", Box::new(Paste))
```
### Link Menu Items
Create menu items that open external links:
```rust
menu.link("Documentation", "https://docs.example.com")
.link_with_icon(
"GitHub",
IconName::GitHub,
"https://github.com/example/repo"
)
.separator()
.external_link_icon(false) // Hide external link icons
.link("Support", "https://support.example.com")
```
### Custom Menu Elements
Create custom menu items with complex content:
```rust
use gpui_component::{h_flex, v_flex};
menu.menu_element(Box::new(CustomAction), |window, cx| {
v_flex()
.child("Custom Element")
.child(
div()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child("This is a subtitle")
)
})
.menu_element_with_icon(
IconName::Info,
Box::new(InfoAction),
|window, cx| {
h_flex()
.gap_1()
.child("Status")
.child(
div()
.text_sm()
.text_color(cx.theme().success)
.child("✓ Connected")
)
}
)
```
### Scrollable Menus
:::warning