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