Extract `TabBar` component (#3613)

Marshall Bowers created

This PR extracts a new `TabBar` component from the tab bar
implementation in the workspace.

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_bar.rs |  68 +++++++
crates/ui2/src/components/tab_bar.rs         | 141 ++++++++++++++++
crates/workspace2/src/pane.rs                | 193 +++++++--------------
6 files changed, 284 insertions(+), 124 deletions(-)

Detailed changes

crates/storybook2/src/story_selector.rs 🔗

@@ -29,6 +29,7 @@ pub enum ComponentStory {
     ListItem,
     Scroll,
     Tab,
+    TabBar,
     Text,
     ViewportUnits,
     ZIndex,
@@ -56,6 +57,7 @@ impl ComponentStory {
             Self::Scroll => ScrollStory::view(cx).into(),
             Self::Text => TextStory::view(cx).into(),
             Self::Tab => cx.build_view(|_| ui::TabStory).into(),
+            Self::TabBar => cx.build_view(|cx| ui::TabBarStory::new(cx)).into(),
             Self::ViewportUnits => cx.build_view(|_| crate::stories::ViewportUnitsStory).into(),
             Self::ZIndex => cx.build_view(|_| ZIndexStory).into(),
             Self::Picker => PickerStory::new(cx).into(),

crates/ui2/src/components.rs 🔗

@@ -14,6 +14,7 @@ mod popover_menu;
 mod right_click_menu;
 mod stack;
 mod tab;
+mod tab_bar;
 mod tooltip;
 
 #[cfg(feature = "stories")]
@@ -35,6 +36,7 @@ pub use popover_menu::*;
 pub use right_click_menu::*;
 pub use stack::*;
 pub use tab::*;
+pub use tab_bar::*;
 pub use tooltip::*;
 
 #[cfg(feature = "stories")]

crates/ui2/src/components/stories.rs 🔗

@@ -11,6 +11,7 @@ mod list;
 mod list_header;
 mod list_item;
 mod tab;
+mod tab_bar;
 
 pub use avatar::*;
 pub use button::*;
@@ -25,3 +26,4 @@ pub use list::*;
 pub use list_header::*;
 pub use list_item::*;
 pub use tab::*;
+pub use tab_bar::*;

crates/ui2/src/components/stories/tab_bar.rs 🔗

@@ -0,0 +1,68 @@
+use gpui::{Div, FocusHandle, Render};
+use story::Story;
+
+use crate::{prelude::*, Tab, TabBar, TabPosition};
+
+pub struct TabBarStory {
+    tab_bar_focus_handle: FocusHandle,
+}
+
+impl TabBarStory {
+    pub fn new(cx: &mut ViewContext<Self>) -> Self {
+        Self {
+            tab_bar_focus_handle: cx.focus_handle(),
+        }
+    }
+}
+
+impl Render for TabBarStory {
+    type Element = Div;
+
+    fn render(&mut self, _cx: &mut ViewContext<Self>) -> Self::Element {
+        let tab_count = 20;
+        let selected_tab_index = 3;
+
+        let tabs = (0..tab_count)
+            .map(|index| {
+                Tab::new(index)
+                    .selected(index == selected_tab_index)
+                    .position(if index == 0 {
+                        TabPosition::First
+                    } else if index == tab_count - 1 {
+                        TabPosition::Last
+                    } else {
+                        TabPosition::Middle(index.cmp(&selected_tab_index))
+                    })
+                    .child(Label::new(format!("Tab {}", index + 1)).color(
+                        if index == selected_tab_index {
+                            Color::Default
+                        } else {
+                            Color::Muted
+                        },
+                    ))
+            })
+            .collect::<Vec<_>>();
+
+        Story::container()
+            .child(Story::title_for::<TabBar>())
+            .child(Story::label("Default"))
+            .child(
+                h_stack().child(
+                    TabBar::new("tab_bar_1", self.tab_bar_focus_handle.clone())
+                        .start_child(
+                            IconButton::new("navigate_backward", Icon::ArrowLeft)
+                                .icon_size(IconSize::Small),
+                        )
+                        .start_child(
+                            IconButton::new("navigate_forward", Icon::ArrowRight)
+                                .icon_size(IconSize::Small),
+                        )
+                        .end_child(IconButton::new("new", Icon::Plus).icon_size(IconSize::Small))
+                        .end_child(
+                            IconButton::new("split_pane", Icon::Split).icon_size(IconSize::Small),
+                        )
+                        .children(tabs),
+                ),
+            )
+    }
+}

crates/ui2/src/components/tab_bar.rs 🔗

@@ -0,0 +1,141 @@
+use gpui::{AnyElement, FocusHandle, Focusable, Stateful};
+use smallvec::SmallVec;
+
+use crate::prelude::*;
+
+#[derive(IntoElement)]
+pub struct TabBar {
+    id: ElementId,
+    focus_handle: FocusHandle,
+    start_children: SmallVec<[AnyElement; 2]>,
+    children: SmallVec<[AnyElement; 2]>,
+    end_children: SmallVec<[AnyElement; 2]>,
+}
+
+impl TabBar {
+    pub fn new(id: impl Into<ElementId>, focus_handle: FocusHandle) -> Self {
+        Self {
+            id: id.into(),
+            focus_handle,
+            start_children: SmallVec::new(),
+            children: SmallVec::new(),
+            end_children: SmallVec::new(),
+        }
+    }
+
+    pub fn start_children_mut(&mut self) -> &mut SmallVec<[AnyElement; 2]> {
+        &mut self.start_children
+    }
+
+    pub fn start_child(mut self, start_child: impl IntoElement) -> Self
+    where
+        Self: Sized,
+    {
+        self.start_children_mut()
+            .push(start_child.into_element().into_any());
+        self
+    }
+
+    pub fn start_children(
+        mut self,
+        start_children: impl IntoIterator<Item = impl IntoElement>,
+    ) -> Self
+    where
+        Self: Sized,
+    {
+        self.start_children_mut().extend(
+            start_children
+                .into_iter()
+                .map(|child| child.into_any_element()),
+        );
+        self
+    }
+
+    pub fn end_children_mut(&mut self) -> &mut SmallVec<[AnyElement; 2]> {
+        &mut self.end_children
+    }
+
+    pub fn end_child(mut self, end_child: impl IntoElement) -> Self
+    where
+        Self: Sized,
+    {
+        self.end_children_mut()
+            .push(end_child.into_element().into_any());
+        self
+    }
+
+    pub fn end_children(mut self, end_children: impl IntoIterator<Item = impl IntoElement>) -> Self
+    where
+        Self: Sized,
+    {
+        self.end_children_mut().extend(
+            end_children
+                .into_iter()
+                .map(|child| child.into_any_element()),
+        );
+        self
+    }
+}
+
+impl ParentElement for TabBar {
+    fn children_mut(&mut self) -> &mut SmallVec<[AnyElement; 2]> {
+        &mut self.children
+    }
+}
+
+impl RenderOnce for TabBar {
+    type Rendered = Focusable<Stateful<Div>>;
+
+    fn render(self, cx: &mut WindowContext) -> Self::Rendered {
+        const HEIGHT_IN_REMS: f32 = 30. / 16.;
+
+        div()
+            .id(self.id)
+            .group("tab_bar")
+            .track_focus(&self.focus_handle)
+            .w_full()
+            .h(rems(HEIGHT_IN_REMS))
+            .overflow_hidden()
+            .flex()
+            .flex_none()
+            .bg(cx.theme().colors().tab_bar_background)
+            .child(
+                h_stack()
+                    .flex_none()
+                    .gap_1()
+                    .px_1()
+                    .border_b()
+                    .border_r()
+                    .border_color(cx.theme().colors().border)
+                    .children(self.start_children),
+            )
+            .child(
+                div()
+                    .relative()
+                    .flex_1()
+                    .h_full()
+                    .overflow_hidden_x()
+                    .child(
+                        div()
+                            .absolute()
+                            .top_0()
+                            .left_0()
+                            .z_index(1)
+                            .size_full()
+                            .border_b()
+                            .border_color(cx.theme().colors().border),
+                    )
+                    .child(h_stack().id("tabs").z_index(2).children(self.children)),
+            )
+            .child(
+                h_stack()
+                    .flex_none()
+                    .gap_1()
+                    .px_1()
+                    .border_b()
+                    .border_l()
+                    .border_color(cx.theme().colors().border)
+                    .children(self.end_children),
+            )
+    }
+}

crates/workspace2/src/pane.rs 🔗

@@ -7,10 +7,10 @@ use crate::{
 use anyhow::Result;
 use collections::{HashMap, HashSet, VecDeque};
 use gpui::{
-    actions, impl_actions, overlay, prelude::*, rems, Action, AnchorCorner, AnyWeakView,
-    AppContext, AsyncWindowContext, DismissEvent, Div, EntityId, EventEmitter, FocusHandle,
-    Focusable, FocusableView, Model, MouseButton, NavigationDirection, Pixels, Point, PromptLevel,
-    Render, Subscription, Task, View, ViewContext, VisualContext, WeakView, WindowContext,
+    actions, impl_actions, overlay, prelude::*, Action, AnchorCorner, AnyWeakView, AppContext,
+    AsyncWindowContext, DismissEvent, Div, EntityId, EventEmitter, FocusHandle, Focusable,
+    FocusableView, Model, MouseButton, NavigationDirection, Pixels, Point, PromptLevel, Render,
+    Subscription, Task, View, ViewContext, VisualContext, WeakView, WindowContext,
 };
 use parking_lot::Mutex;
 use project::{Project, ProjectEntryId, ProjectPath};
@@ -28,7 +28,7 @@ use std::{
 
 use ui::{
     h_stack, prelude::*, right_click_menu, ButtonSize, Color, Icon, IconButton, IconSize,
-    Indicator, Label, Tab, TabPosition, Tooltip,
+    Indicator, Label, Tab, TabBar, TabPosition, Tooltip,
 };
 use ui::{v_stack, ContextMenu};
 use util::{maybe, truncate_and_remove_front};
@@ -1562,133 +1562,78 @@ impl Pane {
     }
 
     fn render_tab_bar(&mut self, cx: &mut ViewContext<'_, Pane>) -> impl IntoElement {
-        div()
-            .id("tab_bar")
-            .group("tab_bar")
-            .track_focus(&self.tab_bar_focus_handle)
-            .w_full()
-            // 30px @ 16px/rem
-            .h(rems(1.875))
-            .overflow_hidden()
-            .flex()
-            .flex_none()
-            .bg(cx.theme().colors().tab_bar_background)
-            // Left Side
-            .child(
-                h_stack()
-                    .flex()
-                    .flex_none()
-                    .gap_1()
-                    .px_1()
-                    .border_b()
-                    .border_r()
-                    .border_color(cx.theme().colors().border)
-                    // Nav Buttons
+        TabBar::new("tab_bar", self.tab_bar_focus_handle.clone())
+            .start_child(
+                IconButton::new("navigate_backward", Icon::ArrowLeft)
+                    .icon_size(IconSize::Small)
+                    .on_click({
+                        let view = cx.view().clone();
+                        move |_, cx| view.update(cx, Self::navigate_backward)
+                    })
+                    .disabled(!self.can_navigate_backward()),
+            )
+            .start_child(
+                IconButton::new("navigate_forward", Icon::ArrowRight)
+                    .icon_size(IconSize::Small)
+                    .on_click({
+                        let view = cx.view().clone();
+                        move |_, cx| view.update(cx, Self::navigate_backward)
+                    })
+                    .disabled(!self.can_navigate_forward()),
+            )
+            .end_child(
+                div()
                     .child(
-                        IconButton::new("navigate_backward", Icon::ArrowLeft)
+                        IconButton::new("plus", Icon::Plus)
                             .icon_size(IconSize::Small)
-                            .on_click({
-                                let view = cx.view().clone();
-                                move |_, cx| view.update(cx, Self::navigate_backward)
-                            })
-                            .disabled(!self.can_navigate_backward()),
+                            .on_click(cx.listener(|this, _, cx| {
+                                let menu = ContextMenu::build(cx, |menu, cx| {
+                                    menu.action("New File", NewFile.boxed_clone())
+                                        .action("New Terminal", NewCenterTerminal.boxed_clone())
+                                        .action("New Search", NewSearch.boxed_clone())
+                                });
+                                cx.subscribe(&menu, |this, _, event: &DismissEvent, cx| {
+                                    this.focus(cx);
+                                    this.new_item_menu = None;
+                                })
+                                .detach();
+                                this.new_item_menu = Some(menu);
+                            })),
                     )
-                    .child(
-                        IconButton::new("navigate_forward", Icon::ArrowRight)
-                            .icon_size(IconSize::Small)
-                            .on_click({
-                                let view = cx.view().clone();
-                                move |_, cx| view.update(cx, Self::navigate_backward)
-                            })
-                            .disabled(!self.can_navigate_forward()),
-                    ),
+                    .when_some(self.new_item_menu.as_ref(), |el, new_item_menu| {
+                        el.child(Self::render_menu_overlay(new_item_menu))
+                    }),
             )
-            .child(
+            .end_child(
                 div()
-                    .relative()
-                    .flex_1()
-                    .h_full()
-                    .overflow_hidden_x()
                     .child(
-                        div()
-                            .absolute()
-                            .top_0()
-                            .left_0()
-                            .z_index(1)
-                            .size_full()
-                            .border_b()
-                            .border_color(cx.theme().colors().border),
+                        IconButton::new("split", Icon::Split)
+                            .icon_size(IconSize::Small)
+                            .on_click(cx.listener(|this, _, cx| {
+                                let menu = ContextMenu::build(cx, |menu, cx| {
+                                    menu.action("Split Right", SplitRight.boxed_clone())
+                                        .action("Split Left", SplitLeft.boxed_clone())
+                                        .action("Split Up", SplitUp.boxed_clone())
+                                        .action("Split Down", SplitDown.boxed_clone())
+                                });
+                                cx.subscribe(&menu, |this, _, event: &DismissEvent, cx| {
+                                    this.focus(cx);
+                                    this.split_item_menu = None;
+                                })
+                                .detach();
+                                this.split_item_menu = Some(menu);
+                            })),
                     )
-                    .child(
-                        h_stack().id("tabs").z_index(2).children(
-                            self.items
-                                .iter()
-                                .enumerate()
-                                .zip(self.tab_details(cx))
-                                .map(|((ix, item), detail)| self.render_tab(ix, item, detail, cx)),
-                        ),
-                    ),
+                    .when_some(self.split_item_menu.as_ref(), |el, split_item_menu| {
+                        el.child(Self::render_menu_overlay(split_item_menu))
+                    }),
             )
-            // Right Side
-            .child(
-                h_stack()
-                    .flex()
-                    .flex_none()
-                    .gap_1()
-                    .px_1()
-                    .border_b()
-                    .border_l()
-                    .border_color(cx.theme().colors().border)
-                    .child(
-                        div()
-                            .flex()
-                            .items_center()
-                            .gap_px()
-                            .child(
-                                IconButton::new("plus", Icon::Plus)
-                                    .icon_size(IconSize::Small)
-                                    .on_click(cx.listener(|this, _, cx| {
-                                        let menu = ContextMenu::build(cx, |menu, cx| {
-                                            menu.action("New File", NewFile.boxed_clone())
-                                                .action(
-                                                    "New Terminal",
-                                                    NewCenterTerminal.boxed_clone(),
-                                                )
-                                                .action("New Search", NewSearch.boxed_clone())
-                                        });
-                                        cx.subscribe(&menu, |this, _, event: &DismissEvent, cx| {
-                                            this.focus(cx);
-                                            this.new_item_menu = None;
-                                        })
-                                        .detach();
-                                        this.new_item_menu = Some(menu);
-                                    })),
-                            )
-                            .when_some(self.new_item_menu.as_ref(), |el, new_item_menu| {
-                                el.child(Self::render_menu_overlay(new_item_menu))
-                            })
-                            .child(
-                                IconButton::new("split", Icon::Split)
-                                    .icon_size(IconSize::Small)
-                                    .on_click(cx.listener(|this, _, cx| {
-                                        let menu = ContextMenu::build(cx, |menu, cx| {
-                                            menu.action("Split Right", SplitRight.boxed_clone())
-                                                .action("Split Left", SplitLeft.boxed_clone())
-                                                .action("Split Up", SplitUp.boxed_clone())
-                                                .action("Split Down", SplitDown.boxed_clone())
-                                        });
-                                        cx.subscribe(&menu, |this, _, event: &DismissEvent, cx| {
-                                            this.focus(cx);
-                                            this.split_item_menu = None;
-                                        })
-                                        .detach();
-                                        this.split_item_menu = Some(menu);
-                                    })),
-                            )
-                            .when_some(self.split_item_menu.as_ref(), |el, split_item_menu| {
-                                el.child(Self::render_menu_overlay(split_item_menu))
-                            }),
-                    ),
+            .children(
+                self.items
+                    .iter()
+                    .enumerate()
+                    .zip(self.tab_details(cx))
+                    .map(|((ix, item), detail)| self.render_tab(ix, item, detail, cx)),
             )
     }