cranpose-ui 0.1.99

UI primitives for Cranpose
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
//! Modal dialogs.
//!
//! A dialog is the one surface that takes the whole screen's attention: while
//! it is open, everything behind it is inert, a back gesture closes it rather
//! than the screen behind it, a screen reader announces it and stays inside it,
//! and a text field inside it keeps its focus while everything outside loses
//! the ability to take focus away.
//!
//! Dialogs are built on [`crate::widgets::popup`], so their content escapes
//! every ancestor clip and draws above the whole application, and they are
//! centred inside the window insets so the system bars and the on-screen
//! keyboard never cover them.

#![allow(non_snake_case)]

use super::box_widget::{Box, BoxSpec};
use super::popup::{local_popup_viewport, PopupDismissable};
use crate::safe_area::window_insets;
use crate::{composable, Modifier, SemanticsWidgetRole};
use cranpose_ui_graphics::{Color, Point, Rect, Size};
use cranpose_ui_layout::Alignment;
use std::rc::Rc;

/// Why a dialog is being asked to close.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DismissReason {
    /// The user tapped outside the dialog's surface.
    OutsideTap,
    /// The user made the platform's back gesture, or pressed Escape.
    BackRequested,
}

/// How a dialog behaves.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct DialogSpec {
    /// Whether a tap outside the dialog dismisses it.
    pub dismiss_on_outside_tap: bool,
    /// Whether the platform's back gesture dismisses it.
    pub dismiss_on_back: bool,
    /// Whether the dialog keeps clear of the system bars and the on-screen
    /// keyboard. A dialog that draws its own full-bleed surface turns this off.
    pub apply_window_insets: bool,
}

impl Default for DialogSpec {
    fn default() -> Self {
        Self {
            dismiss_on_outside_tap: true,
            dismiss_on_back: true,
            apply_window_insets: true,
        }
    }
}

impl DialogSpec {
    /// A dialog the user must answer: neither an outside tap nor a back
    /// gesture closes it, so only the dialog's own controls can.
    pub const fn required() -> Self {
        Self {
            dismiss_on_outside_tap: false,
            dismiss_on_back: false,
            apply_window_insets: true,
        }
    }

    /// Sets whether an outside tap dismisses the dialog.
    pub const fn with_dismiss_on_outside_tap(mut self, dismiss: bool) -> Self {
        self.dismiss_on_outside_tap = dismiss;
        self
    }

    /// Sets whether the back gesture dismisses the dialog.
    pub const fn with_dismiss_on_back(mut self, dismiss: bool) -> Self {
        self.dismiss_on_back = dismiss;
        self
    }

    /// Sets whether the dialog keeps clear of system insets.
    pub const fn with_window_insets(mut self, apply: bool) -> Self {
        self.apply_window_insets = apply;
        self
    }
}

/// The default dim behind a dialog, matching what every platform's own modal
/// surfaces use closely enough that a dialog does not look foreign.
pub const DEFAULT_SCRIM: Color = Color(0.0, 0.0, 0.0, 0.4);

/// The rectangle a dialog's surface is placed in.
///
/// The dialog fills the window minus its insets; the surface itself is centred
/// inside that and sized by its own content.
pub(crate) fn dialog_bounds(viewport: Size, insets: cranpose_ui_graphics::EdgeInsets) -> Rect {
    let width = (viewport.width - insets.left - insets.right).max(0.0);
    let height = (viewport.height - insets.top - insets.bottom).max(0.0);
    Rect::from_origin_size(
        Point {
            x: insets.left,
            y: insets.top,
        },
        Size { width, height },
    )
}

/// A modal dialog holding `content`, dismissed through `on_dismiss`.
///
/// `on_dismiss` receives why the dialog is closing, so an application can treat
/// "tapped away" differently from "went back" — a draft, for example, is
/// usually kept in one case and discarded in the other.
///
/// ```rust,no_run
/// # use cranpose_ui::widgets::{Dialog, DialogSpec};
/// # use cranpose_ui::Modifier;
/// # fn Content() {}
/// # let open = true;
/// if open {
///     Dialog(DialogSpec::default(), |_reason| { /* close it */ }, || {
///         Content();
///     });
/// }
/// ```
#[composable]
pub fn Dialog<C>(spec: DialogSpec, on_dismiss: impl Fn(DismissReason) + 'static, content: C)
where
    C: Fn() + 'static,
{
    DialogWithScrim(spec, DEFAULT_SCRIM, on_dismiss, content);
}

/// A modal dialog whose scrim colour is chosen by the caller.
#[composable]
pub fn DialogWithScrim<C>(
    spec: DialogSpec,
    scrim: Color,
    on_dismiss: impl Fn(DismissReason) + 'static,
    content: C,
) where
    C: Fn() + 'static,
{
    // The depth this dialog's own `register_modal` registration will occupy,
    // fixed at this dialog's first composition (before `DisposableEffect`
    // below actually registers it) and held stable for as long as the dialog
    // stays mounted — recomputing it from the live `modal_depth()` on every
    // recomposition would double-count this dialog's own registration once
    // it commits. Provided to the content below so its fields (and any
    // nested dialog) know how deep they are.
    let own_modal_depth =
        cranpose_core::remember(|| crate::modal::current_modal_depth() + 1).with(|depth| *depth);

    let viewport = local_popup_viewport().current().get();
    let insets = if spec.apply_window_insets {
        window_insets().combined()
    } else {
        cranpose_ui_graphics::EdgeInsets::default()
    };
    let bounds = dialog_bounds(viewport, insets);

    let on_dismiss = Rc::new(on_dismiss);
    let content = Rc::new(content);

    // A dialog whose outside taps do nothing still needs the scrim, or the
    // controls behind it would keep taking input while it is open. The
    // callback therefore always exists and only *acts* when the spec allows it.
    let dismiss_on_outside_tap = spec.dismiss_on_outside_tap;
    let scrim_dismiss = {
        let on_dismiss = Rc::clone(&on_dismiss);
        move || {
            if dismiss_on_outside_tap {
                on_dismiss(DismissReason::OutsideTap);
            }
        }
    };

    // The back gesture is taken while the dialog is composed. A dialog that
    // does not close on back still takes it, because the screen behind a modal
    // must not react to a gesture aimed at the modal.
    let dismiss_on_back = spec.dismiss_on_back;
    let back = {
        let on_dismiss = Rc::clone(&on_dismiss);
        cranpose_core::rememberUpdatedState::<Rc<dyn Fn()>>(Rc::new(move || {
            if dismiss_on_back {
                on_dismiss(DismissReason::BackRequested);
            }
        }))
    };
    cranpose_core::DisposableEffect!((), move |scope| {
        let registration = crate::modal::register_modal(Rc::new(move || (back.value())()));
        scope.on_dispose(move || drop(registration))
    });

    PopupDismissable(bounds, Point { x: 0.0, y: 0.0 }, scrim_dismiss, move || {
        let content = Rc::clone(&content);
        Box(
            Modifier::empty()
                .size(Size {
                    width: bounds.width,
                    height: bounds.height,
                })
                .background(scrim)
                .semantics(move |config| {
                    config.role = Some(SemanticsWidgetRole::Dialog);
                    config.is_modal = true;
                }),
            BoxSpec::default().content_alignment(Alignment::CENTER),
            move || {
                let content = Rc::clone(&content);
                cranpose_core::CompositionLocalProvider(
                    [crate::modal::local_modal_depth().provides(own_modal_depth)],
                    move || content(),
                );
            },
        );
    });
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn a_dialog_fills_the_window_inside_its_insets() {
        let viewport = Size {
            width: 400.0,
            height: 800.0,
        };
        let insets = cranpose_ui_graphics::EdgeInsets::from_components(8.0, 24.0, 8.0, 48.0);
        let bounds = dialog_bounds(viewport, insets);
        assert_eq!(bounds.x, 8.0);
        assert_eq!(bounds.y, 24.0);
        assert_eq!(bounds.width, 384.0);
        assert_eq!(bounds.height, 728.0);
    }

    #[test]
    fn insets_larger_than_the_window_collapse_rather_than_going_negative() {
        let viewport = Size {
            width: 100.0,
            height: 100.0,
        };
        let insets = cranpose_ui_graphics::EdgeInsets::from_components(80.0, 80.0, 80.0, 80.0);
        let bounds = dialog_bounds(viewport, insets);
        assert_eq!(bounds.width, 0.0);
        assert_eq!(bounds.height, 0.0);
    }

    #[test]
    fn a_required_dialog_refuses_both_dismissals_but_still_covers_the_screen() {
        let spec = DialogSpec::required();
        assert!(!spec.dismiss_on_outside_tap);
        assert!(!spec.dismiss_on_back);
        assert!(spec.apply_window_insets);
    }

    #[test]
    fn the_default_dialog_dismisses_both_ways_until_told_otherwise() {
        let spec = DialogSpec::default();
        assert!(spec.dismiss_on_outside_tap);
        assert!(spec.dismiss_on_back);
        assert!(!spec.with_dismiss_on_back(false).dismiss_on_back);
        assert!(
            !spec
                .with_dismiss_on_outside_tap(false)
                .dismiss_on_outside_tap
        );
        assert!(!spec.with_window_insets(false).apply_window_insets);
    }

    /// Pins the defect this module's doc comment promises and nothing used to
    /// implement: a text field behind an open dialog must not be able to take
    /// focus away from the dialog, and once the dialog closes the field
    /// behind it must be focusable again — and the field the closed dialog
    /// held must not be left "focused" with nothing there to receive input.
    #[test]
    fn a_dialog_traps_focus_inside_it_and_releases_it_on_close() {
        use crate::layout::{LayoutBox, LayoutEngine, LayoutTree};
        use crate::text::TextStyle;
        use crate::text_field_focus::{focused_editor_state, has_focused_field};
        use crate::widgets::basic_text_field::BasicTextField;
        use crate::widgets::popup::PopupHost;
        use cranpose_core::{
            location_key, mutableStateOf, remember, Composition, MemoryApplier, MutableState,
            NodeId,
        };
        use cranpose_foundation::text::TextFieldState;
        use cranpose_foundation::{PointerEvent, PointerEventKind};
        use cranpose_ui_graphics::Size;
        use std::cell::{Cell, RefCell};

        let _app_context = crate::render_state::app_context_test_scope();
        crate::modal::clear_modals();

        // `Composition::new` installs the runtime `TextFieldState` needs, so
        // it must exist before any state is allocated.
        let mut composition = Composition::new(MemoryApplier::new());

        let outside_state = TextFieldState::new("outside");
        let inside_state = TextFieldState::new("inside");
        let outside_id: Rc<Cell<Option<NodeId>>> = Rc::new(Cell::new(None));
        let inside_id: Rc<Cell<Option<NodeId>>> = Rc::new(Cell::new(None));
        let dialog_open_slot: Rc<RefCell<Option<MutableState<bool>>>> = Rc::new(RefCell::new(None));

        let mut content = {
            let outside_id = Rc::clone(&outside_id);
            let inside_id = Rc::clone(&inside_id);
            let dialog_open_slot = Rc::clone(&dialog_open_slot);
            move || {
                let outside_id = Rc::clone(&outside_id);
                let inside_id = Rc::clone(&inside_id);
                let dialog_open_slot = Rc::clone(&dialog_open_slot);
                PopupHost(move || {
                    let dialog_open = remember(|| mutableStateOf(true)).with(|state| *state);
                    *dialog_open_slot.borrow_mut() = Some(dialog_open);

                    outside_id.set(Some(BasicTextField(
                        outside_state,
                        Modifier::empty(),
                        TextStyle::default(),
                    )));

                    if dialog_open.value() {
                        let inside_id = Rc::clone(&inside_id);
                        Dialog(
                            DialogSpec::default(),
                            |_reason: DismissReason| {},
                            move || {
                                inside_id.set(Some(BasicTextField(
                                    inside_state,
                                    Modifier::empty(),
                                    TextStyle::default(),
                                )));
                            },
                        );
                    }
                });
            }
        };

        let key = location_key(file!(), line!(), column!());
        composition.render(key, &mut content).expect("render");

        // The dialog's Popup registers into the host during a subcomposition
        // pass; a follow-up reconcile + layout is needed for it to actually
        // place content, so settle a few frames the way a real host would.
        let mut settle = move |composition: &mut Composition<MemoryApplier>| -> LayoutTree {
            for _ in 0..16 {
                if !composition.should_render() {
                    break;
                }
                composition.reconcile(key, &mut content).expect("reconcile");
            }
            let root = composition.root().expect("root");
            let handle = composition.runtime_handle();
            let mut applier = composition.applier_mut();
            applier.set_runtime_handle(handle);
            let layout = applier
                .compute_layout(root, Size::new(400.0, 800.0))
                .expect("layout");
            applier.clear_runtime_handle();
            drop(applier);
            layout
        };

        fn find_node(node: &LayoutBox, id: NodeId) -> Option<&LayoutBox> {
            if node.node_id == id {
                return Some(node);
            }
            node.children.iter().find_map(|child| find_node(child, id))
        }

        fn tap(layout_root: &LayoutBox, id: &Rc<Cell<Option<NodeId>>>) {
            let id = id.get().expect("field composed");
            let node = find_node(layout_root, id).expect("field placed in the layout");
            let handler = node
                .node_data
                .modifier_slices()
                .pointer_inputs()
                .first()
                .cloned()
                .expect("field has a pointer handler");
            let position = cranpose_ui_graphics::Point { x: 1.0, y: 1.0 };
            handler(PointerEvent::new(
                PointerEventKind::Down,
                position,
                position,
            ));
        }

        let mut layout = settle(&mut composition);
        for _ in 0..7 {
            layout = settle(&mut composition);
        }

        // The dialog is open. Its own field can take focus...
        tap(layout.root(), &inside_id);
        assert!(has_focused_field(), "the dialog's own field must focus");
        assert_eq!(
            focused_editor_state().map(|s| s.text),
            Some("inside".to_string()),
            "the dialog's own field must be the one focused"
        );

        // ...but the field behind the dialog must not be able to steal it.
        tap(layout.root(), &outside_id);
        assert_eq!(
            focused_editor_state().map(|s| s.text),
            Some("inside".to_string()),
            "a field behind an open dialog must not take focus from it"
        );

        // Closing the dialog must not leave focus pointing at the field that
        // just went away with it.
        let dialog_open = dialog_open_slot
            .borrow()
            .as_ref()
            .copied()
            .expect("dialog_open captured");
        dialog_open.set(false);
        layout = settle(&mut composition);
        assert!(
            !has_focused_field(),
            "closing the dialog must release focus from the field it held"
        );

        // The field behind it can now take focus again.
        tap(layout.root(), &outside_id);
        assert_eq!(
            focused_editor_state().map(|s| s.text),
            Some("outside".to_string()),
            "once the dialog is closed, the field behind it must be focusable"
        );

        crate::modal::clear_modals();
        crate::text_field_focus::clear_focus();
    }
}