Skip to main content

gpui_component/tab/
tab.rs

1use std::{rc::Rc, time::Duration};
2
3use crate::animation::{Lerp, ease_in_out_cubic};
4use crate::{ActiveTheme, Icon, IconName, Selectable, Sizable, Size, StyledExt, h_flex};
5use gpui::prelude::FluentBuilder as _;
6use gpui::{
7    Animation, AnimationExt as _, AnyElement, App, Background, ClickEvent, Edges, ElementId,
8    FillOptions, Hsla, InteractiveElement, IntoElement, ParentElement, PathBuilder, PathStyle,
9    Pixels, Point, RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, canvas,
10    div, point, px, relative,
11};
12
13/// Room between two [`TabVariant::Folder`] tabs, holding their separator.
14pub(super) const FOLDER_TAB_GAP: Pixels = px(4.);
15
16/// Geometry of [`TabVariant::Folder`].
17#[derive(Clone, Copy)]
18pub(super) struct FolderTabMetrics {
19    /// Bar space above the tabs. The content sits this much above the tab's
20    /// center so that it centers on the whole bar.
21    pub(super) top_padding: Pixels,
22    /// Radius of the top corners and of the curves joining the selected tab
23    /// to the content below.
24    pub(super) radius: Pixels,
25    pub(super) padding_x: Pixels,
26    pub(super) separator_height: Pixels,
27}
28
29impl FolderTabMetrics {
30    pub(super) fn new(size: Size, cx: &App) -> Self {
31        let (top_padding, padding_x, separator_height, radius) = match size {
32            Size::XSmall => (px(2.), px(8.), px(12.), cx.theme().radius),
33            Size::Small => (px(4.), px(8.), px(12.), cx.theme().radius),
34            Size::Large => (px(8.), px(16.), px(16.), cx.theme().radius_lg),
35            _ => (px(4.), px(12.), px(16.), cx.theme().radius_lg),
36        };
37        Self {
38            top_padding,
39            // Past a third of the height a curve reaches under its neighbor's
40            // hover fill.
41            radius: radius.min(TabVariant::Folder.height(size) / 3.),
42            padding_x,
43            separator_height,
44        }
45    }
46}
47
48/// Tab variants.
49#[derive(Debug, Clone, Default, Copy, PartialEq, Eq, Hash)]
50pub enum TabVariant {
51    #[default]
52    Tab,
53    Outline,
54    Pill,
55    Segmented,
56    Underline,
57    /// Tabs shaped like a folder's index tabs, as in a web browser: the
58    /// selected tab rises from the bar with rounded top corners and curves out
59    /// into the content below it.
60    Folder,
61}
62
63impl TabVariant {
64    fn height(&self, size: Size) -> Pixels {
65        match size {
66            Size::XSmall => match self {
67                TabVariant::Underline => px(26.),
68                _ => px(20.),
69            },
70            Size::Small => match self {
71                TabVariant::Underline => px(30.),
72                _ => px(24.),
73            },
74            Size::Large => match self {
75                TabVariant::Underline => px(44.),
76                _ => px(36.),
77            },
78            _ => match self {
79                TabVariant::Underline => px(36.),
80                _ => px(32.),
81            },
82        }
83    }
84
85    pub(super) fn inner_height(&self, size: Size) -> Pixels {
86        match size {
87            Size::XSmall => match self {
88                TabVariant::Tab | TabVariant::Outline | TabVariant::Pill => px(18.),
89                TabVariant::Segmented | TabVariant::Folder => px(16.),
90                TabVariant::Underline => px(20.),
91            },
92            Size::Small => match self {
93                TabVariant::Tab | TabVariant::Outline | TabVariant::Pill => px(22.),
94                TabVariant::Segmented | TabVariant::Folder => px(18.),
95                TabVariant::Underline => px(22.),
96            },
97            Size::Large => match self {
98                TabVariant::Tab | TabVariant::Outline | TabVariant::Pill => px(36.),
99                TabVariant::Segmented | TabVariant::Folder => px(28.),
100                TabVariant::Underline => px(32.),
101            },
102            _ => match self {
103                TabVariant::Tab => px(30.),
104                TabVariant::Outline | TabVariant::Pill => px(26.),
105                TabVariant::Segmented | TabVariant::Folder => px(24.),
106                TabVariant::Underline => px(26.),
107            },
108        }
109    }
110
111    /// Default px(12) to match a dock tab bar's px_3
112    fn inner_paddings(&self, size: Size) -> Edges<Pixels> {
113        let mut padding_x = match size {
114            Size::XSmall => px(8.),
115            Size::Small => px(10.),
116            Size::Large => px(16.),
117            _ => px(12.),
118        };
119
120        // A folder tab pads its content itself, see `FolderTabMetrics`.
121        if matches!(self, TabVariant::Underline | TabVariant::Folder) {
122            padding_x = px(0.);
123        }
124
125        Edges {
126            left: padding_x,
127            right: padding_x,
128            ..Default::default()
129        }
130    }
131
132    fn inner_margins(&self, size: Size) -> Edges<Pixels> {
133        match size {
134            Size::XSmall => match self {
135                TabVariant::Underline => Edges {
136                    top: px(1.),
137                    bottom: px(2.),
138                    ..Default::default()
139                },
140                _ => Edges::all(px(0.)),
141            },
142            Size::Small => match self {
143                TabVariant::Underline => Edges {
144                    top: px(2.),
145                    bottom: px(3.),
146                    ..Default::default()
147                },
148                _ => Edges::all(px(0.)),
149            },
150            Size::Large => match self {
151                TabVariant::Underline => Edges {
152                    top: px(5.),
153                    bottom: px(6.),
154                    ..Default::default()
155                },
156                _ => Edges::all(px(0.)),
157            },
158            _ => match self {
159                TabVariant::Underline => Edges {
160                    top: px(3.),
161                    bottom: px(4.),
162                    ..Default::default()
163                },
164                _ => Edges::all(px(0.)),
165            },
166        }
167    }
168
169    fn normal(&self, cx: &App) -> TabStyle {
170        match self {
171            TabVariant::Tab => TabStyle {
172                fg: cx.theme().tab_foreground,
173                bg: cx.theme().transparent.into(),
174                borders: Edges {
175                    left: px(1.),
176                    right: px(1.),
177                    ..Default::default()
178                },
179                border_color: cx.theme().transparent,
180                ..Default::default()
181            },
182            TabVariant::Outline => TabStyle {
183                fg: cx.theme().tab_foreground,
184                bg: cx.theme().transparent.into(),
185                borders: Edges::all(px(1.)),
186                border_color: cx.theme().border,
187                ..Default::default()
188            },
189            TabVariant::Pill => TabStyle {
190                fg: cx.theme().foreground,
191                bg: cx.theme().transparent.into(),
192                ..Default::default()
193            },
194            TabVariant::Segmented => TabStyle {
195                fg: cx.theme().tab_foreground,
196                bg: cx.theme().transparent.into(),
197                ..Default::default()
198            },
199            TabVariant::Underline => TabStyle {
200                fg: cx.theme().tab_foreground,
201                bg: cx.theme().transparent.into(),
202                inner_bg: cx.theme().transparent.into(),
203                borders: Edges {
204                    bottom: px(2.),
205                    ..Default::default()
206                },
207                border_color: cx.theme().transparent,
208                ..Default::default()
209            },
210            TabVariant::Folder => TabStyle {
211                fg: cx.theme().tab_foreground,
212                bg: cx.theme().transparent.into(),
213                ..Default::default()
214            },
215        }
216    }
217
218    fn hovered(&self, selected: bool, cx: &App) -> TabStyle {
219        match self {
220            TabVariant::Tab => TabStyle {
221                fg: cx.theme().tab_active_foreground,
222                bg: cx.theme().transparent.into(),
223                borders: Edges {
224                    left: px(1.),
225                    right: px(1.),
226                    ..Default::default()
227                },
228                border_color: cx.theme().transparent,
229                ..Default::default()
230            },
231            TabVariant::Outline => TabStyle {
232                fg: cx.theme().secondary_foreground,
233                bg: cx.theme().tokens.secondary_hover.into(),
234                borders: Edges::all(px(1.)),
235                border_color: cx.theme().border,
236                ..Default::default()
237            },
238            TabVariant::Pill => TabStyle {
239                fg: cx.theme().secondary_foreground,
240                bg: cx.theme().tokens.secondary.into(),
241                ..Default::default()
242            },
243            TabVariant::Segmented => TabStyle {
244                fg: cx.theme().tab_active_foreground,
245                bg: cx.theme().transparent.into(),
246                inner_bg: if selected {
247                    cx.theme().tokens.background.into()
248                } else {
249                    cx.theme().transparent.into()
250                },
251                ..Default::default()
252            },
253            TabVariant::Underline => TabStyle {
254                fg: cx.theme().tab_active_foreground,
255                bg: cx.theme().transparent.into(),
256                inner_bg: cx.theme().transparent.into(),
257                borders: Edges {
258                    bottom: px(2.),
259                    ..Default::default()
260                },
261                border_color: cx.theme().transparent,
262                ..Default::default()
263            },
264            // The hover fill is an inset rounded layer, see `Tab::render`.
265            TabVariant::Folder => TabStyle {
266                fg: cx.theme().tab_active_foreground,
267                bg: cx.theme().transparent.into(),
268                ..Default::default()
269            },
270        }
271    }
272
273    fn selected(&self, cx: &App) -> TabStyle {
274        match self {
275            TabVariant::Tab => TabStyle {
276                fg: cx.theme().tab_active_foreground,
277                bg: cx.theme().tokens.tab_active.into(),
278                borders: Edges {
279                    left: px(1.),
280                    right: px(1.),
281                    ..Default::default()
282                },
283                border_color: cx.theme().border,
284                ..Default::default()
285            },
286            TabVariant::Outline => TabStyle {
287                fg: cx.theme().primary,
288                bg: cx.theme().transparent.into(),
289                borders: Edges::all(px(1.)),
290                border_color: cx.theme().primary,
291                ..Default::default()
292            },
293            TabVariant::Pill => TabStyle {
294                fg: cx.theme().primary_foreground,
295                bg: cx.theme().tokens.primary.into(),
296                ..Default::default()
297            },
298            TabVariant::Segmented => TabStyle {
299                fg: cx.theme().tab_active_foreground,
300                bg: cx.theme().transparent.into(),
301                inner_bg: cx.theme().tokens.background.into(),
302                shadow: true,
303                ..Default::default()
304            },
305            TabVariant::Underline => TabStyle {
306                fg: cx.theme().tab_active_foreground,
307                bg: cx.theme().transparent.into(),
308                borders: Edges {
309                    bottom: px(2.),
310                    ..Default::default()
311                },
312                border_color: cx.theme().primary,
313                ..Default::default()
314            },
315            // The shape is painted by `folder_tab_shape`.
316            TabVariant::Folder => TabStyle {
317                fg: cx.theme().tab_active_foreground,
318                bg: cx.theme().transparent.into(),
319                ..Default::default()
320            },
321        }
322    }
323
324    fn disabled(&self, selected: bool, cx: &App) -> TabStyle {
325        match self {
326            TabVariant::Tab => TabStyle {
327                fg: cx.theme().muted_foreground,
328                bg: cx.theme().transparent.into(),
329                border_color: if selected {
330                    cx.theme().border
331                } else {
332                    cx.theme().transparent
333                },
334                borders: Edges {
335                    left: px(1.),
336                    right: px(1.),
337                    ..Default::default()
338                },
339                ..Default::default()
340            },
341            TabVariant::Outline => TabStyle {
342                fg: cx.theme().muted_foreground,
343                bg: cx.theme().transparent.into(),
344                borders: Edges::all(px(1.)),
345                border_color: if selected {
346                    cx.theme().primary
347                } else {
348                    cx.theme().border
349                },
350                ..Default::default()
351            },
352            TabVariant::Pill => TabStyle {
353                fg: if selected {
354                    cx.theme().primary_foreground.opacity(0.5)
355                } else {
356                    cx.theme().muted_foreground
357                },
358                bg: if selected {
359                    cx.theme().primary.opacity(0.5).into()
360                } else {
361                    cx.theme().transparent.into()
362                },
363                ..Default::default()
364            },
365            TabVariant::Segmented => TabStyle {
366                fg: cx.theme().muted_foreground,
367                bg: cx.theme().tokens.tab_bar.into(),
368                inner_bg: if selected {
369                    cx.theme().tokens.background.into()
370                } else {
371                    cx.theme().transparent.into()
372                },
373                ..Default::default()
374            },
375            TabVariant::Underline => TabStyle {
376                fg: cx.theme().muted_foreground,
377                bg: cx.theme().transparent.into(),
378                border_color: if selected {
379                    cx.theme().border
380                } else {
381                    cx.theme().transparent
382                },
383                borders: Edges {
384                    bottom: px(2.),
385                    ..Default::default()
386                },
387                ..Default::default()
388            },
389            TabVariant::Folder => TabStyle {
390                fg: cx.theme().muted_foreground,
391                bg: cx.theme().transparent.into(),
392                ..Default::default()
393            },
394        }
395    }
396
397    pub(super) fn tab_bar_radius(&self, size: Size, cx: &App) -> Pixels {
398        if *self != TabVariant::Segmented {
399            return px(0.);
400        }
401
402        match size {
403            Size::XSmall | Size::Small => cx.theme().radius,
404            Size::Large => cx.theme().radius_lg,
405            _ => cx.theme().radius_lg,
406        }
407    }
408
409    fn radius(&self, size: Size, cx: &App) -> Pixels {
410        match self {
411            TabVariant::Outline | TabVariant::Pill => cx.theme().radius_full(),
412            TabVariant::Segmented => match size {
413                Size::XSmall | Size::Small => cx.theme().radius,
414                Size::Large => cx.theme().radius_lg,
415                _ => cx.theme().radius_lg,
416            },
417            _ => px(0.),
418        }
419    }
420
421    pub(super) fn inner_radius(&self, size: Size, cx: &App) -> Pixels {
422        match self {
423            // The inset the active pill sits at, taken off the bar's own radius
424            // so the two curves stay concentric. Floored at zero: a square bar
425            // has nothing to inset from.
426            TabVariant::Segmented => match size {
427                Size::Large => (self.tab_bar_radius(size, cx) - px(3.)).max(px(0.)),
428                _ => (self.tab_bar_radius(size, cx) - px(2.)).max(px(0.)),
429            },
430            _ => px(0.),
431        }
432    }
433
434    /// How far a tab's painting reaches past its bounds on either side.
435    pub(super) fn overhang(&self, size: Size, cx: &App) -> Pixels {
436        match self {
437            TabVariant::Folder => FolderTabMetrics::new(size, cx).radius,
438            _ => px(0.),
439        }
440    }
441}
442
443/// The selected [`TabVariant::Folder`] tab: rounded on top and curving out at
444/// the bottom into the content below, traced after Chrome's tab path.
445fn folder_tab_shape(metrics: FolderTabMetrics, bg: Background) -> impl IntoElement {
446    let curve = metrics.radius;
447    canvas(
448        |_, _, _| {},
449        move |bounds, _, window, _| {
450            let (top, bottom) = (bounds.top(), bounds.bottom());
451            let (left, right) = (bounds.left(), bounds.right());
452            let (tab_left, tab_right) = (left + curve, right - curve);
453            // Like Chrome, keep at least a third of the top flat on narrow tabs.
454            let radius = ((bounds.size.width - curve * 2.) / 3.).clamp(px(0.), curve);
455
456            let mut path =
457                PathBuilder::fill().with_style(PathStyle::Fill(FillOptions::tolerance(0.02)));
458            path.move_to(point(left, bottom));
459            quarter_circle_to(
460                &mut path,
461                point(left, bottom),
462                point(tab_left, bottom),
463                point(tab_left, bottom - curve),
464            );
465            path.line_to(point(tab_left, top + radius));
466            quarter_circle_to(
467                &mut path,
468                point(tab_left, top + radius),
469                point(tab_left, top),
470                point(tab_left + radius, top),
471            );
472            path.line_to(point(tab_right - radius, top));
473            quarter_circle_to(
474                &mut path,
475                point(tab_right - radius, top),
476                point(tab_right, top),
477                point(tab_right, top + radius),
478            );
479            path.line_to(point(tab_right, bottom - curve));
480            quarter_circle_to(
481                &mut path,
482                point(tab_right, bottom - curve),
483                point(tab_right, bottom),
484                point(right, bottom),
485            );
486            path.close();
487            if let Ok(path) = path.build() {
488                window.paint_path(path, bg);
489            }
490        },
491    )
492    .absolute()
493    .top_0()
494    .bottom_0()
495    .left(-curve)
496    .right(-curve)
497}
498
499/// Adds a quarter circle from `from` to `to`, whose tangents meet at `corner`.
500fn quarter_circle_to(
501    path: &mut PathBuilder,
502    from: Point<Pixels>,
503    corner: Point<Pixels>,
504    to: Point<Pixels>,
505) {
506    // Control point distance that makes a cubic Bézier a quarter circle.
507    const K: f32 = 0.552_284_8;
508    path.cubic_bezier_to(to, from + (corner - from) * K, to + (corner - to) * K);
509}
510
511#[allow(dead_code)]
512struct TabStyle {
513    borders: Edges<Pixels>,
514    border_color: Hsla,
515    bg: Background,
516    fg: Hsla,
517    shadow: bool,
518    inner_bg: Background,
519}
520
521impl Default for TabStyle {
522    fn default() -> Self {
523        TabStyle {
524            borders: Edges::all(px(0.)),
525            border_color: gpui::transparent_white(),
526            bg: gpui::transparent_white().into(),
527            fg: gpui::transparent_white(),
528            shadow: false,
529            inner_bg: gpui::transparent_white().into(),
530        }
531    }
532}
533
534/// A Tab element for the [`super::TabBar`].
535#[derive(IntoElement)]
536pub struct Tab {
537    ix: usize,
538    base: gpui_base::Tab,
539    pub(super) label: Option<SharedString>,
540    aria_label: Option<SharedString>,
541    pub(super) icon: Option<Icon>,
542    prefix: Option<AnyElement>,
543    pub(super) tab_bar_prefix: Option<bool>,
544    suffix: Option<AnyElement>,
545    children: Vec<AnyElement>,
546    variant: TabVariant,
547    size: Size,
548    pub(super) disabled: bool,
549    pub(super) selected: bool,
550    pub(super) indicator_active: bool,
551    pub(super) indicator_ready: bool,
552    /// Animation epoch of the [`super::TabBar`] indicator; increments on every
553    /// tab switch. Used to key the selected tab's text color fade so it
554    /// restarts in sync with the indicator slide.
555    pub(super) indicator_epoch: u64,
556    /// Whether a [`TabVariant::Folder`] tab draws the divider after it.
557    pub(super) separator: bool,
558    /// Whether the [`super::TabBar`] reports the pointer over this tab. Kept
559    /// there rather than in a hover group, which would replace the caller's.
560    pub(super) hovered: bool,
561    pub(super) max_width: Option<Pixels>,
562    pub(super) on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
563}
564
565impl From<&'static str> for Tab {
566    fn from(label: &'static str) -> Self {
567        Self::new().label(label)
568    }
569}
570
571impl From<String> for Tab {
572    fn from(label: String) -> Self {
573        Self::new().label(label)
574    }
575}
576
577impl From<SharedString> for Tab {
578    fn from(label: SharedString) -> Self {
579        Self::new().label(label)
580    }
581}
582
583impl From<Icon> for Tab {
584    fn from(icon: Icon) -> Self {
585        Self::default().icon(icon)
586    }
587}
588
589impl From<IconName> for Tab {
590    fn from(icon_name: IconName) -> Self {
591        Self::default().icon(Icon::new(icon_name))
592    }
593}
594
595impl Default for Tab {
596    fn default() -> Self {
597        Self {
598            ix: 0,
599            base: gpui_base::Tab::new(0usize),
600            label: None,
601            aria_label: None,
602            icon: None,
603            tab_bar_prefix: None,
604            children: Vec::new(),
605            disabled: false,
606            selected: false,
607            indicator_active: false,
608            indicator_ready: true,
609            indicator_epoch: 0,
610            separator: false,
611            hovered: false,
612            prefix: None,
613            suffix: None,
614            variant: TabVariant::default(),
615            size: Size::default(),
616            max_width: None,
617            on_click: None,
618        }
619    }
620}
621
622impl Tab {
623    /// Create a new tab with a label.
624    pub fn new() -> Self {
625        Self::default()
626    }
627
628    /// Set label for the tab.
629    pub fn label(mut self, label: impl Into<SharedString>) -> Self {
630        self.label = Some(label.into());
631        self
632    }
633
634    /// Set the accessible label for the tab.
635    pub fn aria_label(mut self, label: impl Into<SharedString>) -> Self {
636        self.aria_label = Some(label.into());
637        self
638    }
639
640    fn a11y_label(&self) -> Option<SharedString> {
641        self.aria_label.clone().or_else(|| self.label.clone())
642    }
643
644    /// Set icon for the tab.
645    pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
646        self.icon = Some(icon.into());
647        self
648    }
649
650    /// Set Tab Variant.
651    pub fn with_variant(mut self, variant: TabVariant) -> Self {
652        self.variant = variant;
653        self
654    }
655
656    /// Use Pill variant.
657    pub fn pill(mut self) -> Self {
658        self.variant = TabVariant::Pill;
659        self
660    }
661
662    /// Use outline variant.
663    pub fn outline(mut self) -> Self {
664        self.variant = TabVariant::Outline;
665        self
666    }
667
668    /// Use Segmented variant.
669    pub fn segmented(mut self) -> Self {
670        self.variant = TabVariant::Segmented;
671        self
672    }
673
674    /// Use Underline variant.
675    pub fn underline(mut self) -> Self {
676        self.variant = TabVariant::Underline;
677        self
678    }
679
680    /// Use Folder variant.
681    pub fn folder(mut self) -> Self {
682        self.variant = TabVariant::Folder;
683        self
684    }
685
686    /// Set the left side of the tab
687    pub fn prefix(mut self, prefix: impl IntoElement) -> Self {
688        self.prefix = Some(prefix.into_any_element());
689        self
690    }
691
692    /// Set the right side of the tab
693    pub fn suffix(mut self, suffix: impl IntoElement) -> Self {
694        self.suffix = Some(suffix.into_any_element());
695        self
696    }
697
698    /// Set disabled state to the tab, default false.
699    pub fn disabled(mut self, disabled: bool) -> Self {
700        self.disabled = disabled;
701        self
702    }
703
704    /// Set the click handler for the tab.
705    pub fn on_click(
706        mut self,
707        on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
708    ) -> Self {
709        self.on_click = Some(Rc::new(on_click));
710        self
711    }
712
713    /// Set index to the tab.
714    pub(crate) fn ix(mut self, ix: usize) -> Self {
715        self.ix = ix;
716        self.base = self.base.id(ix);
717        self
718    }
719
720    /// Set if the tab bar has a prefix.
721    pub(crate) fn tab_bar_prefix(mut self, tab_bar_prefix: bool) -> Self {
722        self.tab_bar_prefix = Some(tab_bar_prefix);
723        self
724    }
725
726    /// Set the maximum width of the tab, see [`super::TabBar::max_width`].
727    pub(super) fn max_width(mut self, max_width: Option<Pixels>) -> Self {
728        self.max_width = max_width;
729        self
730    }
731}
732
733impl ParentElement for Tab {
734    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
735        self.children.extend(elements);
736    }
737}
738
739impl Selectable for Tab {
740    fn selected(mut self, selected: bool) -> Self {
741        self.selected = selected;
742        self
743    }
744
745    fn is_selected(&self) -> bool {
746        self.selected
747    }
748}
749
750impl InteractiveElement for Tab {
751    fn interactivity(&mut self) -> &mut gpui::Interactivity {
752        self.base.interactivity()
753    }
754}
755
756impl StatefulInteractiveElement for Tab {}
757
758impl Styled for Tab {
759    fn style(&mut self) -> &mut gpui::StyleRefinement {
760        self.base.style()
761    }
762}
763
764impl Sizable for Tab {
765    fn with_size(mut self, size: impl Into<Size>) -> Self {
766        self.size = size.into();
767        self
768    }
769}
770
771impl RenderOnce for Tab {
772    fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
773        let mut normal_style = self.variant.normal(cx);
774        let mut selected_style = self.variant.selected(cx);
775        let mut disabled_style = self.variant.disabled(self.selected, cx);
776        let mut hover_style = self.variant.hovered(self.selected, cx);
777        if self.disabled {
778            hover_style = self.variant.disabled(self.selected, cx);
779        }
780        let tab_bar_prefix = self.tab_bar_prefix.unwrap_or_default();
781        if !tab_bar_prefix {
782            if self.ix == 0 && self.variant == TabVariant::Tab {
783                normal_style.borders.left = px(0.);
784                selected_style.borders.left = px(0.);
785                disabled_style.borders.left = px(0.);
786                hover_style.borders.left = px(0.);
787            }
788        }
789        let tab_style = if self.disabled {
790            &disabled_style
791        } else if self.selected {
792            &selected_style
793        } else {
794            &normal_style
795        };
796        let radius = self.variant.radius(self.size, cx);
797        let inner_radius = self.variant.inner_radius(self.size, cx);
798        let inner_paddings = self.variant.inner_paddings(self.size);
799        let inner_margins = self.variant.inner_margins(self.size);
800        let inner_height = self.variant.inner_height(self.size);
801        let height = self.variant.height(self.size);
802        let aria_label = self.a11y_label();
803        let folder = self.variant == TabVariant::Folder;
804        let metrics = FolderTabMetrics::new(self.size, cx);
805        let has_prefix = self.prefix.is_some();
806
807        let segmented_indicator_active =
808            self.variant == TabVariant::Segmented && self.indicator_active;
809        let has_inline_inner_bg =
810            self.selected && segmented_indicator_active && !self.indicator_ready;
811        let inline_inner_bg = tab_style.inner_bg;
812        let (inner_bg, hover_inner_bg) = if segmented_indicator_active && self.indicator_ready {
813            (cx.theme().transparent.into(), cx.theme().transparent.into())
814        } else if has_inline_inner_bg {
815            (inline_inner_bg, inline_inner_bg)
816        } else {
817            (tab_style.inner_bg, hover_style.inner_bg)
818        };
819        let inner_shadow = tab_style.shadow && !segmented_indicator_active;
820
821        // When a sliding indicator is active and ready, it alone represents the
822        // selected state. Suppress the selected tab's own active background/border
823        // so the two don't overlap during the switch animation (Segmented already
824        // does this for its `inner_bg` above). Skip disabled tabs so a
825        // disabled-selected tab keeps its dimmed styling instead of the
826        // full-strength indicator color.
827        let suppress_active_visual =
828            self.selected && !self.disabled && self.indicator_active && self.indicator_ready;
829        // Pill paints its active state via the outer `bg`.
830        let selected_outer_bg = if suppress_active_visual && self.variant == TabVariant::Pill {
831            cx.theme().transparent.into()
832        } else {
833            selected_style.bg
834        };
835        // Underline paints its active state via the bottom `border_color`.
836        let selected_outer_border_color =
837            if suppress_active_visual && self.variant == TabVariant::Underline {
838                cx.theme().transparent
839            } else {
840                selected_style.border_color
841            };
842
843        // For Pill, the newly selected tab's text color (`primary_foreground`)
844        // would otherwise snap to white instantly while the indicator is still
845        // sliding into place. Fade it from the normal color in sync with the
846        // indicator slide (keyed on the indicator epoch so it restarts on each
847        // switch). `epoch == 0` is the initial layout (no slide), so we skip it.
848        let animate_fg = self.selected
849            && !self.disabled
850            && self.variant == TabVariant::Pill
851            && self.indicator_active
852            && self.indicator_ready
853            && self.indicator_epoch > 0;
854        let fg_from = self.variant.normal(cx).fg;
855        let fg_to = tab_style.fg;
856        // Icon-only tabs are fixed-size and exempt from `max_width`.
857        let max_width = self.max_width.filter(|_| self.icon.is_none());
858
859        let inner_content = h_flex()
860            .flex_1()
861            .h(inner_height)
862            .line_height(relative(1.25))
863            .whitespace_nowrap()
864            .items_center()
865            .justify_center()
866            .overflow_hidden()
867            .margins(inner_margins)
868            // Normally the label decides the tab width, so it never shrinks. With
869            // `max_width` it is the one part that gives way.
870            .map(|this| match max_width {
871                Some(_) => this.flex_auto(),
872                None => this.flex_shrink_0(),
873            })
874            .map(|this| match self.icon {
875                Some(icon) => this
876                    .w(inner_height * 1.25)
877                    .child(icon.map(|this| match self.size {
878                        Size::XSmall => this.size_2p5(),
879                        Size::Small => this.size_3p5(),
880                        Size::Large => this.size_4(),
881                        _ => this.size_4(),
882                    })),
883                None => this
884                    .paddings(inner_paddings)
885                    .map(|this| match (self.label, max_width) {
886                        // Text always takes its natural width, so it needs a box
887                        // that is allowed to shrink to ellipsize inside of.
888                        (Some(label), Some(_)) => this.child(
889                            div()
890                                .min_w_0()
891                                .whitespace_nowrap()
892                                .text_ellipsis()
893                                .child(label),
894                        ),
895                        (Some(label), None) => this.child(label),
896                        (None, _) => this,
897                    })
898                    .children(self.children)
899                    // With the prefix's gap, 8px between icon and title, as in a Button.
900                    .when(folder && has_prefix, |this| this.pl(px(4.))),
901            })
902            .bg(inner_bg)
903            .rounded(inner_radius)
904            .when(inner_shadow, |this| this.shadow_xs())
905            .hover(|this| this.bg(hover_inner_bg).rounded(inner_radius));
906
907        let inner_element = if animate_fg {
908            inner_content
909                .with_animation(
910                    ElementId::NamedInteger("tab-fg".into(), self.indicator_epoch),
911                    Animation::new(Duration::from_millis(200)).with_easing(ease_in_out_cubic),
912                    move |this, delta| this.text_color(Lerp::lerp(&fg_from, &fg_to, delta)),
913                )
914                .into_any_element()
915        } else {
916            inner_content.into_any_element()
917        };
918
919        self.base
920            .id(self.ix)
921            .selected(self.selected)
922            .disabled(self.disabled)
923            .when_some(aria_label, |this, label| this.accessibility_label(label))
924            .styles(|styles| {
925                styles
926                    .selected(|style| {
927                        style
928                            .text_color(selected_style.fg)
929                            .bg(selected_outer_bg)
930                            .border_l(selected_style.borders.left)
931                            .border_r(selected_style.borders.right)
932                            .border_t(selected_style.borders.top)
933                            .border_b(selected_style.borders.bottom)
934                            .border_color(selected_outer_border_color)
935                    })
936                    .disabled(|style| {
937                        style
938                            .text_color(disabled_style.fg)
939                            .bg(disabled_style.bg)
940                            .border_l(disabled_style.borders.left)
941                            .border_r(disabled_style.borders.right)
942                            .border_t(disabled_style.borders.top)
943                            .border_b(disabled_style.borders.bottom)
944                            .border_color(disabled_style.border_color)
945                    })
946            })
947            .relative()
948            .flex()
949            // Wrapping would move the overflow onto a clipped second line instead
950            // of letting the label shrink, so a capped tab lays out on one line.
951            .map(|this| match max_width {
952                Some(max_width) => this.flex_nowrap().max_w(max_width),
953                None => this.flex_wrap(),
954            })
955            .gap_1()
956            .items_center()
957            .flex_shrink_0()
958            .h(height)
959            // A folder tab's curves reach past its bounds.
960            .when(!folder, |this| this.overflow_hidden())
961            .map(|this| match self.size {
962                Size::XSmall => this.text_xs(),
963                Size::Large => this.text_base(),
964                _ => this.text_sm(),
965            })
966            .rounded(radius)
967            .when(folder, |this| {
968                this.px(metrics.padding_x).pb(metrics.top_padding)
969            })
970            .when(!self.selected && !self.disabled, |this| {
971                this.text_color(normal_style.fg)
972                    .bg(normal_style.bg)
973                    .border_l(normal_style.borders.left)
974                    .border_r(normal_style.borders.right)
975                    .border_t(normal_style.borders.top)
976                    .border_b(normal_style.borders.bottom)
977                    .border_color(normal_style.border_color)
978            })
979            .hover(|this| {
980                // Always register the hover style: GPUI only refreshes the cached
981                // hover state while one is present. If the selected tab skipped it,
982                // the stale state would keep hover colors after unselecting.
983                if self.selected || self.disabled {
984                    return this;
985                }
986                this.text_color(hover_style.fg)
987                    .bg(hover_style.bg)
988                    .border_l(hover_style.borders.left)
989                    .border_r(hover_style.borders.right)
990                    .border_t(hover_style.borders.top)
991                    .border_b(hover_style.borders.bottom)
992                    .border_color(hover_style.border_color)
993                    .rounded(radius)
994            })
995            .when(folder && self.selected, |this| {
996                this.child(folder_tab_shape(
997                    metrics,
998                    cx.theme().tokens.tab_active.into(),
999                ))
1000            })
1001            .when(
1002                folder && self.hovered && !self.selected && !self.disabled,
1003                |this| {
1004                    this.child(
1005                        div()
1006                            .absolute()
1007                            .top_0()
1008                            .left_0()
1009                            .right_0()
1010                            .bottom(metrics.top_padding)
1011                            .rounded(metrics.radius)
1012                            .bg(cx.theme().tokens.secondary),
1013                    )
1014                },
1015            )
1016            // In the gap after the tab, on a whole pixel, level with the content.
1017            .when(folder && self.separator, |this| {
1018                this.child(
1019                    div()
1020                        .absolute()
1021                        .right(-(FOLDER_TAB_GAP / 2.))
1022                        .top((height - metrics.top_padding - metrics.separator_height) / 2.)
1023                        .w(px(1.))
1024                        .h(metrics.separator_height)
1025                        .bg(cx.theme().border),
1026                )
1027            })
1028            .when(has_inline_inner_bg, |this| {
1029                this.child(
1030                    div()
1031                        .absolute()
1032                        .left_0()
1033                        .right_0()
1034                        .top_0()
1035                        .bottom_0()
1036                        .flex()
1037                        .items_center()
1038                        .child(
1039                            div()
1040                                .w_full()
1041                                .h(inner_height)
1042                                .bg(inline_inner_bg)
1043                                .rounded(inner_radius)
1044                                .when(tab_style.shadow, |this| this.shadow_sm()),
1045                        ),
1046                )
1047            })
1048            // Under `max_width` the label is the only part that gives way, so
1049            // hold the prefix and suffix (e.g. a close button) at their full size.
1050            .when_some(self.prefix, |this, prefix| {
1051                this.child(
1052                    div()
1053                        .when_some(max_width, |this, _| this.flex_shrink_0())
1054                        .child(prefix),
1055                )
1056            })
1057            .child(inner_element)
1058            .when_some(self.suffix, |this, suffix| {
1059                this.child(
1060                    div()
1061                        .when_some(max_width, |this, _| this.flex_shrink_0())
1062                        .child(suffix),
1063                )
1064            })
1065            .when_some(self.on_click.clone(), |this, on_click| {
1066                this.on_click(move |event, window, cx| on_click(event, window, cx))
1067            })
1068    }
1069}
1070
1071#[cfg(test)]
1072mod tests {
1073    use super::*;
1074    use crate::tab::TabBar;
1075    use gpui::{Context, Render, TestAppContext, VisualTestContext};
1076
1077    const VARIANTS: [TabVariant; 6] = [
1078        TabVariant::Tab,
1079        TabVariant::Outline,
1080        TabVariant::Pill,
1081        TabVariant::Segmented,
1082        TabVariant::Underline,
1083        TabVariant::Folder,
1084    ];
1085
1086    const LONG_LABEL: &str = "Account Settings & Preferences";
1087
1088    /// One [`TabBar`], optionally capped, holding the tab `build` returns.
1089    struct TabBarTest {
1090        variant: TabVariant,
1091        max_width: Option<Pixels>,
1092        build: fn(Tab) -> Tab,
1093    }
1094
1095    impl Render for TabBarTest {
1096        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1097            TabBar::new("tabs")
1098                .with_variant(self.variant)
1099                .selected_index(0)
1100                .when_some(self.max_width, |this, width| this.max_width(width))
1101                .child((self.build)(
1102                    Tab::new().debug_selector(|| "tab".to_string()),
1103                ))
1104        }
1105    }
1106
1107    fn show(
1108        cx: &mut TestAppContext,
1109        variant: TabVariant,
1110        max_width: Option<Pixels>,
1111        build: fn(Tab) -> Tab,
1112    ) -> &mut VisualTestContext {
1113        cx.update(crate::init);
1114        let (_, cx) = cx.add_window_view(|_, _| TabBarTest {
1115            variant,
1116            max_width,
1117            build,
1118        });
1119        cx.run_until_parked();
1120        cx
1121    }
1122
1123    /// Outer width of a labelled tab in every variant, capped or not.
1124    fn variant_widths(
1125        cx: &mut TestAppContext,
1126        build: fn(Tab) -> Tab,
1127        max_width: Option<Pixels>,
1128    ) -> Vec<Pixels> {
1129        VARIANTS
1130            .into_iter()
1131            .map(|variant| {
1132                show(cx, variant, max_width, build)
1133                    .debug_bounds("tab")
1134                    .expect("tab not rendered")
1135                    .size
1136                    .width
1137            })
1138            .collect()
1139    }
1140
1141    #[gpui::test]
1142    fn a11y_label_defaults_to_visible_label(_cx: &mut TestAppContext) {
1143        let tab = Tab::new().label("Account");
1144
1145        assert_eq!(tab.a11y_label(), Some("Account".into()));
1146    }
1147
1148    #[gpui::test]
1149    fn explicit_a11y_label_overrides_visible_label(_cx: &mut TestAppContext) {
1150        let tab = Tab::new().label("Acct").aria_label("Account settings");
1151
1152        assert_eq!(tab.a11y_label(), Some("Account settings".into()));
1153    }
1154
1155    #[gpui::test]
1156    fn max_width_leaves_short_tabs_untouched(cx: &mut TestAppContext) {
1157        // The box the cap wraps the label in must not report a different
1158        // intrinsic width than the bare label it replaces.
1159        let build: fn(Tab) -> Tab = |tab| tab.label("Go");
1160
1161        let uncapped = variant_widths(cx, build, None);
1162        let capped = variant_widths(cx, build, Some(px(200.)));
1163
1164        assert_eq!(uncapped, capped, "a tab under the cap must not be resized");
1165    }
1166
1167    #[gpui::test]
1168    fn max_width_caps_long_tabs(cx: &mut TestAppContext) {
1169        let build: fn(Tab) -> Tab = |tab| tab.label(LONG_LABEL);
1170
1171        let uncapped = variant_widths(cx, build, None);
1172        let capped = variant_widths(cx, build, Some(px(120.)));
1173
1174        for (variant, (uncapped, capped)) in
1175            VARIANTS.into_iter().zip(uncapped.into_iter().zip(capped))
1176        {
1177            assert!(
1178                uncapped > px(120.),
1179                "{variant:?} is not long enough to exercise the cap ({uncapped:?})"
1180            );
1181            assert!(
1182                capped <= px(120.),
1183                "{variant:?} width {capped:?} exceeds max_width"
1184            );
1185        }
1186    }
1187
1188    #[gpui::test]
1189    fn max_width_keeps_prefix_and_suffix_intact(cx: &mut TestAppContext) {
1190        let cx = show(cx, TabVariant::Segmented, Some(px(140.)), |tab| {
1191            tab.prefix(Icon::new(IconName::BookOpen))
1192                .label(LONG_LABEL)
1193                .suffix(div().size(px(16.)).debug_selector(|| "suffix".to_string()))
1194        });
1195
1196        let tab = cx.debug_bounds("tab").expect("tab not rendered");
1197        let suffix = cx.debug_bounds("suffix").expect("suffix not rendered");
1198
1199        assert!(tab.size.width <= px(140.));
1200        assert_eq!(
1201            suffix.size.width,
1202            px(16.),
1203            "the label should absorb the truncation, not the suffix"
1204        );
1205        assert!(
1206            suffix.right() <= tab.right(),
1207            "suffix must stay within the tab"
1208        );
1209        // A wrapping tab pushes the suffix onto a second line, which the fixed
1210        // tab height then clips: it keeps its size and stays inside the tab,
1211        // but lands back at the left edge instead of after the label.
1212        assert!(
1213            suffix.left() > tab.center().x,
1214            "suffix must follow the label, not wrap below it"
1215        );
1216    }
1217
1218    /// Icon-only tabs are sized to a square by construction, so the cap has to
1219    /// leave them alone however narrow it is.
1220    #[gpui::test]
1221    fn max_width_exempts_icon_only_tabs(cx: &mut TestAppContext) {
1222        let build: fn(Tab) -> Tab = |tab| tab.icon(Icon::new(IconName::BookOpen));
1223        let width = |cx: &mut TestAppContext, max_width| {
1224            show(cx, TabVariant::Tab, max_width, build)
1225                .debug_bounds("tab")
1226                .expect("tab not rendered")
1227                .size
1228                .width
1229        };
1230
1231        let uncapped = width(cx, None);
1232        assert!(
1233            uncapped > px(16.),
1234            "the cap has to be narrower than the icon tab to be meaningful"
1235        );
1236        assert_eq!(
1237            width(cx, Some(px(16.))),
1238            uncapped,
1239            "an icon-only tab must ignore max_width"
1240        );
1241    }
1242}