Skip to main content

frust_core/
insets.rs

1//! Window insets: Flutter's `ViewportMetrics` inset model ported into the
2//! framework core.
3//!
4//! A [`WindowInsets`] value flows shell → [`RenderRoot`](crate::app::RenderRoot)
5//! → layout/paint contexts, delivered exactly like the theme: the shell reads
6//! the platform's per-edge occlusion (Android `WindowInsets`, iOS
7//! `safeAreaInsets`/keyboard frame), converts device px to **logical** px at the
8//! FFI boundary, and pushes a [`WindowInsets`] onto the render root. A widget
9//! (v1: `SafeArea`) recovers the resolved [`WindowInsets::padding`] through
10//! [`LayoutCtx::window_insets`](crate::widget::LayoutCtx::window_insets) /
11//! [`PaintCtx::window_insets`](crate::widget::PaintCtx::window_insets).
12//!
13//! Unlike the theme this is a **concrete** core-owned type (not `Box<dyn Any>`):
14//! it carries only `f64` scalars, so `frust-core` names it directly with no
15//! downstream-crate dependency and threads it by copy.
16//!
17//! # Coordinate space and origin-independence
18//!
19//! All values are **logical px** (the shell divides device px by the density
20//! before crossing into core). The insets are **global** — measured against the
21//! window, not any particular widget's origin — so containers need no per-child
22//! adjustment: a `SafeArea` consumes them knowing it spans the window.
23//!
24//! # Consumption (Flutter's `MediaQuery.removePadding`)
25//!
26//! A widget that pads its subtree by the safe-area padding also *removes* what
27//! it consumed from that subtree: it derives a reduced value with
28//! [`WindowInsets::consuming`] and installs it for its children through
29//! [`LayoutCtx::with_window_insets`](crate::widget::LayoutCtx::with_window_insets)
30//! / [`PaintCtx::with_window_insets`](crate::widget::PaintCtx::with_window_insets).
31//! Descendants then read zero padding on the consumed edges, so a self-insetting
32//! widget nested inside a `SafeArea` does not inset a second time. Outside such
33//! a scope the value is the single root-seeded one. A pod floated through the
34//! overlay portal carries its owner's consumed view along in its
35//! [`OverlayEntry`](crate::overlay::OverlayEntry), so the same guarantee — a
36//! paint-time read agrees with the layout-time one — holds for floated content
37//! too, even though the root paints it from a separate pass.
38//!
39//! [`CornerInsets`] are the exception: they are window-corner facts a
40//! `SafeArea` neither pads by nor removes, and a widget that laid out around
41//! them does not rewrite them for its subtree.
42
43/// The footprint of a system window control at one corner, in logical pixels.
44///
45/// `width` is measured inward from the safe-area rectangle's vertical edge and
46/// `height` inward from its horizontal edge. See [`CornerInsets`] for the full
47/// contract.
48#[derive(Clone, Copy, Debug, Default, PartialEq)]
49pub struct CornerInset {
50    /// Horizontal extent, in logical px.
51    pub width: f64,
52    /// Vertical extent, in logical px.
53    pub height: f64,
54}
55
56impl CornerInset {
57    /// No window control at this corner.
58    pub const ZERO: CornerInset = CornerInset {
59        width: 0.0,
60        height: 0.0,
61    };
62
63    /// Construct from a width and a height, in logical px.
64    pub const fn new(width: f64, height: f64) -> Self {
65        Self { width, height }
66    }
67}
68
69/// Window-control footprints at the four window corners, in logical pixels.
70///
71/// * Values are **logical px** (see the [module docs](self)).
72/// * Corners are **physical**: frust has no RTL layout, so the shell resolves
73///   direction. In an RTL locale the iPadOS window control lands top-RIGHT.
74/// * Each value is the extent by which a system window control **protrudes
75///   beyond the safe-area rectangle** at that corner: `width` is measured
76///   inward from the safe area's vertical edge, `height` inward from its
77///   horizontal edge. A widget whose content band starts at the safe-area top
78///   therefore overlaps the corner iff `height > 0.0` -- whether it self-insets
79///   the top (reads `padding().top`) or sits under a top-consuming `SafeArea`
80///   (reads 0) -- because in both cases the control's bottom edge is
81///   safe-area-top + `height`.
82/// * Corners are **never consumed** and never part of
83///   [`WindowInsets::padding`]; [`WindowInsets::consuming`] leaves them alone.
84/// * Today only the iOS shell reports them (the iPadOS 26+ window control).
85///   Android, desktop, web and iOS < 26 leave them zero.
86/// * Accepted gap: a bar that does not consume the horizontal safe-area insets
87///   under-shifts for a control on a notched edge (no platform draws one there).
88/// * Bars shift for overlap only when spanning the window's left/right edges
89///   (typical of app bars at the top). Content hosted in a detail pane, sheet,
90///   dialog, or below other content still receives the window-wide corner values
91///   (corners are never consumed; layout cannot see its window-space origin) and
92///   would over-shift. Shipped bars expose `corner_shift(false)` as the author's
93///   opt-out; no automatic detection exists.
94#[derive(Clone, Copy, Debug, Default, PartialEq)]
95pub struct CornerInsets {
96    /// Top-left corner footprint.
97    pub top_left: CornerInset,
98    /// Top-right corner footprint.
99    pub top_right: CornerInset,
100    /// Bottom-left corner footprint.
101    pub bottom_left: CornerInset,
102    /// Bottom-right corner footprint.
103    pub bottom_right: CornerInset,
104}
105
106impl CornerInsets {
107    /// No window control at any corner (the default).
108    pub const ZERO: CornerInsets = CornerInsets {
109        top_left: CornerInset::ZERO,
110        top_right: CornerInset::ZERO,
111        bottom_left: CornerInset::ZERO,
112        bottom_right: CornerInset::ZERO,
113    };
114
115    /// Construct from the four corner footprints.
116    pub const fn new(
117        top_left: CornerInset,
118        top_right: CornerInset,
119        bottom_left: CornerInset,
120        bottom_right: CornerInset,
121    ) -> Self {
122        Self {
123            top_left,
124            top_right,
125            bottom_left,
126            bottom_right,
127        }
128    }
129}
130
131/// Per-edge inset amounts, in logical pixels.
132///
133/// The framework-core counterpart of `frust-widgets`' layout `EdgeInsets`
134/// (that one is a `Padding` container's spacing; this one is the platform
135/// occlusion model — a different layer, so it is not reused). Every value is a
136/// non-negative logical-px distance from the corresponding window edge.
137#[derive(Clone, Copy, Debug, Default, PartialEq)]
138pub struct EdgeInsets {
139    /// Inset from the left edge.
140    pub left: f64,
141    /// Inset from the top edge.
142    pub top: f64,
143    /// Inset from the right edge.
144    pub right: f64,
145    /// Inset from the bottom edge.
146    pub bottom: f64,
147}
148
149impl EdgeInsets {
150    /// The zero inset — no occlusion on any edge (the default, and the value a
151    /// [`WindowInsets`] carries until a shell pushes a real one).
152    pub const ZERO: EdgeInsets = EdgeInsets {
153        left: 0.0,
154        top: 0.0,
155        right: 0.0,
156        bottom: 0.0,
157    };
158
159    /// Construct per-edge insets directly.
160    pub fn new(left: f64, top: f64, right: f64, bottom: f64) -> Self {
161        Self {
162            left,
163            top,
164            right,
165            bottom,
166        }
167    }
168
169    /// Per-edge maximum of `self` and `other`.
170    ///
171    /// Mirrors Flutter's engine-side merge of `Type.systemBars()` with the
172    /// display cutout (`FlutterView.java:751-793`): a shell that assembles its
173    /// `view_padding` from several platform inset sources combines them per edge
174    /// with this rather than summing.
175    #[must_use]
176    pub fn max(self, other: EdgeInsets) -> EdgeInsets {
177        EdgeInsets {
178            left: self.left.max(other.left),
179            top: self.top.max(other.top),
180            right: self.right.max(other.right),
181            bottom: self.bottom.max(other.bottom),
182        }
183    }
184
185    /// Per-edge saturating subtraction: `max(0.0, self.edge - other.edge)` for
186    /// each edge, clamping a would-be-negative result to zero.
187    ///
188    /// This is the building block of [`WindowInsets::padding`] (Flutter's
189    /// `padding = max(0.0, viewPadding - viewInsets)`,
190    /// `media_query.dart:152-170`): where the IME (`view_insets`) fully covers a
191    /// system-bar edge (`view_padding`), the derived safe-area padding for that
192    /// edge collapses to zero rather than going negative.
193    #[must_use]
194    pub fn saturating_sub(self, other: EdgeInsets) -> EdgeInsets {
195        EdgeInsets {
196            left: (self.left - other.left).max(0.0),
197            top: (self.top - other.top).max(0.0),
198            right: (self.right - other.right).max(0.0),
199            bottom: (self.bottom - other.bottom).max(0.0),
200        }
201    }
202}
203
204/// The window's inset state, mirroring Flutter's `ViewportMetrics`
205/// (`media_query.dart`).
206///
207/// Carries the two per-edge sets a shell transports; the third (the derived
208/// safe-area [`padding`](WindowInsets::padding)) is computed on demand, never
209/// stored:
210///
211/// * [`view_padding`](WindowInsets::view_padding) — system-UI-occluded edges
212///   (status/navigation bars, display cutout). Never includes the IME.
213/// * [`view_insets`](WindowInsets::view_insets) — fully-obscured area, in
214///   practice the on-screen keyboard (IME). The status bar is never part of
215///   this on either platform.
216///
217/// All values are **logical px** (see the [module docs](self)). `Default` is
218/// the all-zero state (no occlusion). `PartialEq` lets a shell compare the
219/// freshly-read platform insets against the last-pushed value and skip a no-op
220/// [`set_insets`](crate::app::RenderRoot::set_insets).
221#[derive(Clone, Copy, Debug, Default, PartialEq)]
222pub struct WindowInsets {
223    /// System-UI-occluded edges (status/navigation bars, cutout), in logical px.
224    pub view_padding: EdgeInsets,
225    /// Fully-obscured edges (the IME/keyboard), in logical px.
226    pub view_insets: EdgeInsets,
227    /// Window-control corners, in logical px; see [`CornerInsets`].
228    pub corner_insets: CornerInsets,
229}
230
231impl WindowInsets {
232    /// Construct from the two transported per-edge sets; corners are
233    /// [`CornerInsets::ZERO`] (see [`with_corner_insets`](Self::with_corner_insets)).
234    pub fn new(view_padding: EdgeInsets, view_insets: EdgeInsets) -> Self {
235        Self {
236            view_padding,
237            view_insets,
238            corner_insets: CornerInsets::ZERO,
239        }
240    }
241
242    /// These insets with the window-control `corner_insets` set. `padding()`
243    /// is unaffected.
244    #[must_use]
245    pub fn with_corner_insets(mut self, corner_insets: CornerInsets) -> Self {
246        self.corner_insets = corner_insets;
247        self
248    }
249
250    /// The derived safe-area padding: `max(0.0, view_padding - view_insets)`
251    /// per edge (Flutter's formula, `media_query.dart:152-170`).
252    ///
253    /// This is what a `SafeArea` widget insets by — where the IME
254    /// (`view_insets`) overlaps a system-bar edge (`view_padding`), that edge's
255    /// safe-area padding clamps to zero (the IME already handles keyboard
256    /// avoidance for that edge). Computed on demand; never transported.
257    pub fn padding(&self) -> EdgeInsets {
258        self.view_padding.saturating_sub(self.view_insets)
259    }
260
261    /// These insets with the safe-area [`padding`](WindowInsets::padding) on
262    /// each enabled edge marked as consumed — the value a widget that has
263    /// already padded by those edges hands to its subtree.
264    ///
265    /// Flutter parity: `MediaQuery.removePadding` as applied by `SafeArea`. For
266    /// each enabled edge, `view_padding.<edge>` is reduced by
267    /// `self.padding().<edge>` (saturating at zero); `view_insets` is left
268    /// untouched. The invariants are:
269    ///
270    /// * `consuming(..).padding().<edge> == 0.0` on every enabled edge;
271    /// * `view_insets` is unchanged, so the IME still reaches descendants for
272    ///   keyboard avoidance;
273    /// * disabled edges are unchanged in both sets;
274    /// * `consuming(..).corner_insets == self.corner_insets` — corners are never
275    ///   consumed;
276    /// * the operation is idempotent — consuming an already-consumed edge is a
277    ///   no-op, which is what makes nested `SafeArea`s consume only once.
278    #[must_use]
279    pub fn consuming(self, left: bool, top: bool, right: bool, bottom: bool) -> WindowInsets {
280        let padding = self.padding();
281        let consume = |enabled: bool, view_padding: f64, padding: f64| {
282            if enabled {
283                (view_padding - padding).max(0.0)
284            } else {
285                view_padding
286            }
287        };
288        WindowInsets {
289            view_padding: EdgeInsets {
290                left: consume(left, self.view_padding.left, padding.left),
291                top: consume(top, self.view_padding.top, padding.top),
292                right: consume(right, self.view_padding.right, padding.right),
293                bottom: consume(bottom, self.view_padding.bottom, padding.bottom),
294            },
295            view_insets: self.view_insets,
296            corner_insets: self.corner_insets,
297        }
298    }
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304
305    fn sample_corners() -> CornerInsets {
306        CornerInsets::new(
307            CornerInset::new(0.0, 0.0),
308            CornerInset::new(72.0, 24.0),
309            CornerInset::new(1.0, 2.0),
310            CornerInset::new(3.0, 4.0),
311        )
312    }
313
314    #[test]
315    fn corner_insets_default_is_zero() {
316        assert_eq!(CornerInsets::default(), CornerInsets::ZERO);
317        assert_eq!(CornerInset::default(), CornerInset::ZERO);
318        assert_eq!(CornerInset::ZERO, CornerInset::new(0.0, 0.0));
319    }
320
321    #[test]
322    fn window_insets_new_has_zero_corners() {
323        let w = WindowInsets::new(EdgeInsets::new(0.0, 24.0, 0.0, 0.0), EdgeInsets::ZERO);
324        assert_eq!(w.corner_insets, CornerInsets::ZERO);
325        assert_eq!(WindowInsets::default().corner_insets, CornerInsets::ZERO);
326    }
327
328    #[test]
329    fn with_corner_insets_sets_corners_and_keeps_padding() {
330        let base = WindowInsets::new(
331            EdgeInsets::new(0.0, 24.0, 0.0, 34.0),
332            EdgeInsets::new(0.0, 0.0, 0.0, 10.0),
333        );
334        let with = base.with_corner_insets(sample_corners());
335        assert_eq!(with.corner_insets, sample_corners());
336        assert_eq!(with.padding(), base.padding());
337        assert_eq!(with.view_padding, base.view_padding);
338        assert_eq!(with.view_insets, base.view_insets);
339    }
340
341    #[test]
342    fn consuming_leaves_corner_insets_untouched() {
343        let w = WindowInsets::new(EdgeInsets::new(5.0, 24.0, 6.0, 34.0), EdgeInsets::ZERO)
344            .with_corner_insets(sample_corners());
345        let c = w.consuming(true, true, true, true);
346        assert_eq!(c.corner_insets, sample_corners());
347        assert_eq!(c.padding(), EdgeInsets::ZERO);
348    }
349
350    #[test]
351    fn edge_insets_zero_is_all_zero() {
352        assert_eq!(EdgeInsets::ZERO, EdgeInsets::new(0.0, 0.0, 0.0, 0.0));
353        assert_eq!(EdgeInsets::ZERO, EdgeInsets::default());
354    }
355
356    #[test]
357    fn edge_insets_max_is_per_edge() {
358        let a = EdgeInsets::new(10.0, 0.0, 5.0, 30.0);
359        let b = EdgeInsets::new(0.0, 24.0, 8.0, 20.0);
360        assert_eq!(a.max(b), EdgeInsets::new(10.0, 24.0, 8.0, 30.0));
361    }
362
363    #[test]
364    fn edge_insets_saturating_sub_clamps_to_zero() {
365        let padding = EdgeInsets::new(10.0, 24.0, 10.0, 34.0);
366        // The IME covers the whole bottom (and then some) but no other edge.
367        let ime = EdgeInsets::new(0.0, 0.0, 0.0, 300.0);
368        assert_eq!(
369            padding.saturating_sub(ime),
370            EdgeInsets::new(10.0, 24.0, 10.0, 0.0),
371            "bottom clamps to 0, others untouched"
372        );
373    }
374
375    #[test]
376    fn window_insets_padding_is_flutter_formula() {
377        // A phone with a 24px status bar / 34px home indicator and a keyboard up.
378        let insets = WindowInsets::new(
379            EdgeInsets::new(0.0, 24.0, 0.0, 34.0),
380            EdgeInsets::new(0.0, 0.0, 0.0, 340.0),
381        );
382        // Bottom safe-area padding collapses to 0 while the IME is up (its 340px
383        // overlaps the 34px home-indicator inset); the top status bar is intact.
384        assert_eq!(insets.padding(), EdgeInsets::new(0.0, 24.0, 0.0, 0.0));
385    }
386
387    #[test]
388    fn window_insets_default_is_zero_padding() {
389        assert_eq!(WindowInsets::default().padding(), EdgeInsets::ZERO);
390    }
391
392    #[test]
393    fn consuming_all_edges_zeroes_padding_and_keeps_view_insets() {
394        let insets = WindowInsets::new(
395            EdgeInsets::new(10.0, 20.0, 30.0, 40.0),
396            EdgeInsets::new(0.0, 0.0, 0.0, 15.0),
397        );
398        let consumed = insets.consuming(true, true, true, true);
399        assert_eq!(consumed.padding(), EdgeInsets::ZERO);
400        // Bottom: padding was 40 - 15 = 25, so view_padding drops to 15 (the
401        // part the IME already covers); the other edges drop to 0.
402        assert_eq!(consumed.view_padding, EdgeInsets::new(0.0, 0.0, 0.0, 15.0));
403        assert_eq!(consumed.view_insets, insets.view_insets);
404    }
405
406    #[test]
407    fn consuming_bottom_only_leaves_other_edges_visible() {
408        let insets = WindowInsets::new(EdgeInsets::new(10.0, 20.0, 30.0, 40.0), EdgeInsets::ZERO);
409        let consumed = insets.consuming(false, false, false, true);
410        assert_eq!(consumed.padding(), EdgeInsets::new(10.0, 20.0, 30.0, 0.0));
411        assert_eq!(
412            consumed.view_padding,
413            EdgeInsets::new(10.0, 20.0, 30.0, 0.0)
414        );
415        assert_eq!(consumed.view_insets, EdgeInsets::ZERO);
416    }
417
418    #[test]
419    fn consuming_an_ime_covered_edge_changes_nothing() {
420        // The IME (300) fully covers the 40px bottom system inset, so that
421        // edge's padding is already 0: consuming it leaves view_padding intact.
422        let insets = WindowInsets::new(
423            EdgeInsets::new(0.0, 24.0, 0.0, 40.0),
424            EdgeInsets::new(0.0, 0.0, 0.0, 300.0),
425        );
426        assert_eq!(insets.padding().bottom, 0.0);
427        let consumed = insets.consuming(false, false, false, true);
428        assert_eq!(consumed, insets);
429        assert_eq!(consumed.padding().bottom, 0.0);
430        assert_eq!(consumed.view_insets.bottom, 300.0);
431    }
432
433    #[test]
434    fn consuming_is_idempotent() {
435        let insets = WindowInsets::new(
436            EdgeInsets::new(10.0, 20.0, 30.0, 40.0),
437            EdgeInsets::new(5.0, 0.0, 0.0, 60.0),
438        );
439        for edges in [
440            (true, true, true, true),
441            (true, false, true, false),
442            (false, true, false, true),
443            (false, false, false, false),
444        ] {
445            let (l, t, r, b) = edges;
446            let once = insets.consuming(l, t, r, b);
447            assert_eq!(once.consuming(l, t, r, b), once, "edges {edges:?}");
448        }
449        assert_eq!(insets.consuming(false, false, false, false), insets);
450    }
451}