cranpose_ui/modal.rs
1//! The stack of modal surfaces that are open right now.
2//!
3//! Modality is a user-interface fact — a dialog is open, everything behind it
4//! is inert — while the back gesture is a platform one. This registry is where
5//! they meet: dialogs push themselves on while they are composed, and the
6//! application shell asks the innermost one to close when the platform reports
7//! a back request. Nothing outside the framework registers or dispatches.
8
9use cranpose_core::{compositionLocalOf, try_mutableStateOf, CompositionLocal, MutableState};
10use std::cell::{Cell, RefCell};
11use std::rc::Rc;
12
13struct ModalEntry {
14 id: u64,
15 on_back: Rc<dyn Fn()>,
16}
17
18thread_local! {
19 static MODALS: RefCell<Vec<ModalEntry>> = const { RefCell::new(Vec::new()) };
20 static NEXT_ID: Cell<u64> = const { Cell::new(1) };
21 /// The count, always accurate whether or not a runtime exists.
22 static DEPTH_COUNT: Cell<usize> = const { Cell::new(0) };
23 /// Observable depth, so a composable that reads it recomposes when a modal
24 /// opens or closes. Allocated on the first read that has a runtime to
25 /// allocate from; a unit test with no runtime still sees the count.
26 static DEPTH: RefCell<Option<MutableState<usize>>> = const { RefCell::new(None) };
27}
28
29fn depth_state() -> Option<MutableState<usize>> {
30 DEPTH.with(|cell| {
31 let mut cell = cell.borrow_mut();
32 if cell.is_none() {
33 *cell = try_mutableStateOf(DEPTH_COUNT.with(Cell::get));
34 }
35 *cell
36 })
37}
38
39fn publish_depth() {
40 let depth = MODALS.with(|modals| modals.borrow().len());
41 DEPTH_COUNT.with(|count| count.set(depth));
42 if let Some(state) = DEPTH.with(|cell| *cell.borrow()) {
43 if state.get() != depth {
44 state.set(depth);
45 }
46 }
47}
48
49/// Keeps a modal surface on the stack until it is dropped.
50pub struct ModalRegistration {
51 id: u64,
52 /// The depth this registration occupied when it was pushed — fixed for
53 /// its lifetime, not recomputed from its (possibly shifting) position in
54 /// the stack. [`local_modal_depth`] readers inside this modal's content
55 /// are given this same value, so a field's recorded depth and its modal's
56 /// registration depth always mean the same thing when compared.
57 depth: usize,
58}
59
60impl Drop for ModalRegistration {
61 fn drop(&mut self) {
62 MODALS.with(|modals| modals.borrow_mut().retain(|entry| entry.id != self.id));
63 publish_depth();
64 // A field focused at exactly this depth lived inside the content this
65 // registration guarded, which is now gone with it — leaving focus
66 // pointed at it would strand the platform keyboard open with no
67 // field behind it. A field at another depth (outside this modal, or
68 // inside a sibling modal open at the same time) is untouched.
69 if crate::render_state::has_current_app_context() {
70 crate::text_field_focus::clear_focus_for_closed_modal(self.depth);
71 }
72 }
73}
74
75/// Pushes a modal surface onto the stack. The innermost registration is the one
76/// [`dispatch_modal_back`] asks to close.
77pub fn register_modal(on_back: Rc<dyn Fn()>) -> ModalRegistration {
78 let id = NEXT_ID.with(|next| {
79 let id = next.get();
80 next.set(id + 1);
81 id
82 });
83 let depth = MODALS.with(|modals| {
84 let mut modals = modals.borrow_mut();
85 modals.push(ModalEntry { id, on_back });
86 modals.len()
87 });
88 publish_depth();
89 ModalRegistration { id, depth }
90}
91
92/// How many modal surfaces are open. Reading this in a composable subscribes to
93/// it, so the reader recomposes when a modal opens or closes.
94pub fn modal_depth() -> usize {
95 match depth_state() {
96 Some(state) => state.get(),
97 None => DEPTH_COUNT.with(Cell::get),
98 }
99}
100
101/// How many modal surfaces are open, read without subscribing to it.
102///
103/// [`modal_depth`] is the composable's read: it goes through the observable
104/// mirror so that a reader recomposes when a modal opens or closes, and that
105/// mirror belongs to whichever runtime was current when it was first
106/// allocated. A pointer callback is not a composable and is not guaranteed to
107/// be on that runtime — a focus request arriving under a different one would
108/// reach a state whose runtime is gone. So the paths that only need the
109/// number read the count itself, which is the stack's own length and is
110/// correct whether a runtime exists or not.
111pub(crate) fn current_modal_depth() -> usize {
112 DEPTH_COUNT.with(Cell::get)
113}
114
115/// CompositionLocal carrying the modal depth at which content is being
116/// composed: zero outside any dialog, or the depth of the innermost dialog
117/// that encloses the current composition.
118///
119/// A modal provides this to its own content, set to the depth its own
120/// [`register_modal`] registration occupies (see [`ModalRegistration`]).
121/// Text fields read it and hand it to their focus request, so a field behind
122/// an open dialog — whose read of this local stays at a shallower depth —
123/// can be told apart from one inside it.
124///
125/// The same `CompositionLocal` instance is returned on every call (cached per
126/// thread), so the modal that provides it and the field that reads it observe
127/// one shared local.
128pub fn local_modal_depth() -> CompositionLocal<usize> {
129 thread_local! {
130 static LOCAL: RefCell<Option<CompositionLocal<usize>>> = const { RefCell::new(None) };
131 }
132 LOCAL.with(|cell| {
133 cell.borrow_mut()
134 .get_or_insert_with(|| compositionLocalOf(|| 0usize))
135 .clone()
136 })
137}
138
139/// Asks the innermost modal surface to close.
140///
141/// Returns whether a modal took the request. A modal that chooses not to close
142/// still takes it, because the screen behind a modal must not react to a back
143/// gesture aimed at the modal.
144pub fn dispatch_modal_back() -> bool {
145 let innermost = MODALS.with(|modals| {
146 modals
147 .borrow()
148 .last()
149 .map(|entry| Rc::clone(&entry.on_back))
150 });
151 match innermost {
152 Some(on_back) => {
153 on_back();
154 true
155 }
156 None => false,
157 }
158}
159
160/// Clears every registration. Used by tests and by host teardown so one
161/// composition's modals never outlive it.
162pub fn clear_modals() {
163 MODALS.with(|modals| modals.borrow_mut().clear());
164 publish_depth();
165}
166
167#[cfg(test)]
168mod tests {
169 use super::*;
170
171 #[test]
172 fn the_innermost_modal_takes_the_back_request() {
173 clear_modals();
174 let outer = Rc::new(Cell::new(0u32));
175 let inner = Rc::new(Cell::new(0u32));
176 let outer_counter = Rc::clone(&outer);
177 let inner_counter = Rc::clone(&inner);
178
179 let _outer = register_modal(Rc::new(move || outer_counter.set(outer_counter.get() + 1)));
180 let inner_registration =
181 register_modal(Rc::new(move || inner_counter.set(inner_counter.get() + 1)));
182
183 assert!(dispatch_modal_back());
184 assert_eq!(inner.get(), 1);
185 assert_eq!(outer.get(), 0);
186
187 drop(inner_registration);
188 assert!(dispatch_modal_back());
189 assert_eq!(outer.get(), 1);
190 clear_modals();
191 }
192
193 #[test]
194 fn a_back_request_with_no_modal_open_is_not_taken() {
195 clear_modals();
196 assert!(!dispatch_modal_back());
197 }
198
199 #[test]
200 fn the_depth_counts_what_is_open_and_falls_back_to_zero() {
201 clear_modals();
202 assert_eq!(modal_depth(), 0);
203
204 let outer = register_modal(Rc::new(|| {}));
205 assert_eq!(modal_depth(), 1);
206 let inner = register_modal(Rc::new(|| {}));
207 assert_eq!(modal_depth(), 2);
208
209 // Dropping a registration is what closes a modal, so the depth the
210 // shell's back handler reads has to follow the drop rather than a call.
211 drop(inner);
212 assert_eq!(modal_depth(), 1);
213 drop(outer);
214 assert_eq!(modal_depth(), 0);
215 }
216
217 #[test]
218 fn registrations_leave_the_stack_when_dropped() {
219 clear_modals();
220 {
221 let _first = register_modal(Rc::new(|| {}));
222 let _second = register_modal(Rc::new(|| {}));
223 assert_eq!(MODALS.with(|modals| modals.borrow().len()), 2);
224 }
225 assert_eq!(MODALS.with(|modals| modals.borrow().len()), 0);
226 assert!(!dispatch_modal_back());
227 }
228}