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    /// The visual style of the dialog's scrim.
162    #[builder(default)]
163    pub style: DialogStyle,
164}
165
166#[derive(Default)]
167pub struct DialogProviderState {
168    is_open: bool,
169    timer: Option<Instant>,
170}
171
172impl DialogProviderState {
173    /// Open the dialog
174    pub fn open(&mut self) {
175        if self.is_open {
176            // Already opened, no action needed
177        } else {
178            self.is_open = true; // Mark as open
179            let mut timer = Instant::now();
180            if let Some(old_timer) = self.timer {
181                let elapsed = old_timer.elapsed();
182                if elapsed < ANIM_TIME {
183                    // If we are still in the middle of an animation
184                    timer += ANIM_TIME - elapsed; // We need to 'catch up' the timer
185                }
186            }
187            self.timer = Some(timer);
188        }
189    }
190
191    /// Close the dialog
192    pub fn close(&mut self) {
193        if self.is_open {
194            self.is_open = false; // Mark as closed
195            let mut timer = Instant::now();
196            if let Some(old_timer) = self.timer {
197                let elapsed = old_timer.elapsed();
198                if elapsed < ANIM_TIME {
199                    // If we are still in the middle of an animation
200                    timer += ANIM_TIME - elapsed; // We need to 'catch up' the timer
201                }
202            }
203            self.timer = Some(timer);
204        }
205    }
206}
207
208fn render_scrim(args: &DialogProviderArgs, is_open: bool, progress: f32) {
209    match args.style {
210        DialogStyle::Glass => {
211            let blur_radius = blur_radius_for(progress, is_open, 5.0);
212            fluid_glass(
213                FluidGlassArgsBuilder::default()
214                    .on_click(args.on_close_request.clone())
215                    .tint_color(Color::TRANSPARENT)
216                    .width(DimensionValue::Fill {
217                        min: None,
218                        max: None,
219                    })
220                    .height(DimensionValue::Fill {
221                        min: None,
222                        max: None,
223                    })
224                    .dispersion_height(0.0)
225                    .refraction_height(0.0)
226                    .block_input(true)
227                    .blur_radius(blur_radius)
228                    .border(None)
229                    .shape(Shape::RoundedRectangle {
230                        top_left: Dp(0.0),
231                        top_right: Dp(0.0),
232                        bottom_right: Dp(0.0),
233                        bottom_left: Dp(0.0),
234                        g2_k_value: 3.0,
235                    })
236                    .noise_amount(0.0)
237                    .build()
238                    .unwrap(),
239                None,
240                || {},
241            );
242        }
243        DialogStyle::Material => {
244            let alpha = scrim_alpha_for(progress, is_open);
245            surface(
246                SurfaceArgsBuilder::default()
247                    .style(Color::BLACK.with_alpha(alpha).into())
248                    .on_click(args.on_close_request.clone())
249                    .width(DimensionValue::Fill {
250                        min: None,
251                        max: None,
252                    })
253                    .height(DimensionValue::Fill {
254                        min: None,
255                        max: None,
256                    })
257                    .block_input(true)
258                    .build()
259                    .unwrap(),
260                None,
261                || {},
262            );
263        }
264    }
265}
266
267fn make_keyboard_input_handler(
268    on_close: Arc<dyn Fn() + Send + Sync>,
269) -> Box<dyn for<'a> Fn(tessera_ui::InputHandlerInput<'a>) + Send + Sync + 'static> {
270    Box::new(move |input| {
271        input.keyboard_events.drain(..).for_each(|event| {
272            if event.state == winit::event::ElementState::Pressed
273                && let winit::keyboard::PhysicalKey::Code(winit::keyboard::KeyCode::Escape) =
274                    event.physical_key
275            {
276                (on_close)();
277            }
278        });
279    })
280}
281
282#[tessera]
283fn dialog_content_wrapper(
284    style: DialogStyle,
285    alpha: f32,
286    content: impl FnOnce() + Send + Sync + 'static,
287) {
288    boxed(
289        BoxedArgsBuilder::default()
290            .width(DimensionValue::FILLED)
291            .height(DimensionValue::FILLED)
292            .alignment(Alignment::Center)
293            .build()
294            .unwrap(),
295        |scope| {
296            scope.child(move || match style {
297                DialogStyle::Glass => {
298                    fluid_glass(
299                        FluidGlassArgsBuilder::default()
300                            .tint_color(Color::WHITE.with_alpha(alpha / 2.5))
301                            .blur_radius(5.0 * alpha)
302                            .shape(Shape::RoundedRectangle {
303                                top_left: Dp(25.0),
304                                top_right: Dp(25.0),
305                                bottom_right: Dp(25.0),
306                                bottom_left: Dp(25.0),
307                                g2_k_value: 3.0,
308                            })
309                            .refraction_amount(32.0 * alpha)
310                            .block_input(true)
311                            .padding(Dp(16.0))
312                            .build()
313                            .unwrap(),
314                        None,
315                        content,
316                    );
317                }
318                DialogStyle::Material => {
319                    surface(
320                        SurfaceArgsBuilder::default()
321                            .style(Color::WHITE.with_alpha(alpha).into())
322                            .shadow(ShadowProps {
323                                color: Color::BLACK.with_alpha(alpha / 4.0),
324                                ..Default::default()
325                            })
326                            .shape(Shape::RoundedRectangle {
327                                top_left: Dp(25.0),
328                                top_right: Dp(25.0),
329                                bottom_right: Dp(25.0),
330                                bottom_left: Dp(25.0),
331                                g2_k_value: 3.0,
332                            })
333                            .padding(Dp(16.0))
334                            .block_input(true)
335                            .build()
336                            .unwrap(),
337                        None,
338                        content,
339                    );
340                }
341            });
342        },
343    );
344}
345
346/// A provider component that manages the rendering and event flow for a modal dialog.
347///
348/// This component should be used as one of the outermost layers of the application.
349/// It renders the main content, and when `is_open` is true, it overlays a modal
350/// dialog, intercepting all input events to create a modal experience.
351///
352/// The dialog can be closed by calling the `on_close_request` callback, which can be
353/// triggered by clicking the background scrim or pressing the `ESC` key.
354///
355/// # Arguments
356///
357/// - `args` - The arguments for configuring the dialog provider. See [`DialogProviderArgs`].
358/// - `main_content` - A closure that renders the main content of the application,
359///   which is visible whether the dialog is open or closed.
360/// - `dialog_content` - A closure that renders the content of the dialog, which is
361///   only visible when `args.is_open` is `true`.
362#[tessera]
363pub fn dialog_provider(
364    args: DialogProviderArgs,
365    state: Arc<RwLock<DialogProviderState>>,
366    main_content: impl FnOnce(),
367    dialog_content: impl FnOnce(f32) + Send + Sync + 'static,
368) {
369    // 1. Render the main application content unconditionally.
370    main_content();
371
372    // 2. If the dialog is open, render the modal overlay.
373    // Sample state once to avoid repeated locks and improve readability.
374    let (is_open, timer_opt) = {
375        let guard = state.read();
376        (guard.is_open, guard.timer)
377    };
378
379    let is_animating = timer_opt.is_some_and(|t| t.elapsed() < ANIM_TIME);
380
381    if is_open || is_animating {
382        let progress = animation::easing(compute_dialog_progress(timer_opt));
383
384        let content_alpha = if is_open {
385            progress * 1.0 // Transition from 0 to 1 alpha
386        } else {
387            1.0 * (1.0 - progress) // Transition from 1 to 0 alpha
388        };
389
390        // 2a. Scrim (delegated)
391        render_scrim(&args, is_open, progress);
392
393        // 2b. Input Handler for intercepting keyboard events (delegated)
394        let handler = make_keyboard_input_handler(args.on_close_request.clone());
395        input_handler(handler);
396
397        // 2c. Dialog Content
398        // The user-defined dialog content is rendered on top of everything.
399        dialog_content_wrapper(args.style, content_alpha, move || {
400            dialog_content(content_alpha);
401        });
402    }
403}