Skip to main content

rosace_widgets/tree/
bottom_nav.rs

1//! `BottomNavigationBar` (D115/Phase 32 Step 1) — the horizontal
2//! counterpart to [`NavRail`]: 3-5 top-level destinations pinned to the
3//! bottom edge, the mobile-first navigation convention. Drop it in
4//! `Scaffold::bottom_bar`.
5//!
6//! Controlled, like `NavRail`/`TabBar`: the app owns the selected index
7//! (mark one item `.active()`, flip your atom in `.on_press`). Fully
8//! themeable per the Phase 32 customization sweep — every color/metric
9//! has a D094 builder; defaults come from the live theme's tokens.
10
11use std::sync::Arc;
12
13use rosace_core::types::{Point, Rect, Size};
14use rosace_layout::Constraints;
15use rosace_render::Color;
16use rosace_shader::ShaderMaterial;
17
18use super::container::draw_rounded_rect_pub;
19use super::material::{resolve_material, BottomNavMaterial};
20use super::{avail_w, LayoutCtx, PaintCtx, Widget};
21
22/// One destination in a [`BottomNavigationBar`].
23pub struct BottomNavItem {
24    label: String,
25    icon: Option<super::BoxedWidget>,
26    badge: Option<u32>,
27    active: bool,
28    on_press: Option<Arc<dyn Fn() + Send + Sync>>,
29}
30
31impl BottomNavItem {
32    pub fn new(label: impl Into<String>) -> Self {
33        Self { label: label.into(), icon: None, badge: None, active: false, on_press: None }
34    }
35    /// Icon shown above the label (any widget — usually [`super::Icon`]).
36    pub fn icon(mut self, w: impl Widget + 'static) -> Self {
37        self.icon = Some(Box::new(w));
38        self
39    }
40    pub fn badge(mut self, n: u32) -> Self { self.badge = Some(n); self }
41    pub fn active(mut self) -> Self { self.active = true; self }
42    pub fn on_press(mut self, f: impl Fn() + Send + Sync + 'static) -> Self {
43        self.on_press = Some(Arc::new(f));
44        self
45    }
46}
47
48/// The bar itself — a multi-destination leaf (paints its items directly,
49/// the `NavRail` pattern).
50pub struct BottomNavigationBar {
51    items: Vec<BottomNavItem>,
52    height: f32,
53    background: Option<Color>,
54    active_color: Option<Color>,
55    inactive_color: Option<Color>,
56    /// Corner radius for the bar's TOP corners (a floating/inset bar look);
57    /// `0.0` = the classic edge-to-edge flat bar.
58    radius: f32,
59    /// `None` = read from the active theme's `typography.label_small`
60    /// (D127 "environment" track — see `Checkbox::resolved_font_size`'s doc
61    /// for the reasoning).
62    font_size: Option<f32>,
63    /// `0.0` hides the top hairline divider.
64    divider_width: f32,
65    material: Option<ShaderMaterial>,
66}
67
68impl BottomNavigationBar {
69    pub fn new() -> Self {
70        Self {
71            items: Vec::new(),
72            height: 56.0,
73            background: None,
74            active_color: None,
75            inactive_color: None,
76            radius: 0.0,
77            font_size: None,
78            divider_width: 1.0,
79            material: None,
80        }
81    }
82    pub fn item(mut self, i: BottomNavItem) -> Self { self.items.push(i); self }
83    pub fn height(mut self, h: f32) -> Self { self.height = h; self }
84    /// Bar fill — defaults to the theme's `surface`.
85    pub fn background(mut self, c: Color) -> Self { self.background = Some(c); self }
86    /// Selected label/icon tint — defaults to the theme's `primary`.
87    pub fn active_color(mut self, c: Color) -> Self { self.active_color = Some(c); self }
88    /// Unselected tint — defaults to the theme's `on_surface` dimmed.
89    pub fn inactive_color(mut self, c: Color) -> Self { self.inactive_color = Some(c); self }
90    pub fn radius(mut self, r: f32) -> Self { self.radius = r; self }
91    pub fn font_size(mut self, s: f32) -> Self { self.font_size = Some(s); self }
92
93    fn resolved_font_size(&self, theme: &rosace_theme::ThemeData) -> f32 {
94        self.font_size.unwrap_or(theme.typography.label_small.size)
95    }
96    pub fn no_divider(mut self) -> Self { self.divider_width = 0.0; self }
97    /// Per-instance shader material — replaces the bar fill when resolved.
98    /// Beats the theme's `BottomNavMaterial` default (D124 Step 5).
99    pub fn material(mut self, m: ShaderMaterial) -> Self { self.material = Some(m); self }
100}
101
102impl Default for BottomNavigationBar {
103    fn default() -> Self { Self::new() }
104}
105
106impl Widget for BottomNavigationBar {
107    fn layout(&self, ctx: &LayoutCtx) -> Size {
108        Size { width: avail_w(ctx.constraints), height: self.height }
109    }
110
111    fn paint(&self, ctx: &mut PaintCtx) {
112        // Hoisted theme reads (the borrow must end before mutable painting).
113        let (background, active, inactive, outline, err_bg, err_fg) = {
114            let t = &ctx.theme.colors;
115            let on_surface = ctx.tc(t.on_surface);
116            (
117                self.background.unwrap_or_else(|| ctx.tc(t.surface)),
118                self.active_color.unwrap_or_else(|| ctx.tc(t.primary)),
119                self.inactive_color.unwrap_or(Color::rgba(
120                    on_surface.r, on_surface.g, on_surface.b, 150,
121                )),
122                ctx.tc(t.outline),
123                ctx.tc(t.error),
124                ctx.tc(t.on_error),
125            )
126        };
127
128        let r = ctx.rect;
129        // With a material, only paint a fallback it EXPLICITLY carries —
130        // an unconditional base fill is what a backdrop-sampling glass
131        // material would sample instead of the content behind the bar
132        // (same rule as Container/Card).
133        let material = resolve_material::<BottomNavMaterial>(&ctx.theme, self.material.as_ref());
134        let fill = match &material {
135            Some(m) => m.fallback,
136            None => Some(background),
137        };
138        if let Some(fill) = fill {
139            if self.radius > 0.0 {
140                draw_rounded_rect_pub(ctx, r, fill, self.radius);
141            } else {
142                ctx.fill_rect(r, fill);
143            }
144        }
145        if let Some(m) = &material {
146            ctx.shader_fill(r, m.pipeline, m.uniforms.clone());
147        }
148        if self.divider_width > 0.0 && self.radius == 0.0 {
149            ctx.fill_rect(
150                Rect { origin: r.origin, size: Size { width: r.size.width, height: self.divider_width } },
151                outline,
152            );
153        }
154
155        if self.items.is_empty() {
156            return;
157        }
158        // Equal spread — the universal bottom-nav convention.
159        let slot_w = r.size.width / self.items.len() as f32;
160
161        for (i, item) in self.items.iter().enumerate() {
162            let slot = Rect {
163                origin: Point { x: r.origin.x + slot_w * i as f32, y: r.origin.y },
164                size: Size { width: slot_w, height: r.size.height },
165            };
166            let mut slot_ctx = ctx.child(slot);
167            // Destinations are links (the D107 <nav><a> shape, same as NavRail).
168            let mut sem = super::Semantics::new(rosace_core::Role::Link).label(&item.label);
169            if let Some(n) = item.badge { sem = sem.value(n.to_string()); }
170            slot_ctx.semantics(sem);
171
172            // Active pill + hover/press state layer behind the item content
173            // (Material-3 bottom-nav affordance).
174            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);
175            let hov = slot_ctx.hovered();
176            let prs = slot_ctx.pressed();
177            if item.active || hov || prs {
178                let a = if item.active { 0.15 } else if prs { 0.10 } else { 0.06 };
179                let base = if item.active { active } else { inactive };
180                let pill = Rect {
181                    origin: Point { x: slot.origin.x + slot_w * 0.16, y: slot.origin.y + 6.0 },
182                    size: Size { width: slot_w * 0.68, height: (slot.size.height - 12.0).max(4.0) },
183                };
184                draw_rounded_rect_pub(&mut slot_ctx, pill, with_alpha(base, a), 12.0);
185            }
186
187            let tint = if item.active { active }
188                       else if hov { super::lerp_color(inactive, active, 0.5) }
189                       else { inactive };
190            let font_size = self.resolved_font_size(&slot_ctx.theme);
191            let line_h = slot_ctx.font.line_height(font_size);
192
193            // Icon above label when present; label alone centers vertically.
194            let mut label_y = slot.origin.y + (slot.size.height - line_h) / 2.0;
195            if let Some(icon) = &item.icon {
196                let icon_box = 22.0f32;
197                let content_h = icon_box + 3.0 + line_h;
198                let top = slot.origin.y + (slot.size.height - content_h) / 2.0;
199                let is = icon.layout(&slot_ctx.layout_ctx(Constraints::loose(icon_box, icon_box)));
200                icon.paint(&mut slot_ctx.child(Rect {
201                    origin: Point { x: slot.origin.x + (slot.size.width - is.width) / 2.0, y: top },
202                    size: is,
203                }));
204                label_y = top + icon_box + 3.0;
205            }
206
207            let text_w = slot_ctx.font.measure_text(&item.label, font_size);
208            let label_x = slot.origin.x + (slot.size.width - text_w) / 2.0;
209            slot_ctx.draw_text_at(
210                &item.label,
211                Point { x: label_x, y: label_y },
212                tint,
213                font_size,
214            );
215
216            if let Some(n) = item.badge {
217                let ns = n.to_string();
218                let bw = ns.len() as f32 * 7.0 + 8.0;
219                let bx = slot.origin.x + slot.size.width / 2.0 + 6.0;
220                let by = slot.origin.y + 6.0;
221                draw_rounded_rect_pub(
222                    &mut slot_ctx,
223                    Rect { origin: Point { x: bx, y: by }, size: Size { width: bw, height: 15.0 } },
224                    err_bg,
225                    7.5,
226                );
227                slot_ctx.draw_text_at(&ns, Point { x: bx + 4.0, y: by + 2.5 }, err_fg, 8.5);
228            }
229
230            // Interactive-by-identity: always absorb (nav bars sit over content).
231            match &item.on_press {
232                Some(cb) => slot_ctx.register_hit(Arc::clone(cb)),
233                None => slot_ctx.register_hit(Arc::new(|| {})),
234            }
235        }
236    }
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242
243    #[test]
244    fn instance_material_paints_a_shader_fill() {
245        let font = rosace_render::FontCache::embedded();
246        let theme = rosace_theme::built_in::dark_theme();
247        let mut recorder = rosace_render::PictureRecorder::new();
248        let tree = std::rc::Rc::new(std::cell::RefCell::new(super::super::render_tree::RenderTree::new()));
249        let rect = rosace_core::types::Rect {
250            origin: Point { x: 0.0, y: 0.0 },
251            size: Size { width: 390.0, height: 56.0 },
252        };
253        let mut ctx = PaintCtx::root(&mut recorder, rect, &font, theme, tree);
254        let m = ShaderMaterial::new(rosace_shader::PipelineId::user(0x4003), vec![0u8; 16]);
255        BottomNavigationBar::new().material(m).item(BottomNavItem::new("Home")).paint(&mut ctx);
256        let picture = recorder.finish();
257        assert!(picture.commands.iter().any(|c| matches!(c, rosace_render::DrawCommand::ShaderFill { .. })));
258    }
259
260    #[test]
261    fn bar_takes_full_width_and_its_configured_height() {
262        let bar = BottomNavigationBar::new()
263            .height(64.0)
264            .item(BottomNavItem::new("Home"))
265            .item(BottomNavItem::new("Search"));
266        let font = rosace_render::FontCache::embedded();
267        let theme = rosace_theme::built_in::dark_theme();
268        let ctx = LayoutCtx::new(Constraints::loose(390.0, 800.0), &font, &theme);
269        let size = bar.layout(&ctx);
270        assert_eq!(size.width, 390.0);
271        assert_eq!(size.height, 64.0);
272    }
273
274    #[test]
275    fn default_height_matches_the_platform_convention() {
276        let bar = BottomNavigationBar::new();
277        let font = rosace_render::FontCache::embedded();
278        let theme = rosace_theme::built_in::dark_theme();
279        let ctx = LayoutCtx::new(Constraints::loose(320.0, 600.0), &font, &theme);
280        assert_eq!(bar.layout(&ctx).height, 56.0);
281    }
282}