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#[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 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 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#[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 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 pub fn new() -> Self {
479 Self::default()
480 }
481
482 pub fn label(mut self, label: impl Into<SharedString>) -> Self {
484 self.label = Some(label.into());
485 self
486 }
487
488 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 pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
500 self.icon = Some(icon.into());
501 self
502 }
503
504 pub fn with_variant(mut self, variant: TabVariant) -> Self {
506 self.variant = variant;
507 self
508 }
509
510 pub fn pill(mut self) -> Self {
512 self.variant = TabVariant::Pill;
513 self
514 }
515
516 pub fn outline(mut self) -> Self {
518 self.variant = TabVariant::Outline;
519 self
520 }
521
522 pub fn segmented(mut self) -> Self {
524 self.variant = TabVariant::Segmented;
525 self
526 }
527
528 pub fn underline(mut self) -> Self {
530 self.variant = TabVariant::Underline;
531 self
532 }
533
534 pub fn prefix(mut self, prefix: impl IntoElement) -> Self {
536 self.prefix = Some(prefix.into_any_element());
537 self
538 }
539
540 pub fn suffix(mut self, suffix: impl IntoElement) -> Self {
542 self.suffix = Some(suffix.into_any_element());
543 self
544 }
545
546 pub fn disabled(mut self, disabled: bool) -> Self {
548 self.disabled = disabled;
549 self
550 }
551
552 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 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 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 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 let suppress_active_visual =
673 self.selected && !self.disabled && self.indicator_active && self.indicator_ready;
674 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 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 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 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 .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 (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 .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 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 .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 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 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 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 assert!(
1018 suffix.left() > tab.center().x,
1019 "suffix must follow the label, not wrap below it"
1020 );
1021 }
1022
1023 #[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}