Skip to main content

gpui_kit_material/components/
tabs.rs

1//! M3 primary and secondary tabs, fixed or scrollable.
2//!
3//! - Primary tabs head a whole view: label and icon in primary when active,
4//!   a 3dp primary indicator with rounded top corners as wide as the tab's
5//!   content, and icon-over-label tabs 64dp tall (48dp otherwise).
6//! - Secondary tabs sit inside a content area: label in on-surface when
7//!   active, icon beside label, and a 2dp primary indicator across the whole
8//!   tab, 48dp tall.
9//!
10//! Fixed tabs share the width equally; scrollable tabs hug their content
11//! after the M3 52dp leading inset and scroll horizontally, revealing the
12//! focused or selected tab. The indicator slides and resizes between tabs on
13//! the default spatial spring (retargeting from wherever it is), while label
14//! and icon colors cross-fade on the fast effects spring.
15//!
16//! Behavior is GPUI Base's `Tab`/`Tabs` (tab list and tab roles, selection,
17//! position in set, pointer and Enter/Space activation); arrow keys, Home
18//! and End rove focus through the shared [`super::roving`] helper, and
19//! activation is manual unless [`MaterialTabs::auto_activate`] is set.
20use super::button::{ButtonFocusRing, ButtonRipple};
21use super::navigation::{NavBadge, badged_icon, ripple_corners, type_role};
22use super::roving::{self, Change, Orientation, Roving};
23use super::{InteractionPreview, large_badge, small_badge};
24use crate::color::ColorRole;
25use crate::icons::Icon;
26use crate::motion::{self, MotionToken};
27use crate::theme::{blend, color};
28use crate::tokens::{
29    state,
30    typography::{TypeRole, rem},
31};
32use gpui::{prelude::FluentBuilder as _, *};
33use std::{collections::BTreeMap, rc::Rc};
34
35/// The two M3 tab styles.
36#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
37pub enum TabsVariant {
38    #[default]
39    Primary,
40    Secondary,
41}
42
43/// One tab: a unique value, the caller's label (also its accessible name
44/// when only the icon shows), an optional icon and badge.
45#[derive(Clone)]
46pub struct TabItem {
47    value: SharedString,
48    label: SharedString,
49    icon: Option<Icon>,
50    show_label: bool,
51    badge: NavBadge,
52    disabled: bool,
53    preview: Option<InteractionPreview>,
54}
55
56impl TabItem {
57    pub fn new(value: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
58        Self {
59            value: value.into(),
60            label: label.into(),
61            icon: None,
62            show_label: true,
63            badge: NavBadge::None,
64            disabled: false,
65            preview: None,
66        }
67    }
68    pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
69        self.icon = Some(icon.into());
70        self
71    }
72    /// Show only the icon; the label remains the accessible name.
73    pub fn icon_only(mut self) -> Self {
74        self.show_label = false;
75        self
76    }
77    /// A badge on the icon, or after the label of a tab without an icon.
78    pub fn badge(mut self, badge: NavBadge) -> Self {
79        self.badge = badge;
80        self
81    }
82    pub fn disabled(mut self, disabled: bool) -> Self {
83        self.disabled = disabled;
84        self
85    }
86    /// Render this tab as a static specimen in one forced interaction state.
87    /// A tab row holding a previewed tab is a specimen and leaves the tab
88    /// order. See [`InteractionPreview`].
89    pub fn preview_state(mut self, state: InteractionPreview) -> Self {
90        self.preview = Some(state);
91        self
92    }
93    pub fn value(&self) -> &SharedString {
94        &self.value
95    }
96    pub fn label(&self) -> &SharedString {
97        &self.label
98    }
99    fn is_disabled(&self) -> bool {
100        self.disabled || self.preview.is_some_and(InteractionPreview::is_disabled)
101    }
102}
103
104/// M3 tabs over a caller-controlled selection.
105#[derive(IntoElement)]
106pub struct MaterialTabs {
107    id: ElementId,
108    label: SharedString,
109    variant: TabsVariant,
110    scrollable: bool,
111    auto_activate: bool,
112    divider: bool,
113    items: Vec<TabItem>,
114    selected: Option<SharedString>,
115    on_change: Option<Change>,
116}
117
118impl MaterialTabs {
119    /// `label` is the tab list's accessible name.
120    pub fn new(id: impl Into<ElementId>, label: impl Into<SharedString>) -> Self {
121        Self {
122            id: id.into(),
123            label: label.into(),
124            variant: TabsVariant::Primary,
125            scrollable: false,
126            auto_activate: false,
127            divider: true,
128            items: Vec::new(),
129            selected: None,
130            on_change: None,
131        }
132    }
133    pub fn variant(mut self, variant: TabsVariant) -> Self {
134        self.variant = variant;
135        self
136    }
137    /// Content-width tabs that scroll, for many or long tabs.
138    pub fn scrollable(mut self, scrollable: bool) -> Self {
139        self.scrollable = scrollable;
140        self
141    }
142    /// Select a tab as soon as arrow keys focus it (otherwise Enter or Space
143    /// selects the focused tab).
144    pub fn auto_activate(mut self, auto: bool) -> Self {
145        self.auto_activate = auto;
146        self
147    }
148    /// The full-width outline-variant divider under the tabs (on by
149    /// default); the indicator draws over it.
150    pub fn divider(mut self, divider: bool) -> Self {
151        self.divider = divider;
152        self
153    }
154    /// The tabs. Values must be unique.
155    pub fn items(mut self, items: impl IntoIterator<Item = TabItem>) -> Self {
156        self.items = items.into_iter().collect();
157        self
158    }
159    pub fn selected(mut self, value: impl Into<SharedString>) -> Self {
160        self.selected = Some(value.into());
161        self
162    }
163    pub fn on_change(
164        mut self,
165        handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
166    ) -> Self {
167        self.on_change = Some(Rc::new(handler));
168        self
169    }
170}
171
172/// The M3 leading inset of scrollable tabs.
173const SCROLL_INSET: f32 = 52.;
174const TAB_PADDING: f32 = 16.;
175
176/// Container height: icon-over-label primary tabs are 64dp, all others 48dp.
177pub(crate) fn tabs_height(variant: TabsVariant, items: &[TabItem]) -> f32 {
178    let stacked = variant == TabsVariant::Primary
179        && items.iter().any(|item| item.icon.is_some())
180        && items.iter().any(|item| item.show_label);
181    if stacked { 64. } else { 48. }
182}
183
184/// Indicator thickness: 3dp primary, 2dp secondary.
185pub(crate) const fn indicator_height(variant: TabsVariant) -> f32 {
186    match variant {
187        TabsVariant::Primary => 3.,
188        TabsVariant::Secondary => 2.,
189    }
190}
191
192/// The resolved paint of one tab for the current motion frame.
193#[derive(Clone, Copy, Debug, PartialEq)]
194pub(crate) struct TabPaint {
195    pub content: Rgba,
196    pub hover: Rgba,
197    pub press: Rgba,
198}
199
200pub(crate) fn tab_paint(
201    variant: TabsVariant,
202    selected: bool,
203    fade: f32,
204    disabled: bool,
205) -> TabPaint {
206    if disabled {
207        return TabPaint {
208            content: state::disabled(color(ColorRole::ON_SURFACE)),
209            hover: Rgba::default(),
210            press: Rgba::default(),
211        };
212    }
213    let active = match variant {
214        TabsVariant::Primary => color(ColorRole::PRIMARY),
215        TabsVariant::Secondary => color(ColorRole::ON_SURFACE),
216    };
217    let layer = if variant == TabsVariant::Primary && selected {
218        color(ColorRole::PRIMARY)
219    } else {
220        color(ColorRole::ON_SURFACE)
221    };
222    TabPaint {
223        content: blend(
224            color(ColorRole::ON_SURFACE_VARIANT),
225            active,
226            fade.clamp(0., 1.),
227        ),
228        hover: layer.opacity(state::HOVER),
229        press: layer.opacity(state::PRESS),
230    }
231}
232
233/// Tab and content spans, in px from the first tab's leading edge, measured
234/// after layout. The indicator follows these on the next frame.
235#[derive(Default)]
236struct TabGeometry {
237    tabs: Vec<(f32, f32)>,
238    contents: Vec<(f32, f32)>,
239    /// Absolute content spans written by this frame's probes.
240    probed: Vec<(f32, f32)>,
241}
242
243#[cfg(test)]
244thread_local! {
245    pub(crate) static INDICATOR: std::cell::Cell<Option<(f32, f32)>> =
246        const { std::cell::Cell::new(None) };
247}
248
249impl RenderOnce for MaterialTabs {
250    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
251        let variant = self.variant;
252        let primary = variant == TabsVariant::Primary;
253        let count = self.items.len();
254        let height = tabs_height(variant, &self.items);
255        let stacked = height > 48.;
256        let scrollable = self.scrollable;
257
258        let handles_state = window.use_keyed_state((self.id.clone(), "tab-focus"), cx, |_, _| {
259            BTreeMap::<SharedString, FocusHandle>::new()
260        });
261        let handles: Rc<[FocusHandle]> = handles_state
262            .update(cx, |handles, cx| {
263                roving::sync_handles(
264                    handles,
265                    &self.items,
266                    |item| &item.value,
267                    || cx.focus_handle(),
268                )
269            })
270            .into();
271        let scroll = window
272            .use_keyed_state((self.id.clone(), "tab-scroll"), cx, |_, _| {
273                ScrollHandle::new()
274            })
275            .read(cx)
276            .clone();
277        let geometry = window.use_keyed_state((self.id.clone(), "tab-geometry"), cx, |_, _| {
278            TabGeometry::default()
279        });
280        geometry.update(cx, |geometry, _| {
281            geometry.probed.clear();
282            geometry.probed.resize(count, (0., 0.));
283        });
284
285        let selected = self
286            .items
287            .iter()
288            .position(|item| Some(&item.value) == self.selected.as_ref());
289        let specimen = self.items.iter().any(|item| item.preview.is_some());
290        let skip: Rc<[bool]> = self.items.iter().map(TabItem::is_disabled).collect();
291        let select = self
292            .on_change
293            .clone()
294            .filter(|_| self.auto_activate)
295            .map(|on_change| {
296                roving::select_value(self.items.iter().map(|item| item.value.clone()), on_change)
297            });
298        let roving = Roving::new(handles.clone(), Orientation::Row)
299            .skip(skip)
300            .selected(selected)
301            .select_on_focus(select)
302            .reveal(scrollable.then(|| scroll.clone()));
303        let entry = if specimen {
304            None
305        } else {
306            roving.tab_stop(window)
307        };
308
309        // The sliding indicator, once this tab row has been measured.
310        let measured = {
311            let geometry = geometry.read(cx);
312            selected
313                .filter(|_| geometry.tabs.len() == count && geometry.contents.len() == count)
314                .map(|index| {
315                    if primary {
316                        geometry.contents[index]
317                    } else {
318                        geometry.tabs[index]
319                    }
320                })
321        };
322        let rem_size = window.rem_size();
323        let inset = if scrollable {
324            rem(SCROLL_INSET).to_pixels(rem_size)
325        } else {
326            px(0.)
327        };
328        let thickness = rem(indicator_height(variant));
329        let indicator_bar = move || {
330            div()
331                .absolute()
332                .bottom_0()
333                .h(thickness)
334                .bg(color(ColorRole::PRIMARY))
335                .when(primary, |bar| bar.rounded_t(thickness))
336        };
337        let sliding = measured.map(|(left, width)| {
338            let target = Bounds::new(point(inset + px(left), px(0.)), size(px(width), px(0.)));
339            let at = motion::token_transition(
340                (self.id.clone(), "tab-indicator"),
341                target,
342                MotionToken::DefaultSpatial,
343                window,
344                cx,
345            );
346            #[cfg(test)]
347            INDICATOR
348                .with(|cell| cell.set(Some((f32::from(at.origin.x), f32::from(at.size.width)))));
349            indicator_bar().left(at.origin.x).w(at.size.width)
350        });
351        let fallback = sliding.is_none();
352
353        let mut lane = div()
354            .on_children_prepainted({
355                let geometry = geometry.clone();
356                move |bounds, window, cx| {
357                    let Some(first) = bounds.first().map(|b| f32::from(b.left())) else {
358                        return;
359                    };
360                    let tabs: Vec<(f32, f32)> = bounds
361                        .iter()
362                        .take(count)
363                        .map(|b| (f32::from(b.left()) - first, f32::from(b.size.width)))
364                        .collect();
365                    let changed = geometry.update(cx, |geometry, _| {
366                        let contents: Vec<(f32, f32)> = geometry
367                            .probed
368                            .iter()
369                            .map(|(left, width)| (left - first, *width))
370                            .collect();
371                        let changed = geometry.tabs != tabs || geometry.contents != contents;
372                        if changed {
373                            geometry.tabs = tabs;
374                            geometry.contents = contents;
375                        }
376                        changed
377                    });
378                    if changed {
379                        window.defer(cx, |window, _| window.refresh());
380                    }
381                }
382            })
383            .id((self.id.clone(), "tab-lane"))
384            .relative()
385            .w_full()
386            .h_full()
387            .flex()
388            .flex_row()
389            .when(scrollable, |lane| {
390                lane.overflow_x_scroll()
391                    .track_scroll(&scroll)
392                    .pl(rem(SCROLL_INSET))
393            });
394
395        for (index, item) in self.items.into_iter().enumerate() {
396            let is_selected = selected == Some(index);
397            let preview = item.preview;
398            let disabled = item.is_disabled();
399            let enabled = !disabled;
400            let tab_id = ElementId::NamedChild(self.id.clone().into(), item.value.clone());
401            let fade = motion::token_value(
402                (tab_id.clone(), "tab-fade"),
403                if is_selected { 1. } else { 0. },
404                MotionToken::FastEffects,
405                window,
406                cx,
407            );
408            let paint = tab_paint(variant, is_selected, fade, disabled);
409            let focus = handles[index]
410                .clone()
411                .tab_stop(entry == Some(index) && enabled);
412
413            let label = item.show_label.then(|| {
414                div()
415                    .min_w_0()
416                    .whitespace_nowrap()
417                    .text_ellipsis()
418                    .text_color(paint.content)
419                    .map(|label| type_role(label, TypeRole::TitleSmall))
420                    .child(item.label.clone())
421            });
422            let icon = item
423                .icon
424                .clone()
425                .map(|icon| badged_icon(icon.color(paint.content), &item.badge));
426            let inline_badge = match (&item.badge, item.icon.is_some()) {
427                (NavBadge::Small, false) => Some(small_badge().into_any_element()),
428                (NavBadge::Large(text), false) => {
429                    Some(large_badge("tab-badge", text.clone()).into_any_element())
430                }
431                _ => None,
432            };
433            let probe_geometry = geometry.clone();
434            let probe = canvas(
435                move |bounds, _, cx| {
436                    probe_geometry.update(cx, |geometry, _| {
437                        if let Some(slot) = geometry.probed.get_mut(index) {
438                            *slot = (f32::from(bounds.left()), f32::from(bounds.size.width));
439                        }
440                    })
441                },
442                |_, _, _, _| {},
443            )
444            .absolute()
445            .inset_0();
446            let content = div()
447                .relative()
448                .h_full()
449                .min_w_0()
450                .flex()
451                .items_center()
452                .justify_center()
453                .map(|content| {
454                    if stacked {
455                        content.flex_col().gap(rem(2.))
456                    } else {
457                        content.flex_row().gap(rem(8.))
458                    }
459                })
460                .children(icon)
461                .children(label)
462                .children(inline_badge)
463                .child(probe)
464                .when(fallback && primary && is_selected, |content| {
465                    content.child(indicator_bar().left_0().right_0())
466                });
467
468            let click_focus = focus.clone();
469            let on_change = self.on_change.clone();
470            let value = item.value.clone();
471            let reveal = scrollable.then(|| scroll.clone());
472            let tab = gpui_base::Tab::new(tab_id.clone())
473                .selected(is_selected)
474                .disabled(disabled)
475                .accessibility_label(item.label.clone())
476                .set_position(index + 1, count)
477                .track_focus(&focus)
478                .relative()
479                .h_full()
480                .px(rem(TAB_PADDING))
481                .map(|tab| {
482                    if scrollable {
483                        tab.flex_shrink_0()
484                    } else {
485                        tab.flex_1().min_w_0()
486                    }
487                })
488                .map(|tab| match preview {
489                    _ if disabled => tab,
490                    None => tab
491                        .cursor_pointer()
492                        .hover(move |style| style.bg(paint.hover))
493                        .active(move |style| style.bg(paint.press)),
494                    Some(InteractionPreview::Hovered) => tab.bg(paint.hover),
495                    Some(InteractionPreview::Pressed) => tab.bg(paint.press),
496                    Some(_) => tab,
497                })
498                .when(cfg!(test), |tab| {
499                    tab.debug_selector(move || format!("material-tab-{index}"))
500                })
501                .child(content)
502                .when(fallback && !primary && is_selected, |tab| {
503                    tab.child(indicator_bar().left_0().right_0())
504                })
505                .when(preview.is_none(), |tab| {
506                    tab.on_click(move |_, window, cx| {
507                        click_focus.focus(window, cx);
508                        if let Some(scroll) = &reveal {
509                            scroll.scroll_to_item(index);
510                        }
511                        if !is_selected && let Some(on_change) = &on_change {
512                            on_change(&value, window, cx);
513                        }
514                    })
515                });
516            let ink = if primary && is_selected {
517                color(ColorRole::PRIMARY)
518            } else {
519                color(ColorRole::ON_SURFACE)
520            };
521            let ripple = ButtonRipple::new(
522                tab,
523                (tab_id, "tab-ripple").into(),
524                preview.is_none(),
525                enabled,
526                ripple_corners(px(0.)),
527                Edges::all(px(0.).into()),
528                ink,
529                window,
530                cx,
531            );
532            let ring = ButtonFocusRing::with_corners(
533                ripple,
534                focus,
535                enabled,
536                Corners::all(rem(8.).to_pixels(rem_size)),
537            )
538            .inward()
539            .forced(preview == Some(InteractionPreview::Focused));
540            lane = lane.child(
541                div()
542                    .h_full()
543                    .flex()
544                    .map(|slot| {
545                        if scrollable {
546                            slot.flex_shrink_0()
547                        } else {
548                            slot.flex_1().min_w_0()
549                        }
550                    })
551                    .child(ring),
552            );
553        }
554        let lane = lane.children(sliding);
555        let list = gpui_base::Tabs::new(self.id)
556            .aria_label(self.label)
557            .relative()
558            .w_full()
559            .h(rem(height))
560            .bg(color(ColorRole::SURFACE))
561            // The full-width divider under the tabs; the indicator covers it.
562            .when(self.divider, |list| {
563                list.child(
564                    div()
565                        .absolute()
566                        .left_0()
567                        .right_0()
568                        .bottom_0()
569                        .h(px(1.))
570                        .bg(color(ColorRole::OUTLINE_VARIANT))
571                        .when(cfg!(test), |line| {
572                            line.debug_selector(|| "tabs-divider".into())
573                        }),
574                )
575            })
576            .child(lane);
577        roving.install(list)
578    }
579}
580
581#[cfg(test)]
582#[path = "tabs_tests.rs"]
583mod tests;