From 393be3cedfe6775e5607a6cd879ae1ac421044c5 Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Thu, 7 Dec 2023 12:30:43 -0500 Subject: [PATCH] Extract `Tab` component (#3539) This PR extracts a new `Tab` component from the tabs implementation in the workspace. This will allow us to reuse this component anywhere that we need to use tabs. Like our other newer components, the `Tab` component has a relatively open API. It accepts `children` (or `child`) as well as a `start_slot` and `end_slot` to position content in the slots on either end of the content. These slots also respect the `TabCloseSide` and will switch positions based on this value. Screenshot 2023-12-07 at 12 19 42 PM Release Notes: - N/A --- crates/storybook2/src/story_selector.rs | 2 + crates/ui2/src/components.rs | 2 + crates/ui2/src/components/stories.rs | 2 + crates/ui2/src/components/stories/tab.rs | 114 +++++++++++++ crates/ui2/src/components/tab.rs | 198 +++++++++++++++++++++++ crates/workspace2/src/pane.rs | 133 +++++---------- 6 files changed, 358 insertions(+), 93 deletions(-) create mode 100644 crates/ui2/src/components/stories/tab.rs create mode 100644 crates/ui2/src/components/tab.rs diff --git a/crates/storybook2/src/story_selector.rs b/crates/storybook2/src/story_selector.rs index 216762060d..1e899a5783 100644 --- a/crates/storybook2/src/story_selector.rs +++ b/crates/storybook2/src/story_selector.rs @@ -28,6 +28,7 @@ pub enum ComponentStory { ListHeader, ListItem, Scroll, + Tab, Text, ZIndex, Picker, @@ -53,6 +54,7 @@ impl ComponentStory { Self::ListItem => cx.build_view(|_| ui::ListItemStory).into(), Self::Scroll => ScrollStory::view(cx).into(), Self::Text => TextStory::view(cx).into(), + Self::Tab => cx.build_view(|_| ui::TabStory).into(), Self::ZIndex => cx.build_view(|_| ZIndexStory).into(), Self::Picker => PickerStory::new(cx).into(), } diff --git a/crates/ui2/src/components.rs b/crates/ui2/src/components.rs index 583b30a2e0..5bda108a87 100644 --- a/crates/ui2/src/components.rs +++ b/crates/ui2/src/components.rs @@ -13,6 +13,7 @@ mod popover; mod popover_menu; mod right_click_menu; mod stack; +mod tab; mod tooltip; #[cfg(feature = "stories")] @@ -33,6 +34,7 @@ pub use popover::*; pub use popover_menu::*; pub use right_click_menu::*; pub use stack::*; +pub use tab::*; pub use tooltip::*; #[cfg(feature = "stories")] diff --git a/crates/ui2/src/components/stories.rs b/crates/ui2/src/components/stories.rs index 113c2679b7..c54ee99057 100644 --- a/crates/ui2/src/components/stories.rs +++ b/crates/ui2/src/components/stories.rs @@ -10,6 +10,7 @@ mod label; mod list; mod list_header; mod list_item; +mod tab; pub use avatar::*; pub use button::*; @@ -23,3 +24,4 @@ pub use label::*; pub use list::*; pub use list_header::*; pub use list_item::*; +pub use tab::*; diff --git a/crates/ui2/src/components/stories/tab.rs b/crates/ui2/src/components/stories/tab.rs new file mode 100644 index 0000000000..cd5e920396 --- /dev/null +++ b/crates/ui2/src/components/stories/tab.rs @@ -0,0 +1,114 @@ +use std::cmp::Ordering; + +use gpui::{Div, Render}; +use story::Story; + +use crate::{prelude::*, TabPosition}; +use crate::{Indicator, Tab}; + +pub struct TabStory; + +impl Render for TabStory { + type Element = Div; + + fn render(&mut self, _cx: &mut ViewContext) -> Self::Element { + Story::container() + .child(Story::title_for::()) + .child(Story::label("Default")) + .child(h_stack().child(Tab::new("tab_1").child("Tab 1"))) + .child(Story::label("With indicator")) + .child( + h_stack().child( + Tab::new("tab_1") + .start_slot(Indicator::dot().color(Color::Warning)) + .child("Tab 1"), + ), + ) + .child(Story::label("With close button")) + .child( + h_stack().child( + Tab::new("tab_1") + .end_slot( + IconButton::new("close_button", Icon::Close) + .icon_color(Color::Muted) + .size(ButtonSize::None) + .icon_size(IconSize::XSmall), + ) + .child("Tab 1"), + ), + ) + .child(Story::label("List of tabs")) + .child( + h_stack() + .child(Tab::new("tab_1").child("Tab 1")) + .child(Tab::new("tab_2").child("Tab 2")), + ) + .child(Story::label("List of tabs with first tab selected")) + .child( + h_stack() + .child( + Tab::new("tab_1") + .selected(true) + .position(TabPosition::First) + .child("Tab 1"), + ) + .child( + Tab::new("tab_2") + .position(TabPosition::Middle(Ordering::Greater)) + .child("Tab 2"), + ) + .child( + Tab::new("tab_3") + .position(TabPosition::Middle(Ordering::Greater)) + .child("Tab 3"), + ) + .child(Tab::new("tab_4").position(TabPosition::Last).child("Tab 4")), + ) + .child(Story::label("List of tabs with last tab selected")) + .child( + h_stack() + .child( + Tab::new("tab_1") + .position(TabPosition::First) + .child("Tab 1"), + ) + .child( + Tab::new("tab_2") + .position(TabPosition::Middle(Ordering::Less)) + .child("Tab 2"), + ) + .child( + Tab::new("tab_3") + .position(TabPosition::Middle(Ordering::Less)) + .child("Tab 3"), + ) + .child( + Tab::new("tab_4") + .position(TabPosition::Last) + .selected(true) + .child("Tab 4"), + ), + ) + .child(Story::label("List of tabs with second tab selected")) + .child( + h_stack() + .child( + Tab::new("tab_1") + .position(TabPosition::First) + .child("Tab 1"), + ) + .child( + Tab::new("tab_2") + .position(TabPosition::Middle(Ordering::Equal)) + .selected(true) + .child("Tab 2"), + ) + .child( + Tab::new("tab_3") + .position(TabPosition::Middle(Ordering::Greater)) + .child("Tab 3"), + ) + .child(Tab::new("tab_4").position(TabPosition::Last).child("Tab 4")), + ) + } +} diff --git a/crates/ui2/src/components/tab.rs b/crates/ui2/src/components/tab.rs new file mode 100644 index 0000000000..7a40a6ed0d --- /dev/null +++ b/crates/ui2/src/components/tab.rs @@ -0,0 +1,198 @@ +use std::cmp::Ordering; +use std::rc::Rc; + +use gpui::{AnyElement, AnyView, ClickEvent, IntoElement, MouseButton}; +use smallvec::SmallVec; + +use crate::prelude::*; + +/// The position of a [`Tab`] within a list of tabs. +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub enum TabPosition { + /// The tab is first in the list. + First, + + /// The tab is in the middle of the list (i.e., it is not the first or last tab). + /// + /// The [`Ordering`] is where this tab is positioned with respect to the selected tab. + Middle(Ordering), + + /// The tab is last in the list. + Last, +} + +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub enum TabCloseSide { + Start, + End, +} + +#[derive(IntoElement)] +pub struct Tab { + id: ElementId, + selected: bool, + position: TabPosition, + close_side: TabCloseSide, + on_click: Option>, + tooltip: Option AnyView + 'static>>, + start_slot: Option, + end_slot: Option, + children: SmallVec<[AnyElement; 2]>, +} + +impl Tab { + pub fn new(id: impl Into) -> Self { + Self { + id: id.into(), + selected: false, + position: TabPosition::First, + close_side: TabCloseSide::End, + on_click: None, + tooltip: None, + start_slot: None, + end_slot: None, + children: SmallVec::new(), + } + } + + pub fn position(mut self, position: TabPosition) -> Self { + self.position = position; + self + } + + pub fn close_side(mut self, close_side: TabCloseSide) -> Self { + self.close_side = close_side; + self + } + + pub fn on_click(mut self, handler: impl Fn(&ClickEvent, &mut WindowContext) + 'static) -> Self { + self.on_click = Some(Rc::new(handler)); + self + } + + pub fn tooltip(mut self, tooltip: impl Fn(&mut WindowContext) -> AnyView + 'static) -> Self { + self.tooltip = Some(Box::new(tooltip)); + self + } + + pub fn start_slot(mut self, element: impl Into>) -> Self { + self.start_slot = element.into().map(IntoElement::into_any_element); + self + } + + pub fn end_slot(mut self, element: impl Into>) -> Self { + self.end_slot = element.into().map(IntoElement::into_any_element); + self + } +} + +impl Selectable for Tab { + fn selected(mut self, selected: bool) -> Self { + self.selected = selected; + self + } +} + +impl ParentElement for Tab { + fn children_mut(&mut self) -> &mut SmallVec<[AnyElement; 2]> { + &mut self.children + } +} + +impl RenderOnce for Tab { + type Rendered = Div; + + fn render(self, cx: &mut WindowContext) -> Self::Rendered { + const HEIGHT_IN_REMS: f32 = 30. / 16.; + + let (text_color, tab_bg, _tab_hover_bg, _tab_active_bg) = match self.selected { + false => ( + cx.theme().colors().text_muted, + cx.theme().colors().tab_inactive_background, + cx.theme().colors().ghost_element_hover, + cx.theme().colors().ghost_element_active, + ), + true => ( + cx.theme().colors().text, + cx.theme().colors().tab_active_background, + cx.theme().colors().element_hover, + cx.theme().colors().element_active, + ), + }; + + div() + .h(rems(HEIGHT_IN_REMS)) + .bg(tab_bg) + .border_color(cx.theme().colors().border) + .map(|this| match self.position { + TabPosition::First => { + if self.selected { + this.pl_px().border_r().pb_px() + } else { + this.pl_px().pr_px().border_b() + } + } + TabPosition::Last => { + if self.selected { + this.border_l().border_r().pb_px() + } else { + this.pr_px().pl_px().border_b() + } + } + TabPosition::Middle(Ordering::Equal) => this.border_l().border_r().pb_px(), + TabPosition::Middle(Ordering::Less) => this.border_l().pr_px().border_b(), + TabPosition::Middle(Ordering::Greater) => this.border_r().pl_px().border_b(), + }) + .child( + h_stack() + .group("") + .id(self.id) + .relative() + .h_full() + .px_5() + .gap_1() + .text_color(text_color) + // .hover(|style| style.bg(tab_hover_bg)) + // .active(|style| style.bg(tab_active_bg)) + .when_some(self.on_click, |tab, on_click| { + tab.cursor_pointer().on_click(move |event, cx| { + // HACK: GPUI currently fires `on_click` with any mouse button, + // but we only care about the left button. + if event.down.button == MouseButton::Left { + (on_click)(event, cx) + } + }) + }) + .when_some(self.tooltip, |tab, tooltip| { + tab.tooltip(move |cx| tooltip(cx)) + }) + .child( + h_stack() + .w_3() + .h_3() + .justify_center() + .absolute() + .map(|this| match self.close_side { + TabCloseSide::Start => this.right_1(), + TabCloseSide::End => this.left_1(), + }) + .children(self.start_slot), + ) + .child( + h_stack() + .invisible() + .w_3() + .h_3() + .justify_center() + .absolute() + .map(|this| match self.close_side { + TabCloseSide::Start => this.left_1(), + TabCloseSide::End => this.right_1(), + }) + .group_hover("", |style| style.visible()) + .children(self.end_slot), + ) + .children(self.children), + ) + } +} diff --git a/crates/workspace2/src/pane.rs b/crates/workspace2/src/pane.rs index 495819f608..f28759a733 100644 --- a/crates/workspace2/src/pane.rs +++ b/crates/workspace2/src/pane.rs @@ -28,10 +28,10 @@ use std::{ use ui::{ h_stack, prelude::*, right_click_menu, ButtonSize, Color, Icon, IconButton, IconSize, - Indicator, Label, Tooltip, + Indicator, Label, Tab, TabPosition, Tooltip, }; use ui::{v_stack, ContextMenu}; -use util::truncate_and_remove_front; +use util::{maybe, truncate_and_remove_front}; #[derive(PartialEq, Clone, Copy, Deserialize, Debug)] #[serde(rename_all = "camelCase")] @@ -1438,42 +1438,49 @@ impl Pane { let is_active = ix == self.active_item_index; - let indicator = { + let indicator = maybe!({ let indicator_color = match (item.has_conflict(cx), item.is_dirty(cx)) { - (true, _) => Some(Color::Warning), - (_, true) => Some(Color::Accent), - (false, false) => None, + (true, _) => Color::Warning, + (_, true) => Color::Accent, + (false, false) => return None, }; - h_stack() - .w_3() - .h_3() - .justify_center() - .absolute() - .map(|this| match close_side { - ClosePosition::Left => this.right_1(), - ClosePosition::Right => this.left_1(), - }) - .when_some(indicator_color, |this, indicator_color| { - this.child(Indicator::dot().color(indicator_color)) - }) - }; + Some(Indicator::dot().color(indicator_color)) + }); - let close_button = { - let id = item.item_id(); + let id = item.item_id(); - h_stack() - .invisible() - .w_3() - .h_3() - .justify_center() - .absolute() - .map(|this| match close_side { - ClosePosition::Left => this.left_1(), - ClosePosition::Right => this.right_1(), + let is_first_item = ix == 0; + let is_last_item = ix == self.items.len() - 1; + let position_relative_to_active_item = ix.cmp(&self.active_item_index); + + let tab = + Tab::new(ix) + .position(if is_first_item { + TabPosition::First + } else if is_last_item { + TabPosition::Last + } else { + TabPosition::Middle(position_relative_to_active_item) }) - .group_hover("", |style| style.visible()) - .child( + .close_side(match close_side { + ClosePosition::Left => ui::TabCloseSide::Start, + ClosePosition::Right => ui::TabCloseSide::End, + }) + .selected(ix == self.active_item_index()) + .on_click(cx.listener(move |pane: &mut Self, event, cx| { + pane.activate_item(ix, true, true, cx) + })) + // .on_drag(move |pane, cx| pane.render_tab(ix, item.boxed_clone(), detail, cx)) + // .drag_over::(|d| d.bg(cx.theme().colors().element_drop_target)) + // .on_drop(|_view, state: View, cx| { + // eprintln!("{:?}", state.read(cx)); + // }) + .when_some(item.tab_tooltip_text(cx), |tab, text| { + tab.tooltip(move |cx| Tooltip::text(text.clone(), cx)) + }) + .start_slot::(indicator) + .end_slot( // TODO: Fix button size IconButton::new("close tab", Icon::Close) .icon_color(Color::Muted) @@ -1484,67 +1491,7 @@ impl Pane { .detach_and_log_err(cx); })), ) - }; - - let tab = div() - .border_color(cx.theme().colors().border) - .bg(tab_bg) - // 30px @ 16px/rem - .h(rems(1.875)) - .map(|this| { - let is_first_item = ix == 0; - let is_last_item = ix == self.items.len() - 1; - match ix.cmp(&self.active_item_index) { - cmp::Ordering::Less => { - if is_first_item { - this.pl_px().pr_px().border_b() - } else { - this.border_l().pr_px().border_b() - } - } - cmp::Ordering::Greater => { - if is_last_item { - this.pr_px().pl_px().border_b() - } else { - this.border_r().pl_px().border_b() - } - } - cmp::Ordering::Equal => { - if is_first_item { - this.pl_px().border_r().pb_px() - } else { - this.border_l().border_r().pb_px() - } - } - } - }) - .child( - h_stack() - .group("") - .id(ix) - .relative() - .h_full() - .cursor_pointer() - .when_some(item.tab_tooltip_text(cx), |div, text| { - div.tooltip(move |cx| cx.build_view(|cx| Tooltip::new(text.clone())).into()) - }) - .on_click( - cx.listener(move |v: &mut Self, e, cx| v.activate_item(ix, true, true, cx)), - ) - // .on_drag(move |pane, cx| pane.render_tab(ix, item.boxed_clone(), detail, cx)) - // .drag_over::(|d| d.bg(cx.theme().colors().element_drop_target)) - // .on_drop(|_view, state: View, cx| { - // eprintln!("{:?}", state.read(cx)); - // }) - .px_5() - // .hover(|h| h.bg(tab_hover_bg)) - // .active(|a| a.bg(tab_active_bg)) - .gap_1() - .text_color(text_color) - .child(indicator) - .child(close_button) - .child(label), - ); + .child(label); right_click_menu(ix).trigger(tab).menu(|cx| { ContextMenu::build(cx, |menu, cx| {