Skip to main content

frust_widgets/
safe_area.rs

1//! `SafeArea` layout container: pads its child by the
2//! window's safe-area insets, per enabled edge, floored at a per-edge
3//! `.minimum` (Flutter parity — `safe_area.dart:118-127`).
4//!
5//! Unlike [`Padding`](crate::Padding), the inset amount is resolved
6//! dynamically every layout pass from `LayoutCtx::window_insets()`
7//! (`WindowInsets`) rather than a view-declared constant — a live inset push
8//! (rotation, IME show/hide) must re-pad the child with no view rebuild, so
9//! [`SafeAreaWidget`] mirrors `PaddingWidget`'s deflate/offset layout math but
10//! recomputes the inset amount itself each pass instead of wrapping a
11//! `PaddingWidget`.
12//!
13//! # Consumption
14//!
15//! A safe area both **pads** its child and **removes the padding it consumed
16//! from its subtree** (Flutter's `SafeArea` does the same through
17//! `MediaQuery.removePadding`). The child is laid out and painted inside
18//! `LayoutCtx::with_window_insets` / `PaintCtx::with_window_insets` with
19//! [`WindowInsets::consuming`](frust_core::WindowInsets::consuming) applied to
20//! the enabled edges, so a descendant reading `ctx.window_insets().padding()`
21//! sees `0.0` on every edge this safe area already covered. That keeps a
22//! self-insetting descendant (a chrome bar that grows by its own edge's inset)
23//! from insetting a second time, and makes nested safe areas consume once: the
24//! inner one sees zero on the outer's consumed edges and pads there only by
25//! its own `.minimum`. `.minimum` is extra padding, not window inset — it never
26//! affects what is consumed. `view_insets` (the IME) always flows through
27//! unchanged, and a safe area with every edge disabled passes the insets
28//! through untouched.
29//!
30//! Flutter's `maintainBottomViewPadding` (an override that substitutes
31//! `viewPadding.bottom` for the derived `padding.bottom` while the keyboard is
32//! up, to avoid a layout jump) is intentionally **deferred** past v1: the
33//! bottom edge always uses the same [`WindowInsets::padding`] formula as every
34//! other edge, so an IME overlapping the bottom system inset collapses that
35//! edge's resolved padding to zero rather than holding it at the system-bar
36//! value.
37
38use frust_core::{
39    BoxConstraints, BuildCtx, ChangeFlags, ChildPod, EventCtx, EventResult, InputEvent, LayoutCtx,
40    PaintCtx, PaintScene, SemanticsCtx, View, Widget, WindowInsets, any,
41};
42use kurbo::{Point, Size};
43
44/// A declarative safe-area container. See the [module docs](self).
45pub struct SafeAreaView<State: 'static> {
46    left: bool,
47    top: bool,
48    right: bool,
49    bottom: bool,
50    minimum: crate::EdgeInsets,
51    child: frust_core::AnyView<State>,
52}
53
54/// Pad `child` by the window's resolved safe-area insets — all four edges
55/// enabled by default (opt an edge out with `.left`/`.top`/`.right`/`.bottom`),
56/// never less than `.minimum` (zero by default) on an enabled edge. The
57/// enabled edges' insets are consumed: `child`'s subtree reads zero safe-area
58/// padding there (see the [module docs](self)).
59pub fn safe_area<State: 'static, V: View<State>>(child: V) -> SafeAreaView<State> {
60    SafeAreaView {
61        left: true,
62        top: true,
63        right: true,
64        bottom: true,
65        minimum: crate::EdgeInsets::all(0.0),
66        child: any(child),
67    }
68}
69
70impl<State: 'static> SafeAreaView<State> {
71    /// Enable/disable the left-edge inset (default `true`). A disabled edge
72    /// still honors `.minimum` (Flutter parity — `.minimum` applies
73    /// regardless of whether the edge consumes the window inset).
74    pub fn left(mut self, enabled: bool) -> Self {
75        self.left = enabled;
76        self
77    }
78
79    /// Enable/disable the top-edge inset (default `true`).
80    pub fn top(mut self, enabled: bool) -> Self {
81        self.top = enabled;
82        self
83    }
84
85    /// Enable/disable the right-edge inset (default `true`).
86    pub fn right(mut self, enabled: bool) -> Self {
87        self.right = enabled;
88        self
89    }
90
91    /// Enable/disable the bottom-edge inset (default `true`).
92    pub fn bottom(mut self, enabled: bool) -> Self {
93        self.bottom = enabled;
94        self
95    }
96
97    /// Floor for the resolved per-edge padding — an enabled edge never pads by
98    /// less than this even where the window inset is smaller, and a disabled
99    /// edge still pads by at least this (default zero on all edges). Mirrors
100    /// Flutter's `SafeArea.minimum`.
101    pub fn minimum(mut self, minimum: crate::EdgeInsets) -> Self {
102        self.minimum = minimum;
103        self
104    }
105}
106
107/// The retained widget for a [`SafeAreaView`].
108pub struct SafeAreaWidget {
109    left: bool,
110    top: bool,
111    right: bool,
112    bottom: bool,
113    minimum: crate::EdgeInsets,
114    child: ChildPod,
115}
116
117impl<State: 'static> View<State> for SafeAreaView<State> {
118    type Element = SafeAreaWidget;
119
120    fn build(&self, ctx: &mut BuildCtx<'_>) -> SafeAreaWidget {
121        SafeAreaWidget {
122            left: self.left,
123            top: self.top,
124            right: self.right,
125            bottom: self.bottom,
126            minimum: self.minimum,
127            child: crate::authoring::build_child(&self.child, ctx),
128        }
129    }
130
131    fn rebuild(
132        &self,
133        prev: &Self,
134        element: &mut SafeAreaWidget,
135        ctx: &mut BuildCtx<'_>,
136    ) -> ChangeFlags {
137        let mut flags = ChangeFlags::NONE;
138        if prev.left != self.left
139            || prev.top != self.top
140            || prev.right != self.right
141            || prev.bottom != self.bottom
142            || prev.minimum != self.minimum
143        {
144            element.left = self.left;
145            element.top = self.top;
146            element.right = self.right;
147            element.bottom = self.bottom;
148            element.minimum = self.minimum;
149            flags |= ChangeFlags::LAYOUT;
150        }
151        flags |= crate::authoring::rebuild_child(&prev.child, &self.child, &mut element.child, ctx);
152        flags
153    }
154
155    fn teardown(&self, element: &mut SafeAreaWidget, ctx: &mut BuildCtx<'_>) {
156        crate::authoring::teardown_child(&self.child, &mut element.child, ctx);
157    }
158}
159
160impl SafeAreaWidget {
161    /// `insets` with this safe area's enabled edges consumed — the value its
162    /// subtree reads during both layout and paint.
163    fn consumed_insets(&self, insets: WindowInsets) -> WindowInsets {
164        insets.consuming(self.left, self.top, self.right, self.bottom)
165    }
166}
167
168impl Widget for SafeAreaWidget {
169    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
170        // Resolve per-edge padding dynamically: the window insets arrive via
171        // ctx every pass (unlike Padding's view-declared constant), so this is
172        // computed fresh rather than cached on the widget.
173        let padding = ctx.window_insets().padding();
174        let left = (if self.left { padding.left } else { 0.0 }).max(self.minimum.left);
175        let top = (if self.top { padding.top } else { 0.0 }).max(self.minimum.top);
176        let right = (if self.right { padding.right } else { 0.0 }).max(self.minimum.right);
177        let bottom = (if self.bottom { padding.bottom } else { 0.0 }).max(self.minimum.bottom);
178
179        let h = left + right;
180        let v = top + bottom;
181        // Deflate the constraints by the resolved insets (never below zero —
182        // mirrors Padding's layout math).
183        let child_bc = BoxConstraints::new(
184            Size::new(
185                (bc.min().width - h).max(0.0),
186                (bc.min().height - v).max(0.0),
187            ),
188            Size::new(
189                (bc.max().width - h).max(0.0),
190                (bc.max().height - v).max(0.0),
191            ),
192        );
193        // Remove what this safe area consumed from its subtree, so a
194        // self-insetting descendant (or a nested safe area) does not inset by
195        // the same edge again. `minimum` is extra padding, not window inset, so
196        // it plays no part in consumption.
197        let consumed = self.consumed_insets(ctx.window_insets());
198        let child = &mut self.child;
199        let child_size = ctx.with_window_insets(consumed, |ctx| child.layout_child(ctx, &child_bc));
200        self.child.set_origin(Point::new(left, top));
201        bc.constrain(Size::new(child_size.width + h, child_size.height + v))
202    }
203
204    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
205        // The same consumed value the child was laid out under, so a paint-time
206        // inset read agrees with the layout-time one.
207        let consumed = self.consumed_insets(ctx.window_insets());
208        let child = &mut self.child;
209        ctx.with_window_insets(consumed, |ctx| child.paint_child(ctx, scene));
210    }
211
212    fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
213        crate::authoring::route_event_single(&mut self.child, ctx, event)
214    }
215
216    fn semantics(&self, ctx: &mut SemanticsCtx) {
217        // Transparent inset wrapper, mirroring Padding: forward to the child.
218        self.child.semantics_child(ctx);
219    }
220
221    crate::authoring::visit_children!(child);
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227    use crate::test_support::leaf;
228    use frust_core::{
229        PointerButton, PointerEvent, PointerPhase, RenderRoot, WindowEdgeInsets, WindowInsets,
230    };
231    use std::any::Any;
232
233    fn safe_area_widget(root: &RenderRoot<(), SafeAreaView<()>>) -> &SafeAreaWidget {
234        let id = root.root_id().expect("root built");
235        (root.tree().pod(id).expect("root pod").widget() as &dyn Any)
236            .downcast_ref::<SafeAreaWidget>()
237            .expect("root is a SafeAreaWidget")
238    }
239
240    #[test]
241    fn no_insets_behaves_like_minimum_padding() {
242        // With zero window insets, the resolved padding on every edge is just
243        // the (per-edge) minimum — same shape as a `Padding` with that inset.
244        fn logic(_: &mut ()) -> SafeAreaView<()> {
245            safe_area(leaf(40.0, 20.0)).minimum(crate::EdgeInsets {
246                left: 5.0,
247                top: 10.0,
248                right: 15.0,
249                bottom: 20.0,
250            })
251        }
252        let mut root: RenderRoot<(), SafeAreaView<()>> = RenderRoot::new();
253        let mut state = ();
254        root.rebuild(&mut logic, &mut state);
255        let size = root.layout(Size::new(500.0, 500.0));
256        assert_eq!(size, Size::new(60.0, 50.0));
257        let w = safe_area_widget(&root);
258        assert_eq!(w.child.origin(), Point::new(5.0, 10.0));
259        assert_eq!(w.child.size(), Size::new(40.0, 20.0));
260    }
261
262    #[test]
263    fn pushed_insets_pad_enabled_edges_only() {
264        fn logic(_: &mut ()) -> SafeAreaView<()> {
265            safe_area(leaf(40.0, 20.0)).right(false)
266        }
267        let mut root: RenderRoot<(), SafeAreaView<()>> = RenderRoot::new();
268        let mut state = ();
269        root.rebuild(&mut logic, &mut state);
270        root.set_insets(WindowInsets::new(
271            WindowEdgeInsets::new(10.0, 24.0, 30.0, 34.0),
272            WindowEdgeInsets::ZERO,
273        ));
274        let size = root.layout(Size::new(500.0, 500.0));
275        let w = safe_area_widget(&root);
276        // Left/top/bottom pick up the pushed inset; the disabled right edge
277        // stays 0 even though the window inset on that edge is nonzero.
278        assert_eq!(w.child.origin(), Point::new(10.0, 24.0));
279        assert_eq!(size, Size::new(40.0 + 10.0, 20.0 + 24.0 + 34.0));
280    }
281
282    #[test]
283    fn minimum_wins_when_larger() {
284        fn logic(_: &mut ()) -> SafeAreaView<()> {
285            safe_area(leaf(40.0, 20.0)).minimum(crate::EdgeInsets::all(50.0))
286        }
287        let mut root: RenderRoot<(), SafeAreaView<()>> = RenderRoot::new();
288        let mut state = ();
289        root.rebuild(&mut logic, &mut state);
290        root.set_insets(WindowInsets::new(
291            WindowEdgeInsets::new(10.0, 10.0, 10.0, 10.0),
292            WindowEdgeInsets::ZERO,
293        ));
294        root.layout(Size::new(500.0, 500.0));
295        let w = safe_area_widget(&root);
296        assert_eq!(
297            w.child.origin(),
298            Point::new(50.0, 50.0),
299            "the larger minimum wins over the smaller pushed inset on every edge"
300        );
301    }
302
303    #[test]
304    fn ime_overlap_clamps_bottom_padding_to_zero() {
305        // A 24px status bar / 34px home indicator with a 340px keyboard up:
306        // the IME (view_insets) overlaps the bottom system inset, so the
307        // derived safe-area padding for that edge collapses to 0 while the
308        // top status bar stays intact (WindowInsets::padding's formula).
309        fn logic(_: &mut ()) -> SafeAreaView<()> {
310            safe_area(leaf(40.0, 20.0))
311        }
312        let mut root: RenderRoot<(), SafeAreaView<()>> = RenderRoot::new();
313        let mut state = ();
314        root.rebuild(&mut logic, &mut state);
315        root.set_insets(WindowInsets::new(
316            WindowEdgeInsets::new(0.0, 24.0, 0.0, 34.0),
317            WindowEdgeInsets::new(0.0, 0.0, 0.0, 340.0),
318        ));
319        root.layout(Size::new(500.0, 500.0));
320        let w = safe_area_widget(&root);
321        assert_eq!(
322            w.child.origin(),
323            Point::new(0.0, 24.0),
324            "bottom padding clamps to 0 under the IME overlap; top is unaffected"
325        );
326    }
327
328    // -- Event routing through the resolved offset --
329
330    #[derive(Default)]
331    struct Counter {
332        presses: u32,
333    }
334
335    fn pointer_ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
336        InputEvent::Pointer(PointerEvent {
337            phase,
338            position: Point::new(x, y),
339            button: PointerButton::Primary,
340        })
341    }
342
343    /// Build a `safe_area(Button)` with a `.minimum` (so the offset is
344    /// deterministic under `LayoutCtx::new`'s zero window insets), laid out
345    /// under generous constraints.
346    fn button_safe_area(minimum: crate::EdgeInsets) -> SafeAreaWidget {
347        let view: SafeAreaView<Counter> =
348            safe_area(crate::button::<Counter, _>("go", |s: &mut Counter| {
349                s.presses += 1
350            }))
351            .minimum(minimum);
352        let mut counter = 0u64;
353        let mut w = view.build(&mut BuildCtx::new(&mut counter));
354        let mut text_ctx = frust_text::TextContext::new();
355        let mut lctx = LayoutCtx::with_text_context(&mut text_ctx);
356        w.layout(&mut lctx, &BoxConstraints::loose(Size::new(500.0, 500.0)));
357        w
358    }
359
360    #[test]
361    fn event_coordinates_route_through_the_offset() {
362        let mut w = button_safe_area(crate::EdgeInsets::all(10.0));
363        let origin = w.child.origin();
364        assert_eq!(origin, Point::new(10.0, 10.0));
365
366        let mut state = Counter::default();
367        let state_any: &mut dyn Any = &mut state;
368        let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(500.0, 500.0));
369
370        let inside = Point::new(origin.x + 2.0, origin.y + 2.0);
371        assert_eq!(
372            w.event(
373                &mut ctx,
374                &pointer_ev(PointerPhase::Down, inside.x, inside.y)
375            ),
376            EventResult::Handled
377        );
378        assert_eq!(
379            w.event(&mut ctx, &pointer_ev(PointerPhase::Up, inside.x, inside.y)),
380            EventResult::Handled
381        );
382        drop(ctx);
383        assert_eq!(
384            state.presses, 1,
385            "a synthetic tap at the child's offset location must reach it"
386        );
387    }
388}