sidebar: Add suffix for menu item (#792)

| Light | Dark |
| - | - |
| <img width="913" alt="SCR-20250415-mkdr"
src="https://github.com/user-attachments/assets/5c4931e6-4d03-48ec-91c6-ade2d86de75a"
/> | <img width="913" alt="SCR-20250415-mkec"
src="https://github.com/user-attachments/assets/f85af984-ecc3-4cd7-913c-c4e93384dbf1"
/> |
This commit is contained in:
Floyd Wang 2025-04-15 14:02:52 +08:00 committed by GitHub
parent 5ac98a83b5
commit 8930acf648
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 127 additions and 81 deletions

View file

@ -6,6 +6,7 @@ use gpui::{
}; };
use gpui_component::{ use gpui_component::{
badge::Badge,
blue_500, blue_500,
breadcrumb::{Breadcrumb, BreadcrumbItem}, breadcrumb::{Breadcrumb, BreadcrumbItem},
divider::Divider, divider::Divider,
@ -16,7 +17,7 @@ use gpui_component::{
SidebarToggleButton, SidebarToggleButton,
}, },
switch::Switch, switch::Switch,
v_flex, white, ActiveTheme, Icon, IconName, Side, v_flex, white, ActiveTheme, Icon, IconName, Side, Sizable,
}; };
use serde::Deserialize; use serde::Deserialize;
@ -32,6 +33,7 @@ pub struct SidebarStory {
collapsed: bool, collapsed: bool,
side: Side, side: Side,
focus_handle: gpui::FocusHandle, focus_handle: gpui::FocusHandle,
checked: bool,
} }
impl SidebarStory { impl SidebarStory {
@ -50,6 +52,7 @@ impl SidebarStory {
collapsed: false, collapsed: false,
side: Side::Left, side: Side::Left,
focus_handle: cx.focus_handle(), focus_handle: cx.focus_handle(),
checked: false,
} }
} }
@ -308,22 +311,43 @@ impl Render for SidebarStory {
SidebarMenuItem::new(item.label()) SidebarMenuItem::new(item.label())
.icon(item.icon()) .icon(item.icon())
.active(self.active_items.contains_key(item)) .active(self.active_items.contains_key(item))
.children(item.items().into_iter().map(|sub_item| { .children(item.items().into_iter().enumerate().map(
SidebarMenuItem::new(sub_item.label()) |(ix, sub_item)| {
.active(self.active_subitem == Some(sub_item)) SidebarMenuItem::new(sub_item.label())
.on_click(cx.listener(sub_item.handler(&item))) .active(self.active_subitem == Some(sub_item))
})) .when(ix == 0, |this| {
this.suffix(
Switch::new("switch")
.xsmall()
.checked(self.checked)
.on_click(cx.listener(
|this, checked, _, _| {
this.checked = *checked
},
)),
)
})
.on_click(cx.listener(sub_item.handler(&item)))
},
))
.on_click(cx.listener(item.handler())) .on_click(cx.listener(item.handler()))
}), }),
)), )),
) )
.child( .child(
SidebarGroup::new("Projects").child(SidebarMenu::new().children( SidebarGroup::new("Projects").child(SidebarMenu::new().children(
groups[1].iter().map(|item| { groups[1].iter().enumerate().map(|(ix, item)| {
SidebarMenuItem::new(item.label()) SidebarMenuItem::new(item.label())
.icon(item.icon()) .icon(item.icon())
.active(self.last_active_item == *item) .active(self.last_active_item == *item)
.on_click(cx.listener(item.handler())) .when(ix == 0, |this| {
this.suffix(
Badge::new().dot().count(1).child(
div().p_0p5().child(Icon::new(IconName::Bell)),
),
)
})
.when(ix == 1, |this| this.suffix(IconName::Settings2))
}), }),
)), )),
) )

View file

@ -149,26 +149,28 @@ impl RenderOnce for Checkbox {
}), }),
), ),
) )
.child( .when(self.label.is_some() || !self.children.is_empty(), |this| {
v_flex() this.child(
.w_full() v_flex()
.line_height(relative(1.2)) .w_full()
.gap_1() .line_height(relative(1.2))
.map(|this| { .gap_1()
if let Some(label) = self.label { .map(|this| {
this.child( if let Some(label) = self.label {
div() this.child(
.size_full() div()
.text_color(cx.theme().foreground) .size_full()
.line_height(relative(1.)) .text_color(cx.theme().foreground)
.child(label), .line_height(relative(1.))
) .child(label),
} else { )
this } else {
} this
}) }
.children(self.children), })
) .children(self.children),
)
})
.when(self.disabled, |this| { .when(self.disabled, |this| {
this.cursor_not_allowed() this.cursor_not_allowed()
.text_color(cx.theme().muted_foreground) .text_color(cx.theme().muted_foreground)
@ -177,6 +179,7 @@ impl RenderOnce for Checkbox {
self.on_click.filter(|_| !self.disabled), self.on_click.filter(|_| !self.disabled),
|this, on_click| { |this, on_click| {
this.on_click(move |_, window, cx| { this.on_click(move |_, window, cx| {
cx.stop_propagation();
let checked = !self.checked; let checked = !self.checked;
on_click(&checked, window, cx); on_click(&checked, window, cx);
}) })

View file

@ -1,6 +1,6 @@
use crate::{h_flex, v_flex, ActiveTheme as _, Collapsible, Icon, IconName, StyledExt}; use crate::{h_flex, v_flex, ActiveTheme as _, Collapsible, Icon, IconName, StyledExt};
use gpui::{ use gpui::{
div, percentage, prelude::FluentBuilder as _, App, ClickEvent, ElementId, div, percentage, prelude::FluentBuilder as _, AnyElement, App, ClickEvent, ElementId,
InteractiveElement as _, IntoElement, ParentElement as _, RenderOnce, SharedString, InteractiveElement as _, IntoElement, ParentElement as _, RenderOnce, SharedString,
StatefulInteractiveElement as _, Styled as _, Window, StatefulInteractiveElement as _, Styled as _, Window,
}; };
@ -64,6 +64,7 @@ pub struct SidebarMenuItem {
active: bool, active: bool,
collapsed: bool, collapsed: bool,
children: Vec<Self>, children: Vec<Self>,
suffix: Option<AnyElement>,
} }
impl SidebarMenuItem { impl SidebarMenuItem {
@ -77,6 +78,7 @@ impl SidebarMenuItem {
active: false, active: false,
collapsed: false, collapsed: false,
children: Vec::new(), children: Vec::new(),
suffix: None,
} }
} }
@ -118,6 +120,12 @@ impl SidebarMenuItem {
self self
} }
/// Set the suffix for the menu item.
pub fn suffix(mut self, suffix: impl IntoElement) -> Self {
self.suffix = Some(suffix.into_any_element());
self
}
fn is_submenu(&self) -> bool { fn is_submenu(&self) -> bool {
self.children.len() > 0 self.children.len() > 0
} }
@ -129,64 +137,71 @@ impl SidebarMenuItem {
false false
} }
} }
}
fn render_menu_item(&self, _: &Window, cx: &App) -> impl IntoElement { impl RenderOnce for SidebarMenuItem {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let handler = self.handler.clone(); let handler = self.handler.clone();
let is_collapsed = self.collapsed; let is_collapsed = self.collapsed;
let is_active = self.active; let is_active = self.active;
let is_open = self.is_open(); let is_open = self.is_open();
let is_submenu = self.is_submenu(); let is_submenu = self.is_submenu();
h_flex()
.id("item")
.overflow_hidden()
.flex_shrink_0()
.p_2()
.gap_2()
.items_center()
.rounded(cx.theme().radius)
.text_sm()
.hover(|this| {
this.bg(cx.theme().sidebar_accent)
.text_color(cx.theme().sidebar_accent_foreground)
})
.when(is_active && !is_submenu, |this| {
this.font_medium()
.bg(cx.theme().sidebar_accent)
.text_color(cx.theme().sidebar_accent_foreground)
})
.when_some(self.icon.clone(), |this, icon| this.child(icon))
.when(is_collapsed, |this| {
this.justify_center().when(is_active, |this| {
this.bg(cx.theme().sidebar_accent)
.text_color(cx.theme().sidebar_accent_foreground)
})
})
.when(!is_collapsed, |this| {
this.h_7()
.child(div().flex_1().child(self.label.clone()))
.when(is_submenu, |this| {
this.child(
Icon::new(IconName::ChevronRight)
.size_4()
.when(is_open, |this| this.rotate(percentage(90. / 360.))),
)
})
})
.on_click(move |ev, window, cx| handler(ev, window, cx))
}
}
impl RenderOnce for SidebarMenuItem {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let is_submenu = self.is_submenu();
let is_open = self.is_open();
let is_collapsed = self.collapsed;
div() div()
.id(self.id.clone()) .id(self.id.clone())
.w_full() .w_full()
.child(self.render_menu_item(window, cx)) .child(
h_flex()
.size_full()
.id("item")
.overflow_x_hidden()
.flex_shrink_0()
.p_2()
.gap_x_2()
.rounded(cx.theme().radius)
.text_sm()
.hover(|this| {
this.bg(cx.theme().sidebar_accent)
.text_color(cx.theme().sidebar_accent_foreground)
})
.when(is_active && !is_submenu, |this| {
this.font_medium()
.bg(cx.theme().sidebar_accent)
.text_color(cx.theme().sidebar_accent_foreground)
})
.when_some(self.icon.clone(), |this, icon| this.child(icon))
.when(is_collapsed, |this| {
this.justify_center().when(is_active, |this| {
this.bg(cx.theme().sidebar_accent)
.text_color(cx.theme().sidebar_accent_foreground)
})
})
.when(!is_collapsed, |this| {
this.h_7()
.child(
h_flex()
.flex_1()
.gap_x_2()
.justify_between()
.overflow_x_hidden()
.child(
h_flex()
.flex_1()
.overflow_x_hidden()
.child(self.label.clone()),
)
.when_some(self.suffix, |this, suffix| this.child(suffix)),
)
.when(is_submenu, |this| {
this.child(
Icon::new(IconName::ChevronRight)
.size_4()
.when(is_open, |this| this.rotate(percentage(90. / 360.))),
)
})
})
.on_click(move |ev, window, cx| handler(ev, window, cx)),
)
.when(is_submenu && is_open && !is_collapsed, |this| { .when(is_submenu && is_open && !is_collapsed, |this| {
this.child( this.child(
v_flex() v_flex()
@ -194,8 +209,8 @@ impl RenderOnce for SidebarMenuItem {
.border_l_1() .border_l_1()
.border_color(cx.theme().sidebar_border) .border_color(cx.theme().sidebar_border)
.gap_1() .gap_1()
.mx_3p5() .ml_3p5()
.px_2p5() .pl_2p5()
.py_0p5() .py_0p5()
.children( .children(
self.children self.children

View file

@ -112,7 +112,7 @@ impl Element for Switch {
let (bg, toggle_bg) = match self.checked { let (bg, toggle_bg) = match self.checked {
true => (cx.theme().primary, cx.theme().background), true => (cx.theme().primary, cx.theme().background),
false => (cx.theme().input, cx.theme().background), false => (cx.theme().switch, cx.theme().background),
}; };
let (bg, toggle_bg) = match self.disabled { let (bg, toggle_bg) = match self.disabled {

View file

@ -187,6 +187,8 @@ pub struct ThemeColor {
pub success_hover: Hsla, pub success_hover: Hsla,
/// Success active background color. /// Success active background color.
pub success_active: Hsla, pub success_active: Hsla,
/// Switch background color.
pub switch: Hsla,
/// Tab background color. /// Tab background color.
pub tab: Hsla, pub tab: Hsla,
/// Tab active background color. /// Tab active background color.
@ -292,7 +294,7 @@ impl ThemeColor {
secondary_hover: hsl(240.0, 5.9, 98.), secondary_hover: hsl(240.0, 5.9, 98.),
selection: hsl(211.0, 97.0, 85.0), selection: hsl(211.0, 97.0, 85.0),
sidebar: hsl(0.0, 0.0, 98.0), sidebar: hsl(0.0, 0.0, 98.0),
sidebar_accent: hsl(240.0, 4.8, 92.), sidebar_accent: hsl(240.0, 4.8, 95.9),
sidebar_accent_foreground: hsl(240.0, 5.9, 10.0), sidebar_accent_foreground: hsl(240.0, 5.9, 10.0),
sidebar_border: hsl(220.0, 13.0, 91.0), sidebar_border: hsl(220.0, 13.0, 91.0),
sidebar_foreground: hsl(240.0, 5.3, 26.1), sidebar_foreground: hsl(240.0, 5.3, 26.1),
@ -305,6 +307,7 @@ impl ThemeColor {
success_active: crate::green_600(), success_active: crate::green_600(),
success_hover: crate::green_500().opacity(0.9), success_hover: crate::green_500().opacity(0.9),
success_foreground: crate::gray_50(), success_foreground: crate::gray_50(),
switch: hsl(240.0, 5.9, 90.0),
tab: gpui::transparent_black(), tab: gpui::transparent_black(),
tab_active: hsl(0.0, 0.0, 100.0), tab_active: hsl(0.0, 0.0, 100.0),
tab_active_foreground: hsl(240.0, 10., 3.9), tab_active_foreground: hsl(240.0, 10., 3.9),
@ -397,6 +400,7 @@ impl ThemeColor {
success_active: crate::green_800().darken(0.2), success_active: crate::green_800().darken(0.2),
success_foreground: crate::green_50(), success_foreground: crate::green_50(),
success_hover: crate::green_800().opacity(0.8), success_hover: crate::green_800().opacity(0.8),
switch: hsl(0., 0., 31.),
tab: gpui::transparent_black(), tab: gpui::transparent_black(),
tab_active: hsl(0.0, 0.0, 8.0), tab_active: hsl(0.0, 0.0, 8.0),
tab_active_foreground: hsl(0., 0., 78.), tab_active_foreground: hsl(0., 0., 78.),