Skip to main content

cranpose_ui/widgets/
dialog.rs

1//! Modal dialogs.
2//!
3//! A dialog is the one surface that takes the whole screen's attention: while
4//! it is open, everything behind it is inert, a back gesture closes it rather
5//! than the screen behind it, a screen reader announces it and stays inside it,
6//! and a text field inside it keeps its focus while everything outside loses
7//! the ability to take focus away.
8//!
9//! Dialogs are built on [`crate::widgets::popup`], so their content escapes
10//! every ancestor clip and draws above the whole application, and they are
11//! centred inside the window insets so the system bars and the on-screen
12//! keyboard never cover them.
13
14use std::rc::Rc;
15
16use cranpose_ui_graphics::{Brush, Color, Point, Rect, Size};
17use cranpose_ui_layout::Alignment;
18
19use super::{
20    box_widget::{Box, BoxSpec},
21    popup::{PopupDismissable, local_popup_viewport},
22};
23use crate::{Modifier, SemanticsWidgetRole, composable, safe_area::window_insets};
24
25/// Why a dialog is being asked to close.
26#[derive(Clone, Copy, Debug, PartialEq, Eq)]
27pub enum DismissReason {
28    /// The user tapped outside the dialog's surface.
29    OutsideTap,
30    /// The user made the platform's back gesture, or pressed Escape.
31    BackRequested,
32}
33
34/// How a dialog behaves.
35#[derive(Clone, Copy, Debug, PartialEq, Eq)]
36pub struct DialogSpec {
37    /// Whether a tap outside the dialog dismisses it.
38    pub dismiss_on_outside_tap: bool,
39    /// Whether the platform's back gesture dismisses it.
40    pub dismiss_on_back: bool,
41    /// Whether the dialog's surface keeps clear of the system bars and the
42    /// on-screen keyboard; the scrim covers the whole window either way. A
43    /// dialog that draws its own full-bleed surface turns this off.
44    pub apply_window_insets: bool,
45    /// Where the dialog's surface sits in the window: the centre, or the
46    /// bottom edge for a sheet.
47    pub alignment: Alignment,
48}
49
50impl Default for DialogSpec {
51    fn default() -> Self {
52        Self {
53            dismiss_on_outside_tap: true,
54            dismiss_on_back: true,
55            apply_window_insets: true,
56            alignment: Alignment::CENTER,
57        }
58    }
59}
60
61impl DialogSpec {
62    /// A dialog the user must answer: neither an outside tap nor a back
63    /// gesture closes it, so only the dialog's own controls can.
64    pub const fn required() -> Self {
65        Self {
66            dismiss_on_outside_tap: false,
67            dismiss_on_back: false,
68            apply_window_insets: true,
69            alignment: Alignment::CENTER,
70        }
71    }
72
73    /// Sets whether an outside tap dismisses the dialog.
74    pub const fn with_dismiss_on_outside_tap(mut self, dismiss: bool) -> Self {
75        self.dismiss_on_outside_tap = dismiss;
76        self
77    }
78
79    /// Sets whether the back gesture dismisses the dialog.
80    pub const fn with_dismiss_on_back(mut self, dismiss: bool) -> Self {
81        self.dismiss_on_back = dismiss;
82        self
83    }
84
85    /// Sets whether the dialog keeps clear of system insets.
86    pub const fn with_window_insets(mut self, apply: bool) -> Self {
87        self.apply_window_insets = apply;
88        self
89    }
90
91    /// Sets where the dialog's surface sits in the window.
92    pub const fn with_alignment(mut self, alignment: Alignment) -> Self {
93        self.alignment = alignment;
94        self
95    }
96}
97
98/// The default dim behind a dialog, matching what every platform's own modal
99/// surfaces use closely enough that a dialog does not look foreign.
100pub const DEFAULT_SCRIM: Color = Color(0.0, 0.0, 0.0, 0.4);
101
102/// The rectangle a dialog's surface is placed in.
103///
104/// The window minus its insets; the surface itself sits inside that at the
105/// spec's alignment, sized by its own content.
106pub(crate) fn dialog_bounds(viewport: Size, insets: cranpose_ui_graphics::EdgeInsets) -> Rect {
107    let width = (viewport.width - insets.left - insets.right).max(0.0);
108    let height = (viewport.height - insets.top - insets.bottom).max(0.0);
109    Rect::from_origin_size(
110        Point {
111            x: insets.left,
112            y: insets.top,
113        },
114        Size { width, height },
115    )
116}
117
118/// A modal dialog holding `content`, dismissed through `on_dismiss`.
119///
120/// `on_dismiss` receives why the dialog is closing, so an application can treat
121/// "tapped away" differently from "went back" — a draft, for example, is
122/// usually kept in one case and discarded in the other.
123///
124/// ```rust,no_run
125/// # use cranpose_ui::widgets::{Dialog, DialogSpec};
126/// # use cranpose_ui::Modifier;
127/// # fn Content() {}
128/// # let open = true;
129/// if open {
130///     Dialog(
131///         DialogSpec::default(),
132///         |_reason| { /* close it */ },
133///         || {
134///             Content();
135///         },
136///     );
137/// }
138/// ```
139#[composable]
140pub fn Dialog<C>(spec: DialogSpec, on_dismiss: impl Fn(DismissReason) + 'static, content: C)
141where
142    C: Fn() + 'static,
143{
144    DialogWithScrim(spec, DEFAULT_SCRIM, on_dismiss, content);
145}
146
147/// A modal dialog whose scrim colour is chosen by the caller.
148#[composable]
149pub fn DialogWithScrim<C>(
150    spec: DialogSpec,
151    scrim: Color,
152    on_dismiss: impl Fn(DismissReason) + 'static,
153    content: C,
154) where
155    C: Fn() + 'static,
156{
157    DialogWithFadingScrim(spec, scrim, || 1.0, on_dismiss, content);
158}
159
160/// A modal dialog whose scrim shows `scrim` at `scrim_alpha()` of its
161/// alpha. `scrim_alpha` is read while the scrim draws, so a scrim that fades
162/// with an animation, as a sheet's does, redraws without recomposing.
163#[composable]
164pub fn DialogWithFadingScrim<C>(
165    spec: DialogSpec,
166    scrim: Color,
167    scrim_alpha: impl Fn() -> f32 + 'static,
168    on_dismiss: impl Fn(DismissReason) + 'static,
169    content: C,
170) where
171    C: Fn() + 'static,
172{
173    let own_modal_depth =
174        cranpose_core::remember(|| crate::modal::current_modal_depth() + 1).with(|depth| *depth);
175
176    let viewport = local_popup_viewport().current().get();
177    let insets = if spec.apply_window_insets {
178        window_insets().combined()
179    } else {
180        cranpose_ui_graphics::EdgeInsets::default()
181    };
182    let bounds = dialog_bounds(viewport, insets);
183    let window = Rect::from_origin_size(Point { x: 0.0, y: 0.0 }, viewport);
184
185    let on_dismiss = Rc::new(on_dismiss);
186    let content = Rc::new(content);
187
188    let dismiss_on_outside_tap = spec.dismiss_on_outside_tap;
189    let scrim_dismiss = {
190        let on_dismiss = Rc::clone(&on_dismiss);
191        move || {
192            if dismiss_on_outside_tap {
193                on_dismiss(DismissReason::OutsideTap);
194            }
195        }
196    };
197
198    let dismiss_on_back = spec.dismiss_on_back;
199    let back = {
200        let on_dismiss = Rc::clone(&on_dismiss);
201        cranpose_core::rememberUpdatedState::<Rc<dyn Fn()>>(Rc::new(move || {
202            if dismiss_on_back {
203                on_dismiss(DismissReason::BackRequested);
204            }
205        }))
206    };
207    cranpose_core::DisposableEffect((), move |scope| {
208        let registration = crate::modal::register_modal(Rc::new(move || (back.value())()));
209        scope.on_dispose(move || drop(registration))
210    });
211
212    let scrim_alpha = Rc::new(scrim_alpha);
213    PopupDismissable(window, Point { x: 0.0, y: 0.0 }, scrim_dismiss, move || {
214        let content = Rc::clone(&content);
215        let scrim_alpha = Rc::clone(&scrim_alpha);
216        Box(
217            Modifier::empty()
218                .size(viewport)
219                .draw_behind(move |scope| {
220                    let alpha = scrim.3 * scrim_alpha().clamp(0.0, 1.0);
221                    scope.draw_rect(Brush::solid(Color(scrim.0, scrim.1, scrim.2, alpha)));
222                })
223                .focus_target()
224                .stable_semantics(move |config| {
225                    config.role = Some(SemanticsWidgetRole::Dialog);
226                    config.is_modal = true;
227                    // A reader stays inside the dialog, so the dialog itself
228                    // offers the way out that the back gesture takes.
229                    config.dismiss = dismiss_on_back.then(|| {
230                        cranpose_foundation::SemanticsDismiss::new(move || {
231                            (back.value())();
232                            true
233                        })
234                    });
235                }),
236            BoxSpec::default(),
237            move || {
238                let content = Rc::clone(&content);
239                Box(
240                    Modifier::empty()
241                        .absolute_offset(bounds.x, bounds.y)
242                        .size(Size {
243                            width: bounds.width,
244                            height: bounds.height,
245                        }),
246                    BoxSpec::default().content_alignment(spec.alignment),
247                    move || {
248                        let content = Rc::clone(&content);
249                        // Taps on the dialog's own surface stay off the scrim below.
250                        Box(
251                            Modifier::empty().pointer_opaque(),
252                            BoxSpec::default(),
253                            move || {
254                                let content = Rc::clone(&content);
255                                cranpose_core::CompositionLocalProvider(
256                                    [crate::modal::local_modal_depth().provides(own_modal_depth)],
257                                    move || content(),
258                                );
259                            },
260                        );
261                    },
262                );
263            },
264        );
265    });
266}
267
268#[cfg(test)]
269#[path = "tests/dialog_tests.rs"]
270mod tests;