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
13pub(super) const FOLDER_TAB_GAP: Pixels = px(4.);
15
16#[derive(Clone, Copy)]
18pub(super) struct FolderTabMetrics {
19 pub(super) top_padding: Pixels,
22 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 radius: radius.min(TabVariant::Folder.height(size) / 3.),
42 padding_x,
43 separator_height,
44 }
45 }
46}
47
48#[derive(Debug, Clone, Default, Copy, PartialEq, Eq, Hash)]
50pub enum TabVariant {
51 #[default]
52 Tab,
53 Outline,
54 Pill,
55 Segmented,
56 Underline,
57 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 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 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 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 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 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 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
443fn 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 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
499fn quarter_circle_to(
501 path: &mut PathBuilder,
502 from: Point<Pixels>,
503 corner: Point<Pixels>,
504 to: Point<Pixels>,
505) {
506 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#[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 pub(super) indicator_epoch: u64,
556 pub(super) separator: bool,
558 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 pub fn new() -> Self {
625 Self::default()
626 }
627
628 pub fn label(mut self, label: impl Into<SharedString>) -> Self {
630 self.label = Some(label.into());
631 self
632 }
633
634 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 pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
646 self.icon = Some(icon.into());
647 self
648 }
649
650 pub fn with_variant(mut self, variant: TabVariant) -> Self {
652 self.variant = variant;
653 self
654 }
655
656 pub fn pill(mut self) -> Self {
658 self.variant = TabVariant::Pill;
659 self
660 }
661
662 pub fn outline(mut self) -> Self {
664 self.variant = TabVariant::Outline;
665 self
666 }
667
668 pub fn segmented(mut self) -> Self {
670 self.variant = TabVariant::Segmented;
671 self
672 }
673
674 pub fn underline(mut self) -> Self {
676 self.variant = TabVariant::Underline;
677 self
678 }
679
680 pub fn folder(mut self) -> Self {
682 self.variant = TabVariant::Folder;
683 self
684 }
685
686 pub fn prefix(mut self, prefix: impl IntoElement) -> Self {
688 self.prefix = Some(prefix.into_any_element());
689 self
690 }
691
692 pub fn suffix(mut self, suffix: impl IntoElement) -> Self {
694 self.suffix = Some(suffix.into_any_element());
695 self
696 }
697
698 pub fn disabled(mut self, disabled: bool) -> Self {
700 self.disabled = disabled;
701 self
702 }
703
704 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 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 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 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 let suppress_active_visual =
828 self.selected && !self.disabled && self.indicator_active && self.indicator_ready;
829 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 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 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 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 .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
878 .w(inner_height * 1.25)
879 .min_w(inner_height * 1.25)
880 .child(icon.map(|this| match self.size {
881 Size::XSmall => this.size_2p5(),
882 Size::Small => this.size_3p5(),
883 Size::Large => this.size_4(),
884 _ => this.size_4(),
885 })),
886 None => this
887 .paddings(inner_paddings)
888 .map(|this| match (self.label, max_width) {
889 (Some(label), Some(_)) => this.child(
892 div()
893 .min_w_0()
894 .whitespace_nowrap()
895 .text_ellipsis()
896 .child(label),
897 ),
898 (Some(label), None) => this.child(label),
899 (None, _) => this,
900 })
901 .children(self.children)
902 .when(folder && has_prefix, |this| this.pl(px(4.))),
904 })
905 .bg(inner_bg)
906 .rounded(inner_radius)
907 .when(inner_shadow, |this| this.shadow_xs())
908 .hover(|this| this.bg(hover_inner_bg).rounded(inner_radius));
909
910 let inner_element = if animate_fg {
911 inner_content
912 .with_animation(
913 ElementId::NamedInteger("tab-fg".into(), self.indicator_epoch),
914 Animation::new(Duration::from_millis(200)).with_easing(ease_in_out_cubic),
915 move |this, delta| this.text_color(Lerp::lerp(&fg_from, &fg_to, delta)),
916 )
917 .into_any_element()
918 } else {
919 inner_content.into_any_element()
920 };
921
922 self.base
923 .id(self.ix)
924 .selected(self.selected)
925 .disabled(self.disabled)
926 .when_some(aria_label, |this, label| this.accessibility_label(label))
927 .styles(|styles| {
928 styles
929 .selected(|style| {
930 style
931 .text_color(selected_style.fg)
932 .bg(selected_outer_bg)
933 .border_l(selected_style.borders.left)
934 .border_r(selected_style.borders.right)
935 .border_t(selected_style.borders.top)
936 .border_b(selected_style.borders.bottom)
937 .border_color(selected_outer_border_color)
938 })
939 .disabled(|style| {
940 style
941 .text_color(disabled_style.fg)
942 .bg(disabled_style.bg)
943 .border_l(disabled_style.borders.left)
944 .border_r(disabled_style.borders.right)
945 .border_t(disabled_style.borders.top)
946 .border_b(disabled_style.borders.bottom)
947 .border_color(disabled_style.border_color)
948 })
949 })
950 .relative()
951 .flex()
952 .map(|this| match max_width {
955 Some(max_width) => this.flex_nowrap().max_w(max_width),
956 None => this.flex_wrap(),
957 })
958 .gap_1()
959 .items_center()
960 .flex_shrink_0()
961 .h(height)
962 .when(!folder, |this| this.overflow_hidden())
964 .map(|this| match self.size {
965 Size::XSmall => this.text_xs(),
966 Size::Large => this.text_base(),
967 _ => this.text_sm(),
968 })
969 .rounded(radius)
970 .when(folder, |this| {
971 this.px(metrics.padding_x).pb(metrics.top_padding)
972 })
973 .when(!self.selected && !self.disabled, |this| {
974 this.text_color(normal_style.fg)
975 .bg(normal_style.bg)
976 .border_l(normal_style.borders.left)
977 .border_r(normal_style.borders.right)
978 .border_t(normal_style.borders.top)
979 .border_b(normal_style.borders.bottom)
980 .border_color(normal_style.border_color)
981 })
982 .hover(|this| {
983 if self.selected || self.disabled {
987 return this;
988 }
989 this.text_color(hover_style.fg)
990 .bg(hover_style.bg)
991 .border_l(hover_style.borders.left)
992 .border_r(hover_style.borders.right)
993 .border_t(hover_style.borders.top)
994 .border_b(hover_style.borders.bottom)
995 .border_color(hover_style.border_color)
996 .rounded(radius)
997 })
998 .when(folder && self.selected, |this| {
999 this.child(folder_tab_shape(
1000 metrics,
1001 cx.theme().tokens.tab_active.into(),
1002 ))
1003 })
1004 .when(
1005 folder && self.hovered && !self.selected && !self.disabled,
1006 |this| {
1007 this.child(
1008 div()
1009 .absolute()
1010 .top_0()
1011 .left_0()
1012 .right_0()
1013 .bottom(metrics.top_padding)
1014 .rounded(metrics.radius)
1015 .bg(cx.theme().tokens.secondary),
1016 )
1017 },
1018 )
1019 .when(folder && self.separator, |this| {
1021 this.child(
1022 div()
1023 .absolute()
1024 .right(-(FOLDER_TAB_GAP / 2.))
1025 .top((height - metrics.top_padding - metrics.separator_height) / 2.)
1026 .w(px(1.))
1027 .h(metrics.separator_height)
1028 .bg(cx.theme().border),
1029 )
1030 })
1031 .when(has_inline_inner_bg, |this| {
1032 this.child(
1033 div()
1034 .absolute()
1035 .left_0()
1036 .right_0()
1037 .top_0()
1038 .bottom_0()
1039 .flex()
1040 .items_center()
1041 .child(
1042 div()
1043 .w_full()
1044 .h(inner_height)
1045 .bg(inline_inner_bg)
1046 .rounded(inner_radius)
1047 .when(tab_style.shadow, |this| this.shadow_sm()),
1048 ),
1049 )
1050 })
1051 .when_some(self.prefix, |this, prefix| {
1054 this.child(
1055 div()
1056 .when_some(max_width, |this, _| this.flex_shrink_0())
1057 .child(prefix),
1058 )
1059 })
1060 .child(inner_element)
1061 .when_some(self.suffix, |this, suffix| {
1062 this.child(
1063 div()
1064 .when_some(max_width, |this, _| this.flex_shrink_0())
1065 .child(suffix),
1066 )
1067 })
1068 .when_some(self.on_click.clone(), |this, on_click| {
1069 this.on_click(move |event, window, cx| on_click(event, window, cx))
1070 })
1071 }
1072}
1073
1074#[cfg(test)]
1075mod tests {
1076 use super::*;
1077 use crate::tab::TabBar;
1078 use gpui::{Context, Render, TestAppContext, VisualTestContext};
1079
1080 const VARIANTS: [TabVariant; 6] = [
1081 TabVariant::Tab,
1082 TabVariant::Outline,
1083 TabVariant::Pill,
1084 TabVariant::Segmented,
1085 TabVariant::Underline,
1086 TabVariant::Folder,
1087 ];
1088
1089 const LONG_LABEL: &str = "Account Settings & Preferences";
1090
1091 struct TabBarTest {
1093 variant: TabVariant,
1094 max_width: Option<Pixels>,
1095 build: fn(Tab) -> Tab,
1096 }
1097
1098 impl Render for TabBarTest {
1099 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1100 TabBar::new("tabs")
1101 .with_variant(self.variant)
1102 .selected_index(0)
1103 .when_some(self.max_width, |this, width| this.max_width(width))
1104 .child((self.build)(
1105 Tab::new().debug_selector(|| "tab".to_string()),
1106 ))
1107 }
1108 }
1109
1110 fn show(
1111 cx: &mut TestAppContext,
1112 variant: TabVariant,
1113 max_width: Option<Pixels>,
1114 build: fn(Tab) -> Tab,
1115 ) -> &mut VisualTestContext {
1116 cx.update(crate::init);
1117 let (_, cx) = cx.add_window_view(|_, _| TabBarTest {
1118 variant,
1119 max_width,
1120 build,
1121 });
1122 cx.run_until_parked();
1123 cx
1124 }
1125
1126 fn variant_widths(
1128 cx: &mut TestAppContext,
1129 build: fn(Tab) -> Tab,
1130 max_width: Option<Pixels>,
1131 ) -> Vec<Pixels> {
1132 VARIANTS
1133 .into_iter()
1134 .map(|variant| {
1135 show(cx, variant, max_width, build)
1136 .debug_bounds("tab")
1137 .expect("tab not rendered")
1138 .size
1139 .width
1140 })
1141 .collect()
1142 }
1143
1144 #[gpui::test]
1145 fn a11y_label_defaults_to_visible_label(_cx: &mut TestAppContext) {
1146 let tab = Tab::new().label("Account");
1147
1148 assert_eq!(tab.a11y_label(), Some("Account".into()));
1149 }
1150
1151 #[gpui::test]
1152 fn explicit_a11y_label_overrides_visible_label(_cx: &mut TestAppContext) {
1153 let tab = Tab::new().label("Acct").aria_label("Account settings");
1154
1155 assert_eq!(tab.a11y_label(), Some("Account settings".into()));
1156 }
1157
1158 #[gpui::test]
1159 fn max_width_leaves_short_tabs_untouched(cx: &mut TestAppContext) {
1160 let build: fn(Tab) -> Tab = |tab| tab.label("Go");
1163
1164 let uncapped = variant_widths(cx, build, None);
1165 let capped = variant_widths(cx, build, Some(px(200.)));
1166
1167 assert_eq!(uncapped, capped, "a tab under the cap must not be resized");
1168 }
1169
1170 #[gpui::test]
1171 fn max_width_caps_long_tabs(cx: &mut TestAppContext) {
1172 let build: fn(Tab) -> Tab = |tab| tab.label(LONG_LABEL);
1173
1174 let uncapped = variant_widths(cx, build, None);
1175 let capped = variant_widths(cx, build, Some(px(120.)));
1176
1177 for (variant, (uncapped, capped)) in
1178 VARIANTS.into_iter().zip(uncapped.into_iter().zip(capped))
1179 {
1180 assert!(
1181 uncapped > px(120.),
1182 "{variant:?} is not long enough to exercise the cap ({uncapped:?})"
1183 );
1184 assert!(
1185 capped <= px(120.),
1186 "{variant:?} width {capped:?} exceeds max_width"
1187 );
1188 }
1189 }
1190
1191 #[gpui::test]
1192 fn max_width_keeps_prefix_and_suffix_intact(cx: &mut TestAppContext) {
1193 let cx = show(cx, TabVariant::Segmented, Some(px(140.)), |tab| {
1194 tab.prefix(Icon::new(IconName::BookOpen))
1195 .label(LONG_LABEL)
1196 .suffix(div().size(px(16.)).debug_selector(|| "suffix".to_string()))
1197 });
1198
1199 let tab = cx.debug_bounds("tab").expect("tab not rendered");
1200 let suffix = cx.debug_bounds("suffix").expect("suffix not rendered");
1201
1202 assert!(tab.size.width <= px(140.));
1203 assert_eq!(
1204 suffix.size.width,
1205 px(16.),
1206 "the label should absorb the truncation, not the suffix"
1207 );
1208 assert!(
1209 suffix.right() <= tab.right(),
1210 "suffix must stay within the tab"
1211 );
1212 assert!(
1216 suffix.left() > tab.center().x,
1217 "suffix must follow the label, not wrap below it"
1218 );
1219 }
1220
1221 #[gpui::test]
1224 fn max_width_exempts_icon_only_tabs(cx: &mut TestAppContext) {
1225 let build: fn(Tab) -> Tab = |tab| tab.icon(Icon::new(IconName::BookOpen));
1226 let width = |cx: &mut TestAppContext, max_width| {
1227 show(cx, TabVariant::Tab, max_width, build)
1228 .debug_bounds("tab")
1229 .expect("tab not rendered")
1230 .size
1231 .width
1232 };
1233
1234 let uncapped = width(cx, None);
1235 assert!(
1236 uncapped > px(16.),
1237 "the cap has to be narrower than the icon tab to be meaningful"
1238 );
1239 assert_eq!(
1240 width(cx, Some(px(16.))),
1241 uncapped,
1242 "an icon-only tab must ignore max_width"
1243 );
1244 }
1245
1246 #[gpui::test]
1249 fn icon_only_tabs_keep_their_width(cx: &mut TestAppContext) {
1250 let build: fn(Tab) -> Tab = |tab| tab.icon(Icon::new(IconName::BookOpen));
1251 for variant in VARIANTS {
1252 let width = show(cx, variant, None, build)
1253 .debug_bounds("tab")
1254 .expect("tab not rendered")
1255 .size
1256 .width;
1257 let content = variant.inner_height(Size::default()) * 1.25;
1258 assert!(
1259 width >= content,
1260 "{variant:?}: icon-only tab is {width:?}, narrower than its {content:?} content"
1261 );
1262 }
1263 }
1264}