Skip to main content

cranpose_ui/
safe_area.rs

1//! Platform safe-area insets exposed to composition.
2
3use std::cell::RefCell;
4
5use crate::Modifier;
6use cranpose_core::{compositionLocalOf, CompositionLocal};
7use cranpose_ui_graphics::EdgeInsets;
8
9/// Insets needed to keep content clear of system UI and the on-screen keyboard.
10#[derive(Clone, Copy, Debug, Default, PartialEq)]
11pub struct WindowInsets {
12    pub safe_area: EdgeInsets,
13    pub ime: EdgeInsets,
14}
15
16impl WindowInsets {
17    /// Combines overlapping insets by taking the largest obstruction on each edge.
18    pub fn combined(self) -> EdgeInsets {
19        EdgeInsets::from_components(
20            self.safe_area.left.max(self.ime.left),
21            self.safe_area.top.max(self.ime.top),
22            self.safe_area.right.max(self.ime.right),
23            self.safe_area.bottom.max(self.ime.bottom),
24        )
25    }
26}
27
28/// CompositionLocal carrying the platform safe-area insets in logical pixels.
29///
30/// Mobile backends provide the system-bar / notch / home-indicator insets here
31/// so applications can keep content clear of them (for example with
32/// `Modifier.padding_each`). It defaults to [`EdgeInsets::default`] (zero), so
33/// desktop and web compositions are unaffected.
34///
35/// The same `CompositionLocal` instance is returned on every call (cached per
36/// thread), so the platform that provides it and the app that reads it observe
37/// one shared local.
38pub fn local_safe_area_insets() -> CompositionLocal<EdgeInsets> {
39    thread_local! {
40        static LOCAL: RefCell<Option<CompositionLocal<EdgeInsets>>> = const { RefCell::new(None) };
41    }
42    LOCAL.with(|cell| {
43        cell.borrow_mut()
44            .get_or_insert_with(|| compositionLocalOf(EdgeInsets::default))
45            .clone()
46    })
47}
48
49/// CompositionLocal carrying the on-screen soft-keyboard (IME) insets in logical
50/// pixels. The `bottom` field is the height the keyboard currently covers at the
51/// bottom of the window (zero when it is hidden); the other edges are zero.
52///
53/// Mobile backends update this as the keyboard animates in and out so an
54/// application can keep the focused field visible — for example by adding
55/// `bottom` to a scroll container's bottom padding, or scrolling the caret into
56/// view. It defaults to [`EdgeInsets::default`] (zero), so desktop and web
57/// compositions (and mobile frames with no keyboard) are unaffected.
58///
59/// Like [`local_safe_area_insets`], the same `CompositionLocal` instance is
60/// returned on every call (cached per thread).
61pub fn local_ime_insets() -> CompositionLocal<EdgeInsets> {
62    thread_local! {
63        static LOCAL: RefCell<Option<CompositionLocal<EdgeInsets>>> = const { RefCell::new(None) };
64    }
65    LOCAL.with(|cell| {
66        cell.borrow_mut()
67            .get_or_insert_with(|| compositionLocalOf(EdgeInsets::default))
68            .clone()
69    })
70}
71
72/// Returns the framework-owned insets currently visible to the composition.
73pub fn window_insets() -> WindowInsets {
74    WindowInsets {
75        safe_area: local_safe_area_insets().current(),
76        ime: local_ime_insets().current(),
77    }
78}
79
80impl Modifier {
81    /// Adds padding for explicit platform or application insets.
82    pub fn window_insets_padding(self, insets: EdgeInsets) -> Self {
83        self.padding_each(insets.left, insets.top, insets.right, insets.bottom)
84    }
85
86    /// Adds padding for system bars, display cutouts, and rounded display edges.
87    pub fn safe_area_padding(self) -> Self {
88        self.window_insets_padding(local_safe_area_insets().current())
89    }
90}
91
92#[cfg(test)]
93mod tests {
94    use super::{local_ime_insets, local_safe_area_insets, WindowInsets};
95    use cranpose_ui_graphics::EdgeInsets;
96
97    #[test]
98    fn defaults_to_zero_insets() {
99        assert_eq!(
100            local_safe_area_insets().default_value(),
101            EdgeInsets::default()
102        );
103    }
104
105    #[test]
106    fn ime_insets_default_to_zero() {
107        assert_eq!(local_ime_insets().default_value(), EdgeInsets::default());
108    }
109
110    #[test]
111    fn returns_one_shared_local_per_thread() {
112        // `CompositionLocal` is not `Debug`, so compare with `==` directly.
113        assert!(local_safe_area_insets() == local_safe_area_insets());
114        assert!(local_ime_insets() == local_ime_insets());
115        // The IME local is distinct from the safe-area local.
116        assert!(local_ime_insets() != local_safe_area_insets());
117    }
118
119    #[test]
120    fn combined_insets_do_not_double_count_overlapping_edges() {
121        let combined = WindowInsets {
122            safe_area: EdgeInsets::from_components(2.0, 8.0, 4.0, 20.0),
123            ime: EdgeInsets::from_components(0.0, 0.0, 6.0, 100.0),
124        }
125        .combined();
126        assert_eq!(combined, EdgeInsets::from_components(2.0, 8.0, 6.0, 100.0));
127    }
128
129    #[test]
130    fn explicit_window_insets_become_padding() {
131        use crate::modifier::ModifierChainHandle;
132        use crate::Modifier;
133
134        let _app_context = crate::render_state::app_context_test_scope();
135        let insets = EdgeInsets::from_components(1.0, 2.0, 3.0, 4.0);
136        let mut handle = ModifierChainHandle::new();
137        let _ = handle.update(&Modifier::empty().window_insets_padding(insets));
138        assert_eq!(handle.resolved_modifiers().padding(), insets);
139    }
140}