tabs: Add menu to TabBar to display more items. (#731)

<img width="752" alt="image"
src="https://github.com/user-attachments/assets/a7efb600-553a-4b78-a8f4-aaba504d44f0"
/>

- tabs: Use default cursor when Tab is disabled.
- popup_menu: Add to support add menu item with disabled state.
This commit is contained in:
Jason Lee 2025-03-24 18:05:54 +08:00 committed by GitHub
parent 760c0597eb
commit 8a3b858d1a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 329 additions and 106 deletions

View file

@ -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<usize>, 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<usize>, 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);

View file

@ -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()

View file

@ -64,17 +64,20 @@ enum PopupMenuItem {
Item {
icon: Option<Icon>,
label: SharedString,
disabled: bool,
action: Option<Box<dyn Action>>,
handler: Rc<dyn Fn(&mut Window, &mut App)>,
},
ElementItem {
icon: Option<Icon>,
disabled: bool,
render: Box<dyn Fn(&mut Window, &mut App) -> AnyElement + 'static>,
handler: Rc<dyn Fn(&mut Window, &mut App)>,
},
Submenu {
icon: Option<Icon>,
label: SharedString,
disabled: bool,
menu: Entity<PopupMenu>,
},
}
@ -173,17 +176,38 @@ impl PopupMenu {
}
/// Add Menu Item
pub fn menu(mut self, label: impl Into<SharedString>, action: Box<dyn Action>) -> Self {
self.add_menu_item(label, None, action);
pub fn menu(self, label: impl Into<SharedString>, action: Box<dyn Action>) -> Self {
self.menu_with_disabled(label, action, false)
}
/// Add Menu Item with disabled state
pub fn menu_with_disabled(
mut self,
label: impl Into<SharedString>,
action: Box<dyn Action>,
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<SharedString>, href: impl Into<String>) -> Self {
pub fn link(self, label: impl Into<SharedString>, href: impl Into<String>) -> 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<SharedString>,
href: impl Into<String>,
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<SharedString>,
icon: impl Into<Icon>,
href: impl Into<String>,
) -> 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<SharedString>,
icon: impl Into<Icon>,
href: impl Into<String>,
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<SharedString>,
icon: impl Into<Icon>,
action: Box<dyn Action>,
) -> 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<SharedString>,
icon: impl Into<Icon>,
action: Box<dyn Action>,
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<SharedString>,
checked: bool,
action: Box<dyn Action>,
) -> 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<SharedString>,
checked: bool,
action: Box<dyn Action>,
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<F, E>(
self,
action: Box<dyn Action>,
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<F, E>(
self,
icon: impl Into<Icon>,
action: Box<dyn Action>,
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<F, E>(
mut self,
icon: impl Into<Icon>,
action: Box<dyn Action>,
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<F, E>(
self,
checked: bool,
action: Box<dyn Action>,
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<F, E>(
mut self,
checked: bool,
action: Box<dyn Action>,
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<SharedString>,
icon: Option<Icon>,
action: Box<dyn Action>,
) -> &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<SharedString>,
@ -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<SharedString>,
disabled: bool,
window: &mut Window,
cx: &mut Context<Self>,
f: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> 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<Icon>,
label: impl Into<SharedString>,
window: &mut Window,
cx: &mut Context<Self>,
f: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> 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<Icon>,
label: impl Into<SharedString>,
disabled: bool,
window: &mut Window,
cx: &mut Context<Self>,
f: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> 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<SharedString>,
icon: Option<Icon>,
action: Box<dyn Action>,
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)

View file

@ -379,15 +379,15 @@ impl TabVariant {
pub struct Tab {
id: ElementId,
base: Div,
label: SharedString,
pub(super) label: SharedString,
icon: Option<Icon>,
prefix: Option<AnyElement>,
suffix: Option<AnyElement>,
children: Vec<AnyElement>,
variant: TabVariant,
size: Size,
disabled: bool,
selected: bool,
pub(super) disabled: bool,
pub(super) selected: bool,
on_click: Option<Arc<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
}
@ -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()

View file

@ -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<Div>,
@ -22,6 +30,7 @@ pub struct TabBar {
selected_index: Option<usize>,
variant: TabVariant,
size: Size,
menu: bool,
on_click: Option<Arc<dyn Fn(&usize, &mut Window, &mut App) + 'static>>,
}
@ -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))
}
}