diff --git a/crates/story/src/menu_story.rs b/crates/story/src/menu_story.rs index 45d585d1..7888bcee 100644 --- a/crates/story/src/menu_story.rs +++ b/crates/story/src/menu_story.rs @@ -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, message: String, } @@ -67,7 +68,7 @@ impl MenuStory { fn new(_: &mut Window, _: &mut Context) -> 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.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) -> 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() diff --git a/crates/story/src/title_bar.rs b/crates/story/src/title_bar.rs index 5d65f1f4..0428a927 100644 --- a/crates/story/src/title_bar.rs +++ b/crates/story/src/title_bar.rs @@ -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))) diff --git a/crates/ui/src/menu/popup_menu.rs b/crates/ui/src/menu/popup_menu.rs index 1159b8a4..c74512f2 100644 --- a/crates/ui/src/menu/popup_menu.rs +++ b/crates/ui/src/menu/popup_menu.rs @@ -37,6 +37,7 @@ pub enum PopupMenuItem { icon: Option, label: SharedString, disabled: bool, + checked: bool, is_link: bool, action: Option>, // For link item @@ -46,6 +47,7 @@ pub enum PopupMenuItem { ElementItem { icon: Option, disabled: bool, + checked: bool, action: Option>, render: Box AnyElement + 'static>, handler: Option>, @@ -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, /// The focus handle of Entity to handle actions. pub(crate) action_context: Option, - has_icon: bool, selected_index: Option, min_width: Option, max_width: Option, max_height: Option, bounds: Bounds, size: Size, + check_side: Side, /// The parent menu of this menu, if this is a submenu parent_menu: Option>, @@ -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, 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, 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, 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, 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) -> 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, action: Box, 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, _: &mut Window, _: &mut Context, @@ -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, ) -> 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( diff --git a/crates/ui/src/popover.rs b/crates/ui/src/popover.rs index e0f3dc61..f48f093c 100644 --- a/crates/ui/src/popover.rs +++ b/crates/ui/src/popover.rs @@ -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) { diff --git a/crates/ui/src/progress.rs b/crates/ui/src/progress.rs index 4fee8aaa..c3ebd872 100644 --- a/crates/ui/src/progress.rs +++ b/crates/ui/src/progress.rs @@ -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. diff --git a/docs/docs/components/menu.md b/docs/docs/components/menu.md index bdbeb16d..191de13a 100644 --- a/docs/docs/components/menu.md +++ b/docs/docs/components/menu.md @@ -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