tessera_ui_basic_components/
side_bar.rs

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