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::{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 keeps clear of the system bars and the on-screen
42    /// keyboard. A dialog that draws its own full-bleed surface turns this off.
43    pub apply_window_insets: bool,
44}
45
46impl Default for DialogSpec {
47    fn default() -> Self {
48        Self {
49            dismiss_on_outside_tap: true,
50            dismiss_on_back: true,
51            apply_window_insets: true,
52        }
53    }
54}
55
56impl DialogSpec {
57    /// A dialog the user must answer: neither an outside tap nor a back
58    /// gesture closes it, so only the dialog's own controls can.
59    pub const fn required() -> Self {
60        Self {
61            dismiss_on_outside_tap: false,
62            dismiss_on_back: false,
63            apply_window_insets: true,
64        }
65    }
66
67    /// Sets whether an outside tap dismisses the dialog.
68    pub const fn with_dismiss_on_outside_tap(mut self, dismiss: bool) -> Self {
69        self.dismiss_on_outside_tap = dismiss;
70        self
71    }
72
73    /// Sets whether the back gesture dismisses the dialog.
74    pub const fn with_dismiss_on_back(mut self, dismiss: bool) -> Self {
75        self.dismiss_on_back = dismiss;
76        self
77    }
78
79    /// Sets whether the dialog keeps clear of system insets.
80    pub const fn with_window_insets(mut self, apply: bool) -> Self {
81        self.apply_window_insets = apply;
82        self
83    }
84}
85
86/// The default dim behind a dialog, matching what every platform's own modal
87/// surfaces use closely enough that a dialog does not look foreign.
88pub const DEFAULT_SCRIM: Color = Color(0.0, 0.0, 0.0, 0.4);
89
90/// The rectangle a dialog's surface is placed in.
91///
92/// The dialog fills the window minus its insets; the surface itself is centred
93/// inside that and sized by its own content.
94pub(crate) fn dialog_bounds(viewport: Size, insets: cranpose_ui_graphics::EdgeInsets) -> Rect {
95    let width = (viewport.width - insets.left - insets.right).max(0.0);
96    let height = (viewport.height - insets.top - insets.bottom).max(0.0);
97    Rect::from_origin_size(
98        Point {
99            x: insets.left,
100            y: insets.top,
101        },
102        Size { width, height },
103    )
104}
105
106/// A modal dialog holding `content`, dismissed through `on_dismiss`.
107///
108/// `on_dismiss` receives why the dialog is closing, so an application can treat
109/// "tapped away" differently from "went back" — a draft, for example, is
110/// usually kept in one case and discarded in the other.
111///
112/// ```rust,no_run
113/// # use cranpose_ui::widgets::{Dialog, DialogSpec};
114/// # use cranpose_ui::Modifier;
115/// # fn Content() {}
116/// # let open = true;
117/// if open {
118///     Dialog(
119///         DialogSpec::default(),
120///         |_reason| { /* close it */ },
121///         || {
122///             Content();
123///         },
124///     );
125/// }
126/// ```
127#[composable]
128pub fn Dialog<C>(spec: DialogSpec, on_dismiss: impl Fn(DismissReason) + 'static, content: C)
129where
130    C: Fn() + 'static,
131{
132    DialogWithScrim(spec, DEFAULT_SCRIM, on_dismiss, content);
133}
134
135/// A modal dialog whose scrim colour is chosen by the caller.
136#[composable]
137pub fn DialogWithScrim<C>(
138    spec: DialogSpec,
139    scrim: Color,
140    on_dismiss: impl Fn(DismissReason) + 'static,
141    content: C,
142) where
143    C: Fn() + 'static,
144{
145    let own_modal_depth =
146        cranpose_core::remember(|| crate::modal::current_modal_depth() + 1).with(|depth| *depth);
147
148    let viewport = local_popup_viewport().current().get();
149    let insets = if spec.apply_window_insets {
150        window_insets().combined()
151    } else {
152        cranpose_ui_graphics::EdgeInsets::default()
153    };
154    let bounds = dialog_bounds(viewport, insets);
155
156    let on_dismiss = Rc::new(on_dismiss);
157    let content = Rc::new(content);
158
159    let dismiss_on_outside_tap = spec.dismiss_on_outside_tap;
160    let scrim_dismiss = {
161        let on_dismiss = Rc::clone(&on_dismiss);
162        move || {
163            if dismiss_on_outside_tap {
164                on_dismiss(DismissReason::OutsideTap);
165            }
166        }
167    };
168
169    let dismiss_on_back = spec.dismiss_on_back;
170    let back = {
171        let on_dismiss = Rc::clone(&on_dismiss);
172        cranpose_core::rememberUpdatedState::<Rc<dyn Fn()>>(Rc::new(move || {
173            if dismiss_on_back {
174                on_dismiss(DismissReason::BackRequested);
175            }
176        }))
177    };
178    cranpose_core::DisposableEffect((), move |scope| {
179        let registration = crate::modal::register_modal(Rc::new(move || (back.value())()));
180        scope.on_dispose(move || drop(registration))
181    });
182
183    PopupDismissable(bounds, Point { x: 0.0, y: 0.0 }, scrim_dismiss, move || {
184        let content = Rc::clone(&content);
185        Box(
186            Modifier::empty()
187                .size(Size {
188                    width: bounds.width,
189                    height: bounds.height,
190                })
191                .background(scrim)
192                .focus_target()
193                .semantics(move |config| {
194                    config.role = Some(SemanticsWidgetRole::Dialog);
195                    config.is_modal = true;
196                }),
197            BoxSpec::default().content_alignment(Alignment::CENTER),
198            move || {
199                let content = Rc::clone(&content);
200                cranpose_core::CompositionLocalProvider(
201                    [crate::modal::local_modal_depth().provides(own_modal_depth)],
202                    move || content(),
203                );
204            },
205        );
206    });
207}
208
209#[cfg(test)]
210#[path = "tests/dialog_tests.rs"]
211mod tests;