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}