Skip to main content

rgpui_component/tab/
tab_bar.rs

1use std::{cell::RefCell, rc::Rc, time::Duration};
2
3use rgpui::{
4    Anchor, Animation, AnimationExt as _, AnyElement, App, Bounds, Div, Edges, ElementId,
5    InteractiveElement, IntoElement, ParentElement, Pixels, RenderOnce, ScrollHandle, SharedString,
6    Stateful, StatefulInteractiveElement as _, StyleRefinement, Styled, Window, div,
7    prelude::FluentBuilder as _, px,
8};
9use rust_i18n::t;
10use smallvec::SmallVec;
11
12use super::{Tab, TabVariant};
13use crate::animation::{Lerp, ease_in_out_cubic};
14use crate::button::{Button, ButtonVariants as _};
15use crate::menu::{DropdownMenu as _, PopupMenuItem};
16use crate::{
17    ActiveTheme, ElementExt, Icon, IconName, Selectable, Sizable, Size, StyledExt, h_flex,
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: Stateful<Div>,
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    on_click: Option<Rc<dyn Fn(&usize, &mut Window, &mut App) + 'static>>,
54}
55
56impl TabBar {
57    /// Create a new TabBar.
58    pub fn new(id: impl Into<ElementId>) -> Self {
59        let id = id.into();
60        Self {
61            id: id.clone(),
62            base: div().id(id).px(px(-1.)),
63            style: StyleRefinement::default(),
64            children: SmallVec::new(),
65            scroll_handle: None,
66            prefix: None,
67            suffix: None,
68            variant: TabVariant::default(),
69            size: Size::default(),
70            last_empty_space: div().w_3().into_any_element(),
71            selected_index: None,
72            on_click: None,
73            menu: false,
74        }
75    }
76
77    /// Set the Tab variant, all children will inherit the variant.
78    pub fn with_variant(mut self, variant: TabVariant) -> Self {
79        self.variant = variant;
80        self
81    }
82
83    /// Set the Tab variant to Pill, all children will inherit the variant.
84    pub fn pill(mut self) -> Self {
85        self.variant = TabVariant::Pill;
86        self
87    }
88
89    /// Set the Tab variant to Outline, all children will inherit the variant.
90    pub fn outline(mut self) -> Self {
91        self.variant = TabVariant::Outline;
92        self
93    }
94
95    /// Set the Tab variant to Segmented, all children will inherit the variant.
96    pub fn segmented(mut self) -> Self {
97        self.variant = TabVariant::Segmented;
98        self
99    }
100
101    /// Set the Tab variant to Underline, all children will inherit the variant.
102    pub fn underline(mut self) -> Self {
103        self.variant = TabVariant::Underline;
104        self
105    }
106
107    /// Set whether to show the menu button when tabs overflow, default is false.
108    pub fn menu(mut self, menu: bool) -> Self {
109        self.menu = menu;
110        self
111    }
112
113    /// Track the scroll of the TabBar.
114    pub fn track_scroll(mut self, scroll_handle: &ScrollHandle) -> Self {
115        self.scroll_handle = Some(scroll_handle.clone());
116        self
117    }
118
119    /// Set the prefix element of the TabBar
120    pub fn prefix(mut self, prefix: impl IntoElement) -> Self {
121        self.prefix = Some(prefix.into_any_element());
122        self
123    }
124
125    /// Set the suffix element of the TabBar
126    pub fn suffix(mut self, suffix: impl IntoElement) -> Self {
127        self.suffix = Some(suffix.into_any_element());
128        self
129    }
130
131    /// Add children of the TabBar, all children will inherit the variant.
132    pub fn children(mut self, children: impl IntoIterator<Item = impl Into<Tab>>) -> Self {
133        self.children.extend(children.into_iter().map(Into::into));
134        self
135    }
136
137    /// Add child of the TabBar, tab will inherit the variant.
138    pub fn child(mut self, child: impl Into<Tab>) -> Self {
139        self.children.push(child.into());
140        self
141    }
142
143    /// Set the selected index of the TabBar.
144    pub fn selected_index(mut self, index: usize) -> Self {
145        self.selected_index = Some(index);
146        self
147    }
148
149    /// Set the last empty space element of the TabBar.
150    pub fn last_empty_space(mut self, last_empty_space: impl IntoElement) -> Self {
151        self.last_empty_space = last_empty_space.into_any_element();
152        self
153    }
154
155    /// Set the on_click callback of the TabBar, the first parameter is the index of the clicked tab.
156    ///
157    /// When this is set, the children's on_click will be ignored.
158    pub fn on_click<F>(mut self, on_click: F) -> Self
159    where
160        F: Fn(&usize, &mut Window, &mut App) + 'static,
161    {
162        self.on_click = Some(Rc::new(on_click));
163        self
164    }
165
166    /// Render the sliding indicator element for animated tab switching.
167    fn render_indicator(
168        &self,
169        bounds_rc: &Option<Rc<RefCell<TabIndicatorBounds>>>,
170        window: &mut Window,
171        cx: &mut App,
172    ) -> Option<AnyElement> {
173        let has_indicator = matches!(
174            self.variant,
175            TabVariant::Segmented | TabVariant::Pill | TabVariant::Underline
176        );
177        let num_tabs = self.children.len();
178        let selected_ix = self.selected_index.unwrap_or(usize::MAX);
179
180        if !(has_indicator && num_tabs > 0 && selected_ix < num_tabs) {
181            return None;
182        }
183
184        let prev_key = format!("{}-tab-prev", self.id);
185        let anim_key = format!("{}-tab-anim", self.id);
186        let init_key = format!("{}-tab-init", self.id);
187
188        let prev_selected = window.use_keyed_state(prev_key, cx, |_, _| selected_ix);
189        // (from_left, from_width, to_left, to_width, epoch)
190        let anim_params =
191            window.use_keyed_state(anim_key, cx, |_, _| (px(0.), px(0.), px(0.), px(0.), 0u64));
192        let initialized = window.use_keyed_state(init_key, cx, |_, _| false);
193
194        // First frame: trigger re-render to capture bounds via on_prepaint
195        if !*initialized.read(cx) {
196            initialized.update(cx, |v, _| *v = true);
197        }
198
199        self.update_anim_params(selected_ix, bounds_rc, &prev_selected, &anim_params, cx);
200
201        let (from_left, from_width, to_left, to_width, epoch) = *anim_params.read(cx);
202        if to_width <= px(0.) {
203            return None;
204        }
205
206        let variant = self.variant;
207        let size = self.size;
208        let inner_height = variant.inner_height(size);
209        let inner_radius = variant.inner_radius(size, cx);
210
211        let indicator = div()
212            .absolute()
213            .top_0()
214            .bottom_0()
215            .map(|el| match variant {
216                TabVariant::Segmented => el.flex().items_center().child(
217                    div()
218                        .w_full()
219                        .h(inner_height)
220                        .bg(cx.theme().background)
221                        .rounded(inner_radius)
222                        .shadow_xs(),
223                ),
224                TabVariant::Pill => el
225                    .flex()
226                    .items_center()
227                    .child(div().size_full().bg(cx.theme().primary).rounded(px(99.))),
228                TabVariant::Underline => el.child(
229                    div()
230                        .absolute()
231                        .left_0()
232                        .right_0()
233                        .bottom_0()
234                        .h(px(2.))
235                        .bg(cx.theme().primary),
236                ),
237                _ => el,
238            })
239            .with_animation(
240                ElementId::NamedInteger("tab-ind".into(), epoch),
241                Animation::new(Duration::from_millis(200)).with_easing(ease_in_out_cubic),
242                move |el, delta| {
243                    let left = Lerp::lerp(&from_left, &to_left, delta);
244                    let width = Lerp::lerp(&from_width, &to_width, delta);
245                    el.left(left).w(width)
246                },
247            );
248
249        Some(indicator.into_any_element())
250    }
251
252    /// Update animation parameters based on current and previous selection.
253    fn update_anim_params(
254        &self,
255        selected_ix: usize,
256        bounds_rc: &Option<Rc<RefCell<TabIndicatorBounds>>>,
257        prev_selected: &rgpui::Entity<usize>,
258        anim_params: &rgpui::Entity<(Pixels, Pixels, Pixels, Pixels, u64)>,
259        cx: &mut App,
260    ) {
261        let rc = match bounds_rc {
262            Some(rc) => rc,
263            None => return,
264        };
265
266        let prev_ix = *prev_selected.read(cx);
267        let bounds = rc.borrow();
268        let container = bounds.container;
269
270        if container.size.width == px(0.) {
271            if prev_ix != selected_ix {
272                prev_selected.update(cx, |v, _| *v = selected_ix);
273            }
274            return;
275        }
276
277        if prev_ix != selected_ix {
278            let from_b = bounds.tabs.get(prev_ix);
279            let to_b = bounds.tabs.get(selected_ix);
280            match (from_b, to_b) {
281                (Some(from_b), Some(to_b)) => {
282                    let from_left = from_b.origin.x - container.origin.x;
283                    let from_width = from_b.size.width;
284                    let to_left = to_b.origin.x - container.origin.x;
285                    let to_width = to_b.size.width;
286                    let epoch = anim_params.read(cx).4 + 1;
287                    anim_params.update(cx, |v, _| {
288                        *v = (from_left, from_width, to_left, to_width, epoch)
289                    });
290                }
291                (None, Some(to_b)) => {
292                    let left = to_b.origin.x - container.origin.x;
293                    let width = to_b.size.width;
294                    anim_params.update(cx, |v, _| *v = (left, width, left, width, v.4));
295                }
296                _ => {}
297            }
298            drop(bounds);
299            prev_selected.update(cx, |v, _| *v = selected_ix);
300            return;
301        }
302
303        if let Some(to_b) = bounds.tabs.get(selected_ix) {
304            let left = to_b.origin.x - container.origin.x;
305            let width = to_b.size.width;
306            let (_, _, to_left, to_width, epoch) = *anim_params.read(cx);
307
308            if to_width == px(0.) {
309                anim_params.update(cx, |v, _| *v = (left, width, left, width, epoch));
310                return;
311            }
312
313            if left != to_left || width != to_width {
314                anim_params.update(cx, |v, _| *v = (left, width, left, width, epoch));
315            }
316        }
317    }
318}
319
320impl Styled for TabBar {
321    fn style(&mut self) -> &mut StyleRefinement {
322        &mut self.style
323    }
324}
325
326impl Sizable for TabBar {
327    fn with_size(mut self, size: impl Into<Size>) -> Self {
328        self.size = size.into();
329        self
330    }
331}
332
333impl RenderOnce for TabBar {
334    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
335        let default_gap = match self.size {
336            Size::Small | Size::XSmall => px(8.),
337            Size::Large => px(16.),
338            _ => px(12.),
339        };
340        let (bg, paddings, gap) = match self.variant {
341            TabVariant::Tab => {
342                let padding = Edges::all(px(0.));
343                (cx.theme().tab_bar, padding, px(0.))
344            }
345            TabVariant::Outline => {
346                let padding = Edges::all(px(0.));
347                (cx.theme().transparent, padding, default_gap)
348            }
349            TabVariant::Pill => {
350                let padding = Edges::all(px(0.));
351                (cx.theme().transparent, padding, px(4.))
352            }
353            TabVariant::Segmented => {
354                let padding_x = match self.size {
355                    Size::XSmall => px(2.),
356                    Size::Small => px(3.),
357                    _ => px(4.),
358                };
359                let padding = Edges {
360                    left: padding_x,
361                    right: padding_x,
362                    ..Default::default()
363                };
364
365                (cx.theme().tab_bar_segmented, padding, px(2.))
366            }
367            TabVariant::Underline => {
368                // This gap is same as the tab inner_paddings
369                let gap = match self.size {
370                    Size::XSmall => px(10.),
371                    Size::Small => px(12.),
372                    Size::Large => px(20.),
373                    _ => px(16.),
374                };
375
376                (cx.theme().transparent, Edges::all(px(0.)), gap)
377            }
378        };
379
380        let has_indicator = matches!(
381            self.variant,
382            TabVariant::Segmented | TabVariant::Pill | TabVariant::Underline
383        );
384        let num_tabs = self.children.len();
385
386        // Bounds tracking for tab indicator animation.
387        // Uses Rc<RefCell> to avoid triggering re-renders from prepaint writes.
388        let bounds_rc = if has_indicator && num_tabs > 0 {
389            let rc: Rc<RefCell<TabIndicatorBounds>> = window
390                .use_keyed_state(format!("{}-tab-bounds", self.id), cx, |_, _| {
391                    Rc::new(RefCell::new(TabIndicatorBounds::new(num_tabs)))
392                })
393                .read(cx)
394                .clone();
395            rc.borrow_mut().resize(num_tabs);
396            Some(rc)
397        } else {
398            None
399        };
400
401        let indicator_element = self.render_indicator(&bounds_rc, window, cx);
402        let indicator_ready = indicator_element.is_some();
403
404        let has_suffix_or_menu = self.suffix.is_some() || self.menu;
405        let mut item_metas: Vec<(Option<SharedString>, Option<Icon>, bool)> = Vec::new();
406        let selected_index = self.selected_index;
407        let on_click = self.on_click.clone();
408
409        self.base
410            .group("tab-bar")
411            .relative()
412            .flex()
413            .items_center()
414            .bg(bg)
415            .text_color(cx.theme().tab_foreground)
416            .when(
417                self.variant == TabVariant::Underline || self.variant == TabVariant::Tab,
418                |this| {
419                    this.child(
420                        div()
421                            .id("border-b")
422                            .absolute()
423                            .left_0()
424                            .bottom_0()
425                            .size_full()
426                            .border_b_1()
427                            .border_color(cx.theme().border),
428                    )
429                },
430            )
431            .rounded(self.variant.tab_bar_radius(self.size, cx))
432            .paddings(paddings)
433            .refine_style(&self.style)
434            .when_some(self.prefix, |this, prefix| this.child(prefix))
435            .child(
436                h_flex().id("tabs").flex_1().overflow_x_hidden().child(
437                    h_flex()
438                        .id("tabs-inner")
439                        .relative()
440                        .gap(gap)
441                        .overflow_x_scroll()
442                        .when_some(self.scroll_handle, |this, scroll_handle| {
443                            this.track_scroll(&scroll_handle)
444                        })
445                        .when_some(bounds_rc.clone(), |this, rc| {
446                            this.on_prepaint(move |bounds, _, _| {
447                                rc.borrow_mut().container = bounds;
448                            })
449                        })
450                        .when_some(indicator_element, |this, ind| this.child(ind))
451                        .children(self.children.into_iter().enumerate().map(|(ix, child)| {
452                            item_metas.push((
453                                child.label.clone(),
454                                child.icon.clone(),
455                                child.disabled,
456                            ));
457                            let tab_bar_prefix = child.tab_bar_prefix.unwrap_or(true);
458                            let mut tab = child
459                                .ix(ix)
460                                .tab_bar_prefix(tab_bar_prefix)
461                                .with_variant(self.variant)
462                                .with_size(self.size);
463                            tab.indicator_active = has_indicator;
464                            tab.indicator_ready = indicator_ready;
465                            let tab = tab
466                                .when_some(self.selected_index, |this, selected_ix| {
467                                    this.selected(selected_ix == ix)
468                                })
469                                .when_some(self.on_click.clone(), move |this, on_click| {
470                                    this.on_click(move |_, window, cx| on_click(&ix, window, cx))
471                                });
472
473                            if let Some(ref rc) = bounds_rc {
474                                let rc = rc.clone();
475                                div()
476                                    .on_prepaint(move |bounds, _, _| {
477                                        if let Some(slot) = rc.borrow_mut().tabs.get_mut(ix) {
478                                            *slot = bounds;
479                                        }
480                                    })
481                                    .child(tab)
482                                    .into_any_element()
483                            } else {
484                                tab.into_any_element()
485                            }
486                        }))
487                        .when(has_suffix_or_menu, |this| this.child(self.last_empty_space)),
488                ),
489            )
490            .when(self.menu, |this| {
491                this.child(
492                    Button::new("more")
493                        .xsmall()
494                        .ghost()
495                        .icon(IconName::ChevronDown)
496                        .dropdown_menu(move |mut this, _, _| {
497                            this = this.scrollable(true);
498                            for (ix, (label, icon, disabled)) in item_metas.iter().enumerate() {
499                                let base = if let Some(label) = label.clone() {
500                                    PopupMenuItem::new(label)
501                                } else if let Some(icon) = icon.clone() {
502                                    PopupMenuItem::element(move |_, _| icon.clone())
503                                } else {
504                                    PopupMenuItem::new(t!("Dock.Unnamed"))
505                                };
506                                this = this.item(
507                                    base.checked(selected_index == Some(ix))
508                                        .disabled(*disabled)
509                                        .when_some(on_click.clone(), |this, on_click| {
510                                            this.on_click(move |_, window, cx| {
511                                                on_click(&ix, window, cx)
512                                            })
513                                        }),
514                                );
515                            }
516
517                            this
518                        })
519                        .anchor(Anchor::TopRight),
520                )
521            })
522            .when_some(self.suffix, |this, suffix| this.child(suffix))
523    }
524}