Skip to main content

gpui_component/tab/
tab_bar.rs

1use std::{cell::RefCell, rc::Rc};
2
3use gpui::{
4    Anchor, AnyElement, App, Background, Bounds, Edges, ElementId, InteractiveElement, IntoElement,
5    ParentElement, Pixels, RenderOnce, ScrollHandle, SharedString, StatefulInteractiveElement as _,
6    StyleRefinement, Styled, Window, div, prelude::FluentBuilder as _, px,
7};
8use gpui_base::spring;
9use rust_i18n::t;
10use smallvec::SmallVec;
11
12use super::{Tab, TabVariant};
13use crate::button::{Button, ButtonVariants as _};
14use crate::menu::{DropdownMenu as _, PopupMenuItem};
15use crate::{
16    ActiveTheme, ElementExt, Icon, InteractiveElementExt as _, Selectable, Sizable, Size,
17    StyledExt, h_flex, styled::raised_shadow,
18};
19
20struct TabIndicatorBounds {
21    container: Bounds<Pixels>,
22    tabs: Vec<Bounds<Pixels>>,
23}
24
25impl TabIndicatorBounds {
26    fn new(num_tabs: usize) -> Self {
27        Self {
28            container: Bounds::default(),
29            tabs: vec![Bounds::default(); num_tabs],
30        }
31    }
32
33    fn resize(&mut self, num_tabs: usize) {
34        self.tabs.resize(num_tabs, Bounds::default());
35    }
36}
37
38/// A TabBar element that contains multiple [`Tab`] items.
39#[derive(IntoElement)]
40pub struct TabBar {
41    id: ElementId,
42    base: gpui_base::Tabs,
43    style: StyleRefinement,
44    scroll_handle: Option<ScrollHandle>,
45    prefix: Option<AnyElement>,
46    suffix: Option<AnyElement>,
47    children: SmallVec<[Tab; 2]>,
48    last_empty_space: AnyElement,
49    selected_index: Option<usize>,
50    variant: TabVariant,
51    size: Size,
52    menu: bool,
53    max_width: Option<Pixels>,
54    on_click: Option<Rc<dyn Fn(&usize, &mut Window, &mut App) + 'static>>,
55}
56
57impl TabBar {
58    /// Create a new TabBar.
59    pub fn new(id: impl Into<ElementId>) -> Self {
60        let id = id.into();
61        Self {
62            id: id.clone(),
63            base: gpui_base::Tabs::new(id).px(px(-1.)),
64            style: StyleRefinement::default(),
65            children: SmallVec::new(),
66            scroll_handle: None,
67            prefix: None,
68            suffix: None,
69            variant: TabVariant::default(),
70            size: Size::default(),
71            last_empty_space: div().w_3().into_any_element(),
72            selected_index: None,
73            on_click: None,
74            menu: false,
75            max_width: None,
76        }
77    }
78
79    /// Set the Tab variant, all children will inherit the variant.
80    pub fn with_variant(mut self, variant: TabVariant) -> Self {
81        self.variant = variant;
82        self
83    }
84
85    /// Set the Tab variant to Pill, all children will inherit the variant.
86    pub fn pill(mut self) -> Self {
87        self.variant = TabVariant::Pill;
88        self
89    }
90
91    /// Set the Tab variant to Outline, all children will inherit the variant.
92    pub fn outline(mut self) -> Self {
93        self.variant = TabVariant::Outline;
94        self
95    }
96
97    /// Set the Tab variant to Segmented, all children will inherit the variant.
98    pub fn segmented(mut self) -> Self {
99        self.variant = TabVariant::Segmented;
100        self
101    }
102
103    /// Set the Tab variant to Underline, all children will inherit the variant.
104    pub fn underline(mut self) -> Self {
105        self.variant = TabVariant::Underline;
106        self
107    }
108
109    /// Set whether to show the menu button when tabs overflow, default is false.
110    pub fn menu(mut self, menu: bool) -> Self {
111        self.menu = menu;
112        self
113    }
114
115    /// Set the maximum width of each tab. Labels longer than this width are
116    /// truncated with an ellipsis. Does not apply to icon-only tabs. The
117    /// overflow menu still shows the full label.
118    pub fn max_width(mut self, width: impl Into<Pixels>) -> Self {
119        self.max_width = Some(width.into());
120        self
121    }
122
123    /// Track the scroll of the TabBar.
124    ///
125    /// This does not automatically reveal the selected tab. Use the tracked
126    /// [`ScrollHandle`] to request an explicit reveal when needed.
127    pub fn track_scroll(mut self, scroll_handle: &ScrollHandle) -> Self {
128        self.scroll_handle = Some(scroll_handle.clone());
129        self
130    }
131
132    /// Set the prefix element of the TabBar
133    pub fn prefix(mut self, prefix: impl IntoElement) -> Self {
134        self.prefix = Some(prefix.into_any_element());
135        self
136    }
137
138    /// Set the suffix element of the TabBar
139    pub fn suffix(mut self, suffix: impl IntoElement) -> Self {
140        self.suffix = Some(suffix.into_any_element());
141        self
142    }
143
144    /// Add children of the TabBar, all children will inherit the variant.
145    pub fn children(mut self, children: impl IntoIterator<Item = impl Into<Tab>>) -> Self {
146        self.children.extend(children.into_iter().map(Into::into));
147        self
148    }
149
150    /// Add child of the TabBar, tab will inherit the variant.
151    pub fn child(mut self, child: impl Into<Tab>) -> Self {
152        self.children.push(child.into());
153        self
154    }
155
156    /// Set the selected index of the TabBar.
157    pub fn selected_index(mut self, index: usize) -> Self {
158        self.selected_index = Some(index);
159        self
160    }
161
162    /// Set the last empty space element of the TabBar.
163    pub fn last_empty_space(mut self, last_empty_space: impl IntoElement) -> Self {
164        self.last_empty_space = last_empty_space.into_any_element();
165        self
166    }
167
168    /// Set the on_click callback of the TabBar, the first parameter is the index of the clicked tab.
169    ///
170    /// When this is set, the children's on_click will be ignored.
171    pub fn on_click<F>(mut self, on_click: F) -> Self
172    where
173        F: Fn(&usize, &mut Window, &mut App) + 'static,
174    {
175        self.on_click = Some(Rc::new(on_click));
176        self
177    }
178
179    /// Render the sliding indicator element for animated tab switching.
180    ///
181    /// Returns the indicator element together with the current animation
182    /// `epoch`, which increments on every tab switch. Tabs key their own
183    /// transitions (e.g. text color fade) on this epoch so they restart in sync
184    /// with the indicator slide.
185    fn render_indicator(
186        &self,
187        bounds_rc: &Option<Rc<RefCell<TabIndicatorBounds>>>,
188        window: &mut Window,
189        cx: &mut App,
190    ) -> Option<(AnyElement, u64)> {
191        let has_indicator = matches!(
192            self.variant,
193            TabVariant::Segmented | TabVariant::Pill | TabVariant::Underline
194        );
195        let num_tabs = self.children.len();
196        let selected_ix = self.selected_index.unwrap_or(usize::MAX);
197
198        if !(has_indicator && num_tabs > 0 && selected_ix < num_tabs) {
199            return None;
200        }
201
202        let prev_key = format!("{}-tab-prev", self.id);
203        let anim_key = format!("{}-tab-anim", self.id);
204        let init_key = format!("{}-tab-init", self.id);
205
206        let prev_selected = window.use_keyed_state(prev_key, cx, |_, _| selected_ix);
207        // (to_left, to_width, epoch)
208        let anim_params = window.use_keyed_state(anim_key, cx, |_, _| (px(0.), px(0.), 0u64));
209        let initialized = window.use_keyed_state(init_key, cx, |_, _| false);
210
211        // First frame: trigger re-render to capture bounds via on_prepaint
212        if !*initialized.read(cx) {
213            initialized.update(cx, |v, _| *v = true);
214        }
215
216        self.update_anim_params(selected_ix, bounds_rc, &prev_selected, &anim_params, cx);
217
218        let (to_left, to_width, epoch) = *anim_params.read(cx);
219        if to_width <= px(0.) {
220            return None;
221        }
222
223        // The springs hold the indicator's own position and velocity, so a tab
224        // switched again mid-slide is redirected from where the indicator
225        // actually is rather than restarted from the tab it left.
226        let indicator_key = format!("{}-tab-indicator", self.id);
227        let left = spring(
228            (indicator_key.clone(), "left"),
229            to_left,
230            cx.theme().motion_tokens().spring_move,
231            window,
232            cx,
233        );
234        let width = spring(
235            (indicator_key, "width"),
236            to_width,
237            cx.theme().motion_tokens().spring_move,
238            window,
239            cx,
240        );
241
242        let variant = self.variant;
243        let size = self.size;
244        let inner_height = variant.inner_height(size);
245        let inner_radius = variant.inner_radius(size, cx);
246
247        let indicator = div()
248            .absolute()
249            .top_0()
250            .bottom_0()
251            .left(left)
252            .w(width)
253            .map(|el| match variant {
254                TabVariant::Segmented => el.flex().items_center().child(
255                    div()
256                        .w_full()
257                        .h(inner_height)
258                        .bg(cx.theme().tokens.background)
259                        .rounded(inner_radius)
260                        .shadow(raised_shadow()),
261                ),
262                TabVariant::Pill => el.flex().items_center().child(
263                    div()
264                        .size_full()
265                        .bg(cx.theme().tokens.primary)
266                        .rounded(cx.theme().radius_full()),
267                ),
268                TabVariant::Underline => el.child(
269                    div()
270                        .absolute()
271                        .left_0()
272                        .right_0()
273                        .bottom_0()
274                        .h(px(2.))
275                        .bg(cx.theme().tokens.primary),
276                ),
277                _ => el,
278            });
279
280        Some((indicator.into_any_element(), epoch))
281    }
282
283    /// Update animation parameters based on current and previous selection.
284    fn update_anim_params(
285        &self,
286        selected_ix: usize,
287        bounds_rc: &Option<Rc<RefCell<TabIndicatorBounds>>>,
288        prev_selected: &gpui::Entity<usize>,
289        anim_params: &gpui::Entity<(Pixels, Pixels, u64)>,
290        cx: &mut App,
291    ) {
292        let rc = match bounds_rc {
293            Some(rc) => rc,
294            None => return,
295        };
296
297        let prev_ix = *prev_selected.read(cx);
298        let bounds = rc.borrow();
299        let container = bounds.container;
300
301        if container.size.width == px(0.) {
302            if prev_ix != selected_ix {
303                prev_selected.update(cx, |v, _| *v = selected_ix);
304            }
305            return;
306        }
307
308        // The indicator is nested in the first tab wrapper, so its position is
309        // relative to that wrapper rather than the scroll container.
310        let first_tab_origin = bounds
311            .tabs
312            .first()
313            .map(|tab| tab.origin.x)
314            .unwrap_or(container.origin.x);
315
316        if prev_ix != selected_ix {
317            if let Some(to_b) = bounds.tabs.get(selected_ix) {
318                let left = to_b.origin.x - first_tab_origin;
319                let width = to_b.size.width;
320                // Only a switch away from a tab that still exists restarts the
321                // tabs' own epoch-keyed transitions.
322                let epoch = anim_params.read(cx).2;
323                let epoch = match bounds.tabs.get(prev_ix) {
324                    Some(_) => epoch + 1,
325                    None => epoch,
326                };
327                anim_params.update(cx, |v, _| *v = (left, width, epoch));
328            }
329            drop(bounds);
330            prev_selected.update(cx, |v, _| *v = selected_ix);
331            return;
332        }
333
334        if let Some(to_b) = bounds.tabs.get(selected_ix) {
335            let left = to_b.origin.x - first_tab_origin;
336            let width = to_b.size.width;
337            let (to_left, to_width, epoch) = *anim_params.read(cx);
338
339            if left != to_left || width != to_width {
340                anim_params.update(cx, |v, _| *v = (left, width, epoch));
341            }
342        }
343    }
344}
345
346impl Styled for TabBar {
347    fn style(&mut self) -> &mut StyleRefinement {
348        &mut self.style
349    }
350}
351
352impl Sizable for TabBar {
353    fn with_size(mut self, size: impl Into<Size>) -> Self {
354        self.size = size.into();
355        self
356    }
357}
358
359impl RenderOnce for TabBar {
360    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
361        let default_gap = match self.size {
362            Size::Small | Size::XSmall => px(8.),
363            Size::Large => px(16.),
364            _ => px(12.),
365        };
366        let (bg, paddings, gap): (Background, _, _) = match self.variant {
367            TabVariant::Tab => {
368                let padding = Edges::all(px(0.));
369                (cx.theme().tokens.tab_bar.into(), padding, px(0.))
370            }
371            TabVariant::Outline => {
372                let padding = Edges::all(px(0.));
373                (cx.theme().transparent.into(), padding, default_gap)
374            }
375            TabVariant::Pill => {
376                let padding = Edges::all(px(0.));
377                (cx.theme().transparent.into(), padding, px(4.))
378            }
379            TabVariant::Segmented => {
380                let padding_x = match self.size {
381                    Size::XSmall => px(2.),
382                    Size::Small => px(3.),
383                    _ => px(4.),
384                };
385                let padding = Edges {
386                    left: padding_x,
387                    right: padding_x,
388                    ..Default::default()
389                };
390
391                (cx.theme().tokens.tab_bar_segmented.into(), padding, px(2.))
392            }
393            TabVariant::Underline => {
394                // This gap is same as the tab inner_paddings
395                let gap = match self.size {
396                    Size::XSmall => px(10.),
397                    Size::Small => px(12.),
398                    Size::Large => px(20.),
399                    _ => px(16.),
400                };
401
402                (cx.theme().transparent.into(), Edges::all(px(0.)), gap)
403            }
404        };
405
406        let has_indicator = matches!(
407            self.variant,
408            TabVariant::Segmented | TabVariant::Pill | TabVariant::Underline
409        );
410        let num_tabs = self.children.len();
411
412        // Bounds tracking for tab indicator animation.
413        // Uses Rc<RefCell> to avoid triggering re-renders from prepaint writes.
414        let bounds_rc = if has_indicator && num_tabs > 0 {
415            let rc: Rc<RefCell<TabIndicatorBounds>> = window
416                .use_keyed_state(format!("{}-tab-bounds", self.id), cx, |_, _| {
417                    Rc::new(RefCell::new(TabIndicatorBounds::new(num_tabs)))
418                })
419                .read(cx)
420                .clone();
421            rc.borrow_mut().resize(num_tabs);
422            Some(rc)
423        } else {
424            None
425        };
426
427        let padding_x = paddings.left;
428        let indicator = self.render_indicator(&bounds_rc, window, cx);
429        let indicator_epoch = indicator.as_ref().map(|(_, epoch)| *epoch).unwrap_or(0);
430        let mut indicator_element = indicator.map(|(el, _)| el);
431        let indicator_ready = indicator_element.is_some();
432
433        let has_suffix_or_menu = self.suffix.is_some() || self.menu;
434        let mut item_metas: Vec<(Option<SharedString>, Option<Icon>, bool)> = Vec::new();
435        let selected_index = self.selected_index;
436        let on_click = self.on_click.clone();
437        let tabs = self.base;
438        let mut rendered_tabs = Vec::with_capacity(self.children.len());
439        let max_width = self.max_width;
440
441        for (ix, child) in self.children.into_iter().enumerate() {
442            item_metas.push((child.label.clone(), child.icon.clone(), child.disabled));
443            let tab_bar_prefix = child.tab_bar_prefix.unwrap_or(true);
444            let mut tab = child
445                .ix(ix)
446                .tab_bar_prefix(tab_bar_prefix)
447                .max_width(max_width)
448                .with_variant(self.variant)
449                .with_size(self.size);
450            tab.indicator_active = has_indicator;
451            tab.indicator_ready = indicator_ready;
452            tab.indicator_epoch = indicator_epoch;
453            let mut tab = tab
454                .when_some(selected_index, |tab, selected_index| {
455                    tab.selected(selected_index == ix)
456                })
457                .when_some(self.on_click.clone(), move |tab, on_click| {
458                    tab.on_click(move |_, window, cx| on_click(&ix, window, cx))
459                });
460            // The wrapper below is the flex item the bar lays out, so a tab's
461            // own `flex_grow` / `flex_basis` (e.g. `flex_1()`) must size it.
462            let flex_grow = tab.style().flex_grow;
463            let flex_basis = tab.style().flex_basis;
464
465            rendered_tabs.push(if let Some(ref rc) = bounds_rc {
466                let rc = rc.clone();
467                // `tabs-inner` is tracked by `ScrollHandle`, which indexes its
468                // direct children. Keep the indicator inside the first tab so
469                // only logical tabs occupy those indices.
470                div()
471                    .flex_shrink_0()
472                    .map(|mut this| {
473                        this.style().flex_grow = flex_grow;
474                        this.style().flex_basis = flex_basis;
475                        this
476                    })
477                    .on_prepaint(move |bounds, _, _| {
478                        if let Some(slot) = rc.borrow_mut().tabs.get_mut(ix) {
479                            *slot = bounds;
480                        }
481                    })
482                    .relative()
483                    .when(ix == 0, |this| {
484                        this.when_some(indicator_element.take(), |this, indicator| {
485                            this.child(indicator)
486                        })
487                    })
488                    .child(tab)
489                    .into_any_element()
490            } else {
491                tab.into_any_element()
492            });
493        }
494
495        tabs.group("tab-bar")
496            .relative()
497            .flex()
498            .items_center()
499            .bg(bg)
500            .text_color(cx.theme().tab_foreground)
501            .when(
502                self.variant == TabVariant::Underline || self.variant == TabVariant::Tab,
503                |this| {
504                    this.child(
505                        div()
506                            .id("border-b")
507                            .absolute()
508                            .left_0()
509                            .bottom_0()
510                            .size_full()
511                            .border_b_1()
512                            .border_color(cx.theme().border),
513                    )
514                },
515            )
516            .rounded(self.variant.tab_bar_radius(self.size, cx))
517            .paddings(paddings)
518            .refine_style(&self.style)
519            .when_some(self.prefix, |this, prefix| this.child(prefix))
520            .child(
521                h_flex()
522                    .id("tabs")
523                    .flex_1()
524                    .min_w_0()
525                    .mx(-padding_x)
526                    .px(padding_x)
527                    .overflow_x_hidden()
528                    // `on_prepaint` adds a canvas child. Keep that helper on
529                    // the non-scrolling wrapper so it cannot shift tab indices.
530                    .when_some(bounds_rc.clone(), |this, rc| {
531                        this.on_prepaint(move |bounds, _, _| {
532                            rc.borrow_mut().container = bounds;
533                        })
534                    })
535                    .child(
536                        h_flex()
537                            .id("tabs-inner")
538                            // Fill the bar so tabs can grow into the free space;
539                            // as a scroll container it still shrinks below its content.
540                            .flex_1()
541                            // Keep the scroll viewport inside the wrapper padding so
542                            // explicit reveals leave space at both ends of the bar.
543                            .relative()
544                            .gap(gap)
545                            .overflow_x_scroll()
546                            .lock_scroll_axis()
547                            .when_some(self.scroll_handle, |this, scroll_handle| {
548                                this.track_scroll(&scroll_handle)
549                            })
550                            .children(rendered_tabs)
551                            .when(has_suffix_or_menu, |this| this.child(self.last_empty_space)),
552                    ),
553            )
554            .when(self.menu, |this| {
555                this.child(
556                    Button::new("more")
557                        .xsmall()
558                        .ghost()
559                        .dropdown_caret(true)
560                        .dropdown_menu(move |mut this, _, _| {
561                            this = this.scrollable(true);
562                            for (ix, (label, icon, disabled)) in item_metas.iter().enumerate() {
563                                let base = if let Some(label) = label.clone() {
564                                    PopupMenuItem::new(label)
565                                } else if let Some(icon) = icon.clone() {
566                                    PopupMenuItem::element(move |_, _| icon.clone())
567                                } else {
568                                    PopupMenuItem::new(t!("Dock.Unnamed"))
569                                };
570                                this = this.item(
571                                    base.checked(selected_index == Some(ix))
572                                        .disabled(*disabled)
573                                        .when_some(on_click.clone(), |this, on_click| {
574                                            this.on_click(move |_, window, cx| {
575                                                on_click(&ix, window, cx)
576                                            })
577                                        }),
578                                );
579                            }
580
581                            this
582                        })
583                        .anchor(Anchor::TopRight),
584                )
585            })
586            .when_some(self.suffix, |this, suffix| this.child(suffix))
587    }
588}
589
590#[cfg(test)]
591mod tests {
592    use std::{cell::Cell, rc::Rc};
593
594    use gpui::{Context, Modifiers, Render, TestAppContext};
595
596    use super::*;
597
598    struct Harness {
599        group_handler: bool,
600        disabled: bool,
601        child_clicks: Rc<Cell<usize>>,
602        group_clicks: Rc<Cell<usize>>,
603    }
604
605    impl Render for Harness {
606        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
607            let child_clicks = self.child_clicks.clone();
608            let group_clicks = self.group_clicks.clone();
609            TabBar::new("tabs")
610                .w(px(240.))
611                .child(
612                    Tab::new()
613                        .debug_selector(|| "first-tab".into())
614                        .disabled(self.disabled)
615                        .label("First")
616                        .on_click(move |_, _, _| child_clicks.set(child_clicks.get() + 1)),
617                )
618                .when(self.group_handler, |tabs| {
619                    tabs.on_click(move |ix, _, _| group_clicks.set(*ix + 1))
620                })
621        }
622    }
623
624    fn harness(
625        cx: &mut TestAppContext,
626        group_handler: bool,
627        disabled: bool,
628    ) -> (
629        &mut gpui::VisualTestContext,
630        Rc<Cell<usize>>,
631        Rc<Cell<usize>>,
632    ) {
633        cx.update(crate::theme::init);
634        let child_clicks = Rc::new(Cell::new(0));
635        let group_clicks = Rc::new(Cell::new(0));
636        let (_, cx) = cx.add_window_view({
637            let child_clicks = child_clicks.clone();
638            let group_clicks = group_clicks.clone();
639            move |_, _| Harness {
640                group_handler,
641                disabled,
642                child_clicks,
643                group_clicks,
644            }
645        });
646        cx.update(|window, cx| window.draw(cx).clear(cx));
647        (cx, child_clicks, group_clicks)
648    }
649
650    #[gpui::test]
651    fn group_callback_overrides_child_callback(cx: &mut TestAppContext) {
652        let (cx, child_clicks, group_clicks) = harness(cx, true, false);
653        let position = cx.debug_bounds("first-tab").unwrap().center();
654        cx.simulate_click(position, Modifiers::default());
655        assert_eq!(child_clicks.get(), 0);
656        assert_eq!(group_clicks.get(), 1);
657    }
658
659    #[gpui::test]
660    fn child_callback_is_preserved_without_group_callback(cx: &mut TestAppContext) {
661        let (cx, child_clicks, group_clicks) = harness(cx, false, false);
662        let position = cx.debug_bounds("first-tab").unwrap().center();
663        cx.simulate_click(position, Modifiers::default());
664        assert_eq!(child_clicks.get(), 1);
665        assert_eq!(group_clicks.get(), 0);
666    }
667
668    #[gpui::test]
669    fn disabled_tab_suppresses_child_and_group_callbacks(cx: &mut TestAppContext) {
670        let (cx, child_clicks, group_clicks) = harness(cx, true, true);
671        let position = cx.debug_bounds("first-tab").unwrap().center();
672        cx.simulate_click(position, Modifiers::default());
673        assert_eq!(child_clicks.get(), 0);
674        assert_eq!(group_clicks.get(), 0);
675    }
676
677    struct ContentHarness;
678
679    impl Render for ContentHarness {
680        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
681            TabBar::new("content-tabs").w(px(320.)).child(
682                Tab::new()
683                    .prefix(div().debug_selector(|| "tab-prefix".into()).child("P"))
684                    .child(div().debug_selector(|| "tab-child".into()).child("Content"))
685                    .suffix(div().debug_selector(|| "tab-suffix".into()).child("S")),
686            )
687        }
688    }
689
690    #[gpui::test]
691    fn prefix_content_and_suffix_keep_their_order(cx: &mut TestAppContext) {
692        cx.update(crate::theme::init);
693        let (_, cx) = cx.add_window_view(|_, _| ContentHarness);
694        cx.update(|window, cx| window.draw(cx).clear(cx));
695
696        let prefix = cx.debug_bounds("tab-prefix").unwrap();
697        let child = cx.debug_bounds("tab-child").unwrap();
698        let suffix = cx.debug_bounds("tab-suffix").unwrap();
699        assert!(prefix.origin.x < child.origin.x);
700        assert!(child.origin.x < suffix.origin.x);
701        assert!(prefix.size.width > px(0.));
702        assert!(child.size.width > px(0.));
703        assert!(suffix.size.width > px(0.));
704    }
705
706    struct ScrollHarness {
707        scroll_handle: ScrollHandle,
708    }
709
710    struct DynamicScrollHarness {
711        scroll_handle: ScrollHandle,
712        menu: bool,
713        size: Size,
714        tabs: usize,
715        selected_index: usize,
716    }
717
718    struct ManualScrollHarness {
719        scroll_handle: ScrollHandle,
720        tabs: usize,
721        selected_index: usize,
722        label: &'static str,
723        top: Pixels,
724    }
725
726    impl ScrollHarness {
727        fn tabs() -> impl Iterator<Item = Tab> {
728            (0..5).map(|ix| {
729                Tab::new()
730                    .w(px(60.))
731                    .label(format!("Tab {ix}"))
732                    .debug_selector(move || format!("tab-{ix}"))
733            })
734        }
735    }
736
737    impl Render for ScrollHarness {
738        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
739            div().w(px(100.)).child(
740                TabBar::new("scrolling-tabs")
741                    .w_full()
742                    .segmented()
743                    .menu(true)
744                    .track_scroll(&self.scroll_handle)
745                    .selected_index(4)
746                    .children(Self::tabs()),
747            )
748        }
749    }
750
751    impl DynamicScrollHarness {
752        fn tabs(&self) -> impl Iterator<Item = Tab> {
753            (0..self.tabs).map(|ix| {
754                Tab::new()
755                    .w(px(60.))
756                    .label(format!("Tab {ix}"))
757                    .debug_selector(move || format!("dynamic-tab-{ix}"))
758            })
759        }
760    }
761
762    impl Render for DynamicScrollHarness {
763        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
764            div()
765                .w(px(100.))
766                .debug_selector(|| "dynamic-bar".into())
767                .child(
768                    TabBar::new("dynamic-scrolling-tabs")
769                        .with_size(self.size)
770                        .w_full()
771                        .segmented()
772                        .menu(self.menu)
773                        .track_scroll(&self.scroll_handle)
774                        .selected_index(self.selected_index)
775                        .children(self.tabs()),
776                )
777        }
778    }
779
780    impl ManualScrollHarness {
781        fn tabs(&self) -> impl Iterator<Item = Tab> {
782            let width = if self.label == "old" {
783                px(60.)
784            } else {
785                px(120.)
786            };
787            let label = self.label;
788            (0..self.tabs).map(move |ix| {
789                Tab::new()
790                    .w(width)
791                    .label(format!("Tab {ix} {label}"))
792                    .debug_selector(move || format!("manual-tab-{ix}"))
793            })
794        }
795    }
796
797    impl Render for ManualScrollHarness {
798        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
799            div().w(px(160.)).h(px(40.)).child(
800                div().relative().top(self.top).w_full().child(
801                    TabBar::new("manual-scrolling-tabs")
802                        .w_full()
803                        .segmented()
804                        .menu(true)
805                        .track_scroll(&self.scroll_handle)
806                        .selected_index(self.selected_index)
807                        .children(self.tabs()),
808                ),
809            )
810        }
811    }
812
813    fn draw(cx: &mut gpui::VisualTestContext) {
814        cx.run_until_parked();
815        cx.update(|window, cx| window.draw(cx).clear(cx));
816    }
817
818    #[gpui::test]
819    fn scrolling_to_a_tab_uses_logical_tab_indices(cx: &mut TestAppContext) {
820        cx.update(crate::theme::init);
821        let scroll_handle = ScrollHandle::new();
822        let (_, cx) = cx.add_window_view({
823            let scroll_handle = scroll_handle.clone();
824            move |_, _| ScrollHarness { scroll_handle }
825        });
826
827        draw(cx);
828        draw(cx);
829        assert_eq!(scroll_handle.offset().x, px(0.));
830        scroll_handle.scroll_to_item(4);
831        draw(cx);
832        draw(cx);
833
834        let viewport = scroll_handle.bounds();
835        let last_tab = cx.debug_bounds("tab-4").unwrap();
836        assert!(
837            last_tab.left() >= viewport.left(),
838            "last tab {last_tab:?} is left of viewport {viewport:?}, offset {:?}",
839            scroll_handle.offset()
840        );
841        assert!(
842            last_tab.right() <= viewport.right(),
843            "last tab {last_tab:?} is right of viewport {viewport:?}, offset {:?}",
844            scroll_handle.offset()
845        );
846        assert_eq!(scroll_handle.children_count(), 6);
847    }
848
849    #[gpui::test]
850    fn scrolling_to_a_new_tab_preserves_the_explicit_target(cx: &mut TestAppContext) {
851        cx.update(crate::theme::init);
852        let scroll_handle = ScrollHandle::new();
853        let (view, cx) = cx.add_window_view({
854            let scroll_handle = scroll_handle.clone();
855            move |_, _| DynamicScrollHarness {
856                scroll_handle,
857                menu: true,
858                size: Size::default(),
859                tabs: 4,
860                selected_index: 3,
861            }
862        });
863
864        draw(cx);
865        draw(cx);
866
867        view.update(cx, |view, cx| {
868            view.tabs = 5;
869            view.selected_index = 4;
870            view.scroll_handle.scroll_to_item(4);
871            cx.notify();
872        });
873        draw(cx);
874
875        let viewport = scroll_handle.bounds();
876        let last_tab = cx.debug_bounds("dynamic-tab-4").unwrap();
877        assert!(last_tab.left() >= viewport.left());
878        assert!(last_tab.right() <= viewport.right());
879        assert_eq!(scroll_handle.children_count(), 6);
880    }
881
882    #[gpui::test]
883    fn scrolling_to_a_new_tab_preserves_bar_padding(cx: &mut TestAppContext) {
884        cx.update(crate::theme::init);
885        for (size, padding) in [
886            (Size::XSmall, px(2.)),
887            (Size::Small, px(3.)),
888            (Size::Medium, px(4.)),
889            (Size::Large, px(4.)),
890        ] {
891            let scroll_handle = ScrollHandle::new();
892            let (view, cx) = cx.add_window_view({
893                let scroll_handle = scroll_handle.clone();
894                move |_, _| DynamicScrollHarness {
895                    scroll_handle,
896                    menu: false,
897                    size,
898                    tabs: 4,
899                    selected_index: 0,
900                }
901            });
902            draw(cx);
903            draw(cx);
904            view.update(cx, |view, cx| {
905                view.tabs = 5;
906                view.scroll_handle.scroll_to_item(4);
907                cx.notify();
908            });
909            draw(cx);
910            draw(cx);
911            let bar = cx.debug_bounds("dynamic-bar").unwrap();
912            let last_tab = cx.debug_bounds("dynamic-tab-4").unwrap();
913            assert_eq!(
914                bar.right() - last_tab.right(),
915                padding,
916                "right padding for {size:?}"
917            );
918            scroll_handle.scroll_to_item(0);
919            draw(cx);
920            draw(cx);
921            let first_tab = cx.debug_bounds("dynamic-tab-0").unwrap();
922            assert_eq!(
923                first_tab.left() - bar.left(),
924                padding,
925                "left padding for {size:?}"
926            );
927            assert_eq!(scroll_handle.children_count(), 5);
928        }
929    }
930
931    #[gpui::test]
932    fn closing_an_unselected_trailing_tab_preserves_manual_scrolling(cx: &mut TestAppContext) {
933        cx.update(crate::theme::init);
934        let scroll_handle = ScrollHandle::new();
935        let (view, cx) = cx.add_window_view({
936            let scroll_handle = scroll_handle.clone();
937            move |_, _| ManualScrollHarness {
938                scroll_handle,
939                tabs: 6,
940                selected_index: 0,
941                label: "old",
942                top: px(0.),
943            }
944        });
945
946        draw(cx);
947        scroll_handle.set_offset(gpui::point(px(-100.), px(0.)));
948        draw(cx);
949        assert_eq!(scroll_handle.offset().x, px(-100.));
950
951        view.update(cx, |view, cx| {
952            view.tabs = 5;
953            cx.notify();
954        });
955        draw(cx);
956
957        assert_eq!(scroll_handle.offset().x, px(-100.));
958    }
959
960    #[gpui::test]
961    fn changing_selection_does_not_move_manual_scrolling(cx: &mut TestAppContext) {
962        cx.update(crate::theme::init);
963        let scroll_handle = ScrollHandle::new();
964        let (view, cx) = cx.add_window_view({
965            let scroll_handle = scroll_handle.clone();
966            move |_, _| ManualScrollHarness {
967                scroll_handle,
968                tabs: 6,
969                selected_index: 0,
970                label: "old",
971                top: px(0.),
972            }
973        });
974
975        draw(cx);
976        scroll_handle.set_offset(gpui::point(px(-100.), px(0.)));
977        draw(cx);
978
979        view.update(cx, |view, cx| {
980            view.selected_index = 5;
981            cx.notify();
982        });
983        draw(cx);
984
985        assert_eq!(scroll_handle.offset().x, px(-100.));
986    }
987
988    #[gpui::test]
989    fn changing_tab_labels_does_not_move_manual_scrolling(cx: &mut TestAppContext) {
990        cx.update(crate::theme::init);
991        let scroll_handle = ScrollHandle::new();
992        let (view, cx) = cx.add_window_view({
993            let scroll_handle = scroll_handle.clone();
994            move |_, _| ManualScrollHarness {
995                scroll_handle,
996                tabs: 6,
997                selected_index: 0,
998                label: "old",
999                top: px(0.),
1000            }
1001        });
1002
1003        draw(cx);
1004        scroll_handle.set_offset(gpui::point(px(-100.), px(0.)));
1005        draw(cx);
1006
1007        view.update(cx, |view, cx| {
1008            view.label = "new";
1009            cx.notify();
1010        });
1011        draw(cx);
1012
1013        assert_eq!(scroll_handle.offset().x, px(-100.));
1014    }
1015
1016    #[gpui::test]
1017    fn moving_the_tab_bar_preserves_manual_scrolling(cx: &mut TestAppContext) {
1018        cx.update(crate::theme::init);
1019        let scroll_handle = ScrollHandle::new();
1020        let (view, cx) = cx.add_window_view({
1021            let scroll_handle = scroll_handle.clone();
1022            move |_, _| ManualScrollHarness {
1023                scroll_handle,
1024                tabs: 6,
1025                selected_index: 0,
1026                label: "old",
1027                top: px(0.),
1028            }
1029        });
1030
1031        draw(cx);
1032        scroll_handle.set_offset(gpui::point(px(-100.), px(0.)));
1033        draw(cx);
1034        assert_eq!(scroll_handle.offset().x, px(-100.));
1035        let viewport_size = scroll_handle.bounds().size;
1036
1037        view.update(cx, |view, cx| {
1038            view.top = px(20.);
1039            cx.notify();
1040        });
1041        draw(cx);
1042
1043        assert_eq!(scroll_handle.bounds().size, viewport_size);
1044        assert_eq!(scroll_handle.offset().x, px(-100.));
1045    }
1046
1047    struct FlexHarness {
1048        variant: TabVariant,
1049    }
1050
1051    impl Render for FlexHarness {
1052        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1053            div()
1054                .w(px(200.))
1055                .debug_selector(|| "flex-bar".into())
1056                .child(
1057                    TabBar::new("flex-tabs")
1058                        .w_full()
1059                        .with_variant(self.variant)
1060                        .selected_index(0)
1061                        .child(
1062                            Tab::new()
1063                                .flex_1()
1064                                .label("A")
1065                                .debug_selector(|| "flex-tab-0".into()),
1066                        )
1067                        .child(
1068                            Tab::new()
1069                                .flex_1()
1070                                .label("B")
1071                                .debug_selector(|| "flex-tab-1".into()),
1072                        ),
1073                )
1074        }
1075    }
1076
1077    #[gpui::test]
1078    fn flex_tabs_share_the_available_width(cx: &mut TestAppContext) {
1079        cx.update(crate::theme::init);
1080        for variant in [
1081            TabVariant::Tab,
1082            TabVariant::Outline,
1083            TabVariant::Segmented,
1084            TabVariant::Pill,
1085            TabVariant::Underline,
1086        ] {
1087            let (_, cx) = cx.add_window_view(move |_, _| FlexHarness { variant });
1088            draw(cx);
1089            draw(cx);
1090
1091            let bar = cx.debug_bounds("flex-bar").unwrap();
1092            let first = cx.debug_bounds("flex-tab-0").unwrap();
1093            let second = cx.debug_bounds("flex-tab-1").unwrap();
1094            assert_eq!(first.size.width, second.size.width, "{variant:?}");
1095            // Only the bar's own padding may remain on either side.
1096            assert_eq!(
1097                first.left() - bar.left(),
1098                bar.right() - second.right(),
1099                "{variant:?}"
1100            );
1101            assert!(
1102                bar.right() - second.right() <= px(4.),
1103                "{variant:?}: tabs end at {:?} but the bar ends at {:?}",
1104                second.right(),
1105                bar.right()
1106            );
1107        }
1108    }
1109}