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}