Skip to main content

frust_core/
insets.rs

1//! Window insets: Flutter's `ViewportMetrics` inset model in the framework core.
2//!
3//! The shell converts the platform's per-edge occlusion to logical px and pushes a
4//! [`WindowInsets`] onto the render root; widgets read it through
5//! [`LayoutCtx::window_insets`](crate::widget::LayoutCtx::window_insets). Values
6//! are window-global, not relative to any widget's origin.
7//!
8//! A widget that pads by the safe area installs [`WindowInsets::consuming`] for its
9//! subtree (Flutter's `MediaQuery.removePadding`), so nested self-insetting widgets
10//! do not inset twice; an overlay entry carries its owner's consumed value along.
11//! [`CornerInsets`] are never consumed.
12
13/// The footprint of a system window control at one corner, in logical px; see
14/// [`CornerInsets`].
15#[derive(Clone, Copy, Debug, Default, PartialEq)]
16pub struct CornerInset {
17    pub width: f64,
18    pub height: f64,
19}
20
21impl CornerInset {
22    /// No window control at this corner.
23    pub const ZERO: CornerInset = CornerInset {
24        width: 0.0,
25        height: 0.0,
26    };
27
28    pub const fn new(width: f64, height: f64) -> Self {
29        Self { width, height }
30    }
31}
32
33/// Window-control footprints at the four window corners, in logical px.
34///
35/// * Corners are physical (frust has no RTL layout; the shell resolves direction).
36/// * Each value is how far a control protrudes past the safe-area rectangle:
37///   `width` inward from its vertical edge, `height` from its horizontal edge, so
38///   content starting at the safe-area top overlaps the corner iff `height > 0.0`.
39/// * Never consumed or part of [`WindowInsets::padding`]; only iOS reports them (iPadOS 26+).
40/// * Accepted gap: a bar not consuming the horizontal insets under-shifts for a
41///   control on a notched edge (no platform draws one there).
42/// * Accepted gap: content not spanning the window's left/right edges (a detail
43///   pane, sheet or dialog) still gets window-wide values and would over-shift;
44///   shipped bars expose `corner_shift(false)` as the opt-out.
45#[derive(Clone, Copy, Debug, Default, PartialEq)]
46pub struct CornerInsets {
47    pub top_left: CornerInset,
48    pub top_right: CornerInset,
49    pub bottom_left: CornerInset,
50    pub bottom_right: CornerInset,
51}
52
53impl CornerInsets {
54    /// No window control at any corner (the default).
55    pub const ZERO: CornerInsets = CornerInsets {
56        top_left: CornerInset::ZERO,
57        top_right: CornerInset::ZERO,
58        bottom_left: CornerInset::ZERO,
59        bottom_right: CornerInset::ZERO,
60    };
61
62    pub const fn new(
63        top_left: CornerInset,
64        top_right: CornerInset,
65        bottom_left: CornerInset,
66        bottom_right: CornerInset,
67    ) -> Self {
68        Self {
69            top_left,
70            top_right,
71            bottom_left,
72            bottom_right,
73        }
74    }
75}
76
77/// Non-negative per-edge distances from the window edges, in logical px. The
78/// platform occlusion model, distinct from `frust-widgets`' layout `EdgeInsets`.
79#[derive(Clone, Copy, Debug, Default, PartialEq)]
80pub struct EdgeInsets {
81    pub left: f64,
82    pub top: f64,
83    pub right: f64,
84    pub bottom: f64,
85}
86
87impl EdgeInsets {
88    /// No occlusion on any edge.
89    pub const ZERO: EdgeInsets = EdgeInsets {
90        left: 0.0,
91        top: 0.0,
92        right: 0.0,
93        bottom: 0.0,
94    };
95
96    pub fn new(left: f64, top: f64, right: f64, bottom: f64) -> Self {
97        Self {
98            left,
99            top,
100            right,
101            bottom,
102        }
103    }
104
105    /// Per-edge maximum: how a shell merges several platform inset sources, as
106    /// Flutter's engine merges system bars with the display cutout.
107    #[must_use]
108    pub fn max(self, other: EdgeInsets) -> EdgeInsets {
109        EdgeInsets {
110            left: self.left.max(other.left),
111            top: self.top.max(other.top),
112            right: self.right.max(other.right),
113            bottom: self.bottom.max(other.bottom),
114        }
115    }
116
117    /// Per-edge `max(0.0, self - other)`.
118    #[must_use]
119    pub fn saturating_sub(self, other: EdgeInsets) -> EdgeInsets {
120        EdgeInsets {
121            left: (self.left - other.left).max(0.0),
122            top: (self.top - other.top).max(0.0),
123            right: (self.right - other.right).max(0.0),
124            bottom: (self.bottom - other.bottom).max(0.0),
125        }
126    }
127}
128
129/// The window's inset state, mirroring Flutter's `ViewportMetrics`; `Default` is
130/// no occlusion.
131#[derive(Clone, Copy, Debug, Default, PartialEq)]
132pub struct WindowInsets {
133    /// System-UI-occluded edges (status/navigation bars, cutout); never the IME.
134    pub view_padding: EdgeInsets,
135    /// Fully obscured edges: in practice the on-screen keyboard.
136    pub view_insets: EdgeInsets,
137    pub corner_insets: CornerInsets,
138}
139
140impl WindowInsets {
141    /// From the two per-edge sets, with [`CornerInsets::ZERO`].
142    pub fn new(view_padding: EdgeInsets, view_insets: EdgeInsets) -> Self {
143        Self {
144            view_padding,
145            view_insets,
146            corner_insets: CornerInsets::ZERO,
147        }
148    }
149
150    #[must_use]
151    pub fn with_corner_insets(mut self, corner_insets: CornerInsets) -> Self {
152        self.corner_insets = corner_insets;
153        self
154    }
155
156    /// The safe-area padding a `SafeArea` insets by: `view_padding - view_insets`
157    /// per edge, saturating at zero (Flutter's formula). Never transported.
158    pub fn padding(&self) -> EdgeInsets {
159        self.view_padding.saturating_sub(self.view_insets)
160    }
161
162    /// These insets with [`padding`](Self::padding) consumed on each enabled edge:
163    /// those edges then read zero padding, `view_insets` and corners are unchanged
164    /// (the IME still reaches descendants), and repeating it is a no-op.
165    #[must_use]
166    pub fn consuming(self, left: bool, top: bool, right: bool, bottom: bool) -> WindowInsets {
167        let padding = self.padding();
168        let consume = |enabled: bool, view_padding: f64, padding: f64| {
169            if enabled {
170                (view_padding - padding).max(0.0)
171            } else {
172                view_padding
173            }
174        };
175        WindowInsets {
176            view_padding: EdgeInsets {
177                left: consume(left, self.view_padding.left, padding.left),
178                top: consume(top, self.view_padding.top, padding.top),
179                right: consume(right, self.view_padding.right, padding.right),
180                bottom: consume(bottom, self.view_padding.bottom, padding.bottom),
181            },
182            view_insets: self.view_insets,
183            corner_insets: self.corner_insets,
184        }
185    }
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191
192    fn sample_corners() -> CornerInsets {
193        CornerInsets::new(
194            CornerInset::new(0.0, 0.0),
195            CornerInset::new(72.0, 24.0),
196            CornerInset::new(1.0, 2.0),
197            CornerInset::new(3.0, 4.0),
198        )
199    }
200
201    #[test]
202    fn corner_insets_default_is_zero() {
203        assert_eq!(CornerInsets::default(), CornerInsets::ZERO);
204        assert_eq!(CornerInset::default(), CornerInset::ZERO);
205        assert_eq!(CornerInset::ZERO, CornerInset::new(0.0, 0.0));
206    }
207
208    #[test]
209    fn window_insets_new_has_zero_corners() {
210        let w = WindowInsets::new(EdgeInsets::new(0.0, 24.0, 0.0, 0.0), EdgeInsets::ZERO);
211        assert_eq!(w.corner_insets, CornerInsets::ZERO);
212        assert_eq!(WindowInsets::default().corner_insets, CornerInsets::ZERO);
213    }
214
215    #[test]
216    fn with_corner_insets_sets_corners_and_keeps_padding() {
217        let base = WindowInsets::new(
218            EdgeInsets::new(0.0, 24.0, 0.0, 34.0),
219            EdgeInsets::new(0.0, 0.0, 0.0, 10.0),
220        );
221        let with = base.with_corner_insets(sample_corners());
222        assert_eq!(with.corner_insets, sample_corners());
223        assert_eq!(with.padding(), base.padding());
224        assert_eq!(with.view_padding, base.view_padding);
225        assert_eq!(with.view_insets, base.view_insets);
226    }
227
228    #[test]
229    fn consuming_leaves_corner_insets_untouched() {
230        let w = WindowInsets::new(EdgeInsets::new(5.0, 24.0, 6.0, 34.0), EdgeInsets::ZERO)
231            .with_corner_insets(sample_corners());
232        let c = w.consuming(true, true, true, true);
233        assert_eq!(c.corner_insets, sample_corners());
234        assert_eq!(c.padding(), EdgeInsets::ZERO);
235    }
236
237    #[test]
238    fn edge_insets_zero_is_all_zero() {
239        assert_eq!(EdgeInsets::ZERO, EdgeInsets::new(0.0, 0.0, 0.0, 0.0));
240        assert_eq!(EdgeInsets::ZERO, EdgeInsets::default());
241    }
242
243    #[test]
244    fn edge_insets_max_is_per_edge() {
245        let a = EdgeInsets::new(10.0, 0.0, 5.0, 30.0);
246        let b = EdgeInsets::new(0.0, 24.0, 8.0, 20.0);
247        assert_eq!(a.max(b), EdgeInsets::new(10.0, 24.0, 8.0, 30.0));
248    }
249
250    #[test]
251    fn edge_insets_saturating_sub_clamps_to_zero() {
252        let padding = EdgeInsets::new(10.0, 24.0, 10.0, 34.0);
253        // The IME covers the whole bottom (and then some) but no other edge.
254        let ime = EdgeInsets::new(0.0, 0.0, 0.0, 300.0);
255        assert_eq!(
256            padding.saturating_sub(ime),
257            EdgeInsets::new(10.0, 24.0, 10.0, 0.0),
258            "bottom clamps to 0, others untouched"
259        );
260    }
261
262    #[test]
263    fn window_insets_padding_is_flutter_formula() {
264        // A phone with a 24px status bar / 34px home indicator and a keyboard up.
265        let insets = WindowInsets::new(
266            EdgeInsets::new(0.0, 24.0, 0.0, 34.0),
267            EdgeInsets::new(0.0, 0.0, 0.0, 340.0),
268        );
269        // Bottom safe-area padding collapses to 0 while the IME is up (its 340px
270        // overlaps the 34px home-indicator inset); the top status bar is intact.
271        assert_eq!(insets.padding(), EdgeInsets::new(0.0, 24.0, 0.0, 0.0));
272    }
273
274    #[test]
275    fn window_insets_default_is_zero_padding() {
276        assert_eq!(WindowInsets::default().padding(), EdgeInsets::ZERO);
277    }
278
279    #[test]
280    fn consuming_all_edges_zeroes_padding_and_keeps_view_insets() {
281        let insets = WindowInsets::new(
282            EdgeInsets::new(10.0, 20.0, 30.0, 40.0),
283            EdgeInsets::new(0.0, 0.0, 0.0, 15.0),
284        );
285        let consumed = insets.consuming(true, true, true, true);
286        assert_eq!(consumed.padding(), EdgeInsets::ZERO);
287        // Bottom: padding was 40 - 15 = 25, so view_padding drops to 15 (the
288        // part the IME already covers); the other edges drop to 0.
289        assert_eq!(consumed.view_padding, EdgeInsets::new(0.0, 0.0, 0.0, 15.0));
290        assert_eq!(consumed.view_insets, insets.view_insets);
291    }
292
293    #[test]
294    fn consuming_bottom_only_leaves_other_edges_visible() {
295        let insets = WindowInsets::new(EdgeInsets::new(10.0, 20.0, 30.0, 40.0), EdgeInsets::ZERO);
296        let consumed = insets.consuming(false, false, false, true);
297        assert_eq!(consumed.padding(), EdgeInsets::new(10.0, 20.0, 30.0, 0.0));
298        assert_eq!(
299            consumed.view_padding,
300            EdgeInsets::new(10.0, 20.0, 30.0, 0.0)
301        );
302        assert_eq!(consumed.view_insets, EdgeInsets::ZERO);
303    }
304
305    #[test]
306    fn consuming_an_ime_covered_edge_changes_nothing() {
307        // The IME (300) fully covers the 40px bottom system inset, so that
308        // edge's padding is already 0: consuming it leaves view_padding intact.
309        let insets = WindowInsets::new(
310            EdgeInsets::new(0.0, 24.0, 0.0, 40.0),
311            EdgeInsets::new(0.0, 0.0, 0.0, 300.0),
312        );
313        assert_eq!(insets.padding().bottom, 0.0);
314        let consumed = insets.consuming(false, false, false, true);
315        assert_eq!(consumed, insets);
316        assert_eq!(consumed.padding().bottom, 0.0);
317        assert_eq!(consumed.view_insets.bottom, 300.0);
318    }
319
320    #[test]
321    fn consuming_is_idempotent() {
322        let insets = WindowInsets::new(
323            EdgeInsets::new(10.0, 20.0, 30.0, 40.0),
324            EdgeInsets::new(5.0, 0.0, 0.0, 60.0),
325        );
326        for edges in [
327            (true, true, true, true),
328            (true, false, true, false),
329            (false, true, false, true),
330            (false, false, false, false),
331        ] {
332            let (l, t, r, b) = edges;
333            let once = insets.consuming(l, t, r, b);
334            assert_eq!(once.consuming(l, t, r, b), once, "edges {edges:?}");
335        }
336        assert_eq!(insets.consuming(false, false, false, false), insets);
337    }
338}