1use 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
22pub 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 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
48pub struct BottomNavigationBar {
51 items: Vec<BottomNavItem>,
52 height: f32,
53 background: Option<Color>,
54 active_color: Option<Color>,
55 inactive_color: Option<Color>,
56 radius: f32,
59 font_size: Option<f32>,
63 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 pub fn background(mut self, c: Color) -> Self { self.background = Some(c); self }
86 pub fn active_color(mut self, c: Color) -> Self { self.active_color = Some(c); self }
88 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 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 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 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 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 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 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 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 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}