From 8a3b858d1a285dee7ee982a4e5d44d5770627553 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Mon, 24 Mar 2025 18:05:54 +0800 Subject: [PATCH] tabs: Add menu to TabBar to display more items. (#731) image - tabs: Use default cursor when Tab is disabled. - popup_menu: Add to support add menu item with disabled state. --- crates/story/src/tabs_story.rs | 81 +++++++---- crates/ui/src/list/list_item.rs | 4 + crates/ui/src/popup_menu.rs | 249 ++++++++++++++++++++++++++------ crates/ui/src/tab/tab.rs | 8 +- crates/ui/src/tab/tab_bar.rs | 93 ++++++++---- 5 files changed, 329 insertions(+), 106 deletions(-) diff --git a/crates/story/src/tabs_story.rs b/crates/story/src/tabs_story.rs index fff59ffd..9af0c47a 100644 --- a/crates/story/src/tabs_story.rs +++ b/crates/story/src/tabs_story.rs @@ -4,6 +4,7 @@ use gpui::{ use gpui_component::{ button::{Button, ButtonGroup, ButtonVariants}, + checkbox::Checkbox, h_flex, tab::{Tab, TabBar}, v_flex, IconName, Selectable as _, Sizable, Size, @@ -15,6 +16,7 @@ pub struct TabsStory { focus_handle: gpui::FocusHandle, active_tab_ix: usize, size: Size, + menu: bool, } impl super::Story for TabsStory { @@ -41,6 +43,7 @@ impl TabsStory { focus_handle: cx.focus_handle(), active_tab_ix: 0, size: Size::default(), + menu: false, } } @@ -66,45 +69,59 @@ impl Render for TabsStory { v_flex() .gap_6() .child( - ButtonGroup::new("toggle-size") - .outline() - .compact() + h_flex() + .gap_3() .child( - Button::new("xsmall") - .label("XSmall") - .selected(self.size == Size::XSmall), + ButtonGroup::new("toggle-size") + .outline() + .compact() + .child( + Button::new("xsmall") + .label("XSmall") + .selected(self.size == Size::XSmall), + ) + .child( + Button::new("small") + .label("Small") + .selected(self.size == Size::Small), + ) + .child( + Button::new("medium") + .label("Medium") + .selected(self.size == Size::Medium), + ) + .child( + Button::new("large") + .label("Large") + .selected(self.size == Size::Large), + ) + .on_click(cx.listener(|this, selecteds: &Vec, window, cx| { + let size = match selecteds[0] { + 0 => Size::XSmall, + 1 => Size::Small, + 2 => Size::Medium, + 3 => Size::Large, + _ => unreachable!(), + }; + this.set_size(size, window, cx); + })), ) .child( - Button::new("small") - .label("Small") - .selected(self.size == Size::Small), - ) - .child( - Button::new("medium") - .label("Medium") - .selected(self.size == Size::Medium), - ) - .child( - Button::new("large") - .label("Large") - .selected(self.size == Size::Large), - ) - .on_click(cx.listener(|this, selecteds: &Vec, window, cx| { - let size = match selecteds[0] { - 0 => Size::XSmall, - 1 => Size::Small, - 2 => Size::Medium, - 3 => Size::Large, - _ => unreachable!(), - }; - this.set_size(size, window, cx); - })), + Checkbox::new("show-menu") + .label("More menu") + .checked(self.menu) + .on_click(cx.listener(|this, _, _, cx| { + this.menu = !this.menu; + cx.notify(); + })), + ), ) .child( section("Tabs", cx).child( TabBar::new("tabs") .w_full() .with_size(self.size) + .with_menu(self.menu) .selected_index(self.active_tab_ix) .on_click(cx.listener(|this, ix: &usize, window, cx| { this.set_active_tab(*ix, window, cx); @@ -152,6 +169,7 @@ impl Render for TabsStory { .w_full() .underline() .with_size(self.size) + .with_menu(self.menu) .selected_index(self.active_tab_ix) .on_click(cx.listener(|this, ix: &usize, window, cx| { this.set_active_tab(*ix, window, cx); @@ -172,6 +190,7 @@ impl Render for TabsStory { .w_full() .pill() .with_size(self.size) + .with_menu(self.menu) .selected_index(self.active_tab_ix) .on_click(cx.listener(|this, ix: &usize, window, cx| { this.set_active_tab(*ix, window, cx); @@ -192,6 +211,7 @@ impl Render for TabsStory { .w_full() .outline() .with_size(self.size) + .with_menu(self.menu) .selected_index(self.active_tab_ix) .on_click(cx.listener(|this, ix: &usize, window, cx| { this.set_active_tab(*ix, window, cx); @@ -212,6 +232,7 @@ impl Render for TabsStory { .w_full() .segmented() .with_size(self.size) + .with_menu(self.menu) .selected_index(self.active_tab_ix) .on_click(cx.listener(|this, ix: &usize, window, cx| { this.set_active_tab(*ix, window, cx); diff --git a/crates/ui/src/list/list_item.rs b/crates/ui/src/list/list_item.rs index 97257c4d..dda6d95e 100644 --- a/crates/ui/src/list/list_item.rs +++ b/crates/ui/src/list/list_item.rs @@ -143,6 +143,10 @@ impl RenderOnce for ListItem { this.hover(|this| this.bg(cx.theme().list_hover)) }) }) + .when(self.disabled, |this| { + this.cursor_not_allowed() + .text_color(cx.theme().muted_foreground) + }) .child( h_flex() .w_full() diff --git a/crates/ui/src/popup_menu.rs b/crates/ui/src/popup_menu.rs index c86021d9..ce564bdc 100644 --- a/crates/ui/src/popup_menu.rs +++ b/crates/ui/src/popup_menu.rs @@ -64,17 +64,20 @@ enum PopupMenuItem { Item { icon: Option, label: SharedString, + disabled: bool, action: Option>, handler: Rc, }, ElementItem { icon: Option, + disabled: bool, render: Box AnyElement + 'static>, handler: Rc, }, Submenu { icon: Option, label: SharedString, + disabled: bool, menu: Entity, }, } @@ -173,17 +176,38 @@ impl PopupMenu { } /// Add Menu Item - pub fn menu(mut self, label: impl Into, action: Box) -> Self { - self.add_menu_item(label, None, action); + pub fn menu(self, label: impl Into, action: Box) -> Self { + self.menu_with_disabled(label, action, false) + } + + /// Add Menu Item with disabled state + pub fn menu_with_disabled( + mut self, + label: impl Into, + action: Box, + disabled: bool, + ) -> Self { + self.add_menu_item(label, None, action, disabled); self } /// Add Menu to open link - pub fn link(mut self, label: impl Into, href: impl Into) -> Self { + pub fn link(self, label: impl Into, href: impl Into) -> Self { + self.link_with_disabled(label, href, false) + } + + /// Add Menu to open link with disabled state + pub fn link_with_disabled( + mut self, + label: impl Into, + href: impl Into, + disabled: bool, + ) -> Self { let href = href.into(); self.menu_items.push(PopupMenuItem::Item { icon: None, label: label.into(), + disabled, action: None, handler: Rc::new(move |_, cx| cx.open_url(&href)), }); @@ -192,43 +216,77 @@ impl PopupMenu { /// Add Menu to open link pub fn link_with_icon( + self, + label: impl Into, + icon: impl Into, + href: impl Into, + ) -> Self { + self.link_with_icon_and_disabled(label, icon, href, false) + } + + /// Add Menu to open link with icon and disabled state + pub fn link_with_icon_and_disabled( mut self, label: impl Into, icon: impl Into, href: impl Into, + disabled: bool, ) -> Self { let href = href.into(); self.menu_items.push(PopupMenuItem::Item { icon: Some(icon.into()), label: label.into(), + disabled, action: None, handler: Rc::new(move |_, cx| cx.open_url(&href)), }); self } - /// Add Menu Item with Icon + /// Add Menu Item with Icon. pub fn menu_with_icon( - mut self, + self, label: impl Into, icon: impl Into, action: Box, ) -> Self { - self.add_menu_item(label, Some(icon.into()), action); + self.menu_with_icon_and_disabled(label, icon, action, false) + } + + /// Add Menu Item with Icon and disabled state + pub fn menu_with_icon_and_disabled( + mut self, + label: impl Into, + icon: impl Into, + action: Box, + disabled: bool, + ) -> Self { + self.add_menu_item(label, Some(icon.into()), action, disabled); self } /// Add Menu Item with check icon pub fn menu_with_check( - mut self, + self, label: impl Into, checked: bool, action: Box, + ) -> Self { + self.menu_with_check_and_disabled(label, checked, action, false) + } + + /// Add Menu Item with check icon and disabled state + pub fn menu_with_check_and_disabled( + mut self, + label: impl Into, + checked: bool, + action: Box, + disabled: bool, ) -> Self { if checked { - self.add_menu_item(label, Some(IconName::Check.into()), action); + self.add_menu_item(label, Some(IconName::Check.into()), action, disabled); } else { - self.add_menu_item(label, None, action); + self.add_menu_item(label, None, action, disabled); } self @@ -243,11 +301,40 @@ impl PopupMenu { self.menu_element_with_check(false, action, builder) } + /// Add Menu Item with custom element render with disabled state. + pub fn menu_element_with_disabled( + self, + action: Box, + disabled: bool, + builder: F, + ) -> Self + where + F: Fn(&mut Window, &mut App) -> E + 'static, + E: IntoElement, + { + self.menu_element_with_check_and_disabled(false, action, disabled, builder) + } + /// Add Menu Item with custom element render with icon. pub fn menu_element_with_icon( + self, + icon: impl Into, + action: Box, + builder: F, + ) -> Self + where + F: Fn(&mut Window, &mut App) -> E + 'static, + E: IntoElement, + { + self.menu_element_with_icon_and_disabled(icon, action, false, builder) + } + + /// Add Menu Item with custom element render with icon and disabled state + pub fn menu_element_with_icon_and_disabled( mut self, icon: impl Into, action: Box, + disabled: bool, builder: F, ) -> Self where @@ -258,6 +345,7 @@ impl PopupMenu { render: Box::new(move |window, cx| builder(window, cx).into_any_element()), handler: self.wrap_handler(action), icon: Some(icon.into()), + disabled, }); self.has_icon = true; self @@ -265,9 +353,24 @@ impl PopupMenu { /// Add Menu Item with custom element render with check state pub fn menu_element_with_check( + self, + checked: bool, + action: Box, + builder: F, + ) -> Self + where + F: Fn(&mut Window, &mut App) -> E + 'static, + E: IntoElement, + { + self.menu_element_with_check_and_disabled(checked, action, false, builder) + } + + /// Add Menu Item with custom element render with check state and disabled state + pub fn menu_element_with_check_and_disabled( mut self, checked: bool, action: Box, + disabled: bool, builder: F, ) -> Self where @@ -279,6 +382,7 @@ impl PopupMenu { render: Box::new(move |window, cx| builder(window, cx).into_any_element()), handler: self.wrap_handler(action), icon: Some(IconName::Check.into()), + disabled, }); self.has_icon = true; } else { @@ -286,6 +390,7 @@ impl PopupMenu { render: Box::new(move |window, cx| builder(window, cx).into_any_element()), handler: self.wrap_handler(action), icon: None, + disabled, }); } self @@ -297,25 +402,6 @@ impl PopupMenu { }) } - fn add_menu_item( - &mut self, - label: impl Into, - icon: Option, - action: Box, - ) -> &mut Self { - if icon.is_some() { - self.has_icon = true; - } - - self.menu_items.push(PopupMenuItem::Item { - icon, - label: label.into(), - action: Some(action.boxed_clone()), - handler: self.wrap_handler(action), - }); - self - } - /// Add a separator Menu Item pub fn separator(mut self) -> Self { if self.menu_items.is_empty() { @@ -330,6 +416,7 @@ impl PopupMenu { self } + /// Add a Submenu pub fn submenu( self, label: impl Into, @@ -340,11 +427,36 @@ impl PopupMenu { self.submenu_with_icon(None, label, window, cx, f) } + /// Add a Submenu item with disabled state + pub fn submenu_with_disabled( + self, + label: impl Into, + disabled: bool, + window: &mut Window, + cx: &mut Context, + f: impl Fn(PopupMenu, &mut Window, &mut Context) -> PopupMenu + 'static, + ) -> Self { + self.submenu_with_icon_with_disabled(None, label, disabled, window, cx, f) + } + /// Add a Submenu item with icon pub fn submenu_with_icon( + self, + icon: Option, + label: impl Into, + window: &mut Window, + cx: &mut Context, + f: impl Fn(PopupMenu, &mut Window, &mut Context) -> PopupMenu + 'static, + ) -> Self { + self.submenu_with_icon_with_disabled(icon, label, false, window, cx, f) + } + + /// Add a Submenu item with icon and disabled state + pub fn submenu_with_icon_with_disabled( mut self, icon: Option, label: impl Into, + disabled: bool, window: &mut Window, cx: &mut Context, f: impl Fn(PopupMenu, &mut Window, &mut Context) -> PopupMenu + 'static, @@ -359,6 +471,28 @@ impl PopupMenu { icon, label: label.into(), menu: submenu, + disabled, + }); + self + } + + fn add_menu_item( + &mut self, + label: impl Into, + icon: Option, + action: Box, + disabled: bool, + ) -> &mut Self { + if icon.is_some() { + self.has_icon = true; + } + + self.menu_items.push(PopupMenuItem::Item { + icon, + label: label.into(), + disabled, + action: Some(action.boxed_clone()), + handler: self.wrap_handler(action), }); self } @@ -556,8 +690,18 @@ impl PopupMenu { .my_0p5() .bg(cx.theme().muted), ), - PopupMenuItem::ElementItem { render, icon, .. } => this - .on_click(cx.listener(move |this, _, window, cx| this.on_click(ix, window, cx))) + PopupMenuItem::ElementItem { + render, + icon, + disabled, + .. + } => this + .when(!disabled, |this| { + this.on_click( + cx.listener(move |this, _, window, cx| this.on_click(ix, window, cx)), + ) + }) + .disabled(*disabled) .child( h_flex() .min_h(ITEM_HEIGHT) @@ -570,30 +714,41 @@ impl PopupMenu { icon, label, action, + disabled, .. } => { let action = action.as_ref().map(|action| action.boxed_clone()); let key = Self::render_keybinding(action, window, cx); - this.on_click(cx.listener(move |this, _, window, cx| this.on_click(ix, window, cx))) - .child( - h_flex() - .h(ITEM_HEIGHT) - .items_center() - .gap_x_1() - .children(Self::render_icon(has_icon, icon.clone(), window, cx)) - .child( - h_flex() - .flex_1() - .gap_2() - .items_center() - .justify_between() - .child(label.clone()) - .children(key), - ), + this.when(!disabled, |this| { + this.on_click( + cx.listener(move |this, _, window, cx| this.on_click(ix, window, cx)), ) + }) + .disabled(*disabled) + .child( + h_flex() + .h(ITEM_HEIGHT) + .items_center() + .gap_x_1() + .children(Self::render_icon(has_icon, icon.clone(), window, cx)) + .child( + h_flex() + .flex_1() + .gap_2() + .items_center() + .justify_between() + .child(label.clone()) + .children(key), + ), + ) } - PopupMenuItem::Submenu { icon, label, menu } => this.selected(hovered).child( + PopupMenuItem::Submenu { + icon, + label, + menu, + disabled, + } => this.selected(hovered).disabled(*disabled).child( h_flex() .when(hovered, |this| { this.rounded(cx.theme().radius) diff --git a/crates/ui/src/tab/tab.rs b/crates/ui/src/tab/tab.rs index db450b4b..3e65c390 100644 --- a/crates/ui/src/tab/tab.rs +++ b/crates/ui/src/tab/tab.rs @@ -379,15 +379,15 @@ impl TabVariant { pub struct Tab { id: ElementId, base: Div, - label: SharedString, + pub(super) label: SharedString, icon: Option, prefix: Option, suffix: Option, children: Vec, variant: TabVariant, size: Size, - disabled: bool, - selected: bool, + pub(super) disabled: bool, + pub(super) selected: bool, on_click: Option>, } @@ -583,7 +583,7 @@ impl RenderOnce for Tab { .flex_wrap() .items_center() .flex_shrink_0() - .cursor_pointer() + .when(!self.disabled, |this| this.cursor_pointer()) .overflow_hidden() .h(height) .overflow_hidden() diff --git a/crates/ui/src/tab/tab_bar.rs b/crates/ui/src/tab/tab_bar.rs index 0cc43c44..29b96761 100644 --- a/crates/ui/src/tab/tab_bar.rs +++ b/crates/ui/src/tab/tab_bar.rs @@ -1,16 +1,24 @@ use std::sync::Arc; -use crate::{h_flex, ActiveTheme, Selectable, Sizable, Size, StyledExt}; +use crate::button::{Button, ButtonVariants as _}; +use crate::popup_menu::PopupMenuExt as _; +use crate::{h_flex, ActiveTheme, IconName, Selectable, Sizable, Size, StyledExt}; use gpui::prelude::FluentBuilder as _; use gpui::{ - div, AnyElement, App, Div, Edges, ElementId, IntoElement, ParentElement, RenderOnce, - ScrollHandle, Stateful, StatefulInteractiveElement as _, Styled, Window, + div, impl_internal_actions, AnyElement, App, Corner, Div, Edges, ElementId, IntoElement, + ParentElement, RenderOnce, ScrollHandle, Stateful, StatefulInteractiveElement as _, + StyleRefinement, Styled, Window, }; use gpui::{px, InteractiveElement}; use smallvec::SmallVec; use super::{Tab, TabVariant}; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SelectTab(usize); + +impl_internal_actions!(tab_bar, [SelectTab]); + #[derive(IntoElement)] pub struct TabBar { base: Stateful
, @@ -22,6 +30,7 @@ pub struct TabBar { selected_index: Option, variant: TabVariant, size: Size, + menu: bool, on_click: Option>, } @@ -39,6 +48,7 @@ impl TabBar { last_empty_space: div().w_3().into_any_element(), selected_index: None, on_click: None, + menu: false, } } @@ -72,6 +82,12 @@ impl TabBar { self } + /// Enable or disable the popup menu for the TabBar + pub fn with_menu(mut self, menu: bool) -> Self { + self.menu = menu; + self + } + /// Track the scroll of the TabBar pub fn track_scroll(mut self, scroll_handle: ScrollHandle) -> Self { self.scroll_handle = Some(scroll_handle); @@ -125,7 +141,7 @@ impl TabBar { } impl Styled for TabBar { - fn style(&mut self) -> &mut gpui::StyleRefinement { + fn style(&mut self) -> &mut StyleRefinement { self.base.style() } } @@ -190,8 +206,19 @@ impl RenderOnce for TabBar { } }; + let mut item_labels = Vec::new(); + let selected_index = self.selected_index; + self.base .group("tab-bar") + .on_action({ + let on_click = self.on_click.clone(); + move |action: &SelectTab, window: &mut Window, cx: &mut App| { + if let Some(on_click) = on_click.clone() { + on_click(&action.0, window, cx); + } + } + }) .relative() .flex() .items_center() @@ -216,6 +243,7 @@ impl RenderOnce for TabBar { self.variant == TabVariant::Pill || self.variant == TabVariant::Segmented, |this| this.rounded(cx.theme().radius), ) + .paddings(paddings) .when_some(self.prefix, |this, prefix| this.child(prefix)) .child( h_flex() @@ -226,30 +254,45 @@ impl RenderOnce for TabBar { this.track_scroll(&scroll_handle) }) .gap(gap) - .paddings(paddings) - .children( - self.children - .into_iter() - .enumerate() - .map(move |(ix, child)| { - child - .id(ix) - .variant(self.variant) - .with_size(self.size) - .when_some(self.selected_index, |this, selected_ix| { - this.selected(selected_ix == ix) - }) - .when_some(self.on_click.clone(), move |this, on_click| { - this.on_click(move |_, window, cx| { - on_click(&ix, window, cx) - }) - }) - }), - ) - .when(self.suffix.is_some(), |this| { + .children(self.children.into_iter().enumerate().map(|(ix, child)| { + item_labels.push((child.label.clone(), child.disabled)); + child + .id(ix) + .variant(self.variant) + .with_size(self.size) + .when_some(self.selected_index, |this, selected_ix| { + this.selected(selected_ix == ix) + }) + .when_some(self.on_click.clone(), move |this, on_click| { + this.on_click(move |_, window, cx| on_click(&ix, window, cx)) + }) + })) + .when(self.suffix.is_some() || self.menu, |this| { this.child(self.last_empty_space) }), ) + .when(self.menu, |this| { + this.child( + Button::new("more") + .xsmall() + .ghost() + .icon(IconName::ChevronDown) + .popup_menu(move |mut this, _, _| { + this = this.scrollable(); + for (ix, (label, disabled)) in item_labels.iter().enumerate() { + this = this.menu_with_check_and_disabled( + label.clone(), + selected_index == Some(ix), + Box::new(SelectTab(ix)), + *disabled, + ); + } + + this + }) + .anchor(Corner::TopRight), + ) + }) .when_some(self.suffix, |this, suffix| this.child(suffix)) } }