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