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