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
14#![allow(non_snake_case)]
15
16use super::box_widget::{Box, BoxSpec};
17use super::popup::{local_popup_viewport, PopupDismissable};
18use crate::safe_area::window_insets;
19use crate::{composable, Modifier, SemanticsWidgetRole};
20use cranpose_ui_graphics::{Color, Point, Rect, Size};
21use cranpose_ui_layout::Alignment;
22use std::rc::Rc;
23
24/// Why a dialog is being asked to close.
25#[derive(Clone, Copy, Debug, PartialEq, Eq)]
26pub enum DismissReason {
27    /// The user tapped outside the dialog's surface.
28    OutsideTap,
29    /// The user made the platform's back gesture, or pressed Escape.
30    BackRequested,
31}
32
33/// How a dialog behaves.
34#[derive(Clone, Copy, Debug, PartialEq, Eq)]
35pub struct DialogSpec {
36    /// Whether a tap outside the dialog dismisses it.
37    pub dismiss_on_outside_tap: bool,
38    /// Whether the platform's back gesture dismisses it.
39    pub dismiss_on_back: bool,
40    /// Whether the dialog keeps clear of the system bars and the on-screen
41    /// keyboard. A dialog that draws its own full-bleed surface turns this off.
42    pub apply_window_insets: bool,
43}
44
45impl Default for DialogSpec {
46    fn default() -> Self {
47        Self {
48            dismiss_on_outside_tap: true,
49            dismiss_on_back: true,
50            apply_window_insets: true,
51        }
52    }
53}
54
55impl DialogSpec {
56    /// A dialog the user must answer: neither an outside tap nor a back
57    /// gesture closes it, so only the dialog's own controls can.
58    pub const fn required() -> Self {
59        Self {
60            dismiss_on_outside_tap: false,
61            dismiss_on_back: false,
62            apply_window_insets: true,
63        }
64    }
65
66    /// Sets whether an outside tap dismisses the dialog.
67    pub const fn with_dismiss_on_outside_tap(mut self, dismiss: bool) -> Self {
68        self.dismiss_on_outside_tap = dismiss;
69        self
70    }
71
72    /// Sets whether the back gesture dismisses the dialog.
73    pub const fn with_dismiss_on_back(mut self, dismiss: bool) -> Self {
74        self.dismiss_on_back = dismiss;
75        self
76    }
77
78    /// Sets whether the dialog keeps clear of system insets.
79    pub const fn with_window_insets(mut self, apply: bool) -> Self {
80        self.apply_window_insets = apply;
81        self
82    }
83}
84
85/// The default dim behind a dialog, matching what every platform's own modal
86/// surfaces use closely enough that a dialog does not look foreign.
87pub const DEFAULT_SCRIM: Color = Color(0.0, 0.0, 0.0, 0.4);
88
89/// The rectangle a dialog's surface is placed in.
90///
91/// The dialog fills the window minus its insets; the surface itself is centred
92/// inside that and sized by its own content.
93pub(crate) fn dialog_bounds(viewport: Size, insets: cranpose_ui_graphics::EdgeInsets) -> Rect {
94    let width = (viewport.width - insets.left - insets.right).max(0.0);
95    let height = (viewport.height - insets.top - insets.bottom).max(0.0);
96    Rect::from_origin_size(
97        Point {
98            x: insets.left,
99            y: insets.top,
100        },
101        Size { width, height },
102    )
103}
104
105/// A modal dialog holding `content`, dismissed through `on_dismiss`.
106///
107/// `on_dismiss` receives why the dialog is closing, so an application can treat
108/// "tapped away" differently from "went back" — a draft, for example, is
109/// usually kept in one case and discarded in the other.
110///
111/// ```rust,no_run
112/// # use cranpose_ui::widgets::{Dialog, DialogSpec};
113/// # use cranpose_ui::Modifier;
114/// # fn Content() {}
115/// # let open = true;
116/// if open {
117///     Dialog(DialogSpec::default(), |_reason| { /* close it */ }, || {
118///         Content();
119///     });
120/// }
121/// ```
122#[composable]
123pub fn Dialog<C>(spec: DialogSpec, on_dismiss: impl Fn(DismissReason) + 'static, content: C)
124where
125    C: Fn() + 'static,
126{
127    DialogWithScrim(spec, DEFAULT_SCRIM, on_dismiss, content);
128}
129
130/// A modal dialog whose scrim colour is chosen by the caller.
131#[composable]
132pub fn DialogWithScrim<C>(
133    spec: DialogSpec,
134    scrim: Color,
135    on_dismiss: impl Fn(DismissReason) + 'static,
136    content: C,
137) where
138    C: Fn() + 'static,
139{
140    // The depth this dialog's own `register_modal` registration will occupy,
141    // fixed at this dialog's first composition (before `DisposableEffect`
142    // below actually registers it) and held stable for as long as the dialog
143    // stays mounted — recomputing it from the live `modal_depth()` on every
144    // recomposition would double-count this dialog's own registration once
145    // it commits. Provided to the content below so its fields (and any
146    // nested dialog) know how deep they are.
147    let own_modal_depth =
148        cranpose_core::remember(|| crate::modal::current_modal_depth() + 1).with(|depth| *depth);
149
150    let viewport = local_popup_viewport().current().get();
151    let insets = if spec.apply_window_insets {
152        window_insets().combined()
153    } else {
154        cranpose_ui_graphics::EdgeInsets::default()
155    };
156    let bounds = dialog_bounds(viewport, insets);
157
158    let on_dismiss = Rc::new(on_dismiss);
159    let content = Rc::new(content);
160
161    // A dialog whose outside taps do nothing still needs the scrim, or the
162    // controls behind it would keep taking input while it is open. The
163    // callback therefore always exists and only *acts* when the spec allows it.
164    let dismiss_on_outside_tap = spec.dismiss_on_outside_tap;
165    let scrim_dismiss = {
166        let on_dismiss = Rc::clone(&on_dismiss);
167        move || {
168            if dismiss_on_outside_tap {
169                on_dismiss(DismissReason::OutsideTap);
170            }
171        }
172    };
173
174    // The back gesture is taken while the dialog is composed. A dialog that
175    // does not close on back still takes it, because the screen behind a modal
176    // must not react to a gesture aimed at the modal.
177    let dismiss_on_back = spec.dismiss_on_back;
178    let back = {
179        let on_dismiss = Rc::clone(&on_dismiss);
180        cranpose_core::rememberUpdatedState::<Rc<dyn Fn()>>(Rc::new(move || {
181            if dismiss_on_back {
182                on_dismiss(DismissReason::BackRequested);
183            }
184        }))
185    };
186    cranpose_core::DisposableEffect!((), move |scope| {
187        let registration = crate::modal::register_modal(Rc::new(move || (back.value())()));
188        scope.on_dispose(move || drop(registration))
189    });
190
191    PopupDismissable(bounds, Point { x: 0.0, y: 0.0 }, scrim_dismiss, move || {
192        let content = Rc::clone(&content);
193        Box(
194            Modifier::empty()
195                .size(Size {
196                    width: bounds.width,
197                    height: bounds.height,
198                })
199                .background(scrim)
200                .semantics(move |config| {
201                    config.role = Some(SemanticsWidgetRole::Dialog);
202                    config.is_modal = true;
203                }),
204            BoxSpec::default().content_alignment(Alignment::CENTER),
205            move || {
206                let content = Rc::clone(&content);
207                cranpose_core::CompositionLocalProvider(
208                    [crate::modal::local_modal_depth().provides(own_modal_depth)],
209                    move || content(),
210                );
211            },
212        );
213    });
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219
220    #[test]
221    fn a_dialog_fills_the_window_inside_its_insets() {
222        let viewport = Size {
223            width: 400.0,
224            height: 800.0,
225        };
226        let insets = cranpose_ui_graphics::EdgeInsets::from_components(8.0, 24.0, 8.0, 48.0);
227        let bounds = dialog_bounds(viewport, insets);
228        assert_eq!(bounds.x, 8.0);
229        assert_eq!(bounds.y, 24.0);
230        assert_eq!(bounds.width, 384.0);
231        assert_eq!(bounds.height, 728.0);
232    }
233
234    #[test]
235    fn insets_larger_than_the_window_collapse_rather_than_going_negative() {
236        let viewport = Size {
237            width: 100.0,
238            height: 100.0,
239        };
240        let insets = cranpose_ui_graphics::EdgeInsets::from_components(80.0, 80.0, 80.0, 80.0);
241        let bounds = dialog_bounds(viewport, insets);
242        assert_eq!(bounds.width, 0.0);
243        assert_eq!(bounds.height, 0.0);
244    }
245
246    #[test]
247    fn a_required_dialog_refuses_both_dismissals_but_still_covers_the_screen() {
248        let spec = DialogSpec::required();
249        assert!(!spec.dismiss_on_outside_tap);
250        assert!(!spec.dismiss_on_back);
251        assert!(spec.apply_window_insets);
252    }
253
254    #[test]
255    fn the_default_dialog_dismisses_both_ways_until_told_otherwise() {
256        let spec = DialogSpec::default();
257        assert!(spec.dismiss_on_outside_tap);
258        assert!(spec.dismiss_on_back);
259        assert!(!spec.with_dismiss_on_back(false).dismiss_on_back);
260        assert!(
261            !spec
262                .with_dismiss_on_outside_tap(false)
263                .dismiss_on_outside_tap
264        );
265        assert!(!spec.with_window_insets(false).apply_window_insets);
266    }
267
268    /// Pins the defect this module's doc comment promises and nothing used to
269    /// implement: a text field behind an open dialog must not be able to take
270    /// focus away from the dialog, and once the dialog closes the field
271    /// behind it must be focusable again — and the field the closed dialog
272    /// held must not be left "focused" with nothing there to receive input.
273    #[test]
274    fn a_dialog_traps_focus_inside_it_and_releases_it_on_close() {
275        use crate::layout::{LayoutBox, LayoutEngine, LayoutTree};
276        use crate::text::TextStyle;
277        use crate::text_field_focus::{focused_editor_state, has_focused_field};
278        use crate::widgets::basic_text_field::BasicTextField;
279        use crate::widgets::popup::PopupHost;
280        use cranpose_core::{
281            location_key, mutableStateOf, remember, Composition, MemoryApplier, MutableState,
282            NodeId,
283        };
284        use cranpose_foundation::text::TextFieldState;
285        use cranpose_foundation::{PointerEvent, PointerEventKind};
286        use cranpose_ui_graphics::Size;
287        use std::cell::{Cell, RefCell};
288
289        let _app_context = crate::render_state::app_context_test_scope();
290        crate::modal::clear_modals();
291
292        // `Composition::new` installs the runtime `TextFieldState` needs, so
293        // it must exist before any state is allocated.
294        let mut composition = Composition::new(MemoryApplier::new());
295
296        let outside_state = TextFieldState::new("outside");
297        let inside_state = TextFieldState::new("inside");
298        let outside_id: Rc<Cell<Option<NodeId>>> = Rc::new(Cell::new(None));
299        let inside_id: Rc<Cell<Option<NodeId>>> = Rc::new(Cell::new(None));
300        let dialog_open_slot: Rc<RefCell<Option<MutableState<bool>>>> = Rc::new(RefCell::new(None));
301
302        let mut content = {
303            let outside_id = Rc::clone(&outside_id);
304            let inside_id = Rc::clone(&inside_id);
305            let dialog_open_slot = Rc::clone(&dialog_open_slot);
306            move || {
307                let outside_id = Rc::clone(&outside_id);
308                let inside_id = Rc::clone(&inside_id);
309                let dialog_open_slot = Rc::clone(&dialog_open_slot);
310                PopupHost(move || {
311                    let dialog_open = remember(|| mutableStateOf(true)).with(|state| *state);
312                    *dialog_open_slot.borrow_mut() = Some(dialog_open);
313
314                    outside_id.set(Some(BasicTextField(
315                        outside_state,
316                        Modifier::empty(),
317                        TextStyle::default(),
318                    )));
319
320                    if dialog_open.value() {
321                        let inside_id = Rc::clone(&inside_id);
322                        Dialog(
323                            DialogSpec::default(),
324                            |_reason: DismissReason| {},
325                            move || {
326                                inside_id.set(Some(BasicTextField(
327                                    inside_state,
328                                    Modifier::empty(),
329                                    TextStyle::default(),
330                                )));
331                            },
332                        );
333                    }
334                });
335            }
336        };
337
338        let key = location_key(file!(), line!(), column!());
339        composition.render(key, &mut content).expect("render");
340
341        // The dialog's Popup registers into the host during a subcomposition
342        // pass; a follow-up reconcile + layout is needed for it to actually
343        // place content, so settle a few frames the way a real host would.
344        let mut settle = move |composition: &mut Composition<MemoryApplier>| -> LayoutTree {
345            for _ in 0..16 {
346                if !composition.should_render() {
347                    break;
348                }
349                composition.reconcile(key, &mut content).expect("reconcile");
350            }
351            let root = composition.root().expect("root");
352            let handle = composition.runtime_handle();
353            let mut applier = composition.applier_mut();
354            applier.set_runtime_handle(handle);
355            let layout = applier
356                .compute_layout(root, Size::new(400.0, 800.0))
357                .expect("layout");
358            applier.clear_runtime_handle();
359            drop(applier);
360            layout
361        };
362
363        fn find_node(node: &LayoutBox, id: NodeId) -> Option<&LayoutBox> {
364            if node.node_id == id {
365                return Some(node);
366            }
367            node.children.iter().find_map(|child| find_node(child, id))
368        }
369
370        fn tap(layout_root: &LayoutBox, id: &Rc<Cell<Option<NodeId>>>) {
371            let id = id.get().expect("field composed");
372            let node = find_node(layout_root, id).expect("field placed in the layout");
373            let handler = node
374                .node_data
375                .modifier_slices()
376                .pointer_inputs()
377                .first()
378                .cloned()
379                .expect("field has a pointer handler");
380            let position = cranpose_ui_graphics::Point { x: 1.0, y: 1.0 };
381            handler(PointerEvent::new(
382                PointerEventKind::Down,
383                position,
384                position,
385            ));
386        }
387
388        let mut layout = settle(&mut composition);
389        for _ in 0..7 {
390            layout = settle(&mut composition);
391        }
392
393        // The dialog is open. Its own field can take focus...
394        tap(layout.root(), &inside_id);
395        assert!(has_focused_field(), "the dialog's own field must focus");
396        assert_eq!(
397            focused_editor_state().map(|s| s.text),
398            Some("inside".to_string()),
399            "the dialog's own field must be the one focused"
400        );
401
402        // ...but the field behind the dialog must not be able to steal it.
403        tap(layout.root(), &outside_id);
404        assert_eq!(
405            focused_editor_state().map(|s| s.text),
406            Some("inside".to_string()),
407            "a field behind an open dialog must not take focus from it"
408        );
409
410        // Closing the dialog must not leave focus pointing at the field that
411        // just went away with it.
412        let dialog_open = dialog_open_slot
413            .borrow()
414            .as_ref()
415            .copied()
416            .expect("dialog_open captured");
417        dialog_open.set(false);
418        layout = settle(&mut composition);
419        assert!(
420            !has_focused_field(),
421            "closing the dialog must release focus from the field it held"
422        );
423
424        // The field behind it can now take focus again.
425        tap(layout.root(), &outside_id);
426        assert_eq!(
427            focused_editor_state().map(|s| s.text),
428            Some("outside".to_string()),
429            "once the dialog is closed, the field behind it must be focusable"
430        );
431
432        crate::modal::clear_modals();
433        crate::text_field_focus::clear_focus();
434    }
435}