Skip to main content

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