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