diff --git a/crates/story/examples/tabs.rs b/crates/story/examples/tabs.rs new file mode 100644 index 00000000..e07e224a --- /dev/null +++ b/crates/story/examples/tabs.rs @@ -0,0 +1,35 @@ +use gpui::*; +use story::{Assets, TabsStory}; + +pub struct Example { + root: Entity, +} + +impl Example { + pub fn new(window: &mut Window, cx: &mut Context) -> Self { + let root = TabsStory::view(window, cx); + + Self { root } + } + + fn view(window: &mut Window, cx: &mut App) -> Entity { + cx.new(|cx| Self::new(window, cx)) + } +} + +impl Render for Example { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + div().p_4().size_full().child(self.root.clone()) + } +} + +fn main() { + let app = Application::new().with_assets(Assets); + + app.run(move |cx| { + story::init(cx); + cx.activate(true); + + story::create_new_window("Tabs Example", Example::view, cx); + }); +} diff --git a/crates/story/src/lib.rs b/crates/story/src/lib.rs index dbca91ec..b3a91427 100644 --- a/crates/story/src/lib.rs +++ b/crates/story/src/lib.rs @@ -16,6 +16,7 @@ mod scrollable_story; mod sidebar_story; mod switch_story; mod table_story; +mod tabs_story; mod text_story; mod title_bar; mod tooltip_story; @@ -28,6 +29,7 @@ pub use button_story::ButtonStory; pub use calendar_story::CalendarStory; pub use dropdown_story::DropdownStory; pub use form_story::FormStory; +pub use tabs_story::TabsStory; use gpui::{ actions, div, impl_internal_actions, prelude::FluentBuilder as _, px, size, AnyElement, diff --git a/crates/story/src/tabs_story.rs b/crates/story/src/tabs_story.rs new file mode 100644 index 00000000..4ba5c0f9 --- /dev/null +++ b/crates/story/src/tabs_story.rs @@ -0,0 +1,200 @@ +use gpui::{ + App, AppContext, Context, Entity, Focusable, IntoElement, ParentElement, Render, Styled, Window, +}; + +use gpui_component::{ + button::{Button, ButtonGroup, ButtonVariants}, + h_flex, + tab::{Tab, TabBar}, + v_flex, IconName, Selectable as _, Sizable, Size, +}; + +use crate::section; + +pub struct TabsStory { + focus_handle: gpui::FocusHandle, + active_tab_ix: usize, + size: Size, +} + +impl super::Story for TabsStory { + fn title() -> &'static str { + "Tabs" + } + + fn description() -> &'static str { + "A set of layered sections of content—known as tab panels—that are displayed one at a time." + } + + fn new_view(window: &mut Window, cx: &mut App) -> Entity { + Self::view(window, cx) + } +} + +impl TabsStory { + pub fn view(window: &mut Window, cx: &mut App) -> Entity { + cx.new(|cx| Self::new(window, cx)) + } + + fn new(_: &mut Window, cx: &mut Context) -> Self { + Self { + focus_handle: cx.focus_handle(), + active_tab_ix: 0, + size: Size::default(), + } + } + + fn set_active_tab(&mut self, ix: usize, _: &mut Window, cx: &mut Context) { + self.active_tab_ix = ix; + cx.notify(); + } + + fn set_size(&mut self, size: Size, _: &mut Window, cx: &mut Context) { + self.size = size; + cx.notify(); + } +} + +impl Focusable for TabsStory { + fn focus_handle(&self, _: &gpui::App) -> gpui::FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for TabsStory { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + v_flex() + .gap_6() + .child( + ButtonGroup::new("toggle-size") + .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( + section("Normal Tabs", cx).child( + TabBar::new("normal-tabs") + .w_full() + .with_size(self.size) + .selected_index(self.active_tab_ix) + .on_click(cx.listener(|this, ix: &usize, window, cx| { + this.set_active_tab(*ix, window, cx); + })) + .prefix( + h_flex() + .mx_1() + .child( + Button::new("back") + .ghost() + .xsmall() + .icon(IconName::ArrowLeft), + ) + .child( + Button::new("forward") + .ghost() + .xsmall() + .icon(IconName::ArrowRight), + ), + ) + .child(Tab::new("tab-account", "Account")) + .child(Tab::new("tab-profile", "Profile").disabled(true)) + .child(Tab::new("tab-documents", "Documents")) + .child(Tab::new("tab-mail", "Mail")) + .child(Tab::new("tab-appearance", "Appearance")) + .child(Tab::new("tab-settings", "Settings")) + .suffix( + h_flex() + .mx_1() + .child(Button::new("inbox").ghost().xsmall().icon(IconName::Inbox)) + .child( + Button::new("more") + .ghost() + .xsmall() + .icon(IconName::Ellipsis), + ), + ), + ), + ) + .child( + section("Pills Tabs", cx).child( + TabBar::new("pills-tabs") + .w_full() + .pill() + .with_size(self.size) + .selected_index(self.active_tab_ix) + .on_click(cx.listener(|this, ix: &usize, window, cx| { + this.set_active_tab(*ix, window, cx); + })) + .child(Tab::new("tab-account", "Account")) + .child(Tab::new("tab-profile", "Profile").disabled(true)) + .child(Tab::new("tab-documents", "Documents")) + .child(Tab::new("tab-mail", "Mail")) + .child(Tab::new("tab-appearance", "Appearance")) + .child(Tab::new("tab-settings", "Settings")), + ), + ) + .child( + section("Segmented Tabs", cx).child( + TabBar::new("segmented-tabs") + .w_full() + .segmented() + .with_size(self.size) + .selected_index(self.active_tab_ix) + .on_click(cx.listener(|this, ix: &usize, window, cx| { + this.set_active_tab(*ix, window, cx); + })) + .child(Tab::new("tab-account", "Account")) + .child(Tab::new("tab-profile", "Profile").disabled(true)) + .child(Tab::new("tab-documents", "Documents")) + .child(Tab::new("tab-mail", "Mail")) + .child(Tab::new("tab-appearance", "Appearance")) + .child(Tab::new("tab-settings", "Settings")), + ), + ) + .child( + section("Underline Tabs", cx).child( + TabBar::new("underline-tabs") + .w_full() + .underline() + .with_size(self.size) + .selected_index(self.active_tab_ix) + .on_click(cx.listener(|this, ix: &usize, window, cx| { + this.set_active_tab(*ix, window, cx); + })) + .child(Tab::new("tab-account", "Account")) + .child(Tab::new("tab-profile", "Profile").disabled(true)) + .child(Tab::new("tab-documents", "Documents")) + .child(Tab::new("tab-mail", "Mail")) + .child(Tab::new("tab-appearance", "Appearance")) + .child(Tab::new("tab-settings", "Settings")), + ), + ) + } +} diff --git a/crates/ui/src/dock/tab_panel.rs b/crates/ui/src/dock/tab_panel.rs index 5a599949..aa3c40ad 100644 --- a/crates/ui/src/dock/tab_panel.rs +++ b/crates/ui/src/dock/tab_panel.rs @@ -651,6 +651,7 @@ impl TabPanel { let tabs_count = self.panels.len(); TabBar::new("tab-bar") + .mt(-px(1.)) .track_scroll(self.tab_bar_scroll_handle.clone()) .when( left_dock_button.is_some() || bottom_dock_button.is_some(), @@ -672,6 +673,9 @@ impl TabPanel { ) }, ) + .on_click(cx.listener(move |view, ix: &usize, window, cx| { + view.set_active_ix(*ix, window, cx); + })) .children(self.panels.iter().enumerate().filter_map(|(ix, panel)| { let mut active = state.active_panel.as_ref() == Some(panel); let disabled = self.collapsed; @@ -691,10 +695,7 @@ impl TabPanel { .selected(active) .disabled(disabled) .when(!disabled, |this| { - this.on_click(cx.listener(move |view, _, window, cx| { - view.set_active_ix(ix, window, cx); - })) - .when(state.draggable, |this| { + this.when(state.draggable, |this| { this.on_drag( DragPanel::new(panel.clone(), view.clone()), |drag, _, _, cx| { @@ -720,7 +721,7 @@ impl TabPanel { }), ) })) - .child( + .last_empty_space( // empty space to allow move to last tab right div() .id("tab-bar-empty-space") diff --git a/crates/ui/src/styled.rs b/crates/ui/src/styled.rs index ee85f074..701f3a81 100644 --- a/crates/ui/src/styled.rs +++ b/crates/ui/src/styled.rs @@ -58,6 +58,18 @@ pub trait StyledExt: Styled + Sized { .pr(paddings.right.into()) } + /// Apply margins to the element. + fn margins(self, margins: impl Into>) -> Self + where + L: Into + Clone + Default + std::fmt::Debug, + { + let margins = margins.into(); + self.mt(margins.top.into()) + .mb(margins.bottom.into()) + .ml(margins.left.into()) + .mr(margins.right.into()) + } + /// Render a border with a width of 1px, color red fn debug_red(self) -> Self { if cfg!(debug_assertions) { diff --git a/crates/ui/src/tab/tab.rs b/crates/ui/src/tab/tab.rs index 5da59248..3a650dad 100644 --- a/crates/ui/src/tab/tab.rs +++ b/crates/ui/src/tab/tab.rs @@ -1,10 +1,324 @@ -use crate::{ActiveTheme, Selectable}; +use std::sync::Arc; + +use crate::{ActiveTheme, Selectable, Sizable, Size, StyledExt}; use gpui::prelude::FluentBuilder as _; use gpui::{ - div, px, AnyElement, App, Div, ElementId, InteractiveElement, IntoElement, ParentElement as _, - RenderOnce, Stateful, StatefulInteractiveElement, Styled, Window, + div, px, AnyElement, App, ClickEvent, Div, Edges, ElementId, Hsla, InteractiveElement, + IntoElement, ParentElement as _, Pixels, RenderOnce, Stateful, StatefulInteractiveElement, + Styled, Window, }; +#[derive(Debug, Clone, Default, Copy, PartialEq, Eq, Hash)] +pub enum TabVariant { + #[default] + Tab, + Pill, + Segmented, + Underline, +} + +#[allow(dead_code)] +struct TabStyle { + borders: Edges, + border_color: Hsla, + bg: Hsla, + fg: Hsla, + radius: Pixels, + shadow: bool, + inner_bg: Hsla, + inner_radius: Pixels, +} + +impl Default for TabStyle { + fn default() -> Self { + TabStyle { + borders: Edges::all(px(0.)), + border_color: gpui::transparent_white(), + bg: gpui::transparent_white(), + fg: gpui::transparent_white(), + radius: px(0.), + shadow: false, + inner_bg: gpui::transparent_white(), + inner_radius: px(0.), + } + } +} + +impl TabVariant { + fn inner_height(&self, size: Size) -> Pixels { + match size { + Size::Small | Size::XSmall => match self { + TabVariant::Tab => px(25.), + TabVariant::Pill => px(26.), + TabVariant::Segmented => px(26.), + TabVariant::Underline => px(22.), + }, + _ => match self { + TabVariant::Tab => px(30.), + TabVariant::Pill => px(31.), + TabVariant::Segmented => px(30.), + TabVariant::Underline => px(25.), + }, + } + } + + fn inner_paddings(&self, size: Size) -> Edges { + match size { + Size::Small | Size::XSmall => match self { + TabVariant::Tab => Edges { + left: px(10.), + right: px(10.), + ..Default::default() + }, + TabVariant::Pill => Edges { + left: px(14.), + right: px(14.), + ..Default::default() + }, + TabVariant::Segmented => Edges { + left: px(8.), + right: px(8.), + ..Default::default() + }, + TabVariant::Underline => Edges { + left: px(10.), + right: px(10.), + ..Default::default() + }, + }, + _ => match self { + TabVariant::Tab => Edges { + left: px(12.), + right: px(12.), + ..Default::default() + }, + TabVariant::Pill => Edges { + left: px(16.), + right: px(16.), + ..Default::default() + }, + TabVariant::Segmented => Edges { + left: px(10.), + right: px(10.), + ..Default::default() + }, + TabVariant::Underline => Edges { + left: px(12.), + right: px(12.), + ..Default::default() + }, + }, + } + } + + fn inner_margins(&self, size: Size) -> Edges { + match size { + Size::Small | Size::XSmall => match self { + TabVariant::Underline => Edges { + bottom: px(2.), + ..Default::default() + }, + _ => Edges::all(px(0.)), + }, + _ => match self { + TabVariant::Underline => Edges { + bottom: px(4.), + ..Default::default() + }, + _ => Edges::all(px(0.)), + }, + } + } + + fn normal(&self, cx: &App) -> TabStyle { + match self { + TabVariant::Tab => TabStyle { + fg: cx.theme().foreground, + bg: cx.theme().transparent, + borders: Edges { + top: px(1.), + left: px(1.), + right: px(1.), + ..Default::default() + }, + border_color: cx.theme().transparent, + ..Default::default() + }, + TabVariant::Pill => TabStyle { + fg: cx.theme().foreground, + bg: cx.theme().transparent, + borders: Edges::all(px(1.)), + border_color: cx.theme().border, + radius: px(99.), + ..Default::default() + }, + TabVariant::Segmented => TabStyle { + fg: cx.theme().foreground, + bg: cx.theme().transparent, + radius: cx.theme().radius, + ..Default::default() + }, + TabVariant::Underline => TabStyle { + fg: cx.theme().foreground, + bg: cx.theme().transparent, + radius: px(0.), + inner_bg: cx.theme().transparent, + inner_radius: cx.theme().radius, + borders: Edges { + bottom: px(2.), + ..Default::default() + }, + border_color: cx.theme().transparent, + ..Default::default() + }, + } + } + + fn hovered(&self, cx: &App) -> TabStyle { + match self { + TabVariant::Tab => TabStyle { + fg: cx.theme().tab_foreground, + bg: cx.theme().transparent, + borders: Edges { + top: px(1.), + left: px(1.), + right: px(1.), + ..Default::default() + }, + border_color: cx.theme().transparent, + ..Default::default() + }, + TabVariant::Pill => TabStyle { + fg: cx.theme().accent_foreground, + bg: cx.theme().accent, + borders: Edges::all(px(1.)), + border_color: cx.theme().border, + radius: px(99.), + ..Default::default() + }, + TabVariant::Segmented => TabStyle { + fg: cx.theme().tab_foreground, + bg: cx.theme().transparent, + radius: cx.theme().radius, + ..Default::default() + }, + TabVariant::Underline => TabStyle { + fg: cx.theme().tab_foreground, + bg: cx.theme().transparent, + radius: px(0.), + inner_bg: cx.theme().accent, + inner_radius: cx.theme().radius, + borders: Edges { + bottom: px(2.), + ..Default::default() + }, + border_color: cx.theme().transparent, + ..Default::default() + }, + } + } + + fn selected(&self, cx: &App) -> TabStyle { + match self { + TabVariant::Tab => TabStyle { + fg: cx.theme().tab_active_foreground, + bg: cx.theme().tab_active, + borders: Edges { + top: px(1.), + left: px(1.), + right: px(1.), + ..Default::default() + }, + border_color: cx.theme().border, + ..Default::default() + }, + TabVariant::Pill => TabStyle { + fg: cx.theme().accent_foreground, + bg: cx.theme().transparent, + borders: Edges::all(px(1.)), + border_color: cx.theme().primary, + radius: px(99.), + ..Default::default() + }, + TabVariant::Segmented => TabStyle { + fg: cx.theme().tab_active_foreground, + bg: cx.theme().background, + radius: cx.theme().radius, + shadow: true, + ..Default::default() + }, + TabVariant::Underline => TabStyle { + fg: cx.theme().tab_active_foreground, + bg: cx.theme().transparent, + borders: Edges { + bottom: px(2.), + ..Default::default() + }, + border_color: cx.theme().primary, + ..Default::default() + }, + } + } + + fn disabled(&self, selected: bool, cx: &App) -> TabStyle { + match self { + TabVariant::Tab => TabStyle { + fg: cx.theme().muted_foreground, + bg: cx.theme().tab, + border_color: if selected { + cx.theme().border + } else { + cx.theme().transparent + }, + borders: Edges { + top: px(1.), + left: px(1.), + right: px(1.), + ..Default::default() + }, + ..Default::default() + }, + TabVariant::Pill => TabStyle { + fg: cx.theme().muted_foreground, + bg: cx.theme().transparent, + borders: Edges::all(px(1.)), + border_color: if selected { + cx.theme().primary + } else { + cx.theme().border + }, + radius: px(99.), + ..Default::default() + }, + TabVariant::Segmented => TabStyle { + fg: cx.theme().muted_foreground, + bg: if selected { + cx.theme().background + } else { + cx.theme().transparent + }, + radius: cx.theme().radius, + ..Default::default() + }, + TabVariant::Underline => TabStyle { + fg: cx.theme().muted_foreground, + bg: cx.theme().transparent, + radius: cx.theme().radius, + border_color: if selected { + cx.theme().border + } else { + cx.theme().transparent + }, + borders: Edges { + bottom: px(2.), + ..Default::default() + }, + ..Default::default() + }, + } + } +} + #[derive(IntoElement)] pub struct Tab { id: ElementId, @@ -12,8 +326,11 @@ pub struct Tab { label: AnyElement, prefix: Option, suffix: Option, + variant: TabVariant, + size: Size, disabled: bool, selected: bool, + on_click: Option>, } impl Tab { @@ -21,15 +338,42 @@ impl Tab { let id: ElementId = id.into(); Self { id: id.clone(), - base: div().id(id).gap_1().py_1p5().px_3().h(px(30.)), + base: div().id(id).gap_1(), label: label.into_any_element(), disabled: false, selected: false, prefix: None, suffix: None, + variant: TabVariant::default(), + size: Size::default(), + on_click: None, } } + /// Set Tab Variant. + pub fn variant(mut self, variant: TabVariant) -> Self { + self.variant = variant; + self + } + + /// Use Pill variant. + pub fn pill(mut self) -> Self { + self.variant = TabVariant::Pill; + self + } + + /// Use Segmented variant. + pub fn segmented(mut self) -> Self { + self.variant = TabVariant::Segmented; + self + } + + /// Use Underline variant. + pub fn underline(mut self) -> Self { + self.variant = TabVariant::Underline; + self + } + /// Set the left side of the tab pub fn prefix(mut self, prefix: impl Into) -> Self { self.prefix = Some(prefix.into()); @@ -47,6 +391,15 @@ impl Tab { self.disabled = disabled; self } + + /// Set the click handler for the tab. + pub fn on_click( + mut self, + on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_click = Some(Arc::new(on_click)); + self + } } impl Selectable for Tab { @@ -74,14 +427,32 @@ impl Styled for Tab { } } +impl Sizable for Tab { + fn with_size(mut self, size: impl Into) -> Self { + self.size = size.into(); + self + } +} + impl RenderOnce for Tab { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { - let (text_color, bg_color) = match (self.selected, self.disabled) { - (true, false) => (cx.theme().tab_active_foreground, cx.theme().tab_active), - (false, false) => (cx.theme().muted_foreground, cx.theme().tab), - // disabled - (true, true) => (cx.theme().muted_foreground, cx.theme().tab_active), - (false, true) => (cx.theme().muted_foreground, cx.theme().tab), + let mut tab_style = if self.selected { + self.variant.selected(cx) + } else { + self.variant.normal(cx) + }; + let mut hover_style = self.variant.hovered(cx); + if self.disabled { + tab_style = self.variant.disabled(self.selected, cx); + hover_style = self.variant.disabled(self.selected, cx); + } + let inner_paddings = self.variant.inner_paddings(self.size); + let inner_margins = self.variant.inner_margins(self.size); + let inner_height = self.variant.inner_height(self.size); + + let height = match self.size { + Size::Small | Size::XSmall => px(24.), + _ => px(31.), }; self.base @@ -90,16 +461,53 @@ impl RenderOnce for Tab { .flex_shrink_0() .cursor_pointer() .overflow_hidden() - .text_color(text_color) - .bg(bg_color) - .border_x_1() - .border_color(cx.theme().transparent) - .when(self.selected, |this| this.border_color(cx.theme().border)) - .text_sm() - .when_some(self.prefix, |this, prefix| { - this.child(prefix).text_color(text_color) + .line_height(height) + .h(height) + .overflow_hidden() + .text_color(tab_style.fg) + .bg(tab_style.bg) + .border_l(tab_style.borders.left) + .border_r(tab_style.borders.right) + .border_t(tab_style.borders.top) + .border_b(tab_style.borders.bottom) + .border_color(tab_style.border_color) + .rounded(tab_style.radius) + .when(tab_style.shadow, |this| this.shadow_sm()) + .when(!self.selected && !self.disabled, |this| { + this.hover(|this| { + this.text_color(hover_style.fg) + .bg(hover_style.bg) + .border_l(hover_style.borders.left) + .border_r(hover_style.borders.right) + .border_t(hover_style.borders.top) + .border_b(hover_style.borders.bottom) + .border_color(hover_style.border_color) + .rounded(tab_style.radius) + }) }) - .child(div().text_ellipsis().child(self.label)) + .text_sm() + .when_some(self.prefix, |this, prefix| this.child(prefix)) + .child( + div() + .h(inner_height) + .line_height(inner_height) + .paddings(inner_paddings) + .margins(inner_margins) + .text_ellipsis() + .flex_shrink_0() + .child(self.label) + .bg(tab_style.inner_bg) + .rounded(tab_style.inner_radius) + .hover(|this| { + this.bg(hover_style.inner_bg) + .rounded(hover_style.inner_radius) + }), + ) .when_some(self.suffix, |this, suffix| this.child(suffix)) + .when(!self.disabled, |this| { + this.when_some(self.on_click.clone(), |this, on_click| { + this.on_click(move |event, window, cx| on_click(event, window, cx)) + }) + }) } } diff --git a/crates/ui/src/tab/tab_bar.rs b/crates/ui/src/tab/tab_bar.rs index 6090f293..1ccc1be8 100644 --- a/crates/ui/src/tab/tab_bar.rs +++ b/crates/ui/src/tab/tab_bar.rs @@ -1,12 +1,16 @@ -use crate::{h_flex, ActiveTheme}; +use std::sync::Arc; + +use crate::{h_flex, ActiveTheme, Selectable, Sizable, Size, StyledExt}; use gpui::prelude::FluentBuilder as _; use gpui::{ - div, AnyElement, App, Div, ElementId, IntoElement, ParentElement, RenderOnce, ScrollHandle, - StatefulInteractiveElement as _, Styled, Window, + div, rems, AbsoluteLength, AnyElement, App, Div, Edges, ElementId, IntoElement, ParentElement, + RenderOnce, ScrollHandle, StatefulInteractiveElement as _, Styled, Window, }; use gpui::{px, InteractiveElement}; use smallvec::SmallVec; +use super::{Tab, TabVariant}; + #[derive(IntoElement)] pub struct TabBar { base: Div, @@ -14,7 +18,12 @@ pub struct TabBar { scroll_handle: ScrollHandle, prefix: Option, suffix: Option, - children: SmallVec<[AnyElement; 2]>, + children: SmallVec<[Tab; 2]>, + last_empty_space: Option, + selected_index: Option, + variant: TabVariant, + size: Size, + on_click: Option>, } impl TabBar { @@ -26,9 +35,38 @@ impl TabBar { scroll_handle: ScrollHandle::new(), prefix: None, suffix: None, + variant: TabVariant::default(), + size: Size::default(), + last_empty_space: None, + selected_index: None, + on_click: None, } } + /// Set the Tab variant, all children will inherit the variant. + pub fn variant(mut self, variant: TabVariant) -> Self { + self.variant = variant; + self + } + + /// Set the Tab variant to Pill, all children will inherit the variant. + pub fn pill(mut self) -> Self { + self.variant = TabVariant::Pill; + self + } + + /// Set the Tab variant to Segmented, all children will inherit the variant. + pub fn segmented(mut self) -> Self { + self.variant = TabVariant::Segmented; + self + } + + /// Set the Tab variant to Underline, all children will inherit the variant. + pub fn underline(mut self) -> Self { + self.variant = TabVariant::Underline; + self + } + /// Track the scroll of the TabBar pub fn track_scroll(mut self, scroll_handle: ScrollHandle) -> Self { self.scroll_handle = scroll_handle; @@ -46,11 +84,37 @@ impl TabBar { self.suffix = Some(suffix.into_any_element()); self } -} -impl ParentElement for TabBar { - fn extend(&mut self, elements: impl IntoIterator) { - self.children.extend(elements) + /// Add children of the TabBar, all children will inherit the variant. + pub fn children(mut self, children: impl IntoIterator) -> Self { + self.children.extend(children); + self + } + + /// Add child of the TabBar, tab will inherit the variant. + pub fn child(mut self, child: Tab) -> Self { + self.children.push(child); + self + } + + /// Set the selected index of the TabBar. + pub fn selected_index(mut self, index: usize) -> Self { + self.selected_index = Some(index); + self + } + + /// Set the last empty space element of the TabBar + pub fn last_empty_space(mut self, last_empty_space: impl IntoElement) -> Self { + self.last_empty_space = Some(last_empty_space.into_any_element()); + self + } + + /// Set the on_click callback of the TabBar, the first parameter is the index of the clicked tab. + /// + /// When this is set, the children's on_click will be ignored. + pub fn on_click(mut self, on_click: impl Fn(&usize, &mut Window, &mut App) + 'static) -> Self { + self.on_click = Some(Arc::new(on_click)); + self } } @@ -60,8 +124,38 @@ impl Styled for TabBar { } } +impl Sizable for TabBar { + fn with_size(mut self, size: impl Into) -> Self { + self.size = size.into(); + self + } +} + impl RenderOnce for TabBar { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let default_gap = match self.size { + Size::Small | Size::XSmall => px(8.), + _ => px(12.), + }; + let (bg, paddings, gap) = match self.variant { + TabVariant::Tab => { + let padding = Edges::all(AbsoluteLength::Pixels(px(0.))); + (cx.theme().tab_bar, padding, px(0.)) + } + TabVariant::Pill => { + let padding = Edges::all(AbsoluteLength::Rems(rems(0.25))); + (cx.theme().transparent, padding, default_gap) + } + TabVariant::Segmented => { + let padding = Edges::all(AbsoluteLength::Rems(rems(0.25))); + (cx.theme().accent, padding, default_gap / 2.) + } + TabVariant::Underline => { + let padding = Edges::all(AbsoluteLength::Pixels(px(0.))); + (cx.theme().transparent, padding, default_gap / 2.) + } + }; + self.base .id(self.id) .group("tab-bar") @@ -69,16 +163,26 @@ impl RenderOnce for TabBar { .flex() .flex_none() .items_center() - .bg(cx.theme().tab_bar) + .bg(bg) + .paddings(paddings) .text_color(cx.theme().tab_foreground) - .child( - div() - .id("border-b") - .absolute() - .bottom_0() - .size_full() - .border_b_1() - .border_color(cx.theme().border), + .when( + self.variant == TabVariant::Underline || self.variant == TabVariant::Tab, + |this| { + this.child( + div() + .id("border-b") + .absolute() + .bottom_0() + .size_full() + .border_b_1() + .border_color(cx.theme().border), + ) + }, + ) + .when( + self.variant == TabVariant::Pill || self.variant == TabVariant::Segmented, + |this| this.rounded(cx.theme().radius), ) .when_some(self.prefix, |this, prefix| this.child(prefix)) .child( @@ -87,7 +191,26 @@ impl RenderOnce for TabBar { .flex_grow() .overflow_x_scroll() .track_scroll(&self.scroll_handle) - .children(self.children), + .gap(gap) + .children( + self.children + .into_iter() + .enumerate() + .map(move |(ix, child)| { + child + .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) + }) + }) + }), + ) + .children(self.last_empty_space), ) .when_some(self.suffix, |this, suffix| this.child(suffix)) } diff --git a/crates/ui/src/theme.rs b/crates/ui/src/theme.rs index 7f1c5f1e..776ce1f9 100644 --- a/crates/ui/src/theme.rs +++ b/crates/ui/src/theme.rs @@ -214,7 +214,7 @@ pub struct ThemeColor { impl ThemeColor { pub fn light() -> Self { Self { - accent: hsl(240.0, 5.0, 96.0), + accent: hsl(240.0, 4.8, 93.9), accent_foreground: hsl(240.0, 5.9, 10.0), accordion: hsl(0.0, 0.0, 100.0), accordion_active: hsl(240.0, 5.9, 90.0),