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