tessera_ui_basic_components/
dialog.rs

1//! Modal dialog provider — show modal content above the main app UI.
2//!
3//! ## Usage
4//!
5//! Used to show modal dialogs such as alerts, confirmations, wizards and forms; dialogs block interaction with underlying content while active.
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, tessera, winit};
14
15use crate::{
16    alignment::Alignment,
17    animation,
18    boxed::{BoxedArgsBuilder, boxed},
19    fluid_glass::{FluidGlassArgsBuilder, fluid_glass},
20    pipelines::ShadowProps,
21    shape_def::Shape,
22    surface::{SurfaceArgsBuilder, surface},
23};
24
25/// The duration of the full dialog animation.
26const ANIM_TIME: Duration = Duration::from_millis(300);
27
28/// Compute normalized (0..1) linear progress from an optional animation timer.
29/// Placing this here reduces inline complexity inside the component body.
30fn compute_dialog_progress(timer_opt: Option<Instant>) -> f32 {
31    timer_opt.as_ref().map_or(1.0, |timer| {
32        let elapsed = timer.elapsed();
33        if elapsed >= ANIM_TIME {
34            1.0
35        } else {
36            elapsed.as_secs_f32() / ANIM_TIME.as_secs_f32()
37        }
38    })
39}
40
41/// Compute blur radius for glass style scrim.
42fn blur_radius_for(progress: f32, is_open: bool, max_blur_radius: f32) -> f32 {
43    if is_open {
44        progress * max_blur_radius
45    } else {
46        max_blur_radius * (1.0 - progress)
47    }
48}
49
50/// Compute scrim alpha for material style.
51fn scrim_alpha_for(progress: f32, is_open: bool) -> f32 {
52    if is_open {
53        progress * 0.5
54    } else {
55        0.5 * (1.0 - progress)
56    }
57}
58
59/// Defines the visual style of the dialog's scrim.
60#[derive(Default, Clone, Copy)]
61pub enum DialogStyle {
62    /// A translucent glass effect that blurs the content behind it.
63    Glass,
64    /// A simple, semi-transparent dark overlay.
65    #[default]
66    Material,
67}
68
69/// Arguments for the [`dialog_provider`] component.
70#[derive(Builder)]
71#[builder(pattern = "owned")]
72pub struct DialogProviderArgs {
73    /// Callback function triggered when a close request is made, for example by
74    /// clicking the scrim or pressing the `ESC` key.
75    pub on_close_request: Arc<dyn Fn() + Send + Sync>,
76    /// Padding around the dialog content.
77    #[builder(default = "Dp(16.0)")]
78    pub padding: Dp,
79    /// The visual style of the dialog's scrim.
80    #[builder(default)]
81    pub style: DialogStyle,
82}
83
84#[derive(Default)]
85struct DialogProviderStateInner {
86    is_open: bool,
87    timer: Option<Instant>,
88}
89
90#[derive(Clone, Default)]
91pub struct DialogProviderState {
92    inner: Arc<RwLock<DialogProviderStateInner>>,
93}
94
95impl DialogProviderState {
96    /// Creates a new dialog provider state handle.
97    pub fn new() -> Self {
98        Self::default()
99    }
100
101    /// Opens the dialog, starting the animation if necessary.
102    pub fn open(&self) {
103        let mut inner = self.inner.write();
104        if !inner.is_open {
105            inner.is_open = true;
106            let mut timer = Instant::now();
107            if let Some(old_timer) = inner.timer {
108                let elapsed = old_timer.elapsed();
109                if elapsed < ANIM_TIME {
110                    timer += ANIM_TIME - elapsed;
111                }
112            }
113            inner.timer = Some(timer);
114        }
115    }
116
117    /// Closes the dialog, starting the closing animation if necessary.
118    pub fn close(&self) {
119        let mut inner = self.inner.write();
120        if inner.is_open {
121            inner.is_open = false;
122            let mut timer = Instant::now();
123            if let Some(old_timer) = inner.timer {
124                let elapsed = old_timer.elapsed();
125                if elapsed < ANIM_TIME {
126                    timer += ANIM_TIME - elapsed;
127                }
128            }
129            inner.timer = Some(timer);
130        }
131    }
132
133    /// Returns whether the dialog is currently open.
134    pub fn is_open(&self) -> bool {
135        self.inner.read().is_open
136    }
137
138    /// Returns whether the dialog is mid-animation.
139    pub fn is_animating(&self) -> bool {
140        self.inner
141            .read()
142            .timer
143            .is_some_and(|t| t.elapsed() < ANIM_TIME)
144    }
145
146    fn snapshot(&self) -> (bool, Option<Instant>) {
147        let inner = self.inner.read();
148        (inner.is_open, inner.timer)
149    }
150}
151
152fn render_scrim(args: &DialogProviderArgs, is_open: bool, progress: f32) {
153    match args.style {
154        DialogStyle::Glass => {
155            let blur_radius = blur_radius_for(progress, is_open, 5.0);
156            fluid_glass(
157                FluidGlassArgsBuilder::default()
158                    .on_click(args.on_close_request.clone())
159                    .tint_color(Color::TRANSPARENT)
160                    .width(DimensionValue::Fill {
161                        min: None,
162                        max: None,
163                    })
164                    .height(DimensionValue::Fill {
165                        min: None,
166                        max: None,
167                    })
168                    .dispersion_height(Dp(0.0))
169                    .refraction_height(Dp(0.0))
170                    .block_input(true)
171                    .blur_radius(Dp(blur_radius as f64))
172                    .border(None)
173                    .shape(Shape::RoundedRectangle {
174                        top_left: Dp(0.0),
175                        top_right: Dp(0.0),
176                        bottom_right: Dp(0.0),
177                        bottom_left: Dp(0.0),
178                        g2_k_value: 3.0,
179                    })
180                    .noise_amount(0.0)
181                    .build()
182                    .unwrap(),
183                None,
184                || {},
185            );
186        }
187        DialogStyle::Material => {
188            let alpha = scrim_alpha_for(progress, is_open);
189            surface(
190                SurfaceArgsBuilder::default()
191                    .style(Color::BLACK.with_alpha(alpha).into())
192                    .on_click(args.on_close_request.clone())
193                    .width(DimensionValue::Fill {
194                        min: None,
195                        max: None,
196                    })
197                    .height(DimensionValue::Fill {
198                        min: None,
199                        max: None,
200                    })
201                    .block_input(true)
202                    .build()
203                    .unwrap(),
204                None,
205                || {},
206            );
207        }
208    }
209}
210
211fn make_keyboard_input_handler(
212    on_close: Arc<dyn Fn() + Send + Sync>,
213) -> Box<dyn for<'a> Fn(tessera_ui::InputHandlerInput<'a>) + Send + Sync + 'static> {
214    Box::new(move |input| {
215        input.keyboard_events.drain(..).for_each(|event| {
216            if event.state == winit::event::ElementState::Pressed
217                && let winit::keyboard::PhysicalKey::Code(winit::keyboard::KeyCode::Escape) =
218                    event.physical_key
219            {
220                (on_close)();
221            }
222        });
223    })
224}
225
226#[tessera]
227fn dialog_content_wrapper(
228    style: DialogStyle,
229    alpha: f32,
230    padding: Dp,
231    content: impl FnOnce() + Send + Sync + 'static,
232) {
233    boxed(
234        BoxedArgsBuilder::default()
235            .width(DimensionValue::FILLED)
236            .height(DimensionValue::FILLED)
237            .alignment(Alignment::Center)
238            .build()
239            .unwrap(),
240        |scope| {
241            scope.child(move || match style {
242                DialogStyle::Glass => {
243                    fluid_glass(
244                        FluidGlassArgsBuilder::default()
245                            .tint_color(Color::WHITE.with_alpha(alpha / 2.5))
246                            .blur_radius(Dp(5.0 * alpha as f64))
247                            .shape(Shape::RoundedRectangle {
248                                top_left: Dp(25.0),
249                                top_right: Dp(25.0),
250                                bottom_right: Dp(25.0),
251                                bottom_left: Dp(25.0),
252                                g2_k_value: 3.0,
253                            })
254                            .refraction_amount(32.0 * alpha)
255                            .block_input(true)
256                            .padding(padding)
257                            .build()
258                            .unwrap(),
259                        None,
260                        content,
261                    );
262                }
263                DialogStyle::Material => {
264                    surface(
265                        SurfaceArgsBuilder::default()
266                            .style(Color::WHITE.with_alpha(alpha).into())
267                            .shadow(ShadowProps {
268                                color: Color::BLACK.with_alpha(alpha / 4.0),
269                                ..Default::default()
270                            })
271                            .shape(Shape::RoundedRectangle {
272                                top_left: Dp(25.0),
273                                top_right: Dp(25.0),
274                                bottom_right: Dp(25.0),
275                                bottom_left: Dp(25.0),
276                                g2_k_value: 3.0,
277                            })
278                            .padding(padding)
279                            .block_input(true)
280                            .build()
281                            .unwrap(),
282                        None,
283                        content,
284                    );
285                }
286            });
287        },
288    );
289}
290
291/// # dialog_provider
292///
293/// Provide a modal dialog at the top level of an application.
294///
295/// ## Usage
296///
297/// Show modal content for alerts, confirmation dialogs, multi-step forms, or onboarding steps that require blocking user interaction with the main UI.
298///
299/// ## Parameters
300///
301/// - `args` — configuration for dialog appearance and the `on_close_request` callback; see [`DialogProviderArgs`].
302/// - `state` — a clonable [`DialogProviderState`] handle; use `DialogProviderState::new()` to create one.
303/// - `main_content` — closure that renders the always-visible base UI.
304/// - `dialog_content` — closure that renders dialog content; receives a `f32` alpha for animation.
305///
306/// ## Examples
307///
308/// ```
309/// use tessera_ui_basic_components::dialog::DialogProviderState;
310/// let state = DialogProviderState::new();
311/// assert!(!state.is_open());
312/// state.open();
313/// assert!(state.is_open());
314/// state.close();
315/// assert!(!state.is_open());
316/// ```
317#[tessera]
318pub fn dialog_provider(
319    args: DialogProviderArgs,
320    state: DialogProviderState,
321    main_content: impl FnOnce(),
322    dialog_content: impl FnOnce(f32) + Send + Sync + 'static,
323) {
324    // 1. Render the main application content unconditionally.
325    main_content();
326
327    // 2. If the dialog is open, render the modal overlay.
328    // Sample state once to avoid repeated locks and improve readability.
329    let (is_open, timer_opt) = state.snapshot();
330
331    let is_animating = timer_opt.is_some_and(|t| t.elapsed() < ANIM_TIME);
332
333    if is_open || is_animating {
334        let progress = animation::easing(compute_dialog_progress(timer_opt));
335
336        let content_alpha = if is_open {
337            progress * 1.0 // Transition from 0 to 1 alpha
338        } else {
339            1.0 * (1.0 - progress) // Transition from 1 to 0 alpha
340        };
341
342        // 2a. Scrim (delegated)
343        render_scrim(&args, is_open, progress);
344
345        // 2b. Input Handler for intercepting keyboard events (delegated)
346        let handler = make_keyboard_input_handler(args.on_close_request.clone());
347        input_handler(handler);
348
349        // 2c. Dialog Content
350        // The user-defined dialog content is rendered on top of everything.
351        dialog_content_wrapper(args.style, content_alpha, args.padding, move || {
352            dialog_content(content_alpha);
353        });
354    }
355}