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
23pub(crate) struct ModalState {
24    entries: RefCell<Vec<ModalEntry>>,
25    next_id: Cell<u64>,
26    depth: RefCell<Option<OwnedMutableState<usize>>>,
27}
28
29impl ModalState {
30    pub(crate) fn new() -> Self {
31        Self {
32            entries: RefCell::new(Vec::new()),
33            next_id: Cell::new(1),
34            depth: RefCell::new(None),
35        }
36    }
37
38    fn depth_state(&self) -> Option<MutableState<usize>> {
39        let mut state = self.depth.borrow_mut();
40        if state.is_none() {
41            *state = try_mutableStateOf(self.entries.borrow().len())
42                .map(|depth| MutableState::retain(&depth));
43        }
44        state.as_ref().map(OwnedMutableState::handle)
45    }
46
47    fn publish_depth(&self) {
48        let depth = self.entries.borrow().len();
49        let state = self.depth.borrow().as_ref().map(OwnedMutableState::handle);
50        if let Some(state) = state
51            && state.get() != depth
52        {
53            state.set(depth);
54        }
55    }
56}
57
58/// Keeps a modal surface on the stack until it is dropped.
59pub struct ModalRegistration {
60    id: u64,
61    depth: usize,
62    app_context: crate::render_state::AppContextId,
63}
64
65impl Drop for ModalRegistration {
66    fn drop(&mut self) {
67        crate::render_state::enter_app_context_by_id(self.app_context, || {
68            crate::render_state::with_modal_state(|state| {
69                state
70                    .entries
71                    .borrow_mut()
72                    .retain(|entry| entry.id != self.id);
73                state.publish_depth();
74            });
75            crate::text_field_focus::clear_focus_for_closed_modal(self.depth);
76        });
77    }
78}
79
80/// Pushes a modal surface onto the stack. The innermost registration is the one
81/// [`dispatch_modal_back`] asks to close.
82/// The registration belongs to the current [`crate::AppContext`].
83pub fn register_modal(on_back: Rc<dyn Fn()>) -> ModalRegistration {
84    let app_context = crate::render_state::current_app_context_id();
85    crate::render_state::with_modal_state(|state| {
86        let id = state.next_id.get();
87        state.next_id.set(id + 1);
88        state.entries.borrow_mut().push(ModalEntry { id, on_back });
89        let depth = state.entries.borrow().len();
90        state.publish_depth();
91        ModalRegistration {
92            id,
93            depth,
94            app_context,
95        }
96    })
97}
98
99/// How many modal surfaces are open. Reading this in a composable subscribes to
100/// it, so the reader recomposes when a modal opens or closes.
101pub fn modal_depth() -> usize {
102    crate::render_state::with_modal_state(|state| {
103        state
104            .depth_state()
105            .map_or_else(|| state.entries.borrow().len(), |depth| depth.get())
106    })
107}
108
109pub(crate) fn current_modal_depth() -> usize {
110    crate::render_state::with_modal_state(|state| state.entries.borrow().len())
111}
112
113/// CompositionLocal carrying the modal depth at which content is being
114/// composed: zero outside any dialog, or the depth of the innermost dialog
115/// that encloses the current composition.
116///
117/// A modal provides this to its own content, set to the depth its own
118/// [`register_modal`] registration occupies (see [`ModalRegistration`]).
119/// Text fields read it and hand it to their focus request, so a field behind
120/// an open dialog — whose read of this local stays at a shallower depth —
121/// can be told apart from one inside it.
122///
123/// The same `CompositionLocal` instance is returned on every call (cached per
124/// thread), so the modal that provides it and the field that reads it observe
125/// one shared local.
126pub fn local_modal_depth() -> CompositionLocal<usize> {
127    thread_local! {
128        static LOCAL: RefCell<Option<CompositionLocal<usize>>> = const { RefCell::new(None) };
129    }
130    LOCAL.with(|cell| {
131        cell.borrow_mut()
132            .get_or_insert_with(|| compositionLocalOf(|| 0usize))
133            .clone()
134    })
135}
136
137/// Asks the innermost modal surface to close.
138///
139/// Returns whether a modal took the request. A modal that chooses not to close
140/// still takes it, because the screen behind a modal must not react to a back
141/// gesture aimed at the modal.
142pub fn dispatch_modal_back() -> bool {
143    let innermost = crate::render_state::with_modal_state(|state| {
144        state
145            .entries
146            .borrow()
147            .last()
148            .map(|entry| Rc::clone(&entry.on_back))
149    });
150    match innermost {
151        Some(on_back) => {
152            on_back();
153            true
154        }
155        None => false,
156    }
157}
158
159/// Clears every registration in the current [`crate::AppContext`].
160pub fn clear_modals() {
161    crate::render_state::with_modal_state(|state| {
162        state.entries.borrow_mut().clear();
163        state.publish_depth();
164    });
165}
166
167#[cfg(test)]
168#[path = "tests/modal_tests.rs"]
169mod tests;