tessera_ui_basic_components/
side_bar.rs

1//! A component that displays a side bar sliding in from the left.
2//!
3//! ## Usage
4//!
5//! Use to show app navigation or contextual controls.
6use std::{
7    sync::Arc,
8    time::{Duration, Instant},
9};
10
11use derive_builder::Builder;
12use parking_lot::RwLock;
13use tessera_ui::{Color, DimensionValue, Dp, Px, PxPosition, tessera, winit};
14
15use crate::{
16    animation,
17    fluid_glass::{FluidGlassArgsBuilder, fluid_glass},
18    shape_def::Shape,
19    surface::{SurfaceArgsBuilder, surface},
20};
21
22const ANIM_TIME: Duration = Duration::from_millis(300);
23
24/// Defines the visual style of the side bar and its scrim.
25///
26/// The scrim is the overlay that appears behind the side bar, covering the main content.
27#[derive(Default, Clone, Copy)]
28pub enum SideBarStyle {
29    /// A translucent glass effect that blurs the content behind it.
30    /// This style may be more costly and is suitable when a blurred backdrop is desired.
31    Glass,
32    /// A simple, semi-transparent dark overlay. This is the default style.
33    #[default]
34    Material,
35}
36
37#[derive(Builder)]
38pub struct SideBarProviderArgs {
39    /// A callback that is invoked when the user requests to close the side bar.
40    ///
41    /// This can be triggered by clicking the scrim or pressing the `Escape` key.
42    /// The callback is expected to call [`SideBarProviderState::close()`].
43    pub on_close_request: Arc<dyn Fn() + Send + Sync>,
44    /// The visual style used by the provider. See [`SideBarStyle`].
45    #[builder(default)]
46    pub style: SideBarStyle,
47}
48
49/// Manages the open/closed state of a [`side_bar_provider`].
50///
51/// This state object must be created by the application and passed to the
52/// [`side_bar_provider`]. It is used to control the visibility of the side bar
53/// programmatically. Clone the handle freely to share it across UI parts.
54#[derive(Default)]
55struct SideBarProviderStateInner {
56    is_open: bool,
57    timer: Option<Instant>,
58}
59
60#[derive(Clone, Default)]
61pub struct SideBarProviderState {
62    inner: Arc<RwLock<SideBarProviderStateInner>>,
63}
64
65impl SideBarProviderState {
66    /// Creates a new handle. Equivalent to `Default::default()`.
67    pub fn new() -> Self {
68        Self::default()
69    }
70
71    /// Initiates the animation to open the side bar.
72    ///
73    /// If the side bar is already open this has no effect. If it is currently
74    /// closing, the animation will reverse direction and start opening from the
75    /// current animated position.
76    pub fn open(&self) {
77        let mut inner = self.inner.write();
78        if !inner.is_open {
79            inner.is_open = true;
80            let mut timer = Instant::now();
81            if let Some(old_timer) = inner.timer {
82                let elapsed = old_timer.elapsed();
83                if elapsed < ANIM_TIME {
84                    timer += ANIM_TIME - elapsed;
85                }
86            }
87            inner.timer = Some(timer);
88        }
89    }
90
91    /// Initiates the animation to close the side bar.
92    ///
93    /// If the side bar is already closed this has no effect. If it is currently
94    /// opening, the animation will reverse direction and start closing from the
95    /// current animated position.
96    pub fn close(&self) {
97        let mut inner = self.inner.write();
98        if inner.is_open {
99            inner.is_open = false;
100            let mut timer = Instant::now();
101            if let Some(old_timer) = inner.timer {
102                let elapsed = old_timer.elapsed();
103                if elapsed < ANIM_TIME {
104                    timer += ANIM_TIME - elapsed;
105                }
106            }
107            inner.timer = Some(timer);
108        }
109    }
110
111    /// Returns whether the side bar is currently open.
112    pub fn is_open(&self) -> bool {
113        self.inner.read().is_open
114    }
115
116    /// Returns whether the side bar is currently animating.
117    pub fn is_animating(&self) -> bool {
118        self.inner
119            .read()
120            .timer
121            .is_some_and(|t| t.elapsed() < ANIM_TIME)
122    }
123
124    fn snapshot(&self) -> (bool, Option<Instant>) {
125        let inner = self.inner.read();
126        (inner.is_open, inner.timer)
127    }
128}
129
130/// Compute eased progress from an optional timer reference.
131fn calc_progress_from_timer(timer: Option<&Instant>) -> f32 {
132    let raw = match timer {
133        None => 1.0,
134        Some(t) => {
135            let elapsed = t.elapsed();
136            if elapsed >= ANIM_TIME {
137                1.0
138            } else {
139                elapsed.as_secs_f32() / ANIM_TIME.as_secs_f32()
140            }
141        }
142    };
143    animation::easing(raw)
144}
145
146/// Compute blur radius for glass style.
147fn blur_radius_for(progress: f32, is_open: bool, max_blur_radius: f32) -> f32 {
148    if is_open {
149        progress * max_blur_radius
150    } else {
151        max_blur_radius * (1.0 - progress)
152    }
153}
154
155/// Compute scrim alpha for material style.
156fn scrim_alpha_for(progress: f32, is_open: bool) -> f32 {
157    if is_open {
158        progress * 0.5
159    } else {
160        0.5 * (1.0 - progress)
161    }
162}
163
164/// Compute X position for side bar placement.
165fn compute_side_bar_x(child_width: Px, progress: f32, is_open: bool) -> i32 {
166    let child = child_width.0 as f32;
167    let x = if is_open {
168        -child * (1.0 - progress)
169    } else {
170        -child * progress
171    };
172    x as i32
173}
174
175fn render_glass_scrim(args: &SideBarProviderArgs, progress: f32, is_open: bool) {
176    // Glass scrim: compute blur radius and render using fluid_glass.
177    let max_blur_radius = 5.0;
178    let blur_radius = blur_radius_for(progress, is_open, max_blur_radius);
179    fluid_glass(
180        FluidGlassArgsBuilder::default()
181            .on_click(args.on_close_request.clone())
182            .tint_color(Color::TRANSPARENT)
183            .width(DimensionValue::Fill {
184                min: None,
185                max: None,
186            })
187            .height(DimensionValue::Fill {
188                min: None,
189                max: None,
190            })
191            .dispersion_height(Dp(0.0))
192            .refraction_height(Dp(0.0))
193            .block_input(true)
194            .blur_radius(Dp(blur_radius as f64))
195            .border(None)
196            .shape(Shape::RoundedRectangle {
197                top_left: Dp(0.0),
198                top_right: Dp(0.0),
199                bottom_right: Dp(0.0),
200                bottom_left: Dp(0.0),
201                g2_k_value: 3.0,
202            })
203            .noise_amount(0.0)
204            .build()
205            .unwrap(),
206        None,
207        || {},
208    );
209}
210
211fn render_material_scrim(args: &SideBarProviderArgs, progress: f32, is_open: bool) {
212    // Material scrim: compute alpha and render a simple dark surface.
213    let scrim_alpha = scrim_alpha_for(progress, is_open);
214    surface(
215        SurfaceArgsBuilder::default()
216            .style(Color::BLACK.with_alpha(scrim_alpha).into())
217            .on_click(args.on_close_request.clone())
218            .width(DimensionValue::Fill {
219                min: None,
220                max: None,
221            })
222            .height(DimensionValue::Fill {
223                min: None,
224                max: None,
225            })
226            .block_input(true)
227            .build()
228            .unwrap(),
229        None,
230        || {},
231    );
232}
233
234/// Render scrim according to configured style.
235/// Delegates actual rendering to small, focused helpers to keep the
236/// main API surface concise and improve readability.
237fn render_scrim(args: &SideBarProviderArgs, progress: f32, is_open: bool) {
238    match args.style {
239        SideBarStyle::Glass => render_glass_scrim(args, progress, is_open),
240        SideBarStyle::Material => render_material_scrim(args, progress, is_open),
241    }
242}
243
244/// Snapshot provider state to reduce lock duration and centralize access.
245fn snapshot_state(state: &SideBarProviderState) -> (bool, Option<Instant>) {
246    state.snapshot()
247}
248
249/// Create the keyboard handler closure used to close the sheet on Escape.
250fn make_keyboard_closure(
251    on_close: Arc<dyn Fn() + Send + Sync>,
252) -> Box<dyn Fn(tessera_ui::InputHandlerInput<'_>) + Send + Sync> {
253    Box::new(move |input: tessera_ui::InputHandlerInput<'_>| {
254        for event in input.keyboard_events.drain(..) {
255            if event.state == winit::event::ElementState::Pressed
256                && let winit::keyboard::PhysicalKey::Code(winit::keyboard::KeyCode::Escape) =
257                    event.physical_key
258            {
259                (on_close)();
260            }
261        }
262    })
263}
264
265/// Place side bar if present. Extracted to reduce complexity of the parent function.
266fn place_side_bar_if_present(
267    input: &tessera_ui::MeasureInput<'_>,
268    state_for_measure: &SideBarProviderState,
269    progress: f32,
270) {
271    if input.children_ids.len() <= 2 {
272        return;
273    }
274
275    let side_bar_id = input.children_ids[2];
276
277    let child_size = match input.measure_child(side_bar_id, input.parent_constraint) {
278        Ok(s) => s,
279        Err(_) => return,
280    };
281
282    let current_is_open = state_for_measure.is_open();
283    let x = compute_side_bar_x(child_size.width, progress, current_is_open);
284    input.place_child(side_bar_id, PxPosition::new(Px(x), Px(0)));
285}
286
287/// # side_bar_provider
288///
289/// Provides a side bar that slides in from the left, with a scrim overlay.
290///
291/// ## Usage
292///
293/// Use as a top-level provider to display a navigation drawer or other contextual side content.
294///
295/// ## Parameters
296///
297/// - `args` — configures the side bar's style and `on_close_request` callback; see [`SideBarProviderArgs`].
298/// - `state` — a clonable [`SideBarProviderState`] to manage the open/closed state.
299/// - `main_content` — a closure that renders the main UI, which is visible behind the side bar.
300/// - `side_bar_content` — a closure that renders the content of the side bar itself.
301///
302/// ## Examples
303///
304/// ```
305/// use tessera_ui_basic_components::side_bar::SideBarProviderState;
306///
307/// let state = SideBarProviderState::new();
308/// assert!(!state.is_open());
309///
310/// state.open();
311/// assert!(state.is_open());
312///
313/// state.close();
314/// assert!(!state.is_open());
315/// ```
316#[tessera]
317pub fn side_bar_provider(
318    args: SideBarProviderArgs,
319    state: SideBarProviderState,
320    main_content: impl FnOnce() + Send + Sync + 'static,
321    side_bar_content: impl FnOnce() + Send + Sync + 'static,
322) {
323    // Render main content first.
324    main_content();
325
326    // Snapshot state once to minimize locking overhead.
327    let (is_open, timer_opt) = snapshot_state(&state);
328
329    // Fast exit when nothing to render.
330    if !(is_open || timer_opt.is_some_and(|t| t.elapsed() < ANIM_TIME)) {
331        return;
332    }
333
334    // Prepare values used by rendering and placement.
335    let on_close_for_keyboard = args.on_close_request.clone();
336    let progress = calc_progress_from_timer(timer_opt.as_ref());
337
338    // Render the configured scrim.
339    render_scrim(&args, progress, is_open);
340
341    // Register keyboard handler (close on Escape).
342    let keyboard_closure = make_keyboard_closure(on_close_for_keyboard);
343    input_handler(keyboard_closure);
344
345    // Render side bar content with computed alpha.
346    side_bar_content_wrapper(args.style, side_bar_content);
347
348    // Measurement: place main content, scrim and side bar.
349    let state_for_measure = state.clone();
350    let measure_closure = Box::new(move |input: &tessera_ui::MeasureInput<'_>| {
351        // Place main content at origin.
352        let main_content_id = input.children_ids[0];
353        let main_content_size = input.measure_child(main_content_id, input.parent_constraint)?;
354        input.place_child(main_content_id, PxPosition::new(Px(0), Px(0)));
355
356        // Place scrim (if present) covering the whole parent.
357        if input.children_ids.len() > 1 {
358            let scrim_id = input.children_ids[1];
359            input.measure_child(scrim_id, input.parent_constraint)?;
360            input.place_child(scrim_id, PxPosition::new(Px(0), Px(0)));
361        }
362
363        // Place side bar (if present) using extracted helper.
364        place_side_bar_if_present(input, &state_for_measure, progress);
365
366        // Return the main content size (best-effort; unwrap used above to satisfy closure type).
367        Ok(main_content_size)
368    });
369    measure(measure_closure);
370}
371
372#[tessera]
373fn side_bar_content_wrapper(style: SideBarStyle, content: impl FnOnce() + Send + Sync + 'static) {
374    match style {
375        SideBarStyle::Glass => {
376            fluid_glass(
377                FluidGlassArgsBuilder::default()
378                    .shape(Shape::RoundedRectangle {
379                        top_left: Dp(0.0),
380                        top_right: Dp(25.0),
381                        bottom_right: Dp(25.0),
382                        bottom_left: Dp(0.0),
383                        g2_k_value: 3.0,
384                    })
385                    .tint_color(Color::new(0.6, 0.8, 1.0, 0.3))
386                    .width(DimensionValue::from(Dp(250.0)))
387                    .height(tessera_ui::DimensionValue::Fill {
388                        min: None,
389                        max: None,
390                    })
391                    .blur_radius(Dp(10.0))
392                    .padding(Dp(16.0))
393                    .block_input(true)
394                    .build()
395                    .unwrap(),
396                None,
397                content,
398            );
399        }
400        SideBarStyle::Material => {
401            surface(
402                SurfaceArgsBuilder::default()
403                    .style(Color::new(0.9, 0.9, 0.9, 1.0).into())
404                    .width(DimensionValue::from(Dp(250.0)))
405                    .height(tessera_ui::DimensionValue::Fill {
406                        min: None,
407                        max: None,
408                    })
409                    .padding(Dp(16.0))
410                    .shape(Shape::RoundedRectangle {
411                        top_left: Dp(0.0),
412                        top_right: Dp(25.0),
413                        bottom_right: Dp(25.0),
414                        bottom_left: Dp(0.0),
415                        g2_k_value: 3.0,
416                    })
417                    .block_input(true)
418                    .build()
419                    .unwrap(),
420                None,
421                content,
422            );
423        }
424    }
425}