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
876 .w(inner_height * 1.25)
877 .child(icon.map(|this| match self.size {
878 Size::XSmall => this.size_2p5(),
879 Size::Small => this.size_3p5(),
880 Size::Large => this.size_4(),
881 _ => this.size_4(),
882 })),
883 None => this
884 .paddings(inner_paddings)
885 .map(|this| match (self.label, max_width) {
886 (Some(label), Some(_)) => this.child(
889 div()
890 .min_w_0()
891 .whitespace_nowrap()
892 .text_ellipsis()
893 .child(label),
894 ),
895 (Some(label), None) => this.child(label),
896 (None, _) => this,
897 })
898 .children(self.children)
899 .when(folder && has_prefix, |this| this.pl(px(4.))),
901 })
902 .bg(inner_bg)
903 .rounded(inner_radius)
904 .when(inner_shadow, |this| this.shadow_xs())
905 .hover(|this| this.bg(hover_inner_bg).rounded(inner_radius));
906
907 let inner_element = if animate_fg {
908 inner_content
909 .with_animation(
910 ElementId::NamedInteger("tab-fg".into(), self.indicator_epoch),
911 Animation::new(Duration::from_millis(200)).with_easing(ease_in_out_cubic),
912 move |this, delta| this.text_color(Lerp::lerp(&fg_from, &fg_to, delta)),
913 )
914 .into_any_element()
915 } else {
916 inner_content.into_any_element()
917 };
918
919 self.base
920 .id(self.ix)
921 .selected(self.selected)
922 .disabled(self.disabled)
923 .when_some(aria_label, |this, label| this.accessibility_label(label))
924 .styles(|styles| {
925 styles
926 .selected(|style| {
927 style
928 .text_color(selected_style.fg)
929 .bg(selected_outer_bg)
930 .border_l(selected_style.borders.left)
931 .border_r(selected_style.borders.right)
932 .border_t(selected_style.borders.top)
933 .border_b(selected_style.borders.bottom)
934 .border_color(selected_outer_border_color)
935 })
936 .disabled(|style| {
937 style
938 .text_color(disabled_style.fg)
939 .bg(disabled_style.bg)
940 .border_l(disabled_style.borders.left)
941 .border_r(disabled_style.borders.right)
942 .border_t(disabled_style.borders.top)
943 .border_b(disabled_style.borders.bottom)
944 .border_color(disabled_style.border_color)
945 })
946 })
947 .relative()
948 .flex()
949 .map(|this| match max_width {
952 Some(max_width) => this.flex_nowrap().max_w(max_width),
953 None => this.flex_wrap(),
954 })
955 .gap_1()
956 .items_center()
957 .flex_shrink_0()
958 .h(height)
959 .when(!folder, |this| this.overflow_hidden())
961 .map(|this| match self.size {
962 Size::XSmall => this.text_xs(),
963 Size::Large => this.text_base(),
964 _ => this.text_sm(),
965 })
966 .rounded(radius)
967 .when(folder, |this| {
968 this.px(metrics.padding_x).pb(metrics.top_padding)
969 })
970 .when(!self.selected && !self.disabled, |this| {
971 this.text_color(normal_style.fg)
972 .bg(normal_style.bg)
973 .border_l(normal_style.borders.left)
974 .border_r(normal_style.borders.right)
975 .border_t(normal_style.borders.top)
976 .border_b(normal_style.borders.bottom)
977 .border_color(normal_style.border_color)
978 })
979 .hover(|this| {
980 if self.selected || self.disabled {
984 return this;
985 }
986 this.text_color(hover_style.fg)
987 .bg(hover_style.bg)
988 .border_l(hover_style.borders.left)
989 .border_r(hover_style.borders.right)
990 .border_t(hover_style.borders.top)
991 .border_b(hover_style.borders.bottom)
992 .border_color(hover_style.border_color)
993 .rounded(radius)
994 })
995 .when(folder && self.selected, |this| {
996 this.child(folder_tab_shape(
997 metrics,
998 cx.theme().tokens.tab_active.into(),
999 ))
1000 })
1001 .when(
1002 folder && self.hovered && !self.selected && !self.disabled,
1003 |this| {
1004 this.child(
1005 div()
1006 .absolute()
1007 .top_0()
1008 .left_0()
1009 .right_0()
1010 .bottom(metrics.top_padding)
1011 .rounded(metrics.radius)
1012 .bg(cx.theme().tokens.secondary),
1013 )
1014 },
1015 )
1016 .when(folder && self.separator, |this| {
1018 this.child(
1019 div()
1020 .absolute()
1021 .right(-(FOLDER_TAB_GAP / 2.))
1022 .top((height - metrics.top_padding - metrics.separator_height) / 2.)
1023 .w(px(1.))
1024 .h(metrics.separator_height)
1025 .bg(cx.theme().border),
1026 )
1027 })
1028 .when(has_inline_inner_bg, |this| {
1029 this.child(
1030 div()
1031 .absolute()
1032 .left_0()
1033 .right_0()
1034 .top_0()
1035 .bottom_0()
1036 .flex()
1037 .items_center()
1038 .child(
1039 div()
1040 .w_full()
1041 .h(inner_height)
1042 .bg(inline_inner_bg)
1043 .rounded(inner_radius)
1044 .when(tab_style.shadow, |this| this.shadow_sm()),
1045 ),
1046 )
1047 })
1048 .when_some(self.prefix, |this, prefix| {
1051 this.child(
1052 div()
1053 .when_some(max_width, |this, _| this.flex_shrink_0())
1054 .child(prefix),
1055 )
1056 })
1057 .child(inner_element)
1058 .when_some(self.suffix, |this, suffix| {
1059 this.child(
1060 div()
1061 .when_some(max_width, |this, _| this.flex_shrink_0())
1062 .child(suffix),
1063 )
1064 })
1065 .when_some(self.on_click.clone(), |this, on_click| {
1066 this.on_click(move |event, window, cx| on_click(event, window, cx))
1067 })
1068 }
1069}
1070
1071#[cfg(test)]
1072mod tests {
1073 use super::*;
1074 use crate::tab::TabBar;
1075 use gpui::{Context, Render, TestAppContext, VisualTestContext};
1076
1077 const VARIANTS: [TabVariant; 6] = [
1078 TabVariant::Tab,
1079 TabVariant::Outline,
1080 TabVariant::Pill,
1081 TabVariant::Segmented,
1082 TabVariant::Underline,
1083 TabVariant::Folder,
1084 ];
1085
1086 const LONG_LABEL: &str = "Account Settings & Preferences";
1087
1088 struct TabBarTest {
1090 variant: TabVariant,
1091 max_width: Option<Pixels>,
1092 build: fn(Tab) -> Tab,
1093 }
1094
1095 impl Render for TabBarTest {
1096 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1097 TabBar::new("tabs")
1098 .with_variant(self.variant)
1099 .selected_index(0)
1100 .when_some(self.max_width, |this, width| this.max_width(width))
1101 .child((self.build)(
1102 Tab::new().debug_selector(|| "tab".to_string()),
1103 ))
1104 }
1105 }
1106
1107 fn show(
1108 cx: &mut TestAppContext,
1109 variant: TabVariant,
1110 max_width: Option<Pixels>,
1111 build: fn(Tab) -> Tab,
1112 ) -> &mut VisualTestContext {
1113 cx.update(crate::init);
1114 let (_, cx) = cx.add_window_view(|_, _| TabBarTest {
1115 variant,
1116 max_width,
1117 build,
1118 });
1119 cx.run_until_parked();
1120 cx
1121 }
1122
1123 fn variant_widths(
1125 cx: &mut TestAppContext,
1126 build: fn(Tab) -> Tab,
1127 max_width: Option<Pixels>,
1128 ) -> Vec<Pixels> {
1129 VARIANTS
1130 .into_iter()
1131 .map(|variant| {
1132 show(cx, variant, max_width, build)
1133 .debug_bounds("tab")
1134 .expect("tab not rendered")
1135 .size
1136 .width
1137 })
1138 .collect()
1139 }
1140
1141 #[gpui::test]
1142 fn a11y_label_defaults_to_visible_label(_cx: &mut TestAppContext) {
1143 let tab = Tab::new().label("Account");
1144
1145 assert_eq!(tab.a11y_label(), Some("Account".into()));
1146 }
1147
1148 #[gpui::test]
1149 fn explicit_a11y_label_overrides_visible_label(_cx: &mut TestAppContext) {
1150 let tab = Tab::new().label("Acct").aria_label("Account settings");
1151
1152 assert_eq!(tab.a11y_label(), Some("Account settings".into()));
1153 }
1154
1155 #[gpui::test]
1156 fn max_width_leaves_short_tabs_untouched(cx: &mut TestAppContext) {
1157 let build: fn(Tab) -> Tab = |tab| tab.label("Go");
1160
1161 let uncapped = variant_widths(cx, build, None);
1162 let capped = variant_widths(cx, build, Some(px(200.)));
1163
1164 assert_eq!(uncapped, capped, "a tab under the cap must not be resized");
1165 }
1166
1167 #[gpui::test]
1168 fn max_width_caps_long_tabs(cx: &mut TestAppContext) {
1169 let build: fn(Tab) -> Tab = |tab| tab.label(LONG_LABEL);
1170
1171 let uncapped = variant_widths(cx, build, None);
1172 let capped = variant_widths(cx, build, Some(px(120.)));
1173
1174 for (variant, (uncapped, capped)) in
1175 VARIANTS.into_iter().zip(uncapped.into_iter().zip(capped))
1176 {
1177 assert!(
1178 uncapped > px(120.),
1179 "{variant:?} is not long enough to exercise the cap ({uncapped:?})"
1180 );
1181 assert!(
1182 capped <= px(120.),
1183 "{variant:?} width {capped:?} exceeds max_width"
1184 );
1185 }
1186 }
1187
1188 #[gpui::test]
1189 fn max_width_keeps_prefix_and_suffix_intact(cx: &mut TestAppContext) {
1190 let cx = show(cx, TabVariant::Segmented, Some(px(140.)), |tab| {
1191 tab.prefix(Icon::new(IconName::BookOpen))
1192 .label(LONG_LABEL)
1193 .suffix(div().size(px(16.)).debug_selector(|| "suffix".to_string()))
1194 });
1195
1196 let tab = cx.debug_bounds("tab").expect("tab not rendered");
1197 let suffix = cx.debug_bounds("suffix").expect("suffix not rendered");
1198
1199 assert!(tab.size.width <= px(140.));
1200 assert_eq!(
1201 suffix.size.width,
1202 px(16.),
1203 "the label should absorb the truncation, not the suffix"
1204 );
1205 assert!(
1206 suffix.right() <= tab.right(),
1207 "suffix must stay within the tab"
1208 );
1209 assert!(
1213 suffix.left() > tab.center().x,
1214 "suffix must follow the label, not wrap below it"
1215 );
1216 }
1217
1218 #[gpui::test]
1221 fn max_width_exempts_icon_only_tabs(cx: &mut TestAppContext) {
1222 let build: fn(Tab) -> Tab = |tab| tab.icon(Icon::new(IconName::BookOpen));
1223 let width = |cx: &mut TestAppContext, max_width| {
1224 show(cx, TabVariant::Tab, max_width, build)
1225 .debug_bounds("tab")
1226 .expect("tab not rendered")
1227 .size
1228 .width
1229 };
1230
1231 let uncapped = width(cx, None);
1232 assert!(
1233 uncapped > px(16.),
1234 "the cap has to be narrower than the icon tab to be meaningful"
1235 );
1236 assert_eq!(
1237 width(cx, Some(px(16.))),
1238 uncapped,
1239 "an icon-only tab must ignore max_width"
1240 );
1241 }
1242}