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    ShadowProps,
17    alignment::Alignment,
18    animation,
19    boxed::{BoxedArgsBuilder, boxed},
20    fluid_glass::{FluidGlassArgsBuilder, fluid_glass},
21    shape_def::{RoundedCorner, 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/// Shared state for [`dialog_provider`], controlling visibility and animation.
91///
92/// # Example
93///
94/// ```
95/// use tessera_ui_basic_components::dialog::DialogProviderState;
96///
97/// let state = DialogProviderState::new();
98/// assert!(!state.is_open()); // Initially closed
99/// state.open();
100/// assert!(state.is_open()); // Now opened
101/// ```
102#[derive(Clone, Default)]
103pub struct DialogProviderState {
104    inner: Arc<RwLock<DialogProviderStateInner>>,
105}
106
107impl DialogProviderState {
108    /// Creates a new dialog provider state handle.
109    pub fn new() -> Self {
110        Self::default()
111    }
112
113    /// Opens the dialog, starting the animation if necessary.
114    pub fn open(&self) {
115        let mut inner = self.inner.write();
116        if !inner.is_open {
117            inner.is_open = true;
118            let mut timer = Instant::now();
119            if let Some(old_timer) = inner.timer {
120                let elapsed = old_timer.elapsed();
121                if elapsed < ANIM_TIME {
122                    timer += ANIM_TIME - elapsed;
123                }
124            }
125            inner.timer = Some(timer);
126        }
127    }
128
129    /// Closes the dialog, starting the closing animation if necessary.
130    pub fn close(&self) {
131        let mut inner = self.inner.write();
132        if inner.is_open {
133            inner.is_open = false;
134            let mut timer = Instant::now();
135            if let Some(old_timer) = inner.timer {
136                let elapsed = old_timer.elapsed();
137                if elapsed < ANIM_TIME {
138                    timer += ANIM_TIME - elapsed;
139                }
140            }
141            inner.timer = Some(timer);
142        }
143    }
144
145    /// Returns whether the dialog is currently open.
146    pub fn is_open(&self) -> bool {
147        self.inner.read().is_open
148    }
149
150    /// Returns whether the dialog is mid-animation.
151    pub fn is_animating(&self) -> bool {
152        self.inner
153            .read()
154            .timer
155            .is_some_and(|t| t.elapsed() < ANIM_TIME)
156    }
157
158    fn snapshot(&self) -> (bool, Option<Instant>) {
159        let inner = self.inner.read();
160        (inner.is_open, inner.timer)
161    }
162}
163
164fn render_scrim(args: &DialogProviderArgs, is_open: bool, progress: f32) {
165    match args.style {
166        DialogStyle::Glass => {
167            let blur_radius = blur_radius_for(progress, is_open, 5.0);
168            fluid_glass(
169                FluidGlassArgsBuilder::default()
170                    .on_click(args.on_close_request.clone())
171                    .tint_color(Color::TRANSPARENT)
172                    .width(DimensionValue::Fill {
173                        min: None,
174                        max: None,
175                    })
176                    .height(DimensionValue::Fill {
177                        min: None,
178                        max: None,
179                    })
180                    .dispersion_height(Dp(0.0))
181                    .refraction_height(Dp(0.0))
182                    .block_input(true)
183                    .blur_radius(Dp(blur_radius as f64))
184                    .border(None)
185                    .shape(Shape::RoundedRectangle {
186                        top_left: RoundedCorner::manual(Dp(0.0), 3.0),
187                        top_right: RoundedCorner::manual(Dp(0.0), 3.0),
188                        bottom_right: RoundedCorner::manual(Dp(0.0), 3.0),
189                        bottom_left: RoundedCorner::manual(Dp(0.0), 3.0),
190                    })
191                    .noise_amount(0.0)
192                    .build()
193                    .expect("builder construction failed"),
194                None,
195                || {},
196            );
197        }
198        DialogStyle::Material => {
199            let alpha = scrim_alpha_for(progress, is_open);
200            surface(
201                SurfaceArgsBuilder::default()
202                    .style(Color::BLACK.with_alpha(alpha).into())
203                    .on_click(args.on_close_request.clone())
204                    .width(DimensionValue::Fill {
205                        min: None,
206                        max: None,
207                    })
208                    .height(DimensionValue::Fill {
209                        min: None,
210                        max: None,
211                    })
212                    .block_input(true)
213                    .build()
214                    .expect("builder construction failed"),
215                None,
216                || {},
217            );
218        }
219    }
220}
221
222fn make_keyboard_input_handler(
223    on_close: Arc<dyn Fn() + Send + Sync>,
224) -> Box<dyn for<'a> Fn(tessera_ui::InputHandlerInput<'a>) + Send + Sync + 'static> {
225    Box::new(move |input| {
226        input.keyboard_events.drain(..).for_each(|event| {
227            if event.state == winit::event::ElementState::Pressed
228                && let winit::keyboard::PhysicalKey::Code(winit::keyboard::KeyCode::Escape) =
229                    event.physical_key
230            {
231                (on_close)();
232            }
233        });
234    })
235}
236
237#[tessera]
238fn dialog_content_wrapper(
239    style: DialogStyle,
240    alpha: f32,
241    padding: Dp,
242    content: impl FnOnce() + Send + Sync + 'static,
243) {
244    boxed(
245        BoxedArgsBuilder::default()
246            .width(DimensionValue::FILLED)
247            .height(DimensionValue::FILLED)
248            .alignment(Alignment::Center)
249            .build()
250            .expect("builder construction failed"),
251        |scope| {
252            scope.child(move || match style {
253                DialogStyle::Glass => {
254                    fluid_glass(
255                        FluidGlassArgsBuilder::default()
256                            .tint_color(Color::WHITE.with_alpha(alpha / 2.5))
257                            .blur_radius(Dp(5.0 * alpha as f64))
258                            .shape(Shape::RoundedRectangle {
259                                top_left: RoundedCorner::manual(Dp(25.0), 3.0),
260                                top_right: RoundedCorner::manual(Dp(25.0), 3.0),
261                                bottom_right: RoundedCorner::manual(Dp(25.0), 3.0),
262                                bottom_left: RoundedCorner::manual(Dp(25.0), 3.0),
263                            })
264                            .refraction_amount(32.0 * alpha)
265                            .block_input(true)
266                            .padding(padding)
267                            .build()
268                            .expect("builder construction failed"),
269                        None,
270                        content,
271                    );
272                }
273                DialogStyle::Material => {
274                    surface(
275                        SurfaceArgsBuilder::default()
276                            .style(Color::WHITE.with_alpha(alpha).into())
277                            .shadow(ShadowProps {
278                                color: Color::BLACK.with_alpha(alpha / 4.0),
279                                ..Default::default()
280                            })
281                            .shape(Shape::RoundedRectangle {
282                                top_left: RoundedCorner::manual(Dp(25.0), 3.0),
283                                top_right: RoundedCorner::manual(Dp(25.0), 3.0),
284                                bottom_right: RoundedCorner::manual(Dp(25.0), 3.0),
285                                bottom_left: RoundedCorner::manual(Dp(25.0), 3.0),
286                            })
287                            .padding(padding)
288                            .block_input(true)
289                            .build()
290                            .expect("builder construction failed"),
291                        None,
292                        content,
293                    );
294                }
295            });
296        },
297    );
298}
299
300/// # dialog_provider
301///
302/// Provide a modal dialog at the top level of an application.
303///
304/// ## Usage
305///
306/// Show modal content for alerts, confirmation dialogs, multi-step forms, or onboarding steps that require blocking user interaction with the main UI.
307///
308/// ## Parameters
309///
310/// - `args` — configuration for dialog appearance and the `on_close_request` callback; see [`DialogProviderArgs`].
311/// - `state` — a clonable [`DialogProviderState`] handle; use `DialogProviderState::new()` to create one.
312/// - `main_content` — closure that renders the always-visible base UI.
313/// - `dialog_content` — closure that renders dialog content; receives a `f32` alpha for animation.
314///
315/// ## Examples
316///
317/// ```
318/// use tessera_ui_basic_components::dialog::DialogProviderState;
319/// let state = DialogProviderState::new();
320/// assert!(!state.is_open());
321/// state.open();
322/// assert!(state.is_open());
323/// state.close();
324/// assert!(!state.is_open());
325/// ```
326#[tessera]
327pub fn dialog_provider(
328    args: DialogProviderArgs,
329    state: DialogProviderState,
330    main_content: impl FnOnce(),
331    dialog_content: impl FnOnce(f32) + Send + Sync + 'static,
332) {
333    // 1. Render the main application content unconditionally.
334    main_content();
335
336    // 2. If the dialog is open, render the modal overlay.
337    // Sample state once to avoid repeated locks and improve readability.
338    let (is_open, timer_opt) = state.snapshot();
339
340    let is_animating = timer_opt.is_some_and(|t| t.elapsed() < ANIM_TIME);
341
342    if is_open || is_animating {
343        let progress = animation::easing(compute_dialog_progress(timer_opt));
344
345        let content_alpha = if is_open {
346            progress * 1.0 // Transition from 0 to 1 alpha
347        } else {
348            1.0 * (1.0 - progress) // Transition from 1 to 0 alpha
349        };
350
351        // 2a. Scrim (delegated)
352        render_scrim(&args, is_open, progress);
353
354        // 2b. Input Handler for intercepting keyboard events (delegated)
355        let handler = make_keyboard_input_handler(args.on_close_request.clone());
356        input_handler(handler);
357
358        // 2c. Dialog Content
359        // The user-defined dialog content is rendered on top of everything.
360        dialog_content_wrapper(args.style, content_alpha, args.padding, move || {
361            dialog_content(content_alpha);
362        });
363    }
364}