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}