Extract `Tab` component (#3539)

Marshall Bowers created

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.

<img width="763" alt="Screenshot 2023-12-07 at 12 19 42 PM"
src="https://github.com/zed-industries/zed/assets/1486634/78e4db6a-f807-431e-8777-f52f2631e6ed">

Release Notes:

- N/A

Change summary

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

Detailed changes

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(),
         }

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")]

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::*;

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>) -> Self::Element {
+        Story::container()
+            .child(Story::title_for::<Tab>())
+            .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")),
+            )
+    }
+}

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<Rc<dyn Fn(&ClickEvent, &mut WindowContext) + 'static>>,
+    tooltip: Option<Box<dyn Fn(&mut WindowContext) -> AnyView + 'static>>,
+    start_slot: Option<AnyElement>,
+    end_slot: Option<AnyElement>,
+    children: SmallVec<[AnyElement; 2]>,
+}
+
+impl Tab {
+    pub fn new(id: impl Into<ElementId>) -> 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<E: IntoElement>(mut self, element: impl Into<Option<E>>) -> Self {
+        self.start_slot = element.into().map(IntoElement::into_any_element);
+        self
+    }
+
+    pub fn end_slot<E: IntoElement>(mut self, element: impl Into<Option<E>>) -> 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),
+            )
+    }
+}

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(),
+            Some(Indicator::dot().color(indicator_color))
+        });
+
+        let id = item.item_id();
+
+        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)
                 })
-                .when_some(indicator_color, |this, indicator_color| {
-                    this.child(Indicator::dot().color(indicator_color))
+                .close_side(match close_side {
+                    ClosePosition::Left => ui::TabCloseSide::Start,
+                    ClosePosition::Right => ui::TabCloseSide::End,
                 })
-        };
-
-        let close_button = {
-            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(),
+                .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::<DraggedTab>(|d| d.bg(cx.theme().colors().element_drop_target))
+                // .on_drop(|_view, state: View<DraggedTab>, cx| {
+                //     eprintln!("{:?}", state.read(cx));
+                // })
+                .when_some(item.tab_tooltip_text(cx), |tab, text| {
+                    tab.tooltip(move |cx| Tooltip::text(text.clone(), cx))
                 })
-                .group_hover("", |style| style.visible())
-                .child(
+                .start_slot::<Indicator>(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::<DraggedTab>(|d| d.bg(cx.theme().colors().element_drop_target))
-                    // .on_drop(|_view, state: View<DraggedTab>, 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| {