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::{
14    Tab, TabVariant,
15    tab::{FOLDER_TAB_GAP, FolderTabMetrics},
16};
17use crate::button::{Button, ButtonVariants as _};
18use crate::menu::{DropdownMenu as _, PopupMenuItem};
19use crate::scroll::ScrollableMask;
20use crate::{
21    ActiveTheme, ElementExt, Icon, InteractiveElementExt as _, Selectable, Sizable, Size,
22    StyledExt, h_flex, styled::raised_shadow,
23};
24
25struct TabIndicatorBounds {
26    container: Bounds<Pixels>,
27    tabs: Vec<Bounds<Pixels>>,
28    /// Where the last render placed the indicator: its left edge from the
29    /// first tab's, and its width (zero before the tabs were measured).
30    placed: (Pixels, Pixels),
31}
32
33impl TabIndicatorBounds {
34    fn new(num_tabs: usize) -> Self {
35        Self {
36            container: Bounds::default(),
37            tabs: vec![Bounds::default(); num_tabs],
38            placed: (px(0.), px(0.)),
39        }
40    }
41
42    fn resize(&mut self, num_tabs: usize) {
43        self.tabs.resize(num_tabs, Bounds::default());
44    }
45}
46
47/// A TabBar element that contains multiple [`Tab`] items.
48#[derive(IntoElement)]
49pub struct TabBar {
50    id: ElementId,
51    base: gpui_base::Tabs,
52    style: StyleRefinement,
53    scroll_handle: Option<ScrollHandle>,
54    prefix: Option<AnyElement>,
55    suffix: Option<AnyElement>,
56    children: SmallVec<[Tab; 2]>,
57    last_empty_space: AnyElement,
58    selected_index: Option<usize>,
59    variant: TabVariant,
60    size: Size,
61    menu: bool,
62    max_width: Option<Pixels>,
63    on_click: Option<Rc<dyn Fn(&usize, &mut Window, &mut App) + 'static>>,
64}
65
66impl TabBar {
67    /// Create a new TabBar.
68    pub fn new(id: impl Into<ElementId>) -> Self {
69        let id = id.into();
70        Self {
71            id: id.clone(),
72            base: gpui_base::Tabs::new(id).px(px(-1.)),
73            style: StyleRefinement::default(),
74            children: SmallVec::new(),
75            scroll_handle: None,
76            prefix: None,
77            suffix: None,
78            variant: TabVariant::default(),
79            size: Size::default(),
80            last_empty_space: div().w_3().into_any_element(),
81            selected_index: None,
82            on_click: None,
83            menu: false,
84            max_width: None,
85        }
86    }
87
88    /// Set the Tab variant, all children will inherit the variant.
89    pub fn with_variant(mut self, variant: TabVariant) -> Self {
90        self.variant = variant;
91        self
92    }
93
94    /// Set the Tab variant to Pill, all children will inherit the variant.
95    pub fn pill(mut self) -> Self {
96        self.variant = TabVariant::Pill;
97        self
98    }
99
100    /// Set the Tab variant to Outline, all children will inherit the variant.
101    pub fn outline(mut self) -> Self {
102        self.variant = TabVariant::Outline;
103        self
104    }
105
106    /// Set the Tab variant to Segmented, all children will inherit the variant.
107    pub fn segmented(mut self) -> Self {
108        self.variant = TabVariant::Segmented;
109        self
110    }
111
112    /// Set the Tab variant to Underline, all children will inherit the variant.
113    pub fn underline(mut self) -> Self {
114        self.variant = TabVariant::Underline;
115        self
116    }
117
118    /// Set the Tab variant to Folder, all children will inherit the variant.
119    pub fn folder(mut self) -> Self {
120        self.variant = TabVariant::Folder;
121        self
122    }
123
124    /// Set whether to show the menu button when tabs overflow, default is false.
125    pub fn menu(mut self, menu: bool) -> Self {
126        self.menu = menu;
127        self
128    }
129
130    /// Set the maximum width of each tab. Labels longer than this width are
131    /// truncated with an ellipsis. Does not apply to icon-only tabs. The
132    /// overflow menu still shows the full label.
133    pub fn max_width(mut self, width: impl Into<Pixels>) -> Self {
134        self.max_width = Some(width.into());
135        self
136    }
137
138    /// Track the scroll of the TabBar.
139    ///
140    /// Clicking a tab scrolls it into view along with part of the next tab,
141    /// but changing the selection, the tabs or the layout does not. Use the
142    /// tracked [`ScrollHandle`] to request an explicit reveal when needed.
143    pub fn track_scroll(mut self, scroll_handle: &ScrollHandle) -> Self {
144        self.scroll_handle = Some(scroll_handle.clone());
145        self
146    }
147
148    /// Set the prefix element of the TabBar
149    pub fn prefix(mut self, prefix: impl IntoElement) -> Self {
150        self.prefix = Some(prefix.into_any_element());
151        self
152    }
153
154    /// Set the suffix element of the TabBar
155    pub fn suffix(mut self, suffix: impl IntoElement) -> Self {
156        self.suffix = Some(suffix.into_any_element());
157        self
158    }
159
160    /// Add children of the TabBar, all children will inherit the variant.
161    pub fn children(mut self, children: impl IntoIterator<Item = impl Into<Tab>>) -> Self {
162        self.children.extend(children.into_iter().map(Into::into));
163        self
164    }
165
166    /// Add child of the TabBar, tab will inherit the variant.
167    pub fn child(mut self, child: impl Into<Tab>) -> Self {
168        self.children.push(child.into());
169        self
170    }
171
172    /// Set the selected index of the TabBar.
173    pub fn selected_index(mut self, index: usize) -> Self {
174        self.selected_index = Some(index);
175        self
176    }
177
178    /// Set the last empty space element of the TabBar.
179    pub fn last_empty_space(mut self, last_empty_space: impl IntoElement) -> Self {
180        self.last_empty_space = last_empty_space.into_any_element();
181        self
182    }
183
184    /// Set the on_click callback of the TabBar, the first parameter is the index of the clicked tab.
185    ///
186    /// When this is set, the children's on_click will be ignored.
187    pub fn on_click<F>(mut self, on_click: F) -> Self
188    where
189        F: Fn(&usize, &mut Window, &mut App) + 'static,
190    {
191        self.on_click = Some(Rc::new(on_click));
192        self
193    }
194
195    /// Render the sliding indicator element for animated tab switching.
196    ///
197    /// Returns the indicator element together with the current animation
198    /// `epoch`, which increments on every tab switch. Tabs key their own
199    /// transitions (e.g. text color fade) on this epoch so they restart in sync
200    /// with the indicator slide.
201    fn render_indicator(
202        &self,
203        bounds_rc: &Option<Rc<RefCell<TabIndicatorBounds>>>,
204        window: &mut Window,
205        cx: &mut App,
206    ) -> Option<(AnyElement, u64)> {
207        let has_indicator = matches!(
208            self.variant,
209            TabVariant::Segmented | TabVariant::Pill | TabVariant::Underline
210        );
211        let num_tabs = self.children.len();
212        let selected_ix = self.selected_index.unwrap_or(usize::MAX);
213
214        if !(has_indicator && num_tabs > 0 && selected_ix < num_tabs) {
215            return None;
216        }
217
218        let prev_key = format!("{}-tab-prev", self.id);
219        let anim_key = format!("{}-tab-anim", self.id);
220        let init_key = format!("{}-tab-init", self.id);
221
222        let prev_selected = window.use_keyed_state(prev_key, cx, |_, _| selected_ix);
223        // (to_left, to_width, epoch)
224        let anim_params = window.use_keyed_state(anim_key, cx, |_, _| (px(0.), px(0.), 0u64));
225        let initialized = window.use_keyed_state(init_key, cx, |_, _| false);
226
227        // First frame: trigger re-render to capture bounds via on_prepaint
228        if !*initialized.read(cx) {
229            initialized.update(cx, |v, _| *v = true);
230        }
231
232        self.update_anim_params(selected_ix, bounds_rc, &prev_selected, &anim_params, cx);
233
234        let (to_left, to_width, epoch) = *anim_params.read(cx);
235        if let Some(rc) = bounds_rc {
236            rc.borrow_mut().placed = (to_left, to_width);
237        }
238        if to_width <= px(0.) {
239            return None;
240        }
241
242        // The springs hold the indicator's own position and velocity, so a tab
243        // switched again mid-slide is redirected from where the indicator
244        // actually is rather than restarted from the tab it left.
245        let indicator_key = format!("{}-tab-indicator", self.id);
246        let left = spring(
247            (indicator_key.clone(), "left"),
248            to_left,
249            cx.theme().motion_tokens().spring_move,
250            window,
251            cx,
252        );
253        let width = spring(
254            (indicator_key, "width"),
255            to_width,
256            cx.theme().motion_tokens().spring_move,
257            window,
258            cx,
259        );
260
261        let variant = self.variant;
262        let size = self.size;
263        let inner_height = variant.inner_height(size);
264        let inner_radius = variant.inner_radius(size, cx);
265
266        let indicator = div()
267            .absolute()
268            .top_0()
269            .bottom_0()
270            .left(left)
271            .w(width)
272            .map(|el| match variant {
273                TabVariant::Segmented => el.flex().items_center().child(
274                    div()
275                        .w_full()
276                        .h(inner_height)
277                        .bg(cx.theme().tokens.background)
278                        .rounded(inner_radius)
279                        .shadow(raised_shadow()),
280                ),
281                TabVariant::Pill => el.flex().items_center().child(
282                    div()
283                        .size_full()
284                        .bg(cx.theme().tokens.primary)
285                        .rounded(cx.theme().radius_full()),
286                ),
287                TabVariant::Underline => el.child(
288                    div()
289                        .absolute()
290                        .left_0()
291                        .right_0()
292                        .bottom_0()
293                        .h(px(2.))
294                        .bg(cx.theme().tokens.primary),
295                ),
296                _ => el,
297            });
298
299        Some((indicator.into_any_element(), epoch))
300    }
301
302    /// Update animation parameters based on current and previous selection.
303    fn update_anim_params(
304        &self,
305        selected_ix: usize,
306        bounds_rc: &Option<Rc<RefCell<TabIndicatorBounds>>>,
307        prev_selected: &gpui::Entity<usize>,
308        anim_params: &gpui::Entity<(Pixels, Pixels, u64)>,
309        cx: &mut App,
310    ) {
311        let rc = match bounds_rc {
312            Some(rc) => rc,
313            None => return,
314        };
315
316        let prev_ix = *prev_selected.read(cx);
317        let bounds = rc.borrow();
318        let container = bounds.container;
319
320        if container.size.width == px(0.) {
321            if prev_ix != selected_ix {
322                prev_selected.update(cx, |v, _| *v = selected_ix);
323            }
324            return;
325        }
326
327        // The indicator is nested in the first tab wrapper, so its position is
328        // relative to that wrapper rather than the scroll container.
329        let first_tab_origin = bounds
330            .tabs
331            .first()
332            .map(|tab| tab.origin.x)
333            .unwrap_or(container.origin.x);
334
335        if prev_ix != selected_ix {
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                // Only a switch away from a tab that still exists restarts the
340                // tabs' own epoch-keyed transitions.
341                let epoch = anim_params.read(cx).2;
342                let epoch = match bounds.tabs.get(prev_ix) {
343                    Some(_) => epoch + 1,
344                    None => epoch,
345                };
346                anim_params.update(cx, |v, _| *v = (left, width, epoch));
347            }
348            drop(bounds);
349            prev_selected.update(cx, |v, _| *v = selected_ix);
350            return;
351        }
352
353        if let Some(to_b) = bounds.tabs.get(selected_ix) {
354            let left = to_b.origin.x - first_tab_origin;
355            let width = to_b.size.width;
356            let (to_left, to_width, epoch) = *anim_params.read(cx);
357
358            if left != to_left || width != to_width {
359                anim_params.update(cx, |v, _| *v = (left, width, epoch));
360            }
361        }
362    }
363}
364
365impl Styled for TabBar {
366    fn style(&mut self) -> &mut StyleRefinement {
367        &mut self.style
368    }
369}
370
371impl Sizable for TabBar {
372    fn with_size(mut self, size: impl Into<Size>) -> Self {
373        self.size = size.into();
374        self
375    }
376}
377
378impl RenderOnce for TabBar {
379    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
380        let default_gap = match self.size {
381            Size::Small | Size::XSmall => px(8.),
382            Size::Large => px(16.),
383            _ => px(12.),
384        };
385        let (bg, paddings, gap): (Background, _, _) = match self.variant {
386            TabVariant::Tab => {
387                let padding = Edges::all(px(0.));
388                (cx.theme().tokens.tab_bar.into(), padding, px(0.))
389            }
390            TabVariant::Outline => {
391                let padding = Edges::all(px(0.));
392                (cx.theme().transparent.into(), padding, default_gap)
393            }
394            TabVariant::Pill => {
395                let padding = Edges::all(px(0.));
396                (cx.theme().transparent.into(), padding, px(4.))
397            }
398            TabVariant::Segmented => {
399                let padding_x = match self.size {
400                    Size::XSmall => px(2.),
401                    Size::Small => px(3.),
402                    _ => px(4.),
403                };
404                let padding = Edges {
405                    left: padding_x,
406                    right: padding_x,
407                    ..Default::default()
408                };
409
410                (cx.theme().tokens.tab_bar_segmented.into(), padding, px(2.))
411            }
412            TabVariant::Underline => {
413                // This gap is same as the tab inner_paddings
414                let gap = match self.size {
415                    Size::XSmall => px(10.),
416                    Size::Small => px(12.),
417                    Size::Large => px(20.),
418                    _ => px(16.),
419                };
420
421                (cx.theme().transparent.into(), Edges::all(px(0.)), gap)
422            }
423            TabVariant::Folder => (
424                cx.theme().tokens.tab_bar.into(),
425                Edges::all(px(0.)),
426                FOLDER_TAB_GAP,
427            ),
428        };
429        let overhang = self.variant.overhang(self.size, cx);
430        // Inside the tabs rather than on the bar, so the bar's prefix and
431        // suffix center on the full height like the tabs' content does.
432        let tabs_top_padding = match self.variant {
433            TabVariant::Folder => FolderTabMetrics::new(self.size, cx).top_padding,
434            _ => px(0.),
435        };
436
437        let has_indicator = matches!(
438            self.variant,
439            TabVariant::Segmented | TabVariant::Pill | TabVariant::Underline
440        );
441        let num_tabs = self.children.len();
442
443        // Bounds tracking for tab indicator animation.
444        // Uses Rc<RefCell> to avoid triggering re-renders from prepaint writes.
445        let bounds_rc = if has_indicator && num_tabs > 0 {
446            let rc: Rc<RefCell<TabIndicatorBounds>> = window
447                .use_keyed_state(format!("{}-tab-bounds", self.id), cx, |_, _| {
448                    Rc::new(RefCell::new(TabIndicatorBounds::new(num_tabs)))
449                })
450                .read(cx)
451                .clone();
452            rc.borrow_mut().resize(num_tabs);
453            Some(rc)
454        } else {
455            None
456        };
457
458        // Without a tracked handle the bar still needs one to reveal a clicked tab.
459        let scroll_handle = self.scroll_handle.clone().unwrap_or_else(|| {
460            window
461                .use_keyed_state(format!("{}-scroll", self.id), cx, |_, _| {
462                    ScrollHandle::new()
463                })
464                .read(cx)
465                .clone()
466        });
467        let reveal = TabReveal {
468            spring_id: format!("{}-reveal", self.id).into(),
469            target: window.use_keyed_state(format!("{}-reveal", self.id), cx, |_, _| None),
470            scroll_handle: scroll_handle.clone(),
471            view: window.current_view(),
472            peek: match self.size {
473                Size::XSmall | Size::Small => px(24.),
474                Size::Large => px(40.),
475                _ => px(32.),
476            },
477            num_tabs,
478        };
479        reveal.advance(window, cx);
480
481        let padding_x = paddings.left;
482        let indicator = self.render_indicator(&bounds_rc, window, cx);
483        let indicator_epoch = indicator.as_ref().map(|(_, epoch)| *epoch).unwrap_or(0);
484        let mut indicator_element = indicator.map(|(el, _)| el);
485        let indicator_ready = indicator_element.is_some();
486
487        let has_suffix_or_menu = self.suffix.is_some() || self.menu;
488        let view = window.current_view();
489        let mut item_metas: Vec<(
490            Option<SharedString>,
491            Option<Icon>,
492            bool,
493            Option<TabClickHandler>,
494        )> = Vec::new();
495        let selected_index = self.selected_index;
496        let tabs = self.base;
497        let mut rendered_tabs = Vec::with_capacity(self.children.len());
498        let max_width = self.max_width;
499        // A hovered folder tab shows its fill and hides the separators beside
500        // it, one of which belongs to its neighbor, so the bar tracks the hover.
501        let hovered_tab = (self.variant == TabVariant::Folder).then(|| {
502            window.use_keyed_state(format!("{}-tab-hovered", self.id), cx, |_, _| None::<usize>)
503        });
504        let hovered_ix = hovered_tab.as_ref().and_then(|state| *state.read(cx));
505        let has_shape = |ix| selected_index == Some(ix) || hovered_ix == Some(ix);
506
507        for (ix, child) in self.children.into_iter().enumerate() {
508            item_metas.push((
509                child.label.clone(),
510                child.icon.clone(),
511                child.disabled,
512                None,
513            ));
514            let tab_bar_prefix = child.tab_bar_prefix.unwrap_or(true);
515            let mut tab = child
516                .ix(ix)
517                .tab_bar_prefix(tab_bar_prefix)
518                .max_width(max_width)
519                .with_variant(self.variant)
520                .with_size(self.size);
521            tab.indicator_active = has_indicator;
522            tab.indicator_ready = indicator_ready;
523            tab.indicator_epoch = indicator_epoch;
524            tab.hovered = hovered_ix == Some(ix);
525            tab.separator = self.variant == TabVariant::Folder
526                && ix + 1 < num_tabs
527                && !has_shape(ix)
528                && !has_shape(ix + 1);
529            let disabled = tab.disabled;
530            let mut tab = tab
531                .when_some(selected_index, |tab, selected_index| {
532                    tab.selected(selected_index == ix)
533                })
534                .map(|mut tab| {
535                    // The bar's callback replaces the tab's own.
536                    let on_click: Option<TabClickHandler> = match self.on_click.clone() {
537                        Some(on_click) => {
538                            Some(Rc::new(move |_, window, cx| on_click(&ix, window, cx)))
539                        }
540                        None => tab.on_click.take(),
541                    };
542                    tab.on_click = on_click.map(|on_click| {
543                        let reveal = reveal.clone();
544                        Rc::new(
545                            move |event: &ClickEvent, window: &mut Window, cx: &mut App| {
546                                reveal.reveal(ix, cx);
547                                on_click(event, window, cx)
548                            },
549                        ) as TabClickHandler
550                    });
551                    tab
552                });
553            // The overflow menu item runs the same handler as the visible tab:
554            // the bar's callback when set, otherwise the tab's own.
555            if let Some(meta) = item_metas.last_mut() {
556                meta.3 = tab.on_click.clone();
557            }
558            // The wrapper below is the flex item the bar lays out, so a tab's
559            // own `flex_grow` / `flex_basis` (e.g. `flex_1()`) must size it.
560            let flex_grow = tab.style().flex_grow;
561            let flex_basis = tab.style().flex_basis;
562
563            rendered_tabs.push(if let Some(ref rc) = bounds_rc {
564                let rc = rc.clone();
565                let selected = selected_index == Some(ix);
566                // `tabs-inner` is tracked by `ScrollHandle`, which indexes its
567                // direct children. Keep the indicator inside the first tab so
568                // only logical tabs occupy those indices.
569                div()
570                    .flex_shrink_0()
571                    .map(|mut this| {
572                        this.style().flex_grow = flex_grow;
573                        this.style().flex_basis = flex_basis;
574                        this
575                    })
576                    .on_prepaint(move |bounds, window, _| {
577                        let mut measured = rc.borrow_mut();
578                        if let Some(slot) = measured.tabs.get_mut(ix) {
579                            *slot = bounds;
580                        }
581                        // Render places the indicator from the bounds an
582                        // earlier frame measured, and a view that nothing
583                        // changed may not render again. When the selected tab
584                        // is measured elsewhere than its indicator (on the
585                        // bar's first frame above all), render the view again
586                        // on the next frame. Not while the bar has no width:
587                        // render leaves the indicator where it was then.
588                        if selected && measured.container.size.width > px(0.) {
589                            let first = measured
590                                .tabs
591                                .first()
592                                .map_or(bounds.origin.x, |tab| tab.origin.x);
593                            if (bounds.origin.x - first, bounds.size.width) != measured.placed {
594                                window.on_next_frame(move |_, cx| cx.notify(view));
595                            }
596                        }
597                    })
598                    .relative()
599                    .when(ix == 0, |this| {
600                        this.when_some(indicator_element.take(), |this, indicator| {
601                            this.child(indicator)
602                        })
603                    })
604                    .child(tab)
605                    .into_any_element()
606            } else if let Some(hovered_tab) = hovered_tab.clone() {
607                div()
608                    .id(("folder-tab", ix))
609                    .flex_shrink_0()
610                    .map(|mut this| {
611                        this.style().flex_grow = flex_grow;
612                        this.style().flex_basis = flex_basis;
613                        this
614                    })
615                    .when(!disabled, |this| {
616                        this.on_hover(move |hovered, _, cx| {
617                            hovered_tab.update(cx, |state, cx| {
618                                let next = match *hovered {
619                                    true => Some(ix),
620                                    false if *state == Some(ix) => None,
621                                    false => *state,
622                                };
623                                if next != *state {
624                                    *state = next;
625                                    cx.notify();
626                                }
627                            });
628                        })
629                    })
630                    .child(tab)
631                    .into_any_element()
632            } else {
633                tab.into_any_element()
634            });
635        }
636
637        tabs.group("tab-bar")
638            .relative()
639            .flex()
640            .items_center()
641            .bg(bg)
642            .text_color(cx.theme().tab_foreground)
643            .when(
644                self.variant == TabVariant::Underline || self.variant == TabVariant::Tab,
645                |this| {
646                    this.child(
647                        div()
648                            .id("border-b")
649                            .absolute()
650                            .left_0()
651                            .bottom_0()
652                            .size_full()
653                            .border_b_1()
654                            .border_color(cx.theme().border),
655                    )
656                },
657            )
658            .rounded(self.variant.tab_bar_radius(self.size, cx))
659            .paddings(paddings)
660            .refine_style(&self.style)
661            .when_some(self.prefix, |this, prefix| this.child(prefix))
662            .child(
663                h_flex()
664                    .id("tabs")
665                    .flex_1()
666                    .min_w_0()
667                    .mx(-padding_x)
668                    .px(padding_x)
669                    .overflow_x_hidden()
670                    // `on_prepaint` adds a canvas child. Keep that helper on
671                    // the non-scrolling wrapper so it cannot shift tab indices.
672                    .when_some(bounds_rc.clone(), |this, rc| {
673                        this.on_prepaint(move |bounds, _, _| {
674                            rc.borrow_mut().container = bounds;
675                        })
676                    })
677                    .child(
678                        h_flex()
679                            .id("tabs-inner")
680                            // Fill the bar so tabs can grow into the free space;
681                            // as a scroll container it still shrinks below its content.
682                            .flex_1()
683                            // Keep the scroll viewport inside the wrapper padding so
684                            // explicit reveals leave space at both ends of the bar.
685                            .relative()
686                            // Room for the end tabs' curves inside the viewport.
687                            .px(overhang)
688                            .pt(tabs_top_padding)
689                            .gap(gap)
690                            .overflow_x_scroll()
691                            .lock_scroll_axis()
692                            .track_scroll(&scroll_handle)
693                            .children(rendered_tabs)
694                            .when(has_suffix_or_menu, |this| this.child(self.last_empty_space)),
695                    )
696                    // Outside the tracked children so tab indices stay unchanged.
697                    .child(ScrollableMask::vertical_wheel(&scroll_handle)),
698            )
699            .when(self.menu, |this| {
700                this.child(
701                    Button::new("more")
702                        .xsmall()
703                        .ghost()
704                        .dropdown_caret(true)
705                        .dropdown_menu(move |mut this, _, _| {
706                            this = this.scrollable(true);
707                            for (ix, (label, icon, disabled, on_click)) in
708                                item_metas.iter().enumerate()
709                            {
710                                let base = if let Some(label) = label.clone() {
711                                    PopupMenuItem::new(label)
712                                } else if let Some(icon) = icon.clone() {
713                                    PopupMenuItem::element(move |_, _| icon.clone())
714                                } else {
715                                    PopupMenuItem::new(t!("Dock.Unnamed"))
716                                };
717                                this = this.item(
718                                    base.checked(selected_index == Some(ix))
719                                        .disabled(*disabled)
720                                        .when_some(on_click.clone(), |this, on_click| {
721                                            this.on_click(move |event, window, cx| {
722                                                on_click(event, window, cx)
723                                            })
724                                        }),
725                                );
726                            }
727
728                            this
729                        })
730                        .anchor(Anchor::TopRight),
731                )
732            })
733            .when_some(self.suffix, |this, suffix| this.child(suffix))
734    }
735}
736
737type TabClickHandler = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>;
738
739/// Scrolls a clicked tab into view, the way an `NSScrollView` reveals a
740/// clicked row, and leaves part of the tab beyond it showing so the strip
741/// visibly continues. Only a click starts this; selection, tab and layout
742/// changes leave the offset where the user put it.
743#[derive(Clone)]
744struct TabReveal {
745    spring_id: SharedString,
746    /// The offset a reveal is travelling to, while one is.
747    target: Entity<Option<RevealTarget>>,
748    scroll_handle: ScrollHandle,
749    view: EntityId,
750    /// How much of the neighbouring tab stays visible.
751    peek: Pixels,
752    num_tabs: usize,
753}
754
755#[derive(Clone, Copy)]
756struct RevealTarget {
757    to: Pixels,
758    /// The offset the previous frame set, `None` before the first frame.
759    last: Option<Pixels>,
760}
761
762impl TabReveal {
763    fn reveal(&self, ix: usize, cx: &mut App) {
764        let Some(to) = self.offset_revealing(ix) else {
765            return;
766        };
767        let running = self.target.read(cx).is_some();
768        if !running && to == self.scroll_handle.offset().x {
769            return;
770        }
771        self.target.update(cx, |target, _| match target {
772            // Retargeting keeps the spring's velocity.
773            Some(target) => target.to = to,
774            None => *target = Some(RevealTarget { to, last: None }),
775        });
776        cx.notify(self.view);
777    }
778
779    /// The horizontal offset that shows tab `ix` and `peek` of each neighbour,
780    /// moving as little as possible. The tab itself wins on a narrow bar.
781    fn offset_revealing(&self, ix: usize) -> Option<Pixels> {
782        let handle = &self.scroll_handle;
783        let viewport = handle.bounds();
784        let tab = handle.bounds_for_item(ix)?;
785        let max = handle.max_offset().x;
786        // Child bounds are laid out unscrolled: content at x is on screen at
787        // x + offset, so the visible content spans `viewport - offset`.
788        let want_left = match ix.checked_sub(1).and_then(|ix| handle.bounds_for_item(ix)) {
789            Some(prev) => (prev.right() - self.peek).max(prev.left()),
790            None => viewport.left(),
791        };
792        let want_right = match (ix + 1 < self.num_tabs)
793            .then(|| handle.bounds_for_item(ix + 1))
794            .flatten()
795        {
796            Some(next) => (next.left() + self.peek).min(next.right()),
797            None => viewport.right() + max,
798        };
799
800        let mut offset = handle.offset().x;
801        let show_left = |offset: &mut Pixels, left: Pixels| {
802            if left < viewport.left() - *offset {
803                *offset = viewport.left() - left;
804            }
805        };
806        let show_right = |offset: &mut Pixels, right: Pixels| {
807            if right > viewport.right() - *offset {
808                *offset = viewport.right() - right;
809            }
810        };
811        // Whichever is applied last wins: the neighbour the click heads
812        // toward over the one behind it, and the tab over both.
813        if tab.center().x + offset > viewport.center().x {
814            show_left(&mut offset, want_left);
815            show_right(&mut offset, want_right);
816        } else {
817            show_right(&mut offset, want_right);
818            show_left(&mut offset, want_left);
819        }
820        show_right(&mut offset, tab.right());
821        show_left(&mut offset, tab.left());
822        Some(offset.min(px(0.)).max(-max))
823    }
824
825    /// Steps a running reveal by one frame.
826    fn advance(&self, window: &mut Window, cx: &mut App) {
827        let Some(target) = *self.target.read(cx) else {
828            return;
829        };
830        let offset = self.scroll_handle.offset();
831        // A wheel or trackpad scroll since the last frame hands the strip back.
832        if target.last.is_some_and(|last| last != offset.x) {
833            self.target.update(cx, |target, _| *target = None);
834            return;
835        }
836
837        let policy = cx.theme().motion_tokens().spring_move;
838        if target.last.is_none() {
839            // Start from where the strip is, not where a previous reveal ended.
840            spring(
841                self.spring_id.clone(),
842                offset.x,
843                policy.with_travel(false),
844                window,
845                cx,
846            );
847        }
848        let x = spring(self.spring_id.clone(), target.to, policy, window, cx);
849        self.scroll_handle.set_offset(point(x, offset.y));
850        self.target.update(cx, |state, _| {
851            *state = (x != target.to).then_some(RevealTarget {
852                last: Some(x),
853                ..target
854            })
855        });
856    }
857}
858
859#[cfg(test)]
860mod tests {
861    use std::{
862        cell::{Cell, RefCell},
863        rc::Rc,
864    };
865
866    use gpui::{Context, Modifiers, MouseButton, MouseUpEvent, Render, TestAppContext};
867
868    use super::*;
869    use crate::scroll::ScrollableElement as _;
870
871    struct Harness {
872        group_handler: bool,
873        disabled: bool,
874        child_clicks: Rc<Cell<usize>>,
875        group_clicks: Rc<Cell<usize>>,
876    }
877
878    impl Render for Harness {
879        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
880            let child_clicks = self.child_clicks.clone();
881            let group_clicks = self.group_clicks.clone();
882            TabBar::new("tabs")
883                .w(px(240.))
884                .child(
885                    Tab::new()
886                        .debug_selector(|| "first-tab".into())
887                        .disabled(self.disabled)
888                        .label("First")
889                        .on_click(move |_, _, _| child_clicks.set(child_clicks.get() + 1)),
890                )
891                .when(self.group_handler, |tabs| {
892                    tabs.on_click(move |ix, _, _| group_clicks.set(*ix + 1))
893                })
894        }
895    }
896
897    fn harness(
898        cx: &mut TestAppContext,
899        group_handler: bool,
900        disabled: bool,
901    ) -> (
902        &mut gpui::VisualTestContext,
903        Rc<Cell<usize>>,
904        Rc<Cell<usize>>,
905    ) {
906        cx.update(crate::theme::init);
907        let child_clicks = Rc::new(Cell::new(0));
908        let group_clicks = Rc::new(Cell::new(0));
909        let (_, cx) = cx.add_window_view({
910            let child_clicks = child_clicks.clone();
911            let group_clicks = group_clicks.clone();
912            move |_, _| Harness {
913                group_handler,
914                disabled,
915                child_clicks,
916                group_clicks,
917            }
918        });
919        cx.update(|window, cx| window.draw(cx).clear(cx));
920        (cx, child_clicks, group_clicks)
921    }
922
923    #[gpui::test]
924    fn group_callback_overrides_child_callback(cx: &mut TestAppContext) {
925        let (cx, child_clicks, group_clicks) = harness(cx, true, false);
926        let position = cx.debug_bounds("first-tab").unwrap().center();
927        cx.simulate_click(position, Modifiers::default());
928        assert_eq!(child_clicks.get(), 0);
929        assert_eq!(group_clicks.get(), 1);
930    }
931
932    #[gpui::test]
933    fn child_callback_is_preserved_without_group_callback(cx: &mut TestAppContext) {
934        let (cx, child_clicks, group_clicks) = harness(cx, false, false);
935        let position = cx.debug_bounds("first-tab").unwrap().center();
936        cx.simulate_click(position, Modifiers::default());
937        assert_eq!(child_clicks.get(), 1);
938        assert_eq!(group_clicks.get(), 0);
939    }
940
941    #[gpui::test]
942    fn disabled_tab_suppresses_child_and_group_callbacks(cx: &mut TestAppContext) {
943        let (cx, child_clicks, group_clicks) = harness(cx, true, true);
944        let position = cx.debug_bounds("first-tab").unwrap().center();
945        cx.simulate_click(position, Modifiers::default());
946        assert_eq!(child_clicks.get(), 0);
947        assert_eq!(group_clicks.get(), 0);
948    }
949
950    struct MenuHarness {
951        group_handler: bool,
952        clicks: Rc<RefCell<Vec<String>>>,
953        events: Rc<RefCell<Vec<ClickEvent>>>,
954    }
955
956    impl Render for MenuHarness {
957        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
958            let events = self.events.clone();
959            let tab = |label: &'static str, clicks: Rc<RefCell<Vec<String>>>| {
960                let events = events.clone();
961                Tab::new()
962                    .debug_selector(move || format!("{label}-tab"))
963                    .label(label)
964                    .on_click(move |event, _, _| {
965                        clicks.borrow_mut().push(format!("tab {label}"));
966                        events.borrow_mut().push(event.clone());
967                    })
968            };
969            let clicks = self.clicks.clone();
970            div().size_full().child(
971                TabBar::new("menu-tabs")
972                    .w(px(240.))
973                    .menu(true)
974                    .child(tab("First", self.clicks.clone()))
975                    .child(tab("Second", self.clicks.clone()))
976                    .when(self.group_handler, |tabs| {
977                        tabs.on_click(move |ix, _, _| clicks.borrow_mut().push(format!("bar {ix}")))
978                    }),
979            )
980        }
981    }
982
983    /// Opens the overflow menu and activates its first item.
984    fn choose_first_menu_item(
985        group_handler: bool,
986        activate: impl FnOnce(&mut gpui::VisualTestContext),
987        cx: &mut TestAppContext,
988    ) -> (Vec<String>, Vec<ClickEvent>) {
989        cx.update(crate::init);
990        let clicks = Rc::new(RefCell::new(Vec::new()));
991        let events = Rc::new(RefCell::new(Vec::new()));
992        let (_, cx) = cx.add_window_view({
993            let clicks = clicks.clone();
994            let events = events.clone();
995            move |_, _| MenuHarness {
996                group_handler,
997                clicks,
998                events,
999            }
1000        });
1001        cx.update(|window, cx| window.draw(cx).clear(cx));
1002        let tab = cx.debug_bounds("First-tab").unwrap();
1003        // The menu button is the last thing in the 240px bar.
1004        let more = point(px(232.), tab.center().y);
1005        cx.simulate_mouse_down(more, MouseButton::Left, Modifiers::default());
1006        cx.simulate_event(MouseUpEvent {
1007            position: more,
1008            button: MouseButton::Left,
1009            modifiers: Modifiers::default(),
1010            click_count: 1,
1011        });
1012        cx.run_until_parked();
1013        cx.update(|window, cx| window.draw(cx).clear(cx));
1014        activate(cx);
1015        cx.run_until_parked();
1016        (clicks.borrow().clone(), events.borrow().clone())
1017    }
1018
1019    #[gpui::test]
1020    fn overflow_menu_runs_the_tab_callback_without_a_bar_callback(cx: &mut TestAppContext) {
1021        assert_eq!(
1022            choose_first_menu_item(false, |cx| cx.simulate_keystrokes("down enter"), cx).0,
1023            vec!["tab First".to_string()]
1024        );
1025    }
1026
1027    #[gpui::test]
1028    fn overflow_menu_prefers_the_bar_callback(cx: &mut TestAppContext) {
1029        assert_eq!(
1030            choose_first_menu_item(true, |cx| cx.simulate_keystrokes("down enter"), cx).0,
1031            vec!["bar 0".to_string()]
1032        );
1033    }
1034
1035    #[cfg(feature = "test-support")]
1036    #[gpui::test]
1037    fn overflow_menu_preserves_pointer_events_and_keyboard_activation(cx: &mut TestAppContext) {
1038        let position = Cell::new(point(px(0.), px(0.)));
1039        let (clicks, events) = choose_first_menu_item(
1040            false,
1041            |cx| {
1042                let item = cx.update(|window, _| {
1043                    gpui_base::test_support::find_all(window, &[], &ElementId::from(0usize))
1044                        .into_iter()
1045                        .find(|item| item.role() == Some(gpui::Role::MenuItem))
1046                        .expect("first overflow menu item")
1047                });
1048                position.set(item.bounds().center());
1049                cx.simulate_click(
1050                    position.get(),
1051                    Modifiers {
1052                        shift: true,
1053                        ..Modifiers::default()
1054                    },
1055                );
1056            },
1057            cx,
1058        );
1059        assert_eq!(clicks, vec!["tab First"]);
1060        assert_eq!(events.len(), 1);
1061        assert!(matches!(events[0], ClickEvent::Mouse(_)));
1062        assert!(events[0].modifiers().shift);
1063        assert_eq!(events[0].mouse_position(), Some(position.get()));
1064
1065        let (clicks, events) =
1066            choose_first_menu_item(false, |cx| cx.simulate_keystrokes("down enter"), cx);
1067        assert_eq!(clicks, vec!["tab First"]);
1068        assert_eq!(events.len(), 1);
1069        assert!(matches!(events[0], ClickEvent::Keyboard(_)));
1070    }
1071
1072    struct ContentHarness;
1073
1074    impl Render for ContentHarness {
1075        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1076            TabBar::new("content-tabs").w(px(320.)).child(
1077                Tab::new()
1078                    .prefix(div().debug_selector(|| "tab-prefix".into()).child("P"))
1079                    .child(div().debug_selector(|| "tab-child".into()).child("Content"))
1080                    .suffix(div().debug_selector(|| "tab-suffix".into()).child("S")),
1081            )
1082        }
1083    }
1084
1085    #[gpui::test]
1086    fn prefix_content_and_suffix_keep_their_order(cx: &mut TestAppContext) {
1087        cx.update(crate::theme::init);
1088        let (_, cx) = cx.add_window_view(|_, _| ContentHarness);
1089        cx.update(|window, cx| window.draw(cx).clear(cx));
1090
1091        let prefix = cx.debug_bounds("tab-prefix").unwrap();
1092        let child = cx.debug_bounds("tab-child").unwrap();
1093        let suffix = cx.debug_bounds("tab-suffix").unwrap();
1094        assert!(prefix.origin.x < child.origin.x);
1095        assert!(child.origin.x < suffix.origin.x);
1096        assert!(prefix.size.width > px(0.));
1097        assert!(child.size.width > px(0.));
1098        assert!(suffix.size.width > px(0.));
1099    }
1100
1101    struct ScrollHarness {
1102        scroll_handle: ScrollHandle,
1103    }
1104
1105    struct DynamicScrollHarness {
1106        scroll_handle: ScrollHandle,
1107        menu: bool,
1108        size: Size,
1109        tabs: usize,
1110        selected_index: usize,
1111    }
1112
1113    struct ManualScrollHarness {
1114        scroll_handle: ScrollHandle,
1115        tabs: usize,
1116        selected_index: usize,
1117        label: &'static str,
1118        top: Pixels,
1119    }
1120
1121    impl ScrollHarness {
1122        fn tabs() -> impl Iterator<Item = Tab> {
1123            (0..5).map(|ix| {
1124                Tab::new()
1125                    .w(px(60.))
1126                    .label(format!("Tab {ix}"))
1127                    .debug_selector(move || format!("tab-{ix}"))
1128            })
1129        }
1130    }
1131
1132    impl Render for ScrollHarness {
1133        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1134            div().w(px(100.)).child(
1135                TabBar::new("scrolling-tabs")
1136                    .w_full()
1137                    .segmented()
1138                    .menu(true)
1139                    .track_scroll(&self.scroll_handle)
1140                    .selected_index(4)
1141                    .children(Self::tabs()),
1142            )
1143        }
1144    }
1145
1146    impl DynamicScrollHarness {
1147        fn tabs(&self) -> impl Iterator<Item = Tab> {
1148            (0..self.tabs).map(|ix| {
1149                Tab::new()
1150                    .w(px(60.))
1151                    .label(format!("Tab {ix}"))
1152                    .debug_selector(move || format!("dynamic-tab-{ix}"))
1153            })
1154        }
1155    }
1156
1157    impl Render for DynamicScrollHarness {
1158        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1159            div()
1160                .w(px(100.))
1161                .debug_selector(|| "dynamic-bar".into())
1162                .child(
1163                    TabBar::new("dynamic-scrolling-tabs")
1164                        .with_size(self.size)
1165                        .w_full()
1166                        .segmented()
1167                        .menu(self.menu)
1168                        .track_scroll(&self.scroll_handle)
1169                        .selected_index(self.selected_index)
1170                        .children(self.tabs()),
1171                )
1172        }
1173    }
1174
1175    impl ManualScrollHarness {
1176        fn tabs(&self) -> impl Iterator<Item = Tab> {
1177            let width = if self.label == "old" {
1178                px(60.)
1179            } else {
1180                px(120.)
1181            };
1182            let label = self.label;
1183            (0..self.tabs).map(move |ix| {
1184                Tab::new()
1185                    .w(width)
1186                    .label(format!("Tab {ix} {label}"))
1187                    .debug_selector(move || format!("manual-tab-{ix}"))
1188            })
1189        }
1190    }
1191
1192    impl Render for ManualScrollHarness {
1193        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1194            div().w(px(160.)).h(px(40.)).child(
1195                div().relative().top(self.top).w_full().child(
1196                    TabBar::new("manual-scrolling-tabs")
1197                        .w_full()
1198                        .segmented()
1199                        .menu(true)
1200                        .track_scroll(&self.scroll_handle)
1201                        .selected_index(self.selected_index)
1202                        .children(self.tabs()),
1203                ),
1204            )
1205        }
1206    }
1207
1208    fn draw(cx: &mut gpui::VisualTestContext) {
1209        cx.run_until_parked();
1210        cx.update(|window, cx| window.draw(cx).clear(cx));
1211    }
1212
1213    #[gpui::test]
1214    fn scrolling_to_a_tab_uses_logical_tab_indices(cx: &mut TestAppContext) {
1215        cx.update(crate::theme::init);
1216        let scroll_handle = ScrollHandle::new();
1217        let (_, cx) = cx.add_window_view({
1218            let scroll_handle = scroll_handle.clone();
1219            move |_, _| ScrollHarness { scroll_handle }
1220        });
1221
1222        draw(cx);
1223        draw(cx);
1224        assert_eq!(scroll_handle.offset().x, px(0.));
1225        scroll_handle.scroll_to_item(4);
1226        draw(cx);
1227        draw(cx);
1228
1229        let viewport = scroll_handle.bounds();
1230        let last_tab = cx.debug_bounds("tab-4").unwrap();
1231        assert!(
1232            last_tab.left() >= viewport.left(),
1233            "last tab {last_tab:?} is left of viewport {viewport:?}, offset {:?}",
1234            scroll_handle.offset()
1235        );
1236        assert!(
1237            last_tab.right() <= viewport.right(),
1238            "last tab {last_tab:?} is right of viewport {viewport:?}, offset {:?}",
1239            scroll_handle.offset()
1240        );
1241        assert_eq!(scroll_handle.children_count(), 6);
1242    }
1243
1244    #[gpui::test]
1245    fn scrolling_to_a_new_tab_preserves_the_explicit_target(cx: &mut TestAppContext) {
1246        cx.update(crate::theme::init);
1247        let scroll_handle = ScrollHandle::new();
1248        let (view, cx) = cx.add_window_view({
1249            let scroll_handle = scroll_handle.clone();
1250            move |_, _| DynamicScrollHarness {
1251                scroll_handle,
1252                menu: true,
1253                size: Size::default(),
1254                tabs: 4,
1255                selected_index: 3,
1256            }
1257        });
1258
1259        draw(cx);
1260        draw(cx);
1261
1262        view.update(cx, |view, cx| {
1263            view.tabs = 5;
1264            view.selected_index = 4;
1265            view.scroll_handle.scroll_to_item(4);
1266            cx.notify();
1267        });
1268        draw(cx);
1269
1270        let viewport = scroll_handle.bounds();
1271        let last_tab = cx.debug_bounds("dynamic-tab-4").unwrap();
1272        assert!(last_tab.left() >= viewport.left());
1273        assert!(last_tab.right() <= viewport.right());
1274        assert_eq!(scroll_handle.children_count(), 6);
1275    }
1276
1277    #[gpui::test]
1278    fn scrolling_to_a_new_tab_preserves_bar_padding(cx: &mut TestAppContext) {
1279        cx.update(crate::theme::init);
1280        for (size, padding) in [
1281            (Size::XSmall, px(2.)),
1282            (Size::Small, px(3.)),
1283            (Size::Medium, px(4.)),
1284            (Size::Large, px(4.)),
1285        ] {
1286            let scroll_handle = ScrollHandle::new();
1287            let (view, cx) = cx.add_window_view({
1288                let scroll_handle = scroll_handle.clone();
1289                move |_, _| DynamicScrollHarness {
1290                    scroll_handle,
1291                    menu: false,
1292                    size,
1293                    tabs: 4,
1294                    selected_index: 0,
1295                }
1296            });
1297            draw(cx);
1298            draw(cx);
1299            view.update(cx, |view, cx| {
1300                view.tabs = 5;
1301                view.scroll_handle.scroll_to_item(4);
1302                cx.notify();
1303            });
1304            draw(cx);
1305            draw(cx);
1306            let bar = cx.debug_bounds("dynamic-bar").unwrap();
1307            let last_tab = cx.debug_bounds("dynamic-tab-4").unwrap();
1308            assert_eq!(
1309                bar.right() - last_tab.right(),
1310                padding,
1311                "right padding for {size:?}"
1312            );
1313            scroll_handle.scroll_to_item(0);
1314            draw(cx);
1315            draw(cx);
1316            let first_tab = cx.debug_bounds("dynamic-tab-0").unwrap();
1317            assert_eq!(
1318                first_tab.left() - bar.left(),
1319                padding,
1320                "left padding for {size:?}"
1321            );
1322            assert_eq!(scroll_handle.children_count(), 5);
1323        }
1324    }
1325
1326    #[gpui::test]
1327    fn closing_an_unselected_trailing_tab_preserves_manual_scrolling(cx: &mut TestAppContext) {
1328        cx.update(crate::theme::init);
1329        let scroll_handle = ScrollHandle::new();
1330        let (view, cx) = cx.add_window_view({
1331            let scroll_handle = scroll_handle.clone();
1332            move |_, _| ManualScrollHarness {
1333                scroll_handle,
1334                tabs: 6,
1335                selected_index: 0,
1336                label: "old",
1337                top: px(0.),
1338            }
1339        });
1340
1341        draw(cx);
1342        scroll_handle.set_offset(gpui::point(px(-100.), px(0.)));
1343        draw(cx);
1344        assert_eq!(scroll_handle.offset().x, px(-100.));
1345
1346        view.update(cx, |view, cx| {
1347            view.tabs = 5;
1348            cx.notify();
1349        });
1350        draw(cx);
1351
1352        assert_eq!(scroll_handle.offset().x, px(-100.));
1353    }
1354
1355    #[gpui::test]
1356    fn changing_selection_does_not_move_manual_scrolling(cx: &mut TestAppContext) {
1357        cx.update(crate::theme::init);
1358        let scroll_handle = ScrollHandle::new();
1359        let (view, cx) = cx.add_window_view({
1360            let scroll_handle = scroll_handle.clone();
1361            move |_, _| ManualScrollHarness {
1362                scroll_handle,
1363                tabs: 6,
1364                selected_index: 0,
1365                label: "old",
1366                top: px(0.),
1367            }
1368        });
1369
1370        draw(cx);
1371        scroll_handle.set_offset(gpui::point(px(-100.), px(0.)));
1372        draw(cx);
1373
1374        view.update(cx, |view, cx| {
1375            view.selected_index = 5;
1376            cx.notify();
1377        });
1378        draw(cx);
1379
1380        assert_eq!(scroll_handle.offset().x, px(-100.));
1381    }
1382
1383    #[gpui::test]
1384    fn changing_tab_labels_does_not_move_manual_scrolling(cx: &mut TestAppContext) {
1385        cx.update(crate::theme::init);
1386        let scroll_handle = ScrollHandle::new();
1387        let (view, cx) = cx.add_window_view({
1388            let scroll_handle = scroll_handle.clone();
1389            move |_, _| ManualScrollHarness {
1390                scroll_handle,
1391                tabs: 6,
1392                selected_index: 0,
1393                label: "old",
1394                top: px(0.),
1395            }
1396        });
1397
1398        draw(cx);
1399        scroll_handle.set_offset(gpui::point(px(-100.), px(0.)));
1400        draw(cx);
1401
1402        view.update(cx, |view, cx| {
1403            view.label = "new";
1404            cx.notify();
1405        });
1406        draw(cx);
1407
1408        assert_eq!(scroll_handle.offset().x, px(-100.));
1409    }
1410
1411    #[gpui::test]
1412    fn moving_the_tab_bar_preserves_manual_scrolling(cx: &mut TestAppContext) {
1413        cx.update(crate::theme::init);
1414        let scroll_handle = ScrollHandle::new();
1415        let (view, cx) = cx.add_window_view({
1416            let scroll_handle = scroll_handle.clone();
1417            move |_, _| ManualScrollHarness {
1418                scroll_handle,
1419                tabs: 6,
1420                selected_index: 0,
1421                label: "old",
1422                top: px(0.),
1423            }
1424        });
1425
1426        draw(cx);
1427        scroll_handle.set_offset(gpui::point(px(-100.), px(0.)));
1428        draw(cx);
1429        assert_eq!(scroll_handle.offset().x, px(-100.));
1430        let viewport_size = scroll_handle.bounds().size;
1431
1432        view.update(cx, |view, cx| {
1433            view.top = px(20.);
1434            cx.notify();
1435        });
1436        draw(cx);
1437
1438        assert_eq!(scroll_handle.bounds().size, viewport_size);
1439        assert_eq!(scroll_handle.offset().x, px(-100.));
1440    }
1441
1442    struct FlexHarness {
1443        variant: TabVariant,
1444    }
1445
1446    impl Render for FlexHarness {
1447        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1448            div()
1449                .w(px(200.))
1450                .debug_selector(|| "flex-bar".into())
1451                .child(
1452                    TabBar::new("flex-tabs")
1453                        .w_full()
1454                        .with_variant(self.variant)
1455                        .selected_index(0)
1456                        .child(
1457                            Tab::new()
1458                                .flex_1()
1459                                .label("A")
1460                                .debug_selector(|| "flex-tab-0".into()),
1461                        )
1462                        .child(
1463                            Tab::new()
1464                                .flex_1()
1465                                .label("B")
1466                                .debug_selector(|| "flex-tab-1".into()),
1467                        ),
1468                )
1469        }
1470    }
1471
1472    #[gpui::test]
1473    fn flex_tabs_share_the_available_width(cx: &mut TestAppContext) {
1474        cx.update(crate::theme::init);
1475        for variant in [
1476            TabVariant::Tab,
1477            TabVariant::Outline,
1478            TabVariant::Segmented,
1479            TabVariant::Pill,
1480            TabVariant::Underline,
1481        ] {
1482            let (_, cx) = cx.add_window_view(move |_, _| FlexHarness { variant });
1483            draw(cx);
1484            draw(cx);
1485
1486            let bar = cx.debug_bounds("flex-bar").unwrap();
1487            let first = cx.debug_bounds("flex-tab-0").unwrap();
1488            let second = cx.debug_bounds("flex-tab-1").unwrap();
1489            assert_eq!(first.size.width, second.size.width, "{variant:?}");
1490            // Only the bar's own padding may remain on either side.
1491            assert_eq!(
1492                first.left() - bar.left(),
1493                bar.right() - second.right(),
1494                "{variant:?}"
1495            );
1496            assert!(
1497                bar.right() - second.right() <= px(4.),
1498                "{variant:?}: tabs end at {:?} but the bar ends at {:?}",
1499                second.right(),
1500                bar.right()
1501            );
1502        }
1503    }
1504
1505    struct ClickRevealHarness {
1506        group_handler: bool,
1507        scroll_handle: Option<ScrollHandle>,
1508    }
1509
1510    impl Render for ClickRevealHarness {
1511        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1512            // Neither callback notifies.
1513            div()
1514                .w(px(100.))
1515                .debug_selector(|| "click-bar".into())
1516                .child(
1517                    TabBar::new("click-reveal-tabs")
1518                        .w_full()
1519                        .when_some(self.scroll_handle.as_ref(), |tabs, handle| {
1520                            tabs.track_scroll(handle)
1521                        })
1522                        .children((0..5).map(|ix| {
1523                            Tab::new()
1524                                .w(px(60.))
1525                                .label(format!("Tab {ix}"))
1526                                .debug_selector(move || format!("click-tab-{ix}"))
1527                                .on_click(|_, _, _| {})
1528                        }))
1529                        .when(self.group_handler, |tabs| tabs.on_click(|_, _, _| {})),
1530                )
1531        }
1532    }
1533
1534    fn click_reveal_harness(
1535        cx: &mut TestAppContext,
1536        group_handler: bool,
1537        scroll_handle: Option<ScrollHandle>,
1538        reduce_motion: bool,
1539    ) -> &mut gpui::VisualTestContext {
1540        cx.update(|cx| {
1541            crate::theme::init(cx);
1542            cx.set_reduce_motion(reduce_motion);
1543        });
1544        let (_, cx) = cx.add_window_view(move |_, _| ClickRevealHarness {
1545            group_handler,
1546            scroll_handle,
1547        });
1548        draw(cx);
1549        cx
1550    }
1551
1552    fn next_frame(cx: &mut gpui::VisualTestContext) {
1553        cx.executor()
1554            .advance_clock(std::time::Duration::from_millis(16));
1555        cx.update(|window, cx| window.simulate_next_frame(cx));
1556        draw(cx);
1557    }
1558
1559    #[gpui::test]
1560    fn clicking_a_clipped_tab_reveals_it_and_part_of_the_next(cx: &mut TestAppContext) {
1561        for group_handler in [false, true] {
1562            let cx = click_reveal_harness(cx, group_handler, None, true);
1563            let bar = cx.debug_bounds("click-bar").unwrap();
1564            let tab = cx.debug_bounds("click-tab-1").unwrap();
1565            assert!(tab.right() > bar.right(), "tab 1 starts clipped");
1566
1567            cx.simulate_click(tab.center(), Modifiers::default());
1568            draw(cx);
1569
1570            let tab = cx.debug_bounds("click-tab-1").unwrap();
1571            let next = cx.debug_bounds("click-tab-2").unwrap();
1572            assert!(tab.left() >= bar.left() && tab.right() <= bar.right());
1573            assert_eq!(bar.right() - next.left(), px(32.), "group: {group_handler}");
1574        }
1575    }
1576
1577    #[gpui::test]
1578    fn clicking_the_last_tab_reveals_the_end_of_the_bar(cx: &mut TestAppContext) {
1579        let scroll_handle = ScrollHandle::new();
1580        let cx = click_reveal_harness(cx, true, Some(scroll_handle.clone()), true);
1581        scroll_handle.set_offset(gpui::point(px(-160.), px(0.)));
1582        draw(cx);
1583
1584        let tab = cx.debug_bounds("click-tab-4").unwrap();
1585        cx.simulate_click(
1586            tab.origin + gpui::point(px(10.), px(10.)),
1587            Modifiers::default(),
1588        );
1589        draw(cx);
1590
1591        assert_eq!(scroll_handle.offset().x, -scroll_handle.max_offset().x);
1592    }
1593
1594    #[gpui::test]
1595    fn reveal_animates_and_yields_to_manual_scrolling(cx: &mut TestAppContext) {
1596        let scroll_handle = ScrollHandle::new();
1597        let cx = click_reveal_harness(cx, true, Some(scroll_handle.clone()), false);
1598
1599        let tab = cx.debug_bounds("click-tab-1").unwrap();
1600        cx.simulate_click(tab.center(), Modifiers::default());
1601        draw(cx);
1602        next_frame(cx);
1603        let moving = scroll_handle.offset().x;
1604        assert!(
1605            moving < px(0.) && moving > px(-52.),
1606            "mid-flight at {moving:?}"
1607        );
1608
1609        // The user scrolls while the reveal is still travelling.
1610        wheel(
1611            cx,
1612            scroll_handle.bounds().center(),
1613            gpui::ScrollDelta::Lines(point(0., 1.)),
1614            gpui::TouchPhase::Moved,
1615        );
1616        let manual = scroll_handle.offset().x;
1617        assert!(manual > moving);
1618        let after_wheel = cx.debug_bounds("click-tab-1").unwrap();
1619        for _ in 0..30 {
1620            next_frame(cx);
1621        }
1622        assert_eq!(scroll_handle.offset().x, manual);
1623        assert_eq!(cx.debug_bounds("click-tab-1").unwrap(), after_wheel);
1624
1625        // A new click travels all the way.
1626        cx.simulate_click(
1627            tab.center() - gpui::point(px(10.), px(0.)),
1628            Modifiers::default(),
1629        );
1630        for _ in 0..60 {
1631            next_frame(cx);
1632        }
1633        assert_eq!(scroll_handle.offset().x, px(-52.));
1634    }
1635
1636    struct WheelHarness {
1637        scroll_handle: Option<ScrollHandle>,
1638        variant: TabVariant,
1639    }
1640
1641    fn wheel_tabs(handle: Option<&ScrollHandle>, variant: TabVariant, tabs: usize) -> TabBar {
1642        TabBar::new("wheel-tabs")
1643            .w_full()
1644            .with_variant(variant)
1645            .when_some(handle, |tabs, handle| tabs.track_scroll(handle))
1646            .children((0..tabs).map(|ix| {
1647                Tab::new()
1648                    .w(px(60.))
1649                    .label(format!("Tab {ix}"))
1650                    .debug_selector(move || format!("wheel-tab-{ix}"))
1651            }))
1652    }
1653
1654    impl Render for WheelHarness {
1655        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1656            div()
1657                .w(px(160.))
1658                .debug_selector(|| "wheel-bar".into())
1659                .child(wheel_tabs(self.scroll_handle.as_ref(), self.variant, 6))
1660        }
1661    }
1662
1663    fn wheel(
1664        cx: &mut gpui::VisualTestContext,
1665        position: gpui::Point<Pixels>,
1666        delta: gpui::ScrollDelta,
1667        touch_phase: gpui::TouchPhase,
1668    ) {
1669        cx.simulate_event(gpui::ScrollWheelEvent {
1670            position,
1671            delta,
1672            touch_phase,
1673            ..Default::default()
1674        });
1675        draw(cx);
1676    }
1677
1678    #[gpui::test]
1679    fn vertical_mouse_wheel_scrolls_each_variant_with_and_without_a_tracked_handle(
1680        cx: &mut TestAppContext,
1681    ) {
1682        cx.update(crate::theme::init);
1683        for variant in [
1684            TabVariant::Tab,
1685            TabVariant::Outline,
1686            TabVariant::Segmented,
1687            TabVariant::Pill,
1688            TabVariant::Underline,
1689            TabVariant::Folder,
1690        ] {
1691            for tracked in [false, true] {
1692                let scroll_handle = tracked.then(ScrollHandle::new);
1693                let (_, cx) = cx.add_window_view({
1694                    let scroll_handle = scroll_handle.clone();
1695                    move |_, _| WheelHarness {
1696                        scroll_handle,
1697                        variant,
1698                    }
1699                });
1700                draw(cx);
1701                draw(cx);
1702                let bar = cx.debug_bounds("wheel-bar").unwrap();
1703                let first_before = cx.debug_bounds("wheel-tab-0").unwrap();
1704                let last_before = cx.debug_bounds("wheel-tab-5").unwrap();
1705                assert!(
1706                    last_before.left() > bar.right(),
1707                    "{variant:?}, tracked: {tracked}"
1708                );
1709
1710                wheel(
1711                    cx,
1712                    first_before.center(),
1713                    gpui::ScrollDelta::Lines(point(0., -100.)),
1714                    gpui::TouchPhase::Moved,
1715                );
1716                let first_after = cx.debug_bounds("wheel-tab-0").unwrap();
1717                let last_after = cx.debug_bounds("wheel-tab-5").unwrap();
1718                assert!(
1719                    first_after.left() < first_before.left(),
1720                    "{variant:?}, tracked: {tracked}, bar {bar:?}, first {first_before:?}, after {first_after:?}"
1721                );
1722                assert!(last_after.left() >= bar.left() && last_after.right() <= bar.right());
1723                if let Some(handle) = &scroll_handle {
1724                    assert_eq!(handle.offset().x, -handle.max_offset().x);
1725                    assert_eq!(handle.offset().y, px(0.));
1726                }
1727
1728                wheel(
1729                    cx,
1730                    first_before.center(),
1731                    gpui::ScrollDelta::Lines(point(0., 100.)),
1732                    gpui::TouchPhase::Moved,
1733                );
1734                assert_eq!(cx.debug_bounds("wheel-tab-0").unwrap(), first_before);
1735                if let Some(handle) = &scroll_handle {
1736                    assert_eq!(handle.offset(), point(px(0.), px(0.)));
1737                }
1738            }
1739        }
1740    }
1741
1742    #[derive(Clone)]
1743    enum WheelParent {
1744        Div(ScrollHandle),
1745        List(gpui::ListState),
1746        Scrollable,
1747    }
1748
1749    impl WheelParent {
1750        fn offset(&self) -> Option<(usize, Pixels)> {
1751            match self {
1752                Self::Div(handle) => Some((0, -handle.offset().y)),
1753                Self::List(state) => {
1754                    let offset = state.logical_scroll_top();
1755                    Some((offset.item_ix, offset.offset_in_item))
1756                }
1757                // Component Scrollable keeps its viewport handle private. Its
1758                // actual movement is checked through the tab's rendered Y bound.
1759                Self::Scrollable => None,
1760            }
1761        }
1762    }
1763
1764    struct NestedWheelHarness {
1765        parent: WheelParent,
1766        scroll_handle: ScrollHandle,
1767        tabs: usize,
1768    }
1769
1770    impl Render for NestedWheelHarness {
1771        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1772            match &self.parent {
1773                WheelParent::Div(parent) => crate::v_flex()
1774                    .id("wheel-parent")
1775                    .w(px(160.))
1776                    .h(px(100.))
1777                    .overflow_y_scroll()
1778                    .lock_scroll_axis()
1779                    .track_scroll(parent)
1780                    .child(div().w_full().h(px(60.)).flex_shrink_0().child(wheel_tabs(
1781                        Some(&self.scroll_handle),
1782                        TabVariant::Tab,
1783                        self.tabs,
1784                    )))
1785                    .child(div().h(px(400.)).flex_shrink_0())
1786                    .into_any_element(),
1787                WheelParent::Scrollable => crate::v_flex()
1788                    .w(px(160.))
1789                    .h(px(100.))
1790                    .child(div().w_full().h(px(60.)).flex_shrink_0().child(wheel_tabs(
1791                        Some(&self.scroll_handle),
1792                        TabVariant::Tab,
1793                        self.tabs,
1794                    )))
1795                    .child(div().h(px(400.)).flex_shrink_0())
1796                    .overflow_y_scrollbar()
1797                    .into_any_element(),
1798                WheelParent::List(parent) => {
1799                    let handle = self.scroll_handle.clone();
1800                    let tabs = self.tabs;
1801                    div()
1802                        .w(px(160.))
1803                        .h(px(100.))
1804                        .child(
1805                            gpui::list(parent.clone(), move |ix, _, _| {
1806                                if ix == 0 {
1807                                    div()
1808                                        .w_full()
1809                                        .h(px(60.))
1810                                        .child(wheel_tabs(Some(&handle), TabVariant::Tab, tabs))
1811                                        .into_any_element()
1812                                } else {
1813                                    div().w_full().h(px(40.)).into_any_element()
1814                                }
1815                            })
1816                            .w_full()
1817                            .h_full(),
1818                        )
1819                        .into_any_element()
1820                }
1821            }
1822        }
1823    }
1824
1825    #[gpui::test]
1826    fn vertical_mouse_wheel_uses_tabs_before_scrolling_ancestors(cx: &mut TestAppContext) {
1827        cx.update(crate::theme::init);
1828        for parent_kind in 0..3 {
1829            for (tabs, at_end) in [(6, false), (6, true), (1, false)] {
1830                let scroll_handle = ScrollHandle::new();
1831                let parent = match parent_kind {
1832                    0 => WheelParent::Div(ScrollHandle::new()),
1833                    1 => WheelParent::List(
1834                        gpui::ListState::new(10, gpui::ListAlignment::Top, px(0.)).measure_all(),
1835                    ),
1836                    _ => WheelParent::Scrollable,
1837                };
1838                let (_, cx) = cx.add_window_view({
1839                    let scroll_handle = scroll_handle.clone();
1840                    let parent = parent.clone();
1841                    move |_, _| NestedWheelHarness {
1842                        parent,
1843                        scroll_handle,
1844                        tabs,
1845                    }
1846                });
1847                draw(cx);
1848                draw(cx);
1849                if let WheelParent::List(state) = &parent {
1850                    assert!(state.max_offset_for_scrollbar().y > px(0.));
1851                }
1852                if at_end {
1853                    scroll_handle.set_offset(point(-scroll_handle.max_offset().x, px(0.)));
1854                    draw(cx);
1855                }
1856                let before_x = scroll_handle.offset().x;
1857                let first_before = cx.debug_bounds("wheel-tab-0").unwrap();
1858                let position = scroll_handle.bounds().center();
1859                assert_eq!(parent.offset().unwrap_or_default(), (0, px(0.)));
1860                wheel(
1861                    cx,
1862                    position,
1863                    gpui::ScrollDelta::Lines(point(0., -1.)),
1864                    gpui::TouchPhase::Moved,
1865                );
1866                let first_after = cx.debug_bounds("wheel-tab-0").unwrap();
1867                if tabs > 1 && !at_end {
1868                    assert!(scroll_handle.offset().x < before_x);
1869                    assert!(first_after.left() < first_before.left());
1870                    assert_eq!(parent.offset().unwrap_or_default(), (0, px(0.)));
1871                    assert_eq!(first_after.top(), first_before.top());
1872                } else {
1873                    assert_eq!(scroll_handle.offset().x, before_x);
1874                    if let Some(offset) = parent.offset() {
1875                        assert_ne!(
1876                            offset,
1877                            (0, px(0.)),
1878                            "parent {parent_kind}, tabs {tabs}, at_end {at_end}, before_x {before_x:?}, first_before {first_before:?}, after {first_after:?}"
1879                        );
1880                    }
1881                    assert!(first_after.top() < first_before.top());
1882                }
1883            }
1884        }
1885    }
1886
1887    struct WheelLineHeightHarness {
1888        scroll_handle: ScrollHandle,
1889        font_size: Pixels,
1890        line_height: Pixels,
1891    }
1892
1893    impl Render for WheelLineHeightHarness {
1894        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1895            // The scroll viewport inherits these values. Tab's own label styling
1896            // is below that viewport and must not determine its wheel distance.
1897            div()
1898                .w(px(160.))
1899                .h(px(64.))
1900                .text_size(self.font_size)
1901                .line_height(self.line_height)
1902                .child(wheel_tabs(Some(&self.scroll_handle), TabVariant::Tab, 6))
1903        }
1904    }
1905
1906    #[gpui::test]
1907    fn vertical_mouse_wheel_uses_the_scroll_viewports_inherited_line_height(
1908        cx: &mut TestAppContext,
1909    ) {
1910        cx.update(crate::theme::init);
1911        for (font_size, line_height) in [(px(12.), px(18.)), (px(24.), px(48.))] {
1912            let scroll_handle = ScrollHandle::new();
1913            let (_, cx) = cx.add_window_view({
1914                let scroll_handle = scroll_handle.clone();
1915                move |_, _| WheelLineHeightHarness {
1916                    scroll_handle,
1917                    font_size,
1918                    line_height,
1919                }
1920            });
1921            draw(cx);
1922            draw(cx);
1923            let first_before = cx.debug_bounds("wheel-tab-0").unwrap();
1924            assert!(scroll_handle.max_offset().x > line_height);
1925            assert_eq!(scroll_handle.offset(), point(px(0.), px(0.)));
1926
1927            // Use actual tab geometry: a wrapping div can have zero auto height
1928            // while its overflowing children still paint and accept input.
1929            let position = first_before.center();
1930            wheel(
1931                cx,
1932                position,
1933                gpui::ScrollDelta::Lines(point(0., -1.)),
1934                gpui::TouchPhase::Moved,
1935            );
1936            assert_eq!(
1937                scroll_handle.offset(),
1938                point(-line_height, px(0.)),
1939                "font {font_size:?}, line height {line_height:?}"
1940            );
1941            let first_after = cx.debug_bounds("wheel-tab-0").unwrap();
1942            assert_eq!(first_after.left() - first_before.left(), -line_height);
1943            assert_eq!(first_after.top(), first_before.top());
1944
1945            wheel(
1946                cx,
1947                position,
1948                gpui::ScrollDelta::Lines(point(0., 1.)),
1949                gpui::TouchPhase::Moved,
1950            );
1951            assert_eq!(scroll_handle.offset(), point(px(0.), px(0.)));
1952            assert_eq!(cx.debug_bounds("wheel-tab-0").unwrap(), first_before);
1953        }
1954    }
1955
1956    struct WheelHitboxHarness {
1957        scroll_handle: ScrollHandle,
1958        occluded: bool,
1959    }
1960
1961    impl Render for WheelHitboxHarness {
1962        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1963            div()
1964                .relative()
1965                .w(px(240.))
1966                .h(px(64.))
1967                .child(
1968                    wheel_tabs(Some(&self.scroll_handle), TabVariant::Tab, 6)
1969                        .prefix(
1970                            div()
1971                                .w(px(40.))
1972                                .h(px(32.))
1973                                .debug_selector(|| "wheel-prefix".into())
1974                                .child("P"),
1975                        )
1976                        .suffix(
1977                            div()
1978                                .w(px(40.))
1979                                .h(px(32.))
1980                                .debug_selector(|| "wheel-suffix".into())
1981                                .child("S"),
1982                        ),
1983                )
1984                .when(self.occluded, |this| {
1985                    this.child(
1986                        div()
1987                            .absolute()
1988                            .inset_0()
1989                            .occlude()
1990                            .debug_selector(|| "wheel-occluder".into()),
1991                    )
1992                })
1993        }
1994    }
1995
1996    #[gpui::test]
1997    fn vertical_mouse_wheel_respects_the_viewport_and_occluding_surfaces(cx: &mut TestAppContext) {
1998        cx.update(crate::theme::init);
1999        let scroll_handle = ScrollHandle::new();
2000        let (view, cx) = cx.add_window_view({
2001            let scroll_handle = scroll_handle.clone();
2002            move |_, _| WheelHitboxHarness {
2003                scroll_handle,
2004                occluded: false,
2005            }
2006        });
2007        draw(cx);
2008        draw(cx);
2009        let first_before = cx.debug_bounds("wheel-tab-0").unwrap();
2010        assert!(scroll_handle.max_offset().x > px(0.));
2011        assert_eq!(scroll_handle.offset(), point(px(0.), px(0.)));
2012
2013        for selector in ["wheel-prefix", "wheel-suffix"] {
2014            let position = cx.debug_bounds(selector).unwrap().center();
2015            assert!(!scroll_handle.bounds().contains(&position));
2016            wheel(
2017                cx,
2018                position,
2019                gpui::ScrollDelta::Lines(point(0., -1.)),
2020                gpui::TouchPhase::Moved,
2021            );
2022            assert_eq!(scroll_handle.offset(), point(px(0.), px(0.)));
2023            assert_eq!(cx.debug_bounds("wheel-tab-0").unwrap(), first_before);
2024        }
2025
2026        view.update(cx, |view, cx| {
2027            view.occluded = true;
2028            cx.notify();
2029        });
2030        draw(cx);
2031        let position = scroll_handle.bounds().center();
2032        let occluder = cx.debug_bounds("wheel-occluder").unwrap();
2033        assert!(occluder.size.width > px(0.) && occluder.size.height > px(0.));
2034        assert!(occluder.contains(&position));
2035        wheel(
2036            cx,
2037            position,
2038            gpui::ScrollDelta::Lines(point(0., -1.)),
2039            gpui::TouchPhase::Moved,
2040        );
2041        assert_eq!(scroll_handle.offset(), point(px(0.), px(0.)));
2042        assert_eq!(cx.debug_bounds("wheel-tab-0").unwrap(), first_before);
2043
2044        view.update(cx, |view, cx| {
2045            view.occluded = false;
2046            cx.notify();
2047        });
2048        draw(cx);
2049        wheel(
2050            cx,
2051            position,
2052            gpui::ScrollDelta::Lines(point(0., -1.)),
2053            gpui::TouchPhase::Moved,
2054        );
2055        assert!(scroll_handle.offset().x < px(0.));
2056        let first_after = cx.debug_bounds("wheel-tab-0").unwrap();
2057        assert!(first_after.left() < first_before.left());
2058        assert_eq!(first_after.top(), first_before.top());
2059    }
2060
2061    /// A bar of two underlined tabs, the first selected, in a box `width`
2062    /// wide, which the view shows once `shown`.
2063    struct LateBar {
2064        shown: bool,
2065        width: Pixels,
2066    }
2067
2068    impl Render for LateBar {
2069        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2070            div()
2071                .w(self.width)
2072                .overflow_hidden()
2073                .when(self.shown, |this| {
2074                    this.child(
2075                        TabBar::new("late-tabs")
2076                            .underline()
2077                            .selected_index(0)
2078                            .child(Tab::new().label("First"))
2079                            .child(Tab::new().label("Second")),
2080                    )
2081                })
2082        }
2083    }
2084
2085    /// Shows a bar in a window drawn before, as a view shows one once its
2086    /// data came, and draws the bar's first frame.
2087    fn show_late_bar(cx: &mut TestAppContext, width: Pixels) -> &mut gpui::VisualTestContext {
2088        cx.update(crate::theme::init);
2089        let (view, cx) = cx.add_window_view(move |_, _| LateBar {
2090            shown: false,
2091            width,
2092        });
2093        draw(cx);
2094        view.update(cx, |bar, cx| {
2095            bar.shown = true;
2096            cx.notify();
2097        });
2098        draw(cx);
2099        cx
2100    }
2101
2102    /// The underline indicators the frame drawn last painted.
2103    fn painted_indicators(cx: &mut gpui::VisualTestContext) -> Vec<Bounds<Pixels>> {
2104        cx.update(|window, cx| {
2105            let primary: Background = cx.theme().tokens.primary.into();
2106            let scale_factor = window.scale_factor();
2107            window
2108                .painted_quads()
2109                .into_iter()
2110                .filter(|quad| quad.background == primary)
2111                .map(|quad| quad.bounds.map(|value| px(value.0 / scale_factor)))
2112                .filter(|bounds| bounds.size.height == px(2.))
2113                .collect()
2114        })
2115    }
2116
2117    /// The frame after a bar's first, which measured its tabs, draws the
2118    /// indicator under the selected tab, though nothing the view holding
2119    /// the bar renders from changed. Then the bar asks for no more frames.
2120    #[gpui::test]
2121    fn a_bar_shown_later_draws_its_indicator_on_the_next_frame(cx: &mut TestAppContext) {
2122        let cx = show_late_bar(cx, px(300.));
2123        next_frame(cx);
2124        let indicators = painted_indicators(cx);
2125        assert_eq!(indicators.len(), 1, "{indicators:?}");
2126        assert!(indicators[0].size.width > px(0.), "{indicators:?}");
2127        assert!(indicators[0].right() <= px(150.), "{indicators:?}");
2128        assert_eq!(cx.update(|window, cx| window.simulate_next_frame(cx)), 0);
2129    }
2130
2131    /// A bar squeezed to no width asks for no frames to place its indicator.
2132    #[gpui::test]
2133    fn a_bar_with_no_width_asks_for_no_frames(cx: &mut TestAppContext) {
2134        let cx = show_late_bar(cx, px(0.));
2135        next_frame(cx);
2136        assert_eq!(cx.update(|window, cx| window.simulate_next_frame(cx)), 0);
2137    }
2138}