tessera_ui_basic_components/
glass_dialog.rs

1//! Provides a modal glass dialog component for overlaying content and intercepting user input.
2//!
3//! This module defines a dialog provider for creating modal glass dialogs in UI applications.
4//! It allows rendering custom dialog content above the main application, blocking interaction
5//! with underlying elements and intercepting keyboard/mouse events (such as ESC or scrim click)
6//! to trigger close actions. Typical use cases include confirmation dialogs, alerts, and
7//! any scenario requiring user attention before proceeding.
8//!
9//! The dialog is managed via [`GlassDialogProviderArgs`] and the [`glass_dialog_provider`] function.
10//! See the example in [`glass_dialog_provider`] for usage details.
11
12use std::{
13    sync::Arc,
14    time::{Duration, Instant},
15};
16
17use derive_builder::Builder;
18use parking_lot::RwLock;
19use tessera_ui::{Color, DimensionValue, winit};
20use tessera_ui_macros::tessera;
21
22use crate::{
23    animation,
24    fluid_glass::{FluidGlassArgsBuilder, fluid_glass},
25    shape_def::Shape,
26};
27
28/// The duration of the full dialog animation.
29const ANIM_TIME: Duration = Duration::from_millis(300);
30
31/// Arguments for the [`glass_dialog_provider`] component.
32#[derive(Builder)]
33#[builder(pattern = "owned")]
34pub struct GlassDialogProviderArgs {
35    /// Callback function triggered when a close request is made, for example by
36    /// clicking the scrim or pressing the `ESC` key.
37    pub on_close_request: Arc<dyn Fn() + Send + Sync>,
38}
39
40#[derive(Default)]
41pub struct GlassDialogProviderState {
42    is_open: bool,
43    timer: Option<Instant>,
44}
45
46impl GlassDialogProviderState {
47    /// Open the dialog
48    pub fn open(&mut self) {
49        if self.is_open {
50            // Already opened, no action needed
51        } else {
52            self.is_open = true; // Mark as open
53            let mut timer = Instant::now();
54            if let Some(old_timer) = self.timer {
55                let elapsed = old_timer.elapsed();
56                if elapsed < ANIM_TIME {
57                    // If we are still in the middle of an animation
58                    timer += ANIM_TIME - elapsed; // We need to 'catch up' the timer
59                }
60            }
61            self.timer = Some(timer);
62        }
63    }
64
65    /// Close the dialog
66    pub fn close(&mut self) {
67        if !self.is_open {
68            // Already closed, no action needed
69        } else {
70            self.is_open = false; // Mark as closed
71            let mut timer = Instant::now();
72            if let Some(old_timer) = self.timer {
73                let elapsed = old_timer.elapsed();
74                if elapsed < ANIM_TIME {
75                    // If we are still in the middle of an animation
76                    timer += ANIM_TIME - elapsed; // We need to 'catch up' the timer
77                }
78            }
79            self.timer = Some(timer);
80        }
81    }
82}
83
84/// A provider component that manages the rendering and event flow for a modal dialog.
85///
86/// This component should be used as one of the outermost layers of the application.
87/// It renders the main content, and when `is_open` is true, it overlays a modal
88/// dialog, intercepting all input events to create a modal experience.
89///
90/// The dialog can be closed by calling the `on_close_request` callback, which can be
91/// triggered by clicking the background scrim or pressing the `ESC` key.
92///
93/// # Arguments
94///
95/// * `args` - The arguments for configuring the dialog provider. See [`GlassDialogProviderArgs`].
96/// * `main_content` - A closure that renders the main content of the application,
97///   which is visible whether the dialog is open or closed.
98/// * `dialog_content` - A closure that renders the content of the dialog, which is
99///   only visible when `args.is_open` is `true`.
100///
101/// # Example
102///
103/// ```
104/// use std::sync::Arc;
105///
106/// use parking_lot::RwLock;
107/// use tessera_ui::Color;
108/// use tessera_ui_basic_components::{
109///     glass_dialog::{GlassDialogProviderArgsBuilder, GlassDialogProviderState, glass_dialog_provider},
110///     button::{ButtonArgsBuilder, button},
111///     text::{TextArgsBuilder, text},
112///     ripple_state::RippleState,
113/// };
114///
115/// #[derive(Default)]
116/// struct State {
117///     show_dialog: bool,
118/// }
119///
120/// # let state = Arc::new(RwLock::new(State::default()));
121/// # let ripple_state = Arc::new(RippleState::default());
122/// # let dialog_state = Arc::new(RwLock::new(GlassDialogProviderState::default()));
123/// // ...
124///
125/// glass_dialog_provider(
126///     GlassDialogProviderArgsBuilder::default()
127///         .on_close_request(Arc::new({
128///             let state = state.clone();
129///             move || state.write().show_dialog = false
130///         }))
131///         .build()
132///         .unwrap(),
133///     dialog_state.clone(),
134///     // Main content
135///     {
136///         let state = state.clone();
137///         let ripple = ripple_state.clone();
138///         let dialog_state = dialog_state.clone();
139///         move || {
140///             button(
141///                 ButtonArgsBuilder::default()
142///                     .on_click(Arc::new(move || {
143///                         state.write().show_dialog = true;
144///                         dialog_state.write().open();
145///                     }))
146///                     .build()
147///                     .unwrap(),
148///                 ripple, // ripple state
149///                 || {
150///                     text(
151///                         TextArgsBuilder::default()
152///                             .text("Show Dialog".to_string())
153///                             .build()
154///                             .unwrap(),
155///                     );
156///                 },
157///             );
158///         }
159///     },
160///     // Dialog content
161///     {
162///         let state = state.clone();
163///         let ripple = ripple_state.clone();
164///         let dialog_state = dialog_state.clone();
165///         move |content_alpha| {
166///             button(
167///                 ButtonArgsBuilder::default()
168///                     .color(Color::GREEN.with_alpha(content_alpha))
169///                     .on_click(Arc::new(move || {
170///                         state.write().show_dialog = false;
171///                         dialog_state.write().close();
172///                     }))
173///                     .build()
174///                     .unwrap(),
175///                 ripple,
176///                 || {
177///                     text(
178///                         TextArgsBuilder::default()
179///                             .color(Color::BLACK.with_alpha(content_alpha))
180///                             .text("Dialog Content".to_string())
181///                             .build()
182///                             .unwrap(),
183///                     );
184///                 },
185///             );
186///         }
187///     },
188/// );
189/// ```
190#[tessera]
191pub fn glass_dialog_provider(
192    args: GlassDialogProviderArgs,
193    state: Arc<RwLock<GlassDialogProviderState>>,
194    main_content: impl FnOnce(),
195    dialog_content: impl FnOnce(f32),
196) {
197    // 1. Render the main application content unconditionally.
198    main_content();
199
200    // 2. If the dialog is open, render the modal overlay.
201    if state.read().is_open
202        || state
203            .read()
204            .timer
205            .is_some_and(|timer| timer.elapsed() < ANIM_TIME)
206    {
207        let on_close_for_keyboard = args.on_close_request.clone();
208
209        let progress = animation::easing(state.read().timer.as_ref().map_or(1.0, |timer| {
210            let elapsed = timer.elapsed();
211            if elapsed >= ANIM_TIME {
212                1.0 // Animation is complete
213            } else {
214                elapsed.as_secs_f32() / ANIM_TIME.as_secs_f32()
215            }
216        }));
217        let blur_radius = if state.read().is_open {
218            progress * 10.0 // Transition from 0 to 10.0 radius
219        } else {
220            10.0 * (1.0 - progress) // Transition from 10.0 to 0 alpha
221        };
222
223        let content_alpha = if state.read().is_open {
224            progress * 1.0 // Transition from 0 to 1 alpha
225        } else {
226            1.0 * (1.0 - progress) // Transition from 1 to 0 alpha
227        };
228
229        // 2a. Scrim
230        // This Surface covers the entire screen, consuming all mouse clicks
231        // and triggering the close request.
232        fluid_glass(
233            FluidGlassArgsBuilder::default()
234                .on_click(args.on_close_request)
235                .tint_color(Color::TRANSPARENT)
236                .width(DimensionValue::Fill {
237                    min: None,
238                    max: None,
239                })
240                .height(DimensionValue::Fill {
241                    min: None,
242                    max: None,
243                })
244                .dispersion_height(0.0)
245                .refraction_height(0.0)
246                .block_input(true)
247                .blur_radius(blur_radius)
248                .shape(Shape::RoundedRectangle {
249                    corner_radius: 0.0,
250                    g2_k_value: 3.0,
251                })
252                .noise_amount(0.0)
253                .build()
254                .unwrap(),
255            None,
256            || {},
257        );
258
259        // 2b. State Handler for intercepting keyboard events.
260        state_handler(Box::new(move |input| {
261            // Atomically consume all keyboard events to prevent them from propagating
262            // to the main content underneath.
263            let events = input.keyboard_events.drain(..).collect::<Vec<_>>();
264
265            // Check the consumed events for the 'Escape' key press.
266            for event in events {
267                if event.state == winit::event::ElementState::Pressed {
268                    if let winit::keyboard::PhysicalKey::Code(winit::keyboard::KeyCode::Escape) =
269                        event.physical_key
270                    {
271                        (on_close_for_keyboard)();
272                    }
273                }
274            }
275        }));
276
277        // 2c. Dialog Content
278        // The user-defined dialog content is rendered on top of everything.
279        dialog_content(content_alpha);
280    }
281}