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.

<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
This commit is contained in:
Marshall Bowers 2023-12-07 12:30:43 -05:00 committed by GitHub
parent c91207b450
commit 393be3cedf
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 358 additions and 93 deletions

View file

@ -28,6 +28,7 @@ pub enum ComponentStory {
ListHeader, ListHeader,
ListItem, ListItem,
Scroll, Scroll,
Tab,
Text, Text,
ZIndex, ZIndex,
Picker, Picker,
@ -53,6 +54,7 @@ impl ComponentStory {
Self::ListItem => cx.build_view(|_| ui::ListItemStory).into(), Self::ListItem => cx.build_view(|_| ui::ListItemStory).into(),
Self::Scroll => ScrollStory::view(cx).into(), Self::Scroll => ScrollStory::view(cx).into(),
Self::Text => TextStory::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::ZIndex => cx.build_view(|_| ZIndexStory).into(),
Self::Picker => PickerStory::new(cx).into(), Self::Picker => PickerStory::new(cx).into(),
} }

View file

@ -13,6 +13,7 @@ mod popover;
mod popover_menu; mod popover_menu;
mod right_click_menu; mod right_click_menu;
mod stack; mod stack;
mod tab;
mod tooltip; mod tooltip;
#[cfg(feature = "stories")] #[cfg(feature = "stories")]
@ -33,6 +34,7 @@ pub use popover::*;
pub use popover_menu::*; pub use popover_menu::*;
pub use right_click_menu::*; pub use right_click_menu::*;
pub use stack::*; pub use stack::*;
pub use tab::*;
pub use tooltip::*; pub use tooltip::*;
#[cfg(feature = "stories")] #[cfg(feature = "stories")]

View file

@ -10,6 +10,7 @@ mod label;
mod list; mod list;
mod list_header; mod list_header;
mod list_item; mod list_item;
mod tab;
pub use avatar::*; pub use avatar::*;
pub use button::*; pub use button::*;
@ -23,3 +24,4 @@ pub use label::*;
pub use list::*; pub use list::*;
pub use list_header::*; pub use list_header::*;
pub use list_item::*; pub use list_item::*;
pub use tab::*;

View file

@ -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")),
)
}
}

View file

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

View file

@ -28,10 +28,10 @@ use std::{
use ui::{ use ui::{
h_stack, prelude::*, right_click_menu, ButtonSize, Color, Icon, IconButton, IconSize, 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 ui::{v_stack, ContextMenu};
use util::truncate_and_remove_front; use util::{maybe, truncate_and_remove_front};
#[derive(PartialEq, Clone, Copy, Deserialize, Debug)] #[derive(PartialEq, Clone, Copy, Deserialize, Debug)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
@ -1438,42 +1438,49 @@ impl Pane {
let is_active = ix == self.active_item_index; 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)) { let indicator_color = match (item.has_conflict(cx), item.is_dirty(cx)) {
(true, _) => Some(Color::Warning), (true, _) => Color::Warning,
(_, true) => Some(Color::Accent), (_, true) => Color::Accent,
(false, false) => None, (false, false) => return None,
}; };
h_stack() Some(Indicator::dot().color(indicator_color))
.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))
})
};
let close_button = { let id = item.item_id();
let id = item.item_id();
h_stack() let is_first_item = ix == 0;
.invisible() let is_last_item = ix == self.items.len() - 1;
.w_3() let position_relative_to_active_item = ix.cmp(&self.active_item_index);
.h_3()
.justify_center() let tab =
.absolute() Tab::new(ix)
.map(|this| match close_side { .position(if is_first_item {
ClosePosition::Left => this.left_1(), TabPosition::First
ClosePosition::Right => this.right_1(), } else if is_last_item {
TabPosition::Last
} else {
TabPosition::Middle(position_relative_to_active_item)
}) })
.group_hover("", |style| style.visible()) .close_side(match close_side {
.child( 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::<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))
})
.start_slot::<Indicator>(indicator)
.end_slot(
// TODO: Fix button size // TODO: Fix button size
IconButton::new("close tab", Icon::Close) IconButton::new("close tab", Icon::Close)
.icon_color(Color::Muted) .icon_color(Color::Muted)
@ -1484,67 +1491,7 @@ impl Pane {
.detach_and_log_err(cx); .detach_and_log_err(cx);
})), })),
) )
}; .child(label);
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),
);
right_click_menu(ix).trigger(tab).menu(|cx| { right_click_menu(ix).trigger(tab).menu(|cx| {
ContextMenu::build(cx, |menu, cx| { ContextMenu::build(cx, |menu, cx| {