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 Box(
194 Modifier::empty()
195 .size(Size {
196 width: bounds.width,
197 height: bounds.height,
198 })
199 .background(scrim)
200 .focus_target()
201 .focus_requester(&requester)
202 .semantics(move |config| {
203 config.role = Some(SemanticsWidgetRole::Dialog);
204 config.is_modal = true;
205 }),
206 BoxSpec::default().content_alignment(Alignment::CENTER),
207 move || {
208 let content = Rc::clone(&content);
209 cranpose_core::CompositionLocalProvider(
210 [crate::modal::local_modal_depth().provides(own_modal_depth)],
211 move || content(),
212 );
213 },
214 );
215 });
216}
217
218#[cfg(test)]
219mod tests {
220 use super::*;
221
222 #[test]
223 fn a_dialog_fills_the_window_inside_its_insets() {
224 let viewport = Size {
225 width: 400.0,
226 height: 800.0,
227 };
228 let insets = cranpose_ui_graphics::EdgeInsets::from_components(8.0, 24.0, 8.0, 48.0);
229 let bounds = dialog_bounds(viewport, insets);
230 assert_eq!(bounds.x, 8.0);
231 assert_eq!(bounds.y, 24.0);
232 assert_eq!(bounds.width, 384.0);
233 assert_eq!(bounds.height, 728.0);
234 }
235
236 #[test]
237 fn insets_larger_than_the_window_collapse_rather_than_going_negative() {
238 let viewport = Size {
239 width: 100.0,
240 height: 100.0,
241 };
242 let insets = cranpose_ui_graphics::EdgeInsets::from_components(80.0, 80.0, 80.0, 80.0);
243 let bounds = dialog_bounds(viewport, insets);
244 assert_eq!(bounds.width, 0.0);
245 assert_eq!(bounds.height, 0.0);
246 }
247
248 #[test]
249 fn a_required_dialog_refuses_both_dismissals_but_still_covers_the_screen() {
250 let spec = DialogSpec::required();
251 assert!(!spec.dismiss_on_outside_tap);
252 assert!(!spec.dismiss_on_back);
253 assert!(spec.apply_window_insets);
254 }
255
256 #[test]
257 fn the_default_dialog_dismisses_both_ways_until_told_otherwise() {
258 let spec = DialogSpec::default();
259 assert!(spec.dismiss_on_outside_tap);
260 assert!(spec.dismiss_on_back);
261 assert!(!spec.with_dismiss_on_back(false).dismiss_on_back);
262 assert!(
263 !spec
264 .with_dismiss_on_outside_tap(false)
265 .dismiss_on_outside_tap
266 );
267 assert!(!spec.with_window_insets(false).apply_window_insets);
268 }
269
270 #[test]
271 fn a_dialog_traps_focus_inside_it_and_releases_it_on_close() {
272 use std::cell::{Cell, RefCell};
273
274 use cranpose_core::{
275 Composition, MemoryApplier, MutableState, NodeId, location_key, mutableStateOf,
276 remember,
277 };
278 use cranpose_foundation::{PointerEvent, PointerEventKind, text::TextFieldState};
279 use cranpose_ui_graphics::Size;
280
281 use crate::{
282 layout::{LayoutBox, LayoutEngine, LayoutTree},
283 text::TextStyle,
284 text_field_focus::{focused_editor_state, has_focused_field},
285 widgets::{basic_text_field::BasicTextField, popup::PopupHost},
286 };
287
288 let _app_context = crate::render_state::app_context_test_scope();
289 crate::modal::clear_modals();
290
291 let mut composition = Composition::new(MemoryApplier::new());
292
293 let outside_state = TextFieldState::new("outside");
294 let inside_state = TextFieldState::new("inside");
295 let outside_id: Rc<Cell<Option<NodeId>>> = Rc::new(Cell::new(None));
296 let inside_id: Rc<Cell<Option<NodeId>>> = Rc::new(Cell::new(None));
297 let dialog_open_slot: Rc<RefCell<Option<MutableState<bool>>>> = Rc::new(RefCell::new(None));
298
299 let mut content = {
300 let outside_id = Rc::clone(&outside_id);
301 let inside_id = Rc::clone(&inside_id);
302 let dialog_open_slot = Rc::clone(&dialog_open_slot);
303 move || {
304 let outside_id = Rc::clone(&outside_id);
305 let inside_id = Rc::clone(&inside_id);
306 let dialog_open_slot = Rc::clone(&dialog_open_slot);
307 PopupHost(move || {
308 let dialog_open = remember(|| mutableStateOf(true)).with(|state| *state);
309 *dialog_open_slot.borrow_mut() = Some(dialog_open);
310
311 outside_id.set(Some(BasicTextField(
312 outside_state,
313 Modifier::empty(),
314 TextStyle::default(),
315 )));
316
317 if dialog_open.value() {
318 let inside_id = Rc::clone(&inside_id);
319 Dialog(
320 DialogSpec::default(),
321 |_reason: DismissReason| {},
322 move || {
323 inside_id.set(Some(BasicTextField(
324 inside_state,
325 Modifier::empty(),
326 TextStyle::default(),
327 )));
328 },
329 );
330 }
331 });
332 }
333 };
334
335 let key = location_key(file!(), line!(), column!());
336 composition.render(key, &mut content).expect("render");
337
338 let mut settle = move |composition: &mut Composition<MemoryApplier>| -> LayoutTree {
339 for _ in 0..16 {
340 if !composition.should_render() {
341 break;
342 }
343 composition.reconcile(key, &mut content).expect("reconcile");
344 }
345 let root = composition.root().expect("root");
346 let handle = composition.runtime_handle();
347 let mut applier = composition.applier_mut();
348 applier.set_runtime_handle(handle);
349 let layout = applier
350 .compute_layout(root, Size::new(400.0, 800.0))
351 .expect("layout");
352 applier.clear_runtime_handle();
353 drop(applier);
354 layout
355 };
356
357 fn find_node(node: &LayoutBox, id: NodeId) -> Option<&LayoutBox> {
358 if node.node_id == id {
359 return Some(node);
360 }
361 node.children.iter().find_map(|child| find_node(child, id))
362 }
363
364 fn tap(layout_root: &LayoutBox, id: &Rc<Cell<Option<NodeId>>>) {
365 let id = id.get().expect("field composed");
366 let node = find_node(layout_root, id).expect("field placed in the layout");
367 let handler = node
368 .node_data
369 .modifier_slices()
370 .pointer_inputs()
371 .first()
372 .cloned()
373 .expect("field has a pointer handler");
374 let position = cranpose_ui_graphics::Point { x: 1.0, y: 1.0 };
375 handler(PointerEvent::new(
376 PointerEventKind::Down,
377 position,
378 position,
379 ));
380 }
381
382 let mut layout = settle(&mut composition);
383 for _ in 0..7 {
384 layout = settle(&mut composition);
385 }
386
387 tap(layout.root(), &inside_id);
388 assert!(has_focused_field(), "the dialog's own field must focus");
389 assert_eq!(
390 focused_editor_state().map(|s| s.text),
391 Some("inside".to_string()),
392 "the dialog's own field must be the one focused"
393 );
394
395 tap(layout.root(), &outside_id);
396 assert_eq!(
397 focused_editor_state().map(|s| s.text),
398 Some("inside".to_string()),
399 "a field behind an open dialog must not take focus from it"
400 );
401
402 let dialog_open = dialog_open_slot
403 .borrow()
404 .as_ref()
405 .copied()
406 .expect("dialog_open captured");
407 dialog_open.set(false);
408 layout = settle(&mut composition);
409 assert!(
410 !has_focused_field(),
411 "closing the dialog must release focus from the field it held"
412 );
413
414 tap(layout.root(), &outside_id);
415 assert_eq!(
416 focused_editor_state().map(|s| s.text),
417 Some("outside".to_string()),
418 "once the dialog is closed, the field behind it must be focusable"
419 );
420
421 crate::modal::clear_modals();
422 crate::text_field_focus::clear_focus();
423 }
424}