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