1#![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::{PopupDismissable, local_popup_viewport},
24};
25use crate::{Modifier, SemanticsWidgetRole, composable, safe_area::window_insets};
26
27#[derive(Clone, Copy, Debug, PartialEq, Eq)]
29pub enum DismissReason {
30 OutsideTap,
32 BackRequested,
34}
35
36#[derive(Clone, Copy, Debug, PartialEq, Eq)]
38pub struct DialogSpec {
39 pub dismiss_on_outside_tap: bool,
41 pub dismiss_on_back: bool,
43 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 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 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 pub const fn with_dismiss_on_back(mut self, dismiss: bool) -> Self {
77 self.dismiss_on_back = dismiss;
78 self
79 }
80
81 pub const fn with_window_insets(mut self, apply: bool) -> Self {
83 self.apply_window_insets = apply;
84 self
85 }
86}
87
88pub const DEFAULT_SCRIM: Color = Color(0.0, 0.0, 0.0, 0.4);
91
92pub(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#[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#[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 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;
162 let scrim_dismiss = {
163 let on_dismiss = Rc::clone(&on_dismiss);
164 move || {
165 if dismiss_on_outside_tap {
166 on_dismiss(DismissReason::OutsideTap);
167 }
168 }
169 };
170
171 let dismiss_on_back = spec.dismiss_on_back;
172 let back = {
173 let on_dismiss = Rc::clone(&on_dismiss);
174 cranpose_core::rememberUpdatedState::<Rc<dyn Fn()>>(Rc::new(move || {
175 if dismiss_on_back {
176 on_dismiss(DismissReason::BackRequested);
177 }
178 }))
179 };
180 cranpose_core::DisposableEffect((), move |scope| {
181 let registration = crate::modal::register_modal(Rc::new(move || (back.value())()));
182 scope.on_dispose(move || drop(registration))
183 });
184
185 PopupDismissable(bounds, Point { x: 0.0, y: 0.0 }, scrim_dismiss, move || {
186 let content = Rc::clone(&content);
187 let requester = cranpose_core::remember(crate::FocusRequester::default)
188 .with(crate::FocusRequester::clone);
189 let requester_for_open = requester.clone();
190 cranpose_core::LaunchedEffect((), move |_scope| {
191 let _ = requester_for_open.request_focus();
192 });
193 let opener = cranpose_core::remember(crate::active_focus_target).with(|opener| *opener);
194 cranpose_core::DisposableEffect((), move |scope| {
195 scope.on_dispose(move || {
196 if let Some(opener) = opener {
197 crate::request_focus_from_platform(opener);
198 }
199 })
200 });
201 Box(
202 Modifier::empty()
203 .size(Size {
204 width: bounds.width,
205 height: bounds.height,
206 })
207 .background(scrim)
208 .focus_target()
209 .focus_requester(&requester)
210 .semantics(move |config| {
211 config.role = Some(SemanticsWidgetRole::Dialog);
212 config.is_modal = true;
213 }),
214 BoxSpec::default().content_alignment(Alignment::CENTER),
215 move || {
216 let content = Rc::clone(&content);
217 cranpose_core::CompositionLocalProvider(
218 [crate::modal::local_modal_depth().provides(own_modal_depth)],
219 move || content(),
220 );
221 },
222 );
223 });
224}
225
226#[cfg(test)]
227mod tests {
228 use super::*;
229
230 #[test]
231 fn a_dialog_fills_the_window_inside_its_insets() {
232 let viewport = Size {
233 width: 400.0,
234 height: 800.0,
235 };
236 let insets = cranpose_ui_graphics::EdgeInsets::from_components(8.0, 24.0, 8.0, 48.0);
237 let bounds = dialog_bounds(viewport, insets);
238 assert_eq!(bounds.x, 8.0);
239 assert_eq!(bounds.y, 24.0);
240 assert_eq!(bounds.width, 384.0);
241 assert_eq!(bounds.height, 728.0);
242 }
243
244 #[test]
245 fn insets_larger_than_the_window_collapse_rather_than_going_negative() {
246 let viewport = Size {
247 width: 100.0,
248 height: 100.0,
249 };
250 let insets = cranpose_ui_graphics::EdgeInsets::from_components(80.0, 80.0, 80.0, 80.0);
251 let bounds = dialog_bounds(viewport, insets);
252 assert_eq!(bounds.width, 0.0);
253 assert_eq!(bounds.height, 0.0);
254 }
255
256 #[test]
257 fn a_required_dialog_refuses_both_dismissals_but_still_covers_the_screen() {
258 let spec = DialogSpec::required();
259 assert!(!spec.dismiss_on_outside_tap);
260 assert!(!spec.dismiss_on_back);
261 assert!(spec.apply_window_insets);
262 }
263
264 #[test]
265 fn the_default_dialog_dismisses_both_ways_until_told_otherwise() {
266 let spec = DialogSpec::default();
267 assert!(spec.dismiss_on_outside_tap);
268 assert!(spec.dismiss_on_back);
269 assert!(!spec.with_dismiss_on_back(false).dismiss_on_back);
270 assert!(
271 !spec
272 .with_dismiss_on_outside_tap(false)
273 .dismiss_on_outside_tap
274 );
275 assert!(!spec.with_window_insets(false).apply_window_insets);
276 }
277
278 #[test]
279 fn a_dialog_traps_focus_inside_it_and_releases_it_on_close() {
280 use std::cell::{Cell, RefCell};
281
282 use cranpose_core::{
283 Composition, MemoryApplier, MutableState, NodeId, location_key, mutableStateOf,
284 remember,
285 };
286 use cranpose_foundation::{PointerEvent, PointerEventKind, text::TextFieldState};
287 use cranpose_ui_graphics::Size;
288
289 use crate::{
290 layout::{LayoutBox, LayoutEngine, LayoutTree},
291 text::TextStyle,
292 text_field_focus::{focused_editor_state, has_focused_field},
293 widgets::{basic_text_field::BasicTextField, popup::PopupHost},
294 };
295
296 let _app_context = crate::render_state::app_context_test_scope();
297 crate::modal::clear_modals();
298
299 let mut composition = Composition::new(MemoryApplier::new());
300
301 let outside_state = TextFieldState::new("outside");
302 let inside_state = TextFieldState::new("inside");
303 let outside_id: Rc<Cell<Option<NodeId>>> = Rc::new(Cell::new(None));
304 let inside_id: Rc<Cell<Option<NodeId>>> = Rc::new(Cell::new(None));
305 let dialog_open_slot: Rc<RefCell<Option<MutableState<bool>>>> = Rc::new(RefCell::new(None));
306
307 let mut content = {
308 let outside_id = Rc::clone(&outside_id);
309 let inside_id = Rc::clone(&inside_id);
310 let dialog_open_slot = Rc::clone(&dialog_open_slot);
311 move || {
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 PopupHost(move || {
316 let dialog_open = remember(|| mutableStateOf(true)).with(|state| *state);
317 *dialog_open_slot.borrow_mut() = Some(dialog_open);
318
319 outside_id.set(Some(BasicTextField(
320 outside_state,
321 Modifier::empty(),
322 TextStyle::default(),
323 )));
324
325 if dialog_open.value() {
326 let inside_id = Rc::clone(&inside_id);
327 Dialog(
328 DialogSpec::default(),
329 |_reason: DismissReason| {},
330 move || {
331 inside_id.set(Some(BasicTextField(
332 inside_state,
333 Modifier::empty(),
334 TextStyle::default(),
335 )));
336 },
337 );
338 }
339 });
340 }
341 };
342
343 let key = location_key(file!(), line!(), column!());
344 composition.render(key, &mut content).expect("render");
345
346 let mut settle = move |composition: &mut Composition<MemoryApplier>| -> LayoutTree {
347 for _ in 0..16 {
348 if !composition.should_render() {
349 break;
350 }
351 composition.reconcile(key, &mut content).expect("reconcile");
352 }
353 let root = composition.root().expect("root");
354 let handle = composition.runtime_handle();
355 let mut applier = composition.applier_mut();
356 applier.set_runtime_handle(handle);
357 let layout = applier
358 .compute_layout(root, Size::new(400.0, 800.0))
359 .expect("layout");
360 applier.clear_runtime_handle();
361 drop(applier);
362 layout
363 };
364
365 fn find_node(node: &LayoutBox, id: NodeId) -> Option<&LayoutBox> {
366 if node.node_id == id {
367 return Some(node);
368 }
369 node.children.iter().find_map(|child| find_node(child, id))
370 }
371
372 fn tap(layout_root: &LayoutBox, id: &Rc<Cell<Option<NodeId>>>) {
373 let id = id.get().expect("field composed");
374 let node = find_node(layout_root, id).expect("field placed in the layout");
375 let handler = node
376 .node_data
377 .modifier_slices()
378 .pointer_inputs()
379 .first()
380 .cloned()
381 .expect("field has a pointer handler");
382 let position = cranpose_ui_graphics::Point { x: 1.0, y: 1.0 };
383 handler(PointerEvent::new(
384 PointerEventKind::Down,
385 position,
386 position,
387 ));
388 }
389
390 let mut layout = settle(&mut composition);
391 for _ in 0..7 {
392 layout = settle(&mut composition);
393 }
394
395 tap(layout.root(), &inside_id);
396 assert!(has_focused_field(), "the dialog's own field must focus");
397 assert_eq!(
398 focused_editor_state().map(|s| s.text),
399 Some("inside".to_string()),
400 "the dialog's own field must be the one focused"
401 );
402
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 let dialog_open = dialog_open_slot
411 .borrow()
412 .as_ref()
413 .copied()
414 .expect("dialog_open captured");
415 dialog_open.set(false);
416 layout = settle(&mut composition);
417 assert!(
418 !has_focused_field(),
419 "closing the dialog must release focus from the field it held"
420 );
421
422 tap(layout.root(), &outside_id);
423 assert_eq!(
424 focused_editor_state().map(|s| s.text),
425 Some("outside".to_string()),
426 "once the dialog is closed, the field behind it must be focusable"
427 );
428
429 crate::modal::clear_modals();
430 crate::text_field_focus::clear_focus();
431 }
432}