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