Skip to main content

gpui_component/tab/
tab_bar.rs

1use std::{cell::RefCell, rc::Rc};
2
3use gpui::{
4    Anchor, AnyElement, App, Background, Bounds, Edges, ElementId, InteractiveElement, IntoElement,
5    ParentElement, Pixels, RenderOnce, ScrollHandle, SharedString, StatefulInteractiveElement as _,
6    StyleRefinement, Styled, Window, div, prelude::FluentBuilder as _, px,
7};
8use gpui_base::spring;
9use rust_i18n::t;
10use smallvec::SmallVec;
11
12use super::{Tab, TabVariant};
13use crate::button::{Button, ButtonVariants as _};
14use crate::menu::{DropdownMenu as _, PopupMenuItem};
15use crate::{
16    ActiveTheme, ElementExt, Icon, InteractiveElementExt as _, Selectable, Sizable, Size,
17    StyledExt, h_flex, styled::raised_shadow,
18};
19
20struct TabIndicatorBounds {
21    container: Bounds<Pixels>,
22    tabs: Vec<Bounds<Pixels>>,
23}
24
25impl TabIndicatorBounds {
26    fn new(num_tabs: usize) -> Self {
27        Self {
28            container: Bounds::default(),
29            tabs: vec![Bounds::default(); num_tabs],
30        }
31    }
32
33    fn resize(&mut self, num_tabs: usize) {
34        self.tabs.resize(num_tabs, Bounds::default());
35    }
36}
37
38/// A TabBar element that contains multiple [`Tab`] items.
39#[derive(IntoElement)]
40pub struct TabBar {
41    id: ElementId,
42    base: gpui_base::Tabs,
43    style: StyleRefinement,
44    scroll_handle: Option<ScrollHandle>,
45    prefix: Option<AnyElement>,
46    suffix: Option<AnyElement>,
47    children: SmallVec<[Tab; 2]>,
48    last_empty_space: AnyElement,
49    selected_index: Option<usize>,
50    variant: TabVariant,
51    size: Size,
52    menu: bool,
53    max_width: Option<Pixels>,
54    on_click: Option<Rc<dyn Fn(&usize, &mut Window, &mut App) + 'static>>,
55}
56
57impl TabBar {
58    /// Create a new TabBar.
59    pub fn new(id: impl Into<ElementId>) -> Self {
60        let id = id.into();
61        Self {
62            id: id.clone(),
63            base: gpui_base::Tabs::new(id).px(px(-1.)),
64            style: StyleRefinement::default(),
65            children: SmallVec::new(),
66            scroll_handle: None,
67            prefix: None,
68            suffix: None,
69            variant: TabVariant::default(),
70            size: Size::default(),
71            last_empty_space: div().w_3().into_any_element(),
72            selected_index: None,
73            on_click: None,
74            menu: false,
75            max_width: None,
76        }
77    }
78
79    /// Set the Tab variant, all children will inherit the variant.
80    pub fn with_variant(mut self, variant: TabVariant) -> Self {
81        self.variant = variant;
82        self
83    }
84
85    /// Set the Tab variant to Pill, all children will inherit the variant.
86    pub fn pill(mut self) -> Self {
87        self.variant = TabVariant::Pill;
88        self
89    }
90
91    /// Set the Tab variant to Outline, all children will inherit the variant.
92    pub fn outline(mut self) -> Self {
93        self.variant = TabVariant::Outline;
94        self
95    }
96
97    /// Set the Tab variant to Segmented, all children will inherit the variant.
98    pub fn segmented(mut self) -> Self {
99        self.variant = TabVariant::Segmented;
100        self
101    }
102
103    /// Set the Tab variant to Underline, all children will inherit the variant.
104    pub fn underline(mut self) -> Self {
105        self.variant = TabVariant::Underline;
106        self
107    }
108
109    /// Set whether to show the menu button when tabs overflow, default is false.
110    pub fn menu(mut self, menu: bool) -> Self {
111        self.menu = menu;
112        self
113    }
114
115    /// Set the maximum width of each tab. Labels longer than this width are
116    /// truncated with an ellipsis. Does not apply to icon-only tabs. The
117    /// overflow menu still shows the full label.
118    pub fn max_width(mut self, width: impl Into<Pixels>) -> Self {
119        self.max_width = Some(width.into());
120        self
121    }
122
123    /// Track the scroll of the TabBar.
124    pub fn track_scroll(mut self, scroll_handle: &ScrollHandle) -> Self {
125        self.scroll_handle = Some(scroll_handle.clone());
126        self
127    }
128
129    /// Set the prefix element of the TabBar
130    pub fn prefix(mut self, prefix: impl IntoElement) -> Self {
131        self.prefix = Some(prefix.into_any_element());
132        self
133    }
134
135    /// Set the suffix element of the TabBar
136    pub fn suffix(mut self, suffix: impl IntoElement) -> Self {
137        self.suffix = Some(suffix.into_any_element());
138        self
139    }
140
141    /// Add children of the TabBar, all children will inherit the variant.
142    pub fn children(mut self, children: impl IntoIterator<Item = impl Into<Tab>>) -> Self {
143        self.children.extend(children.into_iter().map(Into::into));
144        self
145    }
146
147    /// Add child of the TabBar, tab will inherit the variant.
148    pub fn child(mut self, child: impl Into<Tab>) -> Self {
149        self.children.push(child.into());
150        self
151    }
152
153    /// Set the selected index of the TabBar.
154    pub fn selected_index(mut self, index: usize) -> Self {
155        self.selected_index = Some(index);
156        self
157    }
158
159    /// Set the last empty space element of the TabBar.
160    pub fn last_empty_space(mut self, last_empty_space: impl IntoElement) -> Self {
161        self.last_empty_space = last_empty_space.into_any_element();
162        self
163    }
164
165    /// Set the on_click callback of the TabBar, the first parameter is the index of the clicked tab.
166    ///
167    /// When this is set, the children's on_click will be ignored.
168    pub fn on_click<F>(mut self, on_click: F) -> Self
169    where
170        F: Fn(&usize, &mut Window, &mut App) + 'static,
171    {
172        self.on_click = Some(Rc::new(on_click));
173        self
174    }
175
176    /// Render the sliding indicator element for animated tab switching.
177    ///
178    /// Returns the indicator element together with the current animation
179    /// `epoch`, which increments on every tab switch. Tabs key their own
180    /// transitions (e.g. text color fade) on this epoch so they restart in sync
181    /// with the indicator slide.
182    fn render_indicator(
183        &self,
184        bounds_rc: &Option<Rc<RefCell<TabIndicatorBounds>>>,
185        inset: Pixels,
186        window: &mut Window,
187        cx: &mut App,
188    ) -> Option<(AnyElement, u64)> {
189        let has_indicator = matches!(
190            self.variant,
191            TabVariant::Segmented | TabVariant::Pill | TabVariant::Underline
192        );
193        let num_tabs = self.children.len();
194        let selected_ix = self.selected_index.unwrap_or(usize::MAX);
195
196        if !(has_indicator && num_tabs > 0 && selected_ix < num_tabs) {
197            return None;
198        }
199
200        let prev_key = format!("{}-tab-prev", self.id);
201        let anim_key = format!("{}-tab-anim", self.id);
202        let init_key = format!("{}-tab-init", self.id);
203
204        let prev_selected = window.use_keyed_state(prev_key, cx, |_, _| selected_ix);
205        // (to_left, to_width, epoch)
206        let anim_params = window.use_keyed_state(anim_key, cx, |_, _| (px(0.), px(0.), 0u64));
207        let initialized = window.use_keyed_state(init_key, cx, |_, _| false);
208
209        // First frame: trigger re-render to capture bounds via on_prepaint
210        if !*initialized.read(cx) {
211            initialized.update(cx, |v, _| *v = true);
212        }
213
214        self.update_anim_params(selected_ix, bounds_rc, &prev_selected, &anim_params, cx);
215
216        let (to_left, to_width, epoch) = *anim_params.read(cx);
217        if to_width <= px(0.) {
218            return None;
219        }
220
221        // The springs hold the indicator's own position and velocity, so a tab
222        // switched again mid-slide is redirected from where the indicator
223        // actually is rather than restarted from the tab it left.
224        let indicator_key = format!("{}-tab-indicator", self.id);
225        let left = spring(
226            (indicator_key.clone(), "left"),
227            to_left,
228            cx.theme().motion_tokens().spring_move,
229            window,
230            cx,
231        );
232        let width = spring(
233            (indicator_key, "width"),
234            to_width,
235            cx.theme().motion_tokens().spring_move,
236            window,
237            cx,
238        );
239
240        let variant = self.variant;
241        let size = self.size;
242        let inner_height = variant.inner_height(size);
243        let inner_radius = variant.inner_radius(size, cx);
244
245        let indicator = div()
246            .absolute()
247            .top_0()
248            .bottom_0()
249            .left(left + inset)
250            .w(width)
251            .map(|el| match variant {
252                TabVariant::Segmented => el.flex().items_center().child(
253                    div()
254                        .w_full()
255                        .h(inner_height)
256                        .bg(cx.theme().tokens.background)
257                        .rounded(inner_radius)
258                        .shadow(raised_shadow()),
259                ),
260                TabVariant::Pill => el.flex().items_center().child(
261                    div()
262                        .size_full()
263                        .bg(cx.theme().tokens.primary)
264                        .rounded(cx.theme().radius_full()),
265                ),
266                TabVariant::Underline => el.child(
267                    div()
268                        .absolute()
269                        .left_0()
270                        .right_0()
271                        .bottom_0()
272                        .h(px(2.))
273                        .bg(cx.theme().tokens.primary),
274                ),
275                _ => el,
276            });
277
278        Some((indicator.into_any_element(), epoch))
279    }
280
281    /// Update animation parameters based on current and previous selection.
282    fn update_anim_params(
283        &self,
284        selected_ix: usize,
285        bounds_rc: &Option<Rc<RefCell<TabIndicatorBounds>>>,
286        prev_selected: &gpui::Entity<usize>,
287        anim_params: &gpui::Entity<(Pixels, Pixels, u64)>,
288        cx: &mut App,
289    ) {
290        let rc = match bounds_rc {
291            Some(rc) => rc,
292            None => return,
293        };
294
295        let prev_ix = *prev_selected.read(cx);
296        let bounds = rc.borrow();
297        let container = bounds.container;
298
299        if container.size.width == px(0.) {
300            if prev_ix != selected_ix {
301                prev_selected.update(cx, |v, _| *v = selected_ix);
302            }
303            return;
304        }
305
306        if prev_ix != selected_ix {
307            if let Some(to_b) = bounds.tabs.get(selected_ix) {
308                let left = to_b.origin.x - container.origin.x;
309                let width = to_b.size.width;
310                // Only a switch away from a tab that still exists restarts the
311                // tabs' own epoch-keyed transitions.
312                let epoch = anim_params.read(cx).2;
313                let epoch = match bounds.tabs.get(prev_ix) {
314                    Some(_) => epoch + 1,
315                    None => epoch,
316                };
317                anim_params.update(cx, |v, _| *v = (left, width, epoch));
318            }
319            drop(bounds);
320            prev_selected.update(cx, |v, _| *v = selected_ix);
321            return;
322        }
323
324        if let Some(to_b) = bounds.tabs.get(selected_ix) {
325            let left = to_b.origin.x - container.origin.x;
326            let width = to_b.size.width;
327            let (to_left, to_width, epoch) = *anim_params.read(cx);
328
329            if left != to_left || width != to_width {
330                anim_params.update(cx, |v, _| *v = (left, width, epoch));
331            }
332        }
333    }
334}
335
336impl Styled for TabBar {
337    fn style(&mut self) -> &mut StyleRefinement {
338        &mut self.style
339    }
340}
341
342impl Sizable for TabBar {
343    fn with_size(mut self, size: impl Into<Size>) -> Self {
344        self.size = size.into();
345        self
346    }
347}
348
349impl RenderOnce for TabBar {
350    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
351        let default_gap = match self.size {
352            Size::Small | Size::XSmall => px(8.),
353            Size::Large => px(16.),
354            _ => px(12.),
355        };
356        let (bg, paddings, gap): (Background, _, _) = match self.variant {
357            TabVariant::Tab => {
358                let padding = Edges::all(px(0.));
359                (cx.theme().tokens.tab_bar.into(), padding, px(0.))
360            }
361            TabVariant::Outline => {
362                let padding = Edges::all(px(0.));
363                (cx.theme().transparent.into(), padding, default_gap)
364            }
365            TabVariant::Pill => {
366                let padding = Edges::all(px(0.));
367                (cx.theme().transparent.into(), padding, px(4.))
368            }
369            TabVariant::Segmented => {
370                let padding_x = match self.size {
371                    Size::XSmall => px(2.),
372                    Size::Small => px(3.),
373                    _ => px(4.),
374                };
375                let padding = Edges {
376                    left: padding_x,
377                    right: padding_x,
378                    ..Default::default()
379                };
380
381                (cx.theme().tokens.tab_bar_segmented.into(), padding, px(2.))
382            }
383            TabVariant::Underline => {
384                // This gap is same as the tab inner_paddings
385                let gap = match self.size {
386                    Size::XSmall => px(10.),
387                    Size::Small => px(12.),
388                    Size::Large => px(20.),
389                    _ => px(16.),
390                };
391
392                (cx.theme().transparent.into(), Edges::all(px(0.)), gap)
393            }
394        };
395
396        let has_indicator = matches!(
397            self.variant,
398            TabVariant::Segmented | TabVariant::Pill | TabVariant::Underline
399        );
400        let num_tabs = self.children.len();
401
402        // Bounds tracking for tab indicator animation.
403        // Uses Rc<RefCell> to avoid triggering re-renders from prepaint writes.
404        let bounds_rc = if has_indicator && num_tabs > 0 {
405            let rc: Rc<RefCell<TabIndicatorBounds>> = window
406                .use_keyed_state(format!("{}-tab-bounds", self.id), cx, |_, _| {
407                    Rc::new(RefCell::new(TabIndicatorBounds::new(num_tabs)))
408                })
409                .read(cx)
410                .clone();
411            rc.borrow_mut().resize(num_tabs);
412            Some(rc)
413        } else {
414            None
415        };
416
417        let padding_x = paddings.left;
418        let indicator = self.render_indicator(&bounds_rc, padding_x, window, cx);
419        let indicator_epoch = indicator.as_ref().map(|(_, epoch)| *epoch).unwrap_or(0);
420        let indicator_element = indicator.map(|(el, _)| el);
421        let indicator_ready = indicator_element.is_some();
422
423        let has_suffix_or_menu = self.suffix.is_some() || self.menu;
424        let mut item_metas: Vec<(Option<SharedString>, Option<Icon>, bool)> = Vec::new();
425        let selected_index = self.selected_index;
426        let on_click = self.on_click.clone();
427        let tabs = self.base;
428        let mut rendered_tabs = Vec::with_capacity(self.children.len());
429        let max_width = self.max_width;
430
431        for (ix, child) in self.children.into_iter().enumerate() {
432            item_metas.push((child.label.clone(), child.icon.clone(), child.disabled));
433            let tab_bar_prefix = child.tab_bar_prefix.unwrap_or(true);
434            let mut tab = child
435                .ix(ix)
436                .tab_bar_prefix(tab_bar_prefix)
437                .max_width(max_width)
438                .with_variant(self.variant)
439                .with_size(self.size);
440            tab.indicator_active = has_indicator;
441            tab.indicator_ready = indicator_ready;
442            tab.indicator_epoch = indicator_epoch;
443            let tab = tab
444                .when_some(selected_index, |tab, selected_index| {
445                    tab.selected(selected_index == ix)
446                })
447                .when_some(self.on_click.clone(), move |tab, on_click| {
448                    tab.on_click(move |_, window, cx| on_click(&ix, window, cx))
449                });
450
451            rendered_tabs.push(if let Some(ref rc) = bounds_rc {
452                let rc = rc.clone();
453                div()
454                    .flex_shrink_0()
455                    .on_prepaint(move |bounds, _, _| {
456                        if let Some(slot) = rc.borrow_mut().tabs.get_mut(ix) {
457                            *slot = bounds;
458                        }
459                    })
460                    .child(tab)
461                    .into_any_element()
462            } else {
463                tab.into_any_element()
464            });
465        }
466
467        tabs.group("tab-bar")
468            .relative()
469            .flex()
470            .items_center()
471            .bg(bg)
472            .text_color(cx.theme().tab_foreground)
473            .when(
474                self.variant == TabVariant::Underline || self.variant == TabVariant::Tab,
475                |this| {
476                    this.child(
477                        div()
478                            .id("border-b")
479                            .absolute()
480                            .left_0()
481                            .bottom_0()
482                            .size_full()
483                            .border_b_1()
484                            .border_color(cx.theme().border),
485                    )
486                },
487            )
488            .rounded(self.variant.tab_bar_radius(self.size, cx))
489            .paddings(paddings)
490            .refine_style(&self.style)
491            .when_some(self.prefix, |this, prefix| this.child(prefix))
492            .child(
493                h_flex()
494                    .id("tabs")
495                    .flex_1()
496                    .mx(-padding_x)
497                    .px(padding_x)
498                    .overflow_x_hidden()
499                    .child(
500                        h_flex()
501                            .id("tabs-inner")
502                            .mx(-padding_x)
503                            .px(padding_x)
504                            .relative()
505                            .gap(gap)
506                            .overflow_x_scroll()
507                            .lock_scroll_axis()
508                            .when_some(self.scroll_handle, |this, scroll_handle| {
509                                this.track_scroll(&scroll_handle)
510                            })
511                            .when_some(bounds_rc.clone(), |this, rc| {
512                                this.on_prepaint(move |bounds, _, _| {
513                                    rc.borrow_mut().container = bounds;
514                                })
515                            })
516                            .when_some(indicator_element, |this, ind| this.child(ind))
517                            .children(rendered_tabs)
518                            .when(has_suffix_or_menu, |this| this.child(self.last_empty_space)),
519                    ),
520            )
521            .when(self.menu, |this| {
522                this.child(
523                    Button::new("more")
524                        .xsmall()
525                        .ghost()
526                        .dropdown_caret(true)
527                        .dropdown_menu(move |mut this, _, _| {
528                            this = this.scrollable(true);
529                            for (ix, (label, icon, disabled)) in item_metas.iter().enumerate() {
530                                let base = if let Some(label) = label.clone() {
531                                    PopupMenuItem::new(label)
532                                } else if let Some(icon) = icon.clone() {
533                                    PopupMenuItem::element(move |_, _| icon.clone())
534                                } else {
535                                    PopupMenuItem::new(t!("Dock.Unnamed"))
536                                };
537                                this = this.item(
538                                    base.checked(selected_index == Some(ix))
539                                        .disabled(*disabled)
540                                        .when_some(on_click.clone(), |this, on_click| {
541                                            this.on_click(move |_, window, cx| {
542                                                on_click(&ix, window, cx)
543                                            })
544                                        }),
545                                );
546                            }
547
548                            this
549                        })
550                        .anchor(Anchor::TopRight),
551                )
552            })
553            .when_some(self.suffix, |this, suffix| this.child(suffix))
554    }
555}
556
557#[cfg(test)]
558mod tests {
559    use std::{cell::Cell, rc::Rc};
560
561    use gpui::{Context, Modifiers, Render, TestAppContext};
562
563    use super::*;
564
565    struct Harness {
566        group_handler: bool,
567        disabled: bool,
568        child_clicks: Rc<Cell<usize>>,
569        group_clicks: Rc<Cell<usize>>,
570    }
571
572    impl Render for Harness {
573        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
574            let child_clicks = self.child_clicks.clone();
575            let group_clicks = self.group_clicks.clone();
576            TabBar::new("tabs")
577                .w(px(240.))
578                .child(
579                    Tab::new()
580                        .debug_selector(|| "first-tab".into())
581                        .disabled(self.disabled)
582                        .label("First")
583                        .on_click(move |_, _, _| child_clicks.set(child_clicks.get() + 1)),
584                )
585                .when(self.group_handler, |tabs| {
586                    tabs.on_click(move |ix, _, _| group_clicks.set(*ix + 1))
587                })
588        }
589    }
590
591    fn harness(
592        cx: &mut TestAppContext,
593        group_handler: bool,
594        disabled: bool,
595    ) -> (
596        &mut gpui::VisualTestContext,
597        Rc<Cell<usize>>,
598        Rc<Cell<usize>>,
599    ) {
600        cx.update(crate::theme::init);
601        let child_clicks = Rc::new(Cell::new(0));
602        let group_clicks = Rc::new(Cell::new(0));
603        let (_, cx) = cx.add_window_view({
604            let child_clicks = child_clicks.clone();
605            let group_clicks = group_clicks.clone();
606            move |_, _| Harness {
607                group_handler,
608                disabled,
609                child_clicks,
610                group_clicks,
611            }
612        });
613        cx.update(|window, cx| window.draw(cx).clear(cx));
614        (cx, child_clicks, group_clicks)
615    }
616
617    #[gpui::test]
618    fn group_callback_overrides_child_callback(cx: &mut TestAppContext) {
619        let (cx, child_clicks, group_clicks) = harness(cx, true, false);
620        let position = cx.debug_bounds("first-tab").unwrap().center();
621        cx.simulate_click(position, Modifiers::default());
622        assert_eq!(child_clicks.get(), 0);
623        assert_eq!(group_clicks.get(), 1);
624    }
625
626    #[gpui::test]
627    fn child_callback_is_preserved_without_group_callback(cx: &mut TestAppContext) {
628        let (cx, child_clicks, group_clicks) = harness(cx, false, false);
629        let position = cx.debug_bounds("first-tab").unwrap().center();
630        cx.simulate_click(position, Modifiers::default());
631        assert_eq!(child_clicks.get(), 1);
632        assert_eq!(group_clicks.get(), 0);
633    }
634
635    #[gpui::test]
636    fn disabled_tab_suppresses_child_and_group_callbacks(cx: &mut TestAppContext) {
637        let (cx, child_clicks, group_clicks) = harness(cx, true, true);
638        let position = cx.debug_bounds("first-tab").unwrap().center();
639        cx.simulate_click(position, Modifiers::default());
640        assert_eq!(child_clicks.get(), 0);
641        assert_eq!(group_clicks.get(), 0);
642    }
643
644    struct ContentHarness;
645
646    impl Render for ContentHarness {
647        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
648            TabBar::new("content-tabs").w(px(320.)).child(
649                Tab::new()
650                    .prefix(div().debug_selector(|| "tab-prefix".into()).child("P"))
651                    .child(div().debug_selector(|| "tab-child".into()).child("Content"))
652                    .suffix(div().debug_selector(|| "tab-suffix".into()).child("S")),
653            )
654        }
655    }
656
657    #[gpui::test]
658    fn prefix_content_and_suffix_keep_their_order(cx: &mut TestAppContext) {
659        cx.update(crate::theme::init);
660        let (_, cx) = cx.add_window_view(|_, _| ContentHarness);
661        cx.update(|window, cx| window.draw(cx).clear(cx));
662
663        let prefix = cx.debug_bounds("tab-prefix").unwrap();
664        let child = cx.debug_bounds("tab-child").unwrap();
665        let suffix = cx.debug_bounds("tab-suffix").unwrap();
666        assert!(prefix.origin.x < child.origin.x);
667        assert!(child.origin.x < suffix.origin.x);
668        assert!(prefix.size.width > px(0.));
669        assert!(child.size.width > px(0.));
670        assert!(suffix.size.width > px(0.));
671    }
672}