Skip to main content

rosace_widgets/tree/
tabs.rs

1//! `TabView` (shows one of N children) and `Tabs` (an interactive `TabBar` over
2//! a `TabView`). Selection state is external (a `usize` + `on_change`), matching
3//! `SegmentedControl` — the parent holds it in `ctx.state`, so tabs stay
4//! stateless value types. The tab *strip* is the interactive [`TabBar`] in
5//! `tab.rs`; these compose it with switchable content.
6//!
7//! General-purpose; also the substrate for the in-app DevTools panel
8//! (Elements · Network · Logs …).
9
10use std::sync::Arc;
11use rosace_core::types::{Point, Rect, Size};
12use rosace_render::Color;
13use super::{BoxedWidget, Children, LayoutCtx, PaintCtx, Widget};
14use super::tab::{Tab, TabBar};
15use super::scroll_view::{ScrollAxis, ScrollView};
16
17type OnChange = Arc<dyn Fn(usize) + Send + Sync>;
18
19/// Shows exactly one of its children — the one at `selected`. A thin wrapper:
20/// it declares the selected child as its single child, so the default layout
21/// and paint fill this widget's rect with it.
22pub struct TabView {
23    children: Vec<BoxedWidget>,
24    selected: usize,
25}
26
27impl TabView {
28    pub fn new(children: Vec<BoxedWidget>, selected: usize) -> Self {
29        Self { children, selected }
30    }
31}
32
33impl Widget for TabView {
34    fn children(&self) -> Children<'_> {
35        match self.children.get(self.selected) {
36            Some(c) => Children::One(&**c),
37            None => Children::None,
38        }
39    }
40}
41
42/// An interactive [`TabBar`] over a [`TabView`]: give it labeled content and the
43/// current selection; it lays the bar across the top and the active content
44/// below. Selection is external (`selected` + `on_change`) — the parent keeps
45/// it in `ctx.state`.
46///
47/// ```ignore
48/// let tab = ctx.state(0usize);
49/// let t = tab.clone();
50/// Tabs::new(tab.get(), move |i| t.set(i))
51///     .tab("Elements", elements_view())
52///     .tab("Network",  network_view())
53/// ```
54pub struct Tabs {
55    labels: Vec<String>,
56    contents: Vec<BoxedWidget>,
57    selected: usize,
58    bar_height: f32,
59    on_change: Option<OnChange>,
60    // Bar customization, all forwarded to the internal TabBar (theme-defaulted).
61    background: Option<Color>,
62    active: Option<Color>,
63    inactive: Option<Color>,
64    indicator: Option<Color>,
65    border: Option<Color>,
66    font_size: Option<f32>,
67    animated: bool,
68    /// Natural-width tabs in a horizontal `ScrollView` instead of the bar
69    /// dividing equally — for more tabs than comfortably fit.
70    scrollable: bool,
71}
72
73impl Tabs {
74    pub fn new(selected: usize, on_change: impl Fn(usize) + Send + Sync + 'static) -> Self {
75        Self {
76            labels: Vec::new(),
77            contents: Vec::new(),
78            selected,
79            bar_height: 40.0,
80            on_change: Some(Arc::new(on_change)),
81            background: None, active: None, inactive: None, indicator: None, border: None, font_size: None, animated: true,
82            scrollable: false,
83        }
84    }
85    /// Non-interactive tabs (no selection callback).
86    pub fn readonly(selected: usize) -> Self {
87        Self {
88            labels: Vec::new(), contents: Vec::new(), selected, bar_height: 40.0, on_change: None,
89            background: None, active: None, inactive: None, indicator: None, border: None, font_size: None, animated: true,
90            scrollable: false,
91        }
92    }
93    pub fn tab(mut self, label: impl Into<String>, content: impl Widget + 'static) -> Self {
94        self.labels.push(label.into());
95        self.contents.push(Box::new(content));
96        self
97    }
98    pub fn bar_height(mut self, h: f32) -> Self { self.bar_height = h; self }
99    /// Bar background — theme `surface` if unset.
100    pub fn background(mut self, c: Color) -> Self { self.background = Some(c); self }
101    /// Selected-tab label color — theme `on_surface` if unset.
102    pub fn active_color(mut self, c: Color) -> Self { self.active = Some(c); self }
103    /// Unselected-tab label color — a muted default if unset.
104    pub fn inactive_color(mut self, c: Color) -> Self { self.inactive = Some(c); self }
105    /// Sliding-underline color — theme `primary` if unset.
106    pub fn indicator_color(mut self, c: Color) -> Self { self.indicator = Some(c); self }
107    /// Bottom-divider color — theme `outline` if unset.
108    pub fn border_color(mut self, c: Color) -> Self { self.border = Some(c); self }
109    pub fn font_size(mut self, s: f32) -> Self { self.font_size = Some(s); self }
110    /// Turn the sliding-underline animation off (on by default).
111    pub fn animated(mut self, on: bool) -> Self { self.animated = on; self }
112    /// Size each tab to its natural label width and let the bar scroll
113    /// horizontally instead of squeezing every tab into the available
114    /// width — for a long/variable-count tab list.
115    pub fn scrollable(mut self, on: bool) -> Self { self.scrollable = on; self }
116}
117
118impl Widget for Tabs {
119    fn layout(&self, ctx: &LayoutCtx) -> Size {
120        let w = ctx.constraints.max_width_f32();
121        let h = ctx.constraints.max_height_f32();
122        let w = if w.is_finite() { w } else { 0.0 };
123        let h = if h.is_finite() { h } else { self.bar_height };
124        ctx.constraints.constrain(Size { width: w, height: h })
125    }
126
127    fn paint(&self, ctx: &mut PaintCtx) {
128        let r = ctx.rect;
129        let bar_rect = Rect { origin: r.origin, size: Size { width: r.size.width, height: self.bar_height } };
130        let content_rect = Rect {
131            origin: Point { x: r.origin.x, y: r.origin.y + self.bar_height },
132            size: Size { width: r.size.width, height: (r.size.height - self.bar_height).max(0.0) },
133        };
134
135        // Build the interactive tab strip (cheap value type, rebuilt each paint).
136        let mut bar = TabBar::new().selected(self.selected).height(self.bar_height).animated(self.animated);
137        for label in &self.labels {
138            bar = bar.tab(Tab::new(label));
139        }
140        if let Some(c) = self.background { bar = bar.background(c); }
141        if let Some(c) = self.active { bar = bar.active_color(c); }
142        if let Some(c) = self.inactive { bar = bar.inactive_color(c); }
143        if let Some(c) = self.indicator { bar = bar.indicator_color(c); }
144        if let Some(c) = self.border { bar = bar.border_color(c); }
145        if let Some(s) = self.font_size { bar = bar.font_size(s); }
146        if let Some(cb) = &self.on_change {
147            let cb = cb.clone();
148            bar = bar.on_change(move |i| cb(i));
149        }
150        if self.scrollable {
151            bar = bar.scrollable(true);
152            ScrollView::new(bar).axis(ScrollAxis::Horizontal).paint(&mut ctx.child(bar_rect));
153        } else {
154            bar.paint(&mut ctx.child(bar_rect));
155        }
156
157        // The active content.
158        if let Some(content) = self.contents.get(self.selected) {
159            content.paint(&mut ctx.child(content_rect));
160        }
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167    use crate::tree::Text;
168
169    #[test]
170    fn tabview_declares_the_selected_child() {
171        let tv = TabView::new(vec![Box::new(Text::new("a")), Box::new(Text::new("b"))], 1);
172        assert!(matches!(tv.children(), Children::One(_)));
173    }
174
175    #[test]
176    fn tabview_out_of_range_is_empty() {
177        let tv = TabView::new(vec![Box::new(Text::new("a"))], 9);
178        assert!(matches!(tv.children(), Children::None));
179    }
180
181    #[test]
182    fn tabs_builder_collects_labels_and_content() {
183        let t = Tabs::readonly(0)
184            .tab("One", Text::new("1"))
185            .tab("Two", Text::new("2"));
186        assert_eq!(t.labels, vec!["One", "Two"]);
187        assert_eq!(t.contents.len(), 2);
188    }
189}