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