frus_widgets/safearea.rs
1//! [`SafeArea`] — insets its child away from the parts of the screen the system has
2//! taken: the status bar, the notch or camera cut-out, the gesture handle.
3//!
4//! ```ignore
5//! SafeArea::new(screen) // every edge
6//! SafeArea::new(list).edges(Edges::ALL.without_bottom()) // let it run under the handle
7//! SafeArea::new(form).avoid_keyboard() // and move up when the keyboard opens
8//! ```
9//!
10//! The insets come from the ambient [`MediaQuery`], so nothing has to be threaded down
11//! from the application. On desktop they are zero and this widget is a no-op — which is
12//! the point: the same screen code is correct on both.
13
14use std::cell::Cell;
15use std::rc::Rc;
16
17use frus_core::{Insets, Rect, Scene};
18use frus_layout::Style;
19
20use crate::interaction::Status;
21use crate::media::{Edges, MediaQuery};
22use crate::mediascope::MediaScope;
23use crate::theme::Theme;
24use crate::widget::Widget;
25
26/// Pads its child by the system's occupied edges.
27///
28/// ## Nesting
29///
30/// A `SafeArea` **consumes** the padding it applies: its subtree sees a [`MediaQuery`]
31/// whose consumed edges are already zero, so a second `SafeArea` further down adds
32/// nothing and the notch is not avoided twice — and a widget that clears the status bar
33/// on its own, a [`NavigationBar`](crate::NavigationBar) or a
34/// [`DrawerHeader`](crate::DrawerHeader), is not pushed down by it twice either.
35///
36/// That holds for both constructors. [`SafeArea::new`] used to pad and say nothing, on
37/// the grounds that its child was already built; but the description is resolved **during
38/// the walk**, not at construction, so the child is wrapped in a
39/// [`MediaScope`](crate::MediaScope) that removes the edges — the reference's safe area
40/// does the same with `MediaQuery.removePadding`. [`SafeArea::build`] still hands the
41/// consumed description to its closure, for code that reads it while composing.
42pub struct SafeArea<Msg = crate::callback::Callback> {
43 edges: Edges,
44 minimum: Insets,
45 keyboard: bool,
46 /// Pad the bottom by the intrusion that does **not** move; see
47 /// [`SafeArea::maintain_bottom_view_padding`].
48 maintain_bottom: bool,
49 /// What the child's subtree is told has been consumed: the edges, and whether the
50 /// keyboard's with them. Shared with the scope wrapping the child, because the
51 /// builders below change it after the child was wrapped.
52 consumed: Rc<Cell<(Edges, bool)>>,
53 children: Vec<Box<dyn Widget<Msg>>>,
54}
55
56/// Wraps `child` in the scope that tells its subtree what the safe area took.
57fn consuming<Msg: 'static>(
58 consumed: Rc<Cell<(Edges, bool)>>,
59 child: impl Widget<Msg> + 'static,
60) -> Box<dyn Widget<Msg>> {
61 Box::new(MediaScope::tweak(
62 move |mq: &mut MediaQuery| {
63 let (edges, keyboard) = consumed.get();
64 let mut inner = mq.remove_padding(edges);
65 if keyboard {
66 inner = inner.remove_view_insets(edges);
67 }
68 *mq = inner;
69 },
70 child,
71 ))
72}
73
74impl<Msg: 'static> SafeArea<Msg> {
75 /// Insets `child` away from every occupied edge.
76 pub fn new(child: impl Widget<Msg> + 'static) -> Self {
77 let consumed = Rc::new(Cell::new((Edges::ALL, false)));
78 Self {
79 edges: Edges::ALL,
80 minimum: Insets::ZERO,
81 keyboard: false,
82 maintain_bottom: false,
83 children: vec![consuming(consumed.clone(), child)],
84 consumed,
85 }
86 }
87
88 /// Builds the child **inside** the safe area: `build` receives a [`MediaQuery`]
89 /// whose consumed edges have already been zeroed, and the same value is installed
90 /// as the ambient one for the whole of the call.
91 ///
92 /// Use this whenever a `SafeArea` may end up inside another one.
93 ///
94 /// ```ignore
95 /// SafeArea::build(|mq| Column::new().child(Text::new(format!("{} px", mq.size.width))))
96 /// ```
97 pub fn build<W: Widget<Msg> + 'static>(build: impl FnOnce(MediaQuery) -> W) -> Self {
98 Self::build_with(Edges::ALL, Insets::ZERO, false, build)
99 }
100
101 /// [`build`](Self::build) with the edges, the floor and the keyboard behaviour
102 /// chosen up front — they have to be known **before** the child is built, since
103 /// they decide what the child gets to see.
104 pub fn build_with<W: Widget<Msg> + 'static>(
105 edges: Edges,
106 minimum: Insets,
107 keyboard: bool,
108 build: impl FnOnce(MediaQuery) -> W,
109 ) -> Self {
110 let consumed = Rc::new(Cell::new((edges, keyboard)));
111 let mut area = Self {
112 edges,
113 minimum,
114 keyboard,
115 // Off here, and [`SafeArea::maintain_bottom_view_padding`] still works on
116 // the result: it changes how much this widget pads, not what the child was
117 // told had been consumed, and those are different questions.
118 maintain_bottom: false,
119 consumed: consumed.clone(),
120 children: Vec::new(),
121 };
122
123 let mut inner = MediaQuery::of().remove_padding(edges);
124 if keyboard {
125 inner = inner.remove_view_insets(edges);
126 }
127 let child = inner.scope(|| build(inner));
128 // What the closure was told while composing, the subtree is told again while it
129 // is walked — the two have to agree, or a descendant that reads the surface late
130 // would see the edges the closure was told were gone.
131 area.children.push(consuming(consumed, child));
132 area
133 }
134
135 /// Which edges to inset. The rest are left to run to the screen's border — a list
136 /// that should scroll under the gesture handle, say, keeps its bottom edge free.
137 ///
138 /// Only those are consumed for the subtree: an edge left free is still there for a
139 /// descendant to clear.
140 pub fn edges(mut self, edges: Edges) -> Self {
141 self.edges = edges;
142 self.consumed.set((edges, self.keyboard));
143 self
144 }
145
146 /// A floor for the padding: each side ends up at least this far in, even where the
147 /// system asks for nothing. The usual way to give a screen its margin and its
148 /// notch avoidance in one widget.
149 pub fn minimum(mut self, minimum: Insets) -> Self {
150 self.minimum = minimum;
151 self
152 }
153
154 /// Keeps the bottom padding the keyboard would otherwise consume.
155 ///
156 /// The bottom padding goes to **zero** while the keyboard is up: the navigation bar
157 /// it hides is not an edge anything needs to stay clear of, and that is the right
158 /// answer nearly always. It is the wrong one for a screen holding a flexible child,
159 /// which would then grow by exactly that bar's height the moment a field was tapped
160 /// and shrink again when the keyboard closed — a whole layout twitching because
161 /// somebody started typing.
162 ///
163 /// On, the bottom is padded by [`MediaQuery::view_padding`] instead, which does not
164 /// move. The content sits behind the keyboard rather than above it, which is what it
165 /// was already doing.
166 pub fn maintain_bottom_view_padding(mut self) -> Self {
167 self.maintain_bottom = true;
168 self
169 }
170
171 /// Also avoids the **soft keyboard**, not just the permanent bars.
172 ///
173 /// Off by default, and deliberately: a screen whose content scrolls wants the
174 /// keyboard handled by scrolling the focused field into view, not by shrinking the
175 /// whole screen. Turn it on for a short, non-scrolling form.
176 pub fn avoid_keyboard(mut self) -> Self {
177 self.keyboard = true;
178 self.consumed.set((self.edges, true));
179 self
180 }
181}
182
183/// The resolvers, in an impl of their own: the constructors above wrap the child in a
184/// scope, which asks for `Msg: 'static`, and the `Widget` impl does not — a resolver
185/// declared beside them could not be called from the layout.
186impl<Msg> SafeArea<Msg> {
187 /// The padding to apply: the occupied edges this widget was asked for, floored by
188 /// `minimum`.
189 ///
190 /// Read **when it is asked for**, not when the widget was built. It used to be
191 /// resolved once in the constructor with a comment saying `style` runs outside any
192 /// surface the shell installed — which was true when it was written and stopped being
193 /// true at milestone 408, when the shell began holding one surface across the build,
194 /// the layout *and* the paint. Resolving here is what lets a
195 /// [`MediaScope`](crate::MediaScope) above this widget reach it: a shell that has
196 /// already dealt with the status bar can say so, and the safe area below believes it.
197 fn resolve(&self) -> Insets {
198 let mq = MediaQuery::of();
199 let mut occupied = if self.keyboard { mq.safe() } else { mq.padding };
200 if self.maintain_bottom {
201 occupied.bottom = mq.view_padding.bottom;
202 }
203 let selected = self.edges.select(occupied);
204 Insets::new(
205 selected.top.max(self.minimum.top),
206 selected.right.max(self.minimum.right),
207 selected.bottom.max(self.minimum.bottom),
208 selected.left.max(self.minimum.left),
209 )
210 }
211
212 /// The padding this widget resolves **against the surface in force right now** — for
213 /// tests and for a parent that needs to know how much was taken.
214 pub fn padding(&self) -> Insets {
215 self.resolve()
216 }
217}
218
219impl<Msg: Clone> Widget<Msg> for SafeArea<Msg> {
220 fn style(&self) -> Style {
221 Style {
222 padding: self.resolve(),
223 // **It fills what it is given.** The reference's is a `Padding` under the
224 // screen's own constraints, which are tight: it is the size of the box it
225 // was handed, and its child is that box less the intrusions. A flex node
226 // that grows nothing would instead hug its content, and a screen wrapped in
227 // one would come out the width of its widest line — the failure milestone
228 // 392 chased. Growing is what makes it the box.
229 flex_grow: 1.0,
230 ..Default::default()
231 }
232 }
233
234 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
235 &self.children
236 }
237
238 fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {
239 // Pure layout: the safe area has no decoration of its own.
240 }
241
242 fn on_click(&self) -> Option<Msg> {
243 None
244 }
245}
246
247#[cfg(test)]
248mod tests {
249 use super::*;
250 use crate::{Container, Text};
251 use frus_core::{Size, WindowInsets};
252
253 fn phone() -> MediaQuery {
254 MediaQuery::new(Size::new(360.0, 780.0))
255 .with_insets(WindowInsets::bars(Insets::new(28.0, 0.0, 16.0, 0.0)))
256 }
257
258 /// The padding is asked for **under the surface**, because that is the question:
259 /// milestone 417 made a safe area resolve when it is asked rather than when it was
260 /// built, so that a `MediaScope` above it can change the answer. Asking outside any
261 /// surface is asking about a screen that is not there.
262 #[test]
263 fn it_pads_by_the_occupied_edges() {
264 phone().scope(|| {
265 let area = SafeArea::<()>::new(Container::new());
266 assert_eq!(area.padding().top, 28.0);
267 assert_eq!(area.padding().bottom, 16.0);
268 assert_eq!(area.padding().left, 0.0);
269 });
270 }
271
272 #[test]
273 fn on_a_bare_surface_it_is_a_no_op() {
274 let area = SafeArea::<()>::new(Container::new());
275 assert_eq!(area.padding(), Insets::ZERO);
276 }
277
278 #[test]
279 fn an_unselected_edge_is_left_free() {
280 phone().scope(|| {
281 let area = SafeArea::<()>::new(Container::new()).edges(Edges::ALL.without_bottom());
282 assert_eq!(area.padding().top, 28.0);
283 assert_eq!(
284 area.padding().bottom,
285 0.0,
286 "the content should run under the gesture handle"
287 );
288 });
289 }
290
291 #[test]
292 fn the_minimum_is_a_floor_and_not_an_addition() {
293 phone().scope(|| {
294 let area = SafeArea::<()>::new(Container::new()).minimum(Insets::uniform(20.0));
295 assert_eq!(area.padding().top, 28.0, "28 already clears 20");
296 assert_eq!(area.padding().bottom, 20.0, "16 is raised to 20");
297 assert_eq!(area.padding().left, 20.0, "nothing occupied, so the floor");
298 });
299 }
300
301 fn with_keyboard() -> MediaQuery {
302 phone().with_insets(WindowInsets::from_baseline(
303 Insets::new(28.0, 0.0, 16.0, 0.0),
304 Insets::new(28.0, 0.0, 320.0, 0.0),
305 ))
306 }
307
308 #[test]
309 fn the_keyboard_is_avoided_only_when_asked() {
310 let with_keyboard = with_keyboard();
311 // **Zero**, not sixteen. The navigation bar is under the keyboard, so there is
312 // nothing left at the bottom to stay clear of, and padding by it as well would
313 // leave a strip of nothing between the content and the keys.
314 with_keyboard.scope(|| {
315 let ignoring = SafeArea::<()>::new(Container::new());
316 assert_eq!(ignoring.padding().bottom, 0.0);
317 let avoiding = SafeArea::<()>::new(Container::new()).avoid_keyboard();
318 assert_eq!(avoiding.padding().bottom, 320.0);
319 });
320 }
321
322 /// The bottom padding going to zero is right for nearly every screen and wrong for
323 /// one holding a flexible child, which would grow by the bar's height the moment a
324 /// field was tapped and shrink again when the keyboard closed. Asked to, the safe
325 /// area keeps the intrusion that does not move.
326 #[test]
327 fn the_bottom_can_be_kept_still_while_the_keyboard_comes_and_goes() {
328 let bottom = |mq: MediaQuery| {
329 mq.scope(|| {
330 SafeArea::<()>::new(Container::new())
331 .maintain_bottom_view_padding()
332 .padding()
333 .bottom
334 })
335 };
336 assert_eq!(bottom(phone()), 16.0);
337 assert_eq!(
338 bottom(with_keyboard()),
339 16.0,
340 "the same, keyboard or no keyboard"
341 );
342 // And it is the *view* padding it keeps, not the keyboard: the content still
343 // sits behind the keys rather than being lifted above them.
344 assert!(bottom(with_keyboard()) < 320.0);
345 }
346
347 #[test]
348 fn a_nested_build_does_not_avoid_the_same_notch_twice() {
349 let inner_padding = std::cell::Cell::new(Insets::ZERO);
350 phone().scope(|| {
351 SafeArea::<()>::build(|_| {
352 let inner = SafeArea::<()>::new(Container::new());
353 inner_padding.set(inner.padding());
354 inner
355 })
356 });
357 assert_eq!(
358 inner_padding.get(),
359 Insets::ZERO,
360 "the outer SafeArea consumed the padding"
361 );
362 }
363
364 #[test]
365 fn build_hands_the_consumed_surface_to_the_closure() {
366 let seen = std::cell::Cell::new(Insets::uniform(-1.0));
367 phone().scope(|| {
368 SafeArea::<()>::build(|mq| {
369 seen.set(mq.padding);
370 Text::new("x")
371 })
372 });
373 assert_eq!(seen.get(), Insets::ZERO);
374 }
375
376 #[test]
377 fn build_with_only_consumes_the_edges_it_insets() {
378 let seen = std::cell::Cell::new(Insets::ZERO);
379 phone().scope(|| {
380 SafeArea::<()>::build_with(Edges::ALL.without_bottom(), Insets::ZERO, false, |mq| {
381 seen.set(mq.padding);
382 Text::new("x")
383 })
384 });
385 assert_eq!(seen.get().top, 0.0);
386 assert_eq!(
387 seen.get().bottom,
388 16.0,
389 "an edge left free is still there for a descendant to use"
390 );
391 }
392
393 const MARK: frus_core::Color = frus_core::Color::rgb(0.9, 0.1, 0.5);
394
395 /// The marked block's box in `root`, built and walked under the phone surface.
396 fn marked_in(root: &dyn Widget<()>) -> Rect {
397 let ui = phone().scope(|| {
398 crate::build_ui(
399 root,
400 Size::new(360.0, 780.0),
401 &crate::Runtime::default(),
402 &Theme::default(),
403 )
404 });
405 ui.scene()
406 .primitives()
407 .iter()
408 .find_map(|p| match p {
409 frus_core::Primitive::Rect { rect, color, .. } if *color == MARK => Some(*rect),
410 _ => None,
411 })
412 .expect("the marked block is drawn")
413 }
414
415 /// **`SafeArea::new` consumes what it pads**, as `build` always did (milestone 504).
416 ///
417 /// It used to pad and say nothing, on the grounds that its child was built before it
418 /// existed. But the surface is resolved during the walk, so a scope around the child
419 /// can still tell the subtree — and a `SafeArea` that did not left a status bar for a
420 /// navigation bar under it to clear a second time.
421 #[test]
422 fn a_nested_new_does_not_avoid_the_same_notch_twice() {
423 let root = SafeArea::new(SafeArea::new(Container::new().height(10.0).color(MARK)));
424 assert_eq!(marked_in(&root).y, 28.0, "the status bar once, not twice");
425 }
426
427 /// Only the edges it insets are consumed: one left free is still there for a
428 /// descendant to clear.
429 #[test]
430 fn an_edge_left_free_is_left_for_a_descendant() {
431 let root =
432 SafeArea::new(SafeArea::new(Container::new().height(10.0).color(MARK))).edges(Edges {
433 top: false,
434 ..Edges::ALL
435 });
436 assert_eq!(
437 marked_in(&root).y,
438 28.0,
439 "the inner one still found the status bar the outer one left free"
440 );
441 }
442
443 #[test]
444 fn the_padding_reaches_the_layout_style() {
445 phone().scope(|| {
446 let area = SafeArea::<()>::new(Container::new());
447 assert_eq!(Widget::<()>::style(&area).padding.top, 28.0);
448 });
449 }
450}