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