tessera_ui_basic_components/
bottom_nav_bar.rs

1//! A bottom navigation bar for switching between primary application screens.
2//!
3//! This module provides the [`bottom_nav_bar`] component, which creates a persistent,
4//! horizontal bar at the bottom of the UI. It is designed to work with a router to
5//! control which main screen or "shard" is currently visible.
6//!
7//! # Key Components
8//!
9//! * **[`bottom_nav_bar`]**: The main function that renders the navigation bar.
10//! * **[`BottomNavBarState`]**: A state object that must be created to track the
11//!   currently selected navigation item.
12//! * **[`BottomNavBarScope`]**: A scope provided to the `bottom_nav_bar`'s closure
13//!   to add individual navigation items.
14//!
15//! # Usage
16//!
17//! The typical layout involves placing the `bottom_nav_bar` in a `column` below a
18//! `router_root` component. This ensures the navigation bar remains visible while the
19//! content above it changes.
20//!
21//! 1.  **Create State**: Create an `Arc<RwLock<BottomNavBarState>>` at a high level
22//!     in your application state.
23//! 2.  **Define Layout**: In your root component, create a `column`. Place a `router_root`
24//!     in the first (weighted) child slot and the `bottom_nav_bar` in the second.
25//! 3.  **Add Items**: Inside the `bottom_nav_bar` closure, use the provided scope's
26//!     [`child`](BottomNavBarScope::child) method to add each navigation destination.
27//!     - The first argument to `child` is a closure that renders the item's content (e.g., an icon or text).
28//!     - The second argument is an `on_click` closure where you perform the navigation,
29//!       typically by calling `tessera_ui::router::push` with the destination shard.
30//!
31//! # Example
32//!
33//! ```
34//! use std::sync::Arc;
35//! use parking_lot::RwLock;
36//! use tessera_ui::{tessera, router::{Router, router_root}};
37//! use tessera_ui_basic_components::{
38//!     bottom_nav_bar::{bottom_nav_bar, BottomNavBarState},
39//!     column::{ColumnArgsBuilder, column},
40//!     text::{text, TextArgsBuilder},
41//! };
42//!
43//! // Assume HomeScreenDestination and ProfileScreenDestination are defined shards.
44//! # use tessera_ui::shard;
45//! # #[tessera] #[shard] fn home_screen() {}
46//! # #[tessera] #[shard] fn profile_screen() {}
47//!
48//! #[tessera]
49//! fn app_root() {
50//!     let nav_bar_state = Arc::new(RwLock::new(BottomNavBarState::new(0)));
51//!
52//!     column(ColumnArgsBuilder::default().build().unwrap(), move |scope| {
53//!         // The router viewport takes up the remaining space.
54//!         scope.child_weighted(|| {
55//!             router_root(HomeScreenDestination {});
56//!         }, 1.0);
57//!
58//!         // The navigation bar is always visible at the bottom.
59//!         scope.child(move || {
60//!             bottom_nav_bar(nav_bar_state.clone(), |nav_scope| {
61//!                 // Add the "Home" item.
62//!                 nav_scope.child(
63//!                     || text(TextArgsBuilder::default().text("Home".to_string()).build().unwrap()),
64//!                     move || {
65//!                         Router::with_mut(|router| {
66//!                             router.reset_with(HomeScreenDestination {});
67//!                         });
68//!                     },
69//!                 );
70//!
71//!                 // Add the "Profile" item.
72//!                 nav_scope.child(
73//!                     || text(TextArgsBuilder::default().text("Profile".to_string()).build().unwrap()),
74//!                     move || {
75//!                         Router::with_mut(|router| {
76//!                             router.reset_with(ProfileScreenDestination {});
77//!                         });
78//!                     },
79//!                 );
80//!             });
81//!         });
82//!     });
83//! }
84//! ```
85use std::{
86    collections::HashMap,
87    sync::Arc,
88    time::{Duration, Instant},
89};
90
91use parking_lot::{Mutex, RwLock};
92use tessera_ui::{Color, DimensionValue, tessera};
93
94use crate::{
95    RippleState,
96    alignment::MainAxisAlignment,
97    animation,
98    button::{ButtonArgsBuilder, button},
99    pipelines::ShadowProps,
100    row::{RowArgsBuilder, row},
101    shape_def::Shape,
102    surface::{SurfaceArgsBuilder, surface},
103};
104
105const ANIMATION_DURATION: Duration = Duration::from_millis(300);
106const ACTIVE_COLOR: Color = Color::from_rgb_u8(225, 235, 255);
107const INACTIVE_COLOR: Color = Color::WHITE;
108const ACTIVE_COLOR_SHADOW: Color = Color::from_rgba_u8(100, 115, 140, 100);
109
110fn interpolate_color(from: Color, to: Color, progress: f32) -> Color {
111    Color {
112        r: from.r + (to.r - from.r) * progress,
113        g: from.g + (to.g - from.g) * progress,
114        b: from.b + (to.b - from.b) * progress,
115        a: from.a + (to.a - from.a) * progress,
116    }
117}
118
119/// A horizontal bottom navigation bar that hosts multiple navigation items (children),
120/// each with its own click callback. The currently selected item is visually highlighted
121/// (pill style) and tracked inside a shared [`BottomNavBarState`].
122///
123/// # State Handling
124///
125/// * The `state: Arc<RwLock<BottomNavBarState>>` holds:
126///   - `selected`: index of the active item
127///   - A lazily created `RippleState` per item (for button ripple feedback)
128/// * The active item is rendered with a capsule shape & filled color; inactive items are
129///   rendered as transparent buttons.
130///
131/// # Building Children
132///
133/// Children are registered via the provided closure `scope_config` which receives a
134/// mutable [`BottomNavBarScope`]. Each child is added with:
135/// `scope.child(content_closure, on_click_closure)`.
136///
137/// `on_click_closure` is responsible for performing side effects (e.g. pushing a new route).
138/// The component automatically updates `selected` and triggers the ripple state before
139/// invoking the user `on_click`.
140///
141/// # Layout
142///
143/// Internally the bar is:
144/// * A full‑width `surface` (non‑interactive container)
145/// * A `row` whose children are spaced using `MainAxisAlignment::SpaceAround`
146///
147/// # Notes
148///
149/// * Indices are assigned in the order children are added.
150/// * The bar itself does not do routing — supply routing logic inside each child's
151///   `on_click` closure.
152/// * Thread safety for `selected` & ripple states is provided by `RwLock`.
153#[tessera]
154pub fn bottom_nav_bar<F>(state: Arc<RwLock<BottomNavBarState>>, scope_config: F)
155where
156    F: FnOnce(&mut BottomNavBarScope),
157{
158    let mut child_closures = Vec::new();
159
160    {
161        let mut scope = BottomNavBarScope {
162            child_closures: &mut child_closures,
163        };
164        scope_config(&mut scope);
165    }
166
167    let progress = {
168        let mut state = state.write();
169        state.animation_progress().unwrap_or(1.0)
170    };
171
172    surface(
173        SurfaceArgsBuilder::default()
174            .width(DimensionValue::FILLED)
175            .style(Color::from_rgb(9.333, 9.333, 9.333).into())
176            .shadow(ShadowProps::default())
177            .block_input(true)
178            .build()
179            .unwrap(),
180        None,
181        move || {
182            row(
183                RowArgsBuilder::default()
184                    .width(DimensionValue::FILLED)
185                    .main_axis_alignment(MainAxisAlignment::SpaceAround)
186                    .build()
187                    .unwrap(),
188                move |row_scope| {
189                    for (index, (child_content, on_click)) in child_closures.into_iter().enumerate()
190                    {
191                        let state_clone = state.clone();
192                        row_scope.child(move || {
193                            let (selected, previous_selected) = {
194                                let s = state_clone.read();
195                                (s.selected(), s.previous_selected())
196                            };
197                            let ripple_state = state_clone.write().ripple_state(index);
198
199                            let color;
200                            let shadow_color;
201                            if index == selected {
202                                color = interpolate_color(INACTIVE_COLOR, ACTIVE_COLOR, progress);
203                                shadow_color =
204                                    interpolate_color(INACTIVE_COLOR, ACTIVE_COLOR_SHADOW, progress)
205                            } else if index == previous_selected {
206                                color = interpolate_color(ACTIVE_COLOR, INACTIVE_COLOR, progress);
207                                shadow_color =
208                                    interpolate_color(ACTIVE_COLOR_SHADOW, INACTIVE_COLOR, progress)
209                            } else {
210                                color = INACTIVE_COLOR;
211                                shadow_color = INACTIVE_COLOR;
212                            }
213
214                            let button_args = ButtonArgsBuilder::default()
215                                .color(color)
216                                .shape(Shape::HorizontalCapsule)
217                                .on_click(Arc::new(move || {
218                                    if index != selected {
219                                        state_clone.write().set_selected(index);
220                                        on_click.lock().take().unwrap()();
221                                    }
222                                }))
223                                .shadow(ShadowProps {
224                                    color: shadow_color,
225                                    ..Default::default()
226                                })
227                                .build()
228                                .unwrap();
229
230                            button(button_args, ripple_state, || {
231                                child_content();
232                            });
233                        });
234                    }
235                },
236            );
237        },
238    );
239}
240
241/// Holds selection & per-item ripple state for the bottom navigation bar.
242///
243/// `selected` is the currently active item index. `ripple_states` lazily allocates a
244/// `RippleState` (shared for each item) on first access, enabling ripple animations
245/// on its associated button.
246pub struct BottomNavBarState {
247    selected: usize,
248    previous_selected: usize,
249    ripple_states: HashMap<usize, Arc<RippleState>>,
250    anim_start_time: Option<Instant>,
251}
252
253impl Default for BottomNavBarState {
254    fn default() -> Self {
255        Self::new(0)
256    }
257}
258
259impl BottomNavBarState {
260    /// Create a new state with an initial selected index.
261    pub fn new(selected: usize) -> Self {
262        Self {
263            selected,
264            previous_selected: selected,
265            ripple_states: HashMap::new(),
266            anim_start_time: None,
267        }
268    }
269
270    /// Returns the index of the currently selected navigation item.
271    pub fn selected(&self) -> usize {
272        self.selected
273    }
274
275    /// Returns the index of the previously selected navigation item.
276    /// This is useful for animations when transitioning between selected items.
277    pub fn previous_selected(&self) -> usize {
278        self.previous_selected
279    }
280
281    fn set_selected(&mut self, index: usize) {
282        if self.selected != index {
283            self.previous_selected = self.selected;
284            self.selected = index;
285            self.anim_start_time = Some(Instant::now());
286        }
287    }
288
289    fn animation_progress(&mut self) -> Option<f32> {
290        if let Some(start_time) = self.anim_start_time {
291            let elapsed = start_time.elapsed();
292            if elapsed < ANIMATION_DURATION {
293                Some(animation::easing(
294                    elapsed.as_secs_f32() / ANIMATION_DURATION.as_secs_f32(),
295                ))
296            } else {
297                self.anim_start_time = None;
298                None
299            }
300        } else {
301            None
302        }
303    }
304
305    fn ripple_state(&mut self, index: usize) -> Arc<RippleState> {
306        self.ripple_states
307            .entry(index)
308            .or_insert_with(|| Arc::new(RippleState::new()))
309            .clone()
310    }
311}
312
313/// Scope passed to the closure for defining children of the BottomNavBar.
314pub struct BottomNavBarScope<'a> {
315    child_closures: &'a mut Vec<(
316        Box<dyn FnOnce() + Send + Sync>,
317        Arc<Mutex<Option<Box<dyn FnOnce() + Send + Sync>>>>,
318    )>,
319}
320
321impl<'a> BottomNavBarScope<'a> {
322    /// Add a navigation item.
323    ///
324    /// * `child`: visual content (icon / label). Executed when the bar renders; must be
325    ///   side‑effect free except for building child components.
326    /// * `on_click`: invoked when this item is pressed; typical place for routing logic.
327    ///
328    /// The index of the added child becomes its selection index.
329    pub fn child<C, O>(&mut self, child: C, on_click: O)
330    where
331        C: FnOnce() + Send + Sync + 'static,
332        O: FnOnce() + Send + Sync + 'static,
333    {
334        self.child_closures.push((
335            Box::new(child),
336            Arc::new(Mutex::new(Some(Box::new(on_click)))),
337        ));
338    }
339}