Skip to main content

rosace_widgets/tree/
tab.rs

1use std::sync::Arc;
2use rosace_core::types::{Point, Rect, Size};
3use rosace_render::Color;
4use super::{Widget, LayoutCtx, PaintCtx, avail_w};
5
6/// A single tab descriptor (its label).
7pub struct Tab {
8    pub label: String,
9}
10
11impl Tab {
12    pub fn new(label: impl Into<String>) -> Self { Self { label: label.into() } }
13}
14
15/// A horizontal tab bar with an **animated** sliding underline under the
16/// selected tab. Interactive (mouse + touch) when `.on_change` is wired.
17///
18/// Fully customizable (D094): every color/size has a builder; unset colors
19/// resolve from the active theme (`surface`/`on_surface`/`primary`/`outline`),
20/// so the bar adapts to light/dark and custom themes out of the box. The
21/// underline slides between tabs with the theme's animation curve by default.
22pub struct TabBar {
23    tabs: Vec<Tab>,
24    selected: usize,
25    // Colors are `Option` → `None` means "use the theme token" (resolved in
26    // paint), so the widget is theme-aware unless explicitly overridden.
27    background: Option<Color>,
28    active_color: Option<Color>,
29    inactive_color: Option<Color>,
30    indicator_color: Option<Color>,
31    border_color: Option<Color>,
32    height: f32,
33    /// `None` = read from the active theme's `typography.label_large`
34    /// (D127 "environment" track — see `Checkbox::resolved_font_size`'s doc
35    /// for the reasoning).
36    font_size: Option<f32>,
37    animated: bool,
38    on_change: Option<Arc<dyn Fn(usize) + Send + Sync>>,
39    /// Size each tab to its natural label width instead of dividing the bar
40    /// equally — pair with wrapping the bar in a horizontal `ScrollView`
41    /// (which `Tabs::scrollable` does) when there are more tabs than fit.
42    scrollable: bool,
43}
44
45/// Horizontal padding either side of a tab's label in scrollable mode
46/// (logical px).
47const SCROLLABLE_TAB_PAD: f32 = 20.0;
48
49impl TabBar {
50    pub fn new() -> Self {
51        Self {
52            tabs: Vec::new(),
53            selected: 0,
54            background: None,
55            active_color: None,
56            inactive_color: None,
57            indicator_color: None,
58            border_color: None,
59            height: 40.0,
60            font_size: None,
61            animated: true,
62            on_change: None,
63            scrollable: false,
64        }
65    }
66    pub fn tab(mut self, t: Tab) -> Self { self.tabs.push(t); self }
67    pub fn selected(mut self, i: usize) -> Self { self.selected = i; self }
68    pub fn height(mut self, h: f32) -> Self { self.height = h; self }
69    pub fn font_size(mut self, s: f32) -> Self { self.font_size = Some(s); self }
70
71    fn resolved_font_size(&self, theme: &rosace_theme::ThemeData) -> f32 {
72        self.font_size.unwrap_or(theme.typography.label_large.size)
73    }
74    /// Bar background — theme `surface` if unset.
75    pub fn background(mut self, c: Color) -> Self { self.background = Some(c); self }
76    /// Selected-tab label color — theme `on_surface` if unset.
77    pub fn active_color(mut self, c: Color) -> Self { self.active_color = Some(c); self }
78    /// Unselected-tab label color — a muted `on_surface` if unset.
79    pub fn inactive_color(mut self, c: Color) -> Self { self.inactive_color = Some(c); self }
80    /// Sliding-underline color — theme `primary` if unset.
81    pub fn indicator_color(mut self, c: Color) -> Self { self.indicator_color = Some(c); self }
82    /// Bottom-divider color — theme `outline` if unset.
83    pub fn border_color(mut self, c: Color) -> Self { self.border_color = Some(c); self }
84    /// Turn the sliding-underline animation off (on by default).
85    pub fn animated(mut self, on: bool) -> Self { self.animated = on; self }
86    /// Make the bar interactive: `f(index)` fires on tap of tab `index`.
87    pub fn on_change(mut self, f: impl Fn(usize) + Send + Sync + 'static) -> Self {
88        self.on_change = Some(Arc::new(f));
89        self
90    }
91    /// Size each tab to its natural label width instead of dividing the bar
92    /// equally — for many/long tabs that need horizontal scrolling. Wrap the
93    /// bar in a `ScrollView` (or use `Tabs::scrollable`, which does this for
94    /// you) so overflow is actually reachable, not just clipped.
95    pub fn scrollable(mut self, on: bool) -> Self { self.scrollable = on; self }
96
97    /// Natural width of tab `i`'s label + padding, for `scrollable` mode.
98    fn natural_tab_width(&self, i: usize, font: &rosace_render::FontCache, font_size: f32) -> f32 {
99        font.measure_text(&self.tabs[i].label, font_size) + SCROLLABLE_TAB_PAD * 2.0
100    }
101
102    /// Total natural width of every tab, for `scrollable` mode's layout.
103    fn natural_total_width(&self, font: &rosace_render::FontCache, font_size: f32) -> f32 {
104        self.tabs.iter().map(|t| font.measure_text(&t.label, font_size) + SCROLLABLE_TAB_PAD * 2.0).sum()
105    }
106}
107
108impl Default for TabBar {
109    fn default() -> Self { Self::new() }
110}
111
112impl Widget for TabBar {
113    fn layout(&self, ctx: &LayoutCtx) -> Size {
114        if self.scrollable {
115            // Natural content width, un-clamped to the available width — a
116            // wrapping horizontal ScrollView (Tabs::scrollable) is what
117            // makes the overflow reachable; painted bare, it just overflows
118            // its parent like any other unbounded-width content.
119            let font_size = self.resolved_font_size(ctx.theme);
120            let w = self.natural_total_width(ctx.font, font_size).max(avail_w(ctx.constraints));
121            return Size { width: w, height: self.height };
122        }
123        Size { width: avail_w(ctx.constraints), height: self.height }
124    }
125
126    fn paint(&self, ctx: &mut PaintCtx) {
127        // Resolve theme-defaulted colors up front (borrow-hoist: theme read must
128        // end before the mutable ctx paint calls).
129        let (bg, active, inactive, indicator, border) = {
130            let t = &ctx.theme.colors;
131            let on_surface = ctx.tc(t.on_surface);
132            let surface = ctx.tc(t.surface);
133            (
134                self.background.unwrap_or(surface),
135                self.active_color.unwrap_or(on_surface),
136                // Muted default: blend on_surface toward surface.
137                self.inactive_color.unwrap_or_else(|| super::lerp_color(on_surface, surface, 0.5)),
138                self.indicator_color.unwrap_or_else(|| ctx.tc(t.primary)),
139                self.border_color.unwrap_or_else(|| ctx.tc(t.outline)),
140            )
141        };
142
143        let r = ctx.rect;
144        ctx.fill_rect(r, bg);
145        // Bottom divider.
146        ctx.fill_rect(
147            Rect { origin: Point { x: r.origin.x, y: r.origin.y + r.size.height - 1.0 },
148                   size: Size { width: r.size.width, height: 1.0 } },
149            border,
150        );
151
152        if self.tabs.is_empty() { return; }
153        let font_size = self.resolved_font_size(&ctx.theme);
154
155        // Per-tab widths and their cumulative x offsets — uniform division
156        // normally, natural label width in `scrollable` mode. One array
157        // either way, so the underline/tap-region math below doesn't need
158        // to know which mode it's in.
159        let widths: Vec<f32> = if self.scrollable {
160            (0..self.tabs.len()).map(|i| self.natural_tab_width(i, ctx.font, font_size)).collect()
161        } else {
162            let w = r.size.width / self.tabs.len() as f32;
163            vec![w; self.tabs.len()]
164        };
165        let mut offsets = Vec::with_capacity(widths.len());
166        let mut acc = 0.0;
167        for w in &widths { offsets.push(acc); acc += w; }
168
169        // Animated indicator position: eased toward the selected index, so the
170        // underline slides. `animate_to` uses the theme's animation curve.
171        let pos = if self.animated {
172            ctx.animate_to(self.selected as f32, 0.0)
173        } else {
174            self.selected as f32
175        };
176        // Interpolate the underline's x/width between its two neighboring
177        // tabs' real rects — exact for uniform widths (where they're all
178        // equal anyway) and correct for `scrollable`'s variable widths,
179        // unlike a single `pos * tab_w` which only works when every tab is
180        // the same size.
181        let lo = (pos.floor().max(0.0) as usize).min(widths.len() - 1);
182        let hi = (pos.ceil().max(0.0) as usize).min(widths.len() - 1);
183        let frac = pos - lo as f32;
184        let seg_x = offsets[lo] + (offsets[hi] - offsets[lo]) * frac;
185        let seg_w = widths[lo] + (widths[hi] - widths[lo]) * frac;
186        let underline = Rect {
187            origin: Point { x: r.origin.x + seg_x + seg_w * 0.15, y: r.origin.y + r.size.height - 2.5 },
188            size: Size { width: seg_w * 0.7, height: 2.5 },
189        };
190        ctx.fill_rect(underline, indicator);
191
192        let with_alpha = |c: Color, a: f32| Color::rgba(c.r, c.g, c.b, (a.clamp(0.0, 1.0) * 255.0).round() as u8);
193        for (i, tab) in self.tabs.iter().enumerate() {
194            let tab_x = r.origin.x + offsets[i];
195            let tab_w = widths[i];
196            let tab_rect = Rect { origin: Point { x: tab_x, y: r.origin.y }, size: Size { width: tab_w, height: r.size.height } };
197            let mut child = ctx.child(tab_rect);
198            let hov = child.hovered();
199            let prs = child.pressed();
200            // Hover/press wash behind the tab (an inactive tab under the pointer
201            // reads as reachable — Material's tab state layer).
202            if hov || prs {
203                child.fill_rect(Rect {
204                    origin: Point { x: tab_x + 2.0, y: r.origin.y + 2.0 },
205                    size: Size { width: tab_w - 4.0, height: r.size.height - 4.0 },
206                }, with_alpha(active, if prs { 0.10 } else { 0.06 }));
207            }
208            // Label color crossfades toward `active` as the indicator nears it,
209            // and brightens further while hovered.
210            let nearness = (1.0 - (pos - i as f32).abs()).clamp(0.0, 1.0);
211            let mut label_color = super::lerp_color(inactive, active, nearness);
212            if hov { label_color = super::lerp_color(label_color, active, 0.6); }
213            let text_w = child.font.measure_text(&tab.label, font_size);
214            let line_h = child.font.line_height(font_size);
215            let tx = tab_x + (tab_w - text_w) / 2.0;
216            let ty = r.origin.y + (r.size.height - line_h) / 2.0;
217            child.draw_text_at(&tab.label, Point { x: tx, y: ty }, label_color, font_size);
218
219            child.semantics(
220                super::Semantics::new(rosace_core::Role::Tab)
221                    .label(&tab.label)
222                    .value(if i == self.selected { "selected" } else { "not selected" }),
223            );
224            // Interactive-by-identity: ALWAYS register a hit so a tap on the bar
225            // never falls through to drag-to-pan behind it; fires on_change when wired.
226            match &self.on_change {
227                Some(cb) => { let cb = cb.clone(); child.register_hit(Arc::new(move || cb(i))); }
228                None => child.register_hit(Arc::new(|| {})),
229            }
230        }
231    }
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237    use rosace_layout::Constraints;
238
239    #[test]
240    fn fills_width_and_fixed_height() {
241        let font = rosace_render::FontCache::embedded();
242        let theme = rosace_theme::built_in::dark_theme();
243        let ctx = LayoutCtx::new(Constraints::loose(400.0, 400.0), &font, &theme);
244        let bar = TabBar::new().tab(Tab::new("A")).tab(Tab::new("B")).height(44.0);
245        let size = bar.layout(&ctx);
246        assert_eq!(size.width, 400.0);
247        assert_eq!(size.height, 44.0);
248    }
249}