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}