Skip to main content

waterui_layout/modifiers/
safe_area.rs

1//! Safe area handling for layout containers.
2//!
3//! `WaterUI` uses metadata to signal to native renderers which views should extend
4//! into unsafe screen regions (areas obscured by notches, home indicators, status bars, etc.).
5//!
6//! # Architecture
7//!
8//! Placing native views against the device insets is the **native backend's**
9//! job, and [`IgnoreSafeArea`] is the metadata hint that opts a view out of it.
10//! Layers that `WaterUI` lays out itself — the window's snackbar and overlay
11//! hosts, which a backend sees as one Rust-laid-out container — cannot be inset
12//! from the outside, so a backend publishes the window's insets through
13//! [`SafeAreaInsets`] and those layers pad themselves.
14//!
15//! # Native Backend Responsibilities
16//!
17//! The native renderer must:
18//! 1. **Default behavior**: Apply platform safe area insets to all views
19//! 2. **When encountering `IgnoreSafeArea` metadata**:
20//!    - Ignore safe area constraints on the specified edges
21//!    - Allow the view to extend edge-to-edge for those edges
22//! 3. **Handle changes**: Re-layout when safe area changes (keyboard, rotation, etc.)
23
24use nami::{Computed, SignalExt, signal::IntoComputed};
25use waterui_core::{Environment, metadata::MetadataKey};
26
27use super::padding::EdgeInsets;
28
29/// Specifies which edges should ignore safe area insets.
30///
31/// Used with `IgnoreSafeArea` to control which edges of a view
32/// should extend into the unsafe screen regions.
33#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
34#[allow(clippy::struct_excessive_bools)]
35pub struct EdgeSet {
36    /// Ignore safe area on the top edge.
37    pub top: bool,
38    /// Ignore safe area on the leading edge.
39    pub leading: bool,
40    /// Ignore safe area on the bottom edge.
41    pub bottom: bool,
42    /// Ignore safe area on the trailing edge.
43    pub trailing: bool,
44}
45
46impl EdgeSet {
47    /// All edges - ignore safe area on all sides.
48    pub const ALL: Self = Self {
49        top: true,
50        leading: true,
51        bottom: true,
52        trailing: true,
53    };
54
55    /// No edges - respect safe area on all sides (default).
56    pub const NONE: Self = Self {
57        top: false,
58        leading: false,
59        bottom: false,
60        trailing: false,
61    };
62
63    /// Horizontal edges only (leading and trailing).
64    pub const HORIZONTAL: Self = Self {
65        top: false,
66        leading: true,
67        bottom: false,
68        trailing: true,
69    };
70
71    /// Vertical edges only (top and bottom).
72    pub const VERTICAL: Self = Self {
73        top: true,
74        leading: false,
75        bottom: true,
76        trailing: false,
77    };
78
79    /// Top edge only.
80    pub const TOP: Self = Self {
81        top: true,
82        leading: false,
83        bottom: false,
84        trailing: false,
85    };
86
87    /// Bottom edge only.
88    pub const BOTTOM: Self = Self {
89        top: false,
90        leading: false,
91        bottom: true,
92        trailing: false,
93    };
94
95    /// Creates a custom edge set.
96    #[must_use]
97    #[allow(clippy::fn_params_excessive_bools)]
98    pub const fn new(top: bool, leading: bool, bottom: bool, trailing: bool) -> Self {
99        Self {
100            top,
101            leading,
102            bottom,
103            trailing,
104        }
105    }
106
107    /// Returns true if any edge is set to ignore safe area.
108    #[must_use]
109    pub const fn any(&self) -> bool {
110        self.top || self.leading || self.bottom || self.trailing
111    }
112
113    /// Returns true if all edges are set to ignore safe area.
114    #[must_use]
115    pub const fn all(&self) -> bool {
116        self.top && self.leading && self.bottom && self.trailing
117    }
118}
119
120/// Marker metadata indicating this view should ignore safe area insets.
121///
122/// When a native renderer encounters this metadata, it should:
123/// - In **propose phase**: Use full screen bounds (not safe bounds) for the specified edges
124/// - In **place phase**: Position the view in full screen coordinates for the specified edges
125///
126/// This allows backgrounds, images, and other visual elements to extend
127/// edge-to-edge while content remains in the safe area.
128///
129/// # Example
130///
131/// ```rust
132/// use waterui::prelude::*;
133///
134/// // Extend background to fill entire screen
135/// Color::blue().ignore_safe_area(EdgeSet::ALL);
136///
137/// // Only extend to top (under status bar)
138/// let header_view = text!("Inbox");
139/// header_view.ignore_safe_area(EdgeSet::TOP);
140/// ```
141#[derive(Debug, Clone, Copy, PartialEq, Eq)]
142pub struct IgnoreSafeArea {
143    /// Which edges should ignore the safe area.
144    pub edges: EdgeSet,
145}
146
147impl MetadataKey for IgnoreSafeArea {}
148
149impl IgnoreSafeArea {
150    /// Creates a new `IgnoreSafeArea` with the specified edges.
151    #[must_use]
152    pub const fn new(edges: EdgeSet) -> Self {
153        Self { edges }
154    }
155
156    /// Ignore safe area on all edges.
157    #[must_use]
158    pub const fn all() -> Self {
159        Self {
160            edges: EdgeSet::ALL,
161        }
162    }
163
164    /// Ignore safe area on vertical edges (top and bottom).
165    #[must_use]
166    pub const fn vertical() -> Self {
167        Self {
168            edges: EdgeSet::VERTICAL,
169        }
170    }
171
172    /// Ignore safe area on horizontal edges (leading and trailing).
173    #[must_use]
174    pub const fn horizontal() -> Self {
175        Self {
176            edges: EdgeSet::HORIZONTAL,
177        }
178    }
179}
180
181/// The window's safe area, published by the platform backend.
182///
183/// A backend that knows its device insets — the notch, the status bar, the home
184/// indicator — installs them here once per window and republishes on rotation.
185/// Rust-side layers that the backend cannot inset for itself, such as the
186/// window's snackbar and overlay hosts, read the value out of the environment
187/// and pad themselves. Backends with no such concept install nothing and
188/// [`SafeAreaInsets::resolve`] answers zero, which is the correct answer for a
189/// desktop window.
190///
191/// This does not replace [`IgnoreSafeArea`]: that stays the hint a backend reads
192/// to let a view span the full screen, and native chrome containers keep
193/// insetting their own content. `SafeAreaInsets` exists for the layers `WaterUI`
194/// lays out itself.
195#[derive(Debug, Clone)]
196pub struct SafeAreaInsets(Computed<EdgeInsets>);
197
198impl SafeAreaInsets {
199    /// Wraps a reactive inset signal.
200    #[must_use]
201    pub fn new(insets: impl IntoComputed<EdgeInsets>) -> Self {
202        Self(insets.into_computed())
203    }
204
205    /// The reactive insets carried by this scope.
206    #[must_use]
207    pub const fn signal(&self) -> &Computed<EdgeInsets> {
208        &self.0
209    }
210
211    /// The insets in force at this position, or zero when no backend published
212    /// any.
213    #[must_use]
214    pub fn resolve(env: &Environment) -> Computed<EdgeInsets> {
215        env.get::<Self>().map_or_else(
216            || EdgeInsets::default().into_computed(),
217            |scope| scope.0.clone(),
218        )
219    }
220
221    /// The insets in force at this position plus a constant `margin`.
222    ///
223    /// This is what an overlay layer wants: clear of the hardware, and then
224    /// clear of the window edge by the theme's own spacing.
225    #[must_use]
226    pub fn resolve_with_margin(env: &Environment, margin: EdgeInsets) -> Computed<EdgeInsets> {
227        Self::resolve(env)
228            .map(move |insets| insets + margin.clone())
229            .into_computed()
230    }
231
232    /// Publishes `insets` to the subtree rendered from `env`.
233    pub fn install(env: &mut Environment, insets: impl IntoComputed<EdgeInsets>) {
234        env.insert(Self::new(insets));
235    }
236}
237
238#[cfg(test)]
239mod tests {
240    use super::{EdgeInsets, SafeAreaInsets};
241    use nami::{Signal, binding};
242    use waterui_core::Environment;
243
244    #[test]
245    fn resolves_to_zero_when_no_backend_published_insets() {
246        let env = Environment::new();
247        assert_eq!(SafeAreaInsets::resolve(&env).get(), EdgeInsets::default());
248    }
249
250    #[test]
251    fn resolved_insets_track_the_published_signal() {
252        let published = binding(EdgeInsets::new(59.0, 34.0, 0.0, 0.0));
253        let mut env = Environment::new();
254        SafeAreaInsets::install(&mut env, published.clone());
255
256        let resolved = SafeAreaInsets::resolve(&env);
257        assert_eq!(resolved.get().top(), 59.0);
258
259        // Rotation: the same signal reports the new insets without the
260        // consuming subtree being rebuilt.
261        published.set(EdgeInsets::new(0.0, 21.0, 59.0, 59.0));
262        assert_eq!(resolved.get().top(), 0.0);
263        assert_eq!(resolved.get().leading(), 59.0);
264    }
265
266    #[test]
267    fn margin_stacks_on_top_of_the_published_insets() {
268        let mut env = Environment::new();
269        SafeAreaInsets::install(&mut env, EdgeInsets::new(59.0, 34.0, 0.0, 0.0));
270
271        let padded = SafeAreaInsets::resolve_with_margin(&env, EdgeInsets::all(16.0));
272        assert_eq!(padded.get(), EdgeInsets::new(75.0, 50.0, 16.0, 16.0));
273    }
274
275    #[test]
276    fn margin_alone_applies_without_a_backend() {
277        let env = Environment::new();
278        let padded = SafeAreaInsets::resolve_with_margin(&env, EdgeInsets::all(16.0));
279        assert_eq!(padded.get(), EdgeInsets::all(16.0));
280    }
281}