tessera_ui_basic_components/
dialog.rs

1//! A modal dialog component for displaying critical information or actions.
2//!
3//! This module provides [`dialog_provider`], a component that renders content in a modal
4//! overlay. When active, the dialog sits on top of the primary UI, blocks interactions
5//! with the content behind it (via a "scrim"), and can be dismissed by user actions
6//! like pressing the `Escape` key or clicking the scrim.
7//!
8//! # Key Components
9//!
10//! * **[`dialog_provider`]**: The main function that wraps your UI to provide dialog capabilities.
11//! * **[`DialogProviderState`]**: A state object you create and manage to control the
12//!   dialog's visibility using its [`open()`](DialogProviderState::open) and
13//!   [`close()`](DialogProviderState::close) methods.
14//! * **[`DialogProviderArgs`]**: Configuration for the provider, including the visual
15//!   [`style`](DialogStyle) of the scrim and the mandatory `on_close_request` callback.
16//! * **[`DialogStyle`]**: Defines the scrim's appearance, either `Material` (a simple dark
17//!   overlay) or `Glass` (a blurred, translucent effect).
18//!
19//! # Usage
20//!
21//! The `dialog_provider` acts as a wrapper around your main content. It takes the main
22//! content and the dialog content as separate closures.
23//!
24//! 1.  **Create State**: In your application's state, create an `Arc<RwLock<DialogProviderState>>`.
25//! 2.  **Wrap Content**: Call `dialog_provider` at a high level in your component tree.
26//! 3.  **Provide Content**: Pass two closures to `dialog_provider`:
27//!     - `main_content`: Renders the UI that is always visible.
28//!     - `dialog_content`: Renders the content of the dialog box itself. This closure
29//!       receives an `f32` alpha value for animating its appearance.
30//! 4.  **Control Visibility**: From an event handler (e.g., a button's `on_click`), call
31//!     `dialog_state.write().open()` to show the dialog.
32//! 5.  **Handle Closing**: The `on_close_request` callback you provide is responsible for
33//!     calling `dialog_state.write().close()` to dismiss the dialog.
34//!
35//! # Example
36//!
37//! ```
38//! use std::sync::Arc;
39//! use parking_lot::RwLock;
40//! use tessera_ui::{tessera, Renderer};
41//! use tessera_ui_basic_components::{
42//!     dialog::{dialog_provider, DialogProviderArgsBuilder, DialogProviderState},
43//!     button::{button, ButtonArgsBuilder},
44//!     ripple_state::RippleState,
45//!     text::{text, TextArgsBuilder},
46//! };
47//!
48//! // Define an application state.
49//! #[derive(Default)]
50//! struct AppState {
51//!     dialog_state: Arc<RwLock<DialogProviderState>>,
52//!     ripple_state: Arc<RippleState>,
53//! }
54//!
55//! #[tessera]
56//! fn app(state: Arc<RwLock<AppState>>) {
57//!     let dialog_state = state.read().dialog_state.clone();
58//!
59//!     // Use the dialog_provider.
60//!     dialog_provider(
61//!         DialogProviderArgsBuilder::default()
62//!             // Provide a callback to handle close requests.
63//!             .on_close_request(Arc::new({
64//!                 let dialog_state = dialog_state.clone();
65//!                 move || dialog_state.write().close()
66//!             }))
67//!             .build()
68//!             .unwrap(),
69//!         dialog_state.clone(),
70//!         // Define the main content.
71//!         move || {
72//!             button(
73//!                 ButtonArgsBuilder::default()
74//!                     .on_click(Arc::new({
75//!                         let dialog_state = dialog_state.clone();
76//!                         move || dialog_state.write().open()
77//!                     }))
78//!                     .build()
79//!                     .unwrap(),
80//!                 state.read().ripple_state.clone(),
81//!                 || text(TextArgsBuilder::default().text("Show Dialog".to_string()).build().unwrap())
82//!             );
83//!         },
84//!         // Define the dialog content.
85//!         |alpha| {
86//!             text(TextArgsBuilder::default().text("This is a dialog!".to_string()).build().unwrap());
87//!         }
88//!     );
89//! }
90//! ```
91use std::{
92    sync::Arc,
93    time::{Duration, Instant},
94};
95
96use derive_builder::Builder;
97use parking_lot::RwLock;
98use tessera_ui::{Color, DimensionValue, Dp, tessera, winit};
99
100use crate::{
101    alignment::Alignment,
102    animation,
103    boxed::{BoxedArgsBuilder, boxed},
104    fluid_glass::{FluidGlassArgsBuilder, fluid_glass},
105    pipelines::ShadowProps,
106    shape_def::Shape,
107    surface::{SurfaceArgsBuilder, surface},
108};
109
110/// The duration of the full dialog animation.
111const ANIM_TIME: Duration = Duration::from_millis(300);
112
113/// Compute normalized (0..1) linear progress from an optional animation timer.
114/// Placing this here reduces inline complexity inside the component body.
115fn compute_dialog_progress(timer_opt: Option<Instant>) -> f32 {
116    timer_opt.as_ref().map_or(1.0, |timer| {
117        let elapsed = timer.elapsed();
118        if elapsed >= ANIM_TIME {
119            1.0
120        } else {
121            elapsed.as_secs_f32() / ANIM_TIME.as_secs_f32()
122        }
123    })
124}
125
126/// Compute blur radius for glass style scrim.
127fn blur_radius_for(progress: f32, is_open: bool, max_blur_radius: f32) -> f32 {
128    if is_open {
129        progress * max_blur_radius
130    } else {
131        max_blur_radius * (1.0 - progress)
132    }
133}
134
135/// Compute scrim alpha for material style.
136fn scrim_alpha_for(progress: f32, is_open: bool) -> f32 {
137    if is_open {
138        progress * 0.5
139    } else {
140        0.5 * (1.0 - progress)
141    }
142}
143
144/// Defines the visual style of the dialog's scrim.
145#[derive(Default, Clone, Copy)]
146pub enum DialogStyle {
147    /// A translucent glass effect that blurs the content behind it.
148    Glass,
149    /// A simple, semi-transparent dark overlay.
150    #[default]
151    Material,
152}
153
154/// Arguments for the [`dialog_provider`] component.
155#[derive(Builder)]
156#[builder(pattern = "owned")]
157pub struct DialogProviderArgs {
158    /// Callback function triggered when a close request is made, for example by
159    /// clicking the scrim or pressing the `ESC` key.
160    pub on_close_request: Arc<dyn Fn() + Send + Sync>,
161    /// Padding around the dialog content.
162    #[builder(default = "Dp(16.0)")]
163    pub padding: Dp,
164    /// The visual style of the dialog's scrim.
165    #[builder(default)]
166    pub style: DialogStyle,
167}
168
169#[derive(Default)]
170pub struct DialogProviderState {
171    is_open: bool,
172    timer: Option<Instant>,
173}
174
175impl DialogProviderState {
176    /// Open the dialog
177    pub fn open(&mut self) {
178        if self.is_open {
179            // Already opened, no action needed
180        } else {
181            self.is_open = true; // Mark as open
182            let mut timer = Instant::now();
183            if let Some(old_timer) = self.timer {
184                let elapsed = old_timer.elapsed();
185                if elapsed < ANIM_TIME {
186                    // If we are still in the middle of an animation
187                    timer += ANIM_TIME - elapsed; // We need to 'catch up' the timer
188                }
189            }
190            self.timer = Some(timer);
191        }
192    }
193
194    /// Close the dialog
195    pub fn close(&mut self) {
196        if self.is_open {
197            self.is_open = false; // Mark as closed
198            let mut timer = Instant::now();
199            if let Some(old_timer) = self.timer {
200                let elapsed = old_timer.elapsed();
201                if elapsed < ANIM_TIME {
202                    // If we are still in the middle of an animation
203                    timer += ANIM_TIME - elapsed; // We need to 'catch up' the timer
204                }
205            }
206            self.timer = Some(timer);
207        }
208    }
209}
210
211fn render_scrim(args: &DialogProviderArgs, is_open: bool, progress: f32) {
212    match args.style {
213        DialogStyle::Glass => {
214            let blur_radius = blur_radius_for(progress, is_open, 5.0);
215            fluid_glass(
216                FluidGlassArgsBuilder::default()
217                    .on_click(args.on_close_request.clone())
218                    .tint_color(Color::TRANSPARENT)
219                    .width(DimensionValue::Fill {
220                        min: None,
221                        max: None,
222                    })
223                    .height(DimensionValue::Fill {
224                        min: None,
225                        max: None,
226                    })
227                    .dispersion_height(0.0)
228                    .refraction_height(0.0)
229                    .block_input(true)
230                    .blur_radius(blur_radius)
231                    .border(None)
232                    .shape(Shape::RoundedRectangle {
233                        top_left: Dp(0.0),
234                        top_right: Dp(0.0),
235                        bottom_right: Dp(0.0),
236                        bottom_left: Dp(0.0),
237                        g2_k_value: 3.0,
238                    })
239                    .noise_amount(0.0)
240                    .build()
241                    .unwrap(),
242                None,
243                || {},
244            );
245        }
246        DialogStyle::Material => {
247            let alpha = scrim_alpha_for(progress, is_open);
248            surface(
249                SurfaceArgsBuilder::default()
250                    .style(Color::BLACK.with_alpha(alpha).into())
251                    .on_click(args.on_close_request.clone())
252                    .width(DimensionValue::Fill {
253                        min: None,
254                        max: None,
255                    })
256                    .height(DimensionValue::Fill {
257                        min: None,
258                        max: None,
259                    })
260                    .block_input(true)
261                    .build()
262                    .unwrap(),
263                None,
264                || {},
265            );
266        }
267    }
268}
269
270fn make_keyboard_input_handler(
271    on_close: Arc<dyn Fn() + Send + Sync>,
272) -> Box<dyn for<'a> Fn(tessera_ui::InputHandlerInput<'a>) + Send + Sync + 'static> {
273    Box::new(move |input| {
274        input.keyboard_events.drain(..).for_each(|event| {
275            if event.state == winit::event::ElementState::Pressed
276                && let winit::keyboard::PhysicalKey::Code(winit::keyboard::KeyCode::Escape) =
277                    event.physical_key
278            {
279                (on_close)();
280            }
281        });
282    })
283}
284
285#[tessera]
286fn dialog_content_wrapper(
287    style: DialogStyle,
288    alpha: f32,
289    padding: Dp,
290    content: impl FnOnce() + Send + Sync + 'static,
291) {
292    boxed(
293        BoxedArgsBuilder::default()
294            .width(DimensionValue::FILLED)
295            .height(DimensionValue::FILLED)
296            .alignment(Alignment::Center)
297            .build()
298            .unwrap(),
299        |scope| {
300            scope.child(move || match style {
301                DialogStyle::Glass => {
302                    fluid_glass(
303                        FluidGlassArgsBuilder::default()
304                            .tint_color(Color::WHITE.with_alpha(alpha / 2.5))
305                            .blur_radius(5.0 * alpha)
306                            .shape(Shape::RoundedRectangle {
307                                top_left: Dp(25.0),
308                                top_right: Dp(25.0),
309                                bottom_right: Dp(25.0),
310                                bottom_left: Dp(25.0),
311                                g2_k_value: 3.0,
312                            })
313                            .refraction_amount(32.0 * alpha)
314                            .block_input(true)
315                            .padding(padding)
316                            .build()
317                            .unwrap(),
318                        None,
319                        content,
320                    );
321                }
322                DialogStyle::Material => {
323                    surface(
324                        SurfaceArgsBuilder::default()
325                            .style(Color::WHITE.with_alpha(alpha).into())
326                            .shadow(ShadowProps {
327                                color: Color::BLACK.with_alpha(alpha / 4.0),
328                                ..Default::default()
329                            })
330                            .shape(Shape::RoundedRectangle {
331                                top_left: Dp(25.0),
332                                top_right: Dp(25.0),
333                                bottom_right: Dp(25.0),
334                                bottom_left: Dp(25.0),
335                                g2_k_value: 3.0,
336                            })
337                            .padding(padding)
338                            .block_input(true)
339                            .build()
340                            .unwrap(),
341                        None,
342                        content,
343                    );
344                }
345            });
346        },
347    );
348}
349
350/// A provider component that manages the rendering and event flow for a modal dialog.
351///
352/// This component should be used as one of the outermost layers of the application.
353/// It renders the main content, and when `is_open` is true, it overlays a modal
354/// dialog, intercepting all input events to create a modal experience.
355///
356/// The dialog can be closed by calling the `on_close_request` callback, which can be
357/// triggered by clicking the background scrim or pressing the `ESC` key.
358///
359/// # Arguments
360///
361/// - `args` - The arguments for configuring the dialog provider. See [`DialogProviderArgs`].
362/// - `main_content` - A closure that renders the main content of the application,
363///   which is visible whether the dialog is open or closed.
364/// - `dialog_content` - A closure that renders the content of the dialog, which is
365///   only visible when `args.is_open` is `true`.
366#[tessera]
367pub fn dialog_provider(
368    args: DialogProviderArgs,
369    state: Arc<RwLock<DialogProviderState>>,
370    main_content: impl FnOnce(),
371    dialog_content: impl FnOnce(f32) + Send + Sync + 'static,
372) {
373    // 1. Render the main application content unconditionally.
374    main_content();
375
376    // 2. If the dialog is open, render the modal overlay.
377    // Sample state once to avoid repeated locks and improve readability.
378    let (is_open, timer_opt) = {
379        let guard = state.read();
380        (guard.is_open, guard.timer)
381    };
382
383    let is_animating = timer_opt.is_some_and(|t| t.elapsed() < ANIM_TIME);
384
385    if is_open || is_animating {
386        let progress = animation::easing(compute_dialog_progress(timer_opt));
387
388        let content_alpha = if is_open {
389            progress * 1.0 // Transition from 0 to 1 alpha
390        } else {
391            1.0 * (1.0 - progress) // Transition from 1 to 0 alpha
392        };
393
394        // 2a. Scrim (delegated)
395        render_scrim(&args, is_open, progress);
396
397        // 2b. Input Handler for intercepting keyboard events (delegated)
398        let handler = make_keyboard_input_handler(args.on_close_request.clone());
399        input_handler(handler);
400
401        // 2c. Dialog Content
402        // The user-defined dialog content is rendered on top of everything.
403        dialog_content_wrapper(args.style, content_alpha, args.padding, move || {
404            dialog_content(content_alpha);
405        });
406    }
407}