1#![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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
26pub enum DismissReason {
27 OutsideTap,
29 BackRequested,
31}
32
33#[derive(Clone, Copy, Debug, PartialEq, Eq)]
35pub struct DialogSpec {
36 pub dismiss_on_outside_tap: bool,
38 pub dismiss_on_back: bool,
40 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 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 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 pub const fn with_dismiss_on_back(mut self, dismiss: bool) -> Self {
74 self.dismiss_on_back = dismiss;
75 self
76 }
77
78 pub const fn with_window_insets(mut self, apply: bool) -> Self {
80 self.apply_window_insets = apply;
81 self
82 }
83}
84
85pub const DEFAULT_SCRIM: Color = Color(0.0, 0.0, 0.0, 0.4);
88
89pub(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#[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#[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 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 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 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 #[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 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 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 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 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 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 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}