Skip to main content

ui/components/
tab.rs

1use std::cmp::Ordering;
2
3use gpui::{AnyElement, Div, IntoElement, Pixels, Stateful, px, transparent_black};
4use smallvec::SmallVec;
5
6use crate::TabBarStyle;
7use crate::prelude::*;
8
9const START_TAB_SLOT_SIZE: Pixels = px(12.);
10const END_TAB_SLOT_SIZE: Pixels = px(14.);
11
12/// The position of a [`Tab`] within a [`TabBar`](crate::TabBar)'s row, used
13/// to decide which edges get a border in the [`TabBarStyle::Underline`]
14/// style (ported from Zed's `tab.rs`).
15#[derive(Debug, PartialEq, Eq, Clone, Copy)]
16pub enum TabPosition {
17    First,
18    Middle(Ordering),
19    Last,
20}
21
22/// Which side of a [`Tab`]'s content the close button (or other end-slot
23/// element) sits on, relative to the fixed-size start/end slots.
24#[derive(Debug, PartialEq, Eq, Clone, Copy)]
25pub enum TabCloseSide {
26    Start,
27    End,
28}
29
30/// A single tab within a [`TabBar`](crate::TabBar).
31///
32/// Renders per [`TabBarStyle`] (default [`TabBarStyle::Underline`]); pass the
33/// same style used on the parent `TabBar` via [`Tab::style`] so both agree.
34#[derive(IntoElement, RegisterComponent)]
35pub struct Tab {
36    div: Stateful<Div>,
37    selected: bool,
38    style: TabBarStyle,
39    position: TabPosition,
40    close_side: TabCloseSide,
41    start_slot: Option<AnyElement>,
42    end_slot: Option<AnyElement>,
43    children: SmallVec<[AnyElement; 2]>,
44}
45
46impl Tab {
47    pub fn new(id: impl Into<ElementId>) -> Self {
48        let id = id.into();
49        Self {
50            div: div()
51                .id(id.clone())
52                .debug_selector(|| format!("TAB-{}", id)),
53            selected: false,
54            style: TabBarStyle::default(),
55            position: TabPosition::First,
56            close_side: TabCloseSide::End,
57            start_slot: None,
58            end_slot: None,
59            children: SmallVec::new(),
60        }
61    }
62
63    /// Sets the visual style. Should match the parent [`TabBar`](crate::TabBar)'s style.
64    pub fn style(mut self, style: TabBarStyle) -> Self {
65        self.style = style;
66        self
67    }
68
69    /// Sets this tab's position within its row (see [`TabPosition`]).
70    pub fn position(mut self, position: TabPosition) -> Self {
71        self.position = position;
72        self
73    }
74
75    /// Sets which side the end slot (e.g. close button) sits on relative to
76    /// the fixed-size start/end slots (see [`TabCloseSide`]).
77    pub fn close_side(mut self, close_side: TabCloseSide) -> Self {
78        self.close_side = close_side;
79        self
80    }
81
82    pub fn start_slot<E: IntoElement>(mut self, element: impl Into<Option<E>>) -> Self {
83        self.start_slot = element.into().map(IntoElement::into_any_element);
84        self
85    }
86
87    pub fn end_slot<E: IntoElement>(mut self, element: impl Into<Option<E>>) -> Self {
88        self.end_slot = element.into().map(IntoElement::into_any_element);
89        self
90    }
91
92    pub fn content_height(cx: &App) -> Pixels {
93        DynamicSpacing::Base32.px(cx) - px(1.)
94    }
95
96    pub fn container_height(cx: &App) -> Pixels {
97        DynamicSpacing::Base32.px(cx)
98    }
99}
100
101impl InteractiveElement for Tab {
102    fn interactivity(&mut self) -> &mut gpui::Interactivity {
103        self.div.interactivity()
104    }
105}
106
107impl StatefulInteractiveElement for Tab {}
108
109impl Toggleable for Tab {
110    fn toggle_state(mut self, selected: bool) -> Self {
111        self.selected = selected;
112        self
113    }
114}
115
116impl ParentElement for Tab {
117    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
118        self.children.extend(elements)
119    }
120}
121
122impl RenderOnce for Tab {
123    #[allow(refining_impl_trait)]
124    fn render(self, _: &mut Window, cx: &mut App) -> Stateful<Div> {
125        match self.style {
126            TabBarStyle::Underline => {
127                let (text_color, tab_bg) = if self.selected {
128                    (
129                        cx.theme().colors().text,
130                        cx.theme().colors().tab_active_background,
131                    )
132                } else {
133                    (
134                        cx.theme().colors().text_muted,
135                        cx.theme().colors().tab_inactive_background,
136                    )
137                };
138
139                // Start/end slots are fixed-size wrappers (12px/14px) so the
140                // close "x" (end slot) has a stable hit target regardless of
141                // title length; hoisted per `close_side` so it can sit on
142                // either edge of the content.
143                let start = h_flex()
144                    .size(START_TAB_SLOT_SIZE)
145                    .justify_center()
146                    .children(self.start_slot);
147                let end = h_flex()
148                    .size(END_TAB_SLOT_SIZE)
149                    .justify_center()
150                    .children(self.end_slot);
151                let (start_slot, end_slot) = match self.close_side {
152                    TabCloseSide::End => (start, end),
153                    TabCloseSide::Start => (end, start),
154                };
155
156                self.div
157                    .h(Tab::container_height(cx))
158                    .bg(tab_bg)
159                    .border_color(cx.theme().colors().border)
160                    .map(|this| match self.position {
161                        TabPosition::First => {
162                            if self.selected {
163                                this.pl_px().border_r_1().pb_px()
164                            } else {
165                                this.pl_px().pr_px().border_b_1()
166                            }
167                        }
168                        TabPosition::Last => {
169                            if self.selected {
170                                this.border_l_1().border_r_1().pb_px()
171                            } else {
172                                this.pl_px().border_b_1().border_r_1()
173                            }
174                        }
175                        TabPosition::Middle(Ordering::Equal) => {
176                            this.border_l_1().border_r_1().pb_px()
177                        }
178                        TabPosition::Middle(Ordering::Less) => {
179                            this.border_l_1().pr_px().border_b_1()
180                        }
181                        TabPosition::Middle(Ordering::Greater) => {
182                            this.border_r_1().pl_px().border_b_1()
183                        }
184                    })
185                    .cursor_pointer()
186                    .child(
187                        h_flex()
188                            .group("")
189                            .relative()
190                            .h(Tab::content_height(cx))
191                            .px(DynamicSpacing::Base04.px(cx))
192                            .gap(DynamicSpacing::Base04.rems(cx))
193                            .text_color(text_color)
194                            .child(start_slot)
195                            .children(self.children)
196                            .child(end_slot),
197                    )
198            }
199            TabBarStyle::Pills => {
200                // Title area grows (`flex_1`) so the end slot (e.g. a close
201                // "x") is pinned to the tab's right edge instead of sitting
202                // right after the text.
203                let content = h_flex()
204                    .w_full()
205                    .items_center()
206                    .gap_2()
207                    .children(self.start_slot)
208                    .child(
209                        h_flex()
210                            .flex_1()
211                            .min_w_0()
212                            .items_center()
213                            .gap_2()
214                            .children(self.children),
215                    )
216                    .children(self.end_slot);
217
218                let (text_color, bg) = if self.selected {
219                    (semantic::text(cx), semantic::surface(cx))
220                } else {
221                    (semantic::text_muted(cx), transparent_black())
222                };
223                let hover_bg = semantic::hover_bg(cx);
224
225                self.div
226                    .cursor_pointer()
227                    .px_3()
228                    .py_1p5()
229                    .rounded_md()
230                    .bg(bg)
231                    .text_color(text_color)
232                    .when(self.selected, |this| this.shadow_level(Shadow::Sm))
233                    .when(!self.selected, |this| {
234                        this.hover(move |this| this.bg(hover_bg))
235                    })
236                    .child(content)
237            }
238        }
239    }
240}
241
242impl Component for Tab {
243    fn scope() -> ComponentScope {
244        ComponentScope::Navigation
245    }
246
247    fn description() -> Option<&'static str> {
248        Some(
249            "A tab component that can be used in a tabbed interface, supporting underline and pills styles.",
250        )
251    }
252
253    fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
254        Some(
255            v_flex()
256                .gap_6()
257                .children(vec![
258                    example_group_with_title(
259                        "Underline",
260                        vec![
261                            single_example(
262                                "Default",
263                                Tab::new("underline_default")
264                                    .position(TabPosition::First)
265                                    .child("Default Tab")
266                                    .into_any_element(),
267                            ),
268                            single_example(
269                                "Selected",
270                                Tab::new("underline_selected")
271                                    .position(TabPosition::Middle(Ordering::Equal))
272                                    .toggle_state(true)
273                                    .child("Selected Tab")
274                                    .into_any_element(),
275                            ),
276                            single_example(
277                                "Last",
278                                Tab::new("underline_last")
279                                    .position(TabPosition::Last)
280                                    .child("Last Tab")
281                                    .into_any_element(),
282                            ),
283                        ],
284                    ),
285                    example_group_with_title(
286                        "Pills",
287                        vec![
288                            single_example(
289                                "Default",
290                                Tab::new("pills_default")
291                                    .style(TabBarStyle::Pills)
292                                    .child("Default Tab")
293                                    .into_any_element(),
294                            ),
295                            single_example(
296                                "Selected",
297                                Tab::new("pills_selected")
298                                    .style(TabBarStyle::Pills)
299                                    .toggle_state(true)
300                                    .child("Selected Tab")
301                                    .into_any_element(),
302                            ),
303                        ],
304                    ),
305                ])
306                .into_any_element(),
307        )
308    }
309}