tessera_ui_basic_components/
bottom_sheet.rs

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