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