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 /// The insets on the sides they name, in either reading direction (milestone 588).
221 fn style_themed(&self, theme: &crate::theme::Theme) -> Style {
222 Style {
223 padding: frus_core::InsetsGeometry::Physical(self.resolve()).laid_out(theme.direction),
224 ..self.style()
225 }
226 }
227
228 fn style(&self) -> Style {
229 Style {
230 padding: self.resolve(),
231 // **It fills what it is given.** The reference's is a `Padding` under the
232 // screen's own constraints, which are tight: it is the size of the box it
233 // was handed, and its child is that box less the intrusions. A flex node
234 // that grows nothing would instead hug its content, and a screen wrapped in
235 // one would come out the width of its widest line — the failure milestone
236 // 392 chased. Growing is what makes it the box.
237 flex_grow: 1.0,
238 ..Default::default()
239 }
240 }
241
242 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
243 &self.children
244 }
245
246 fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {
247 // Pure layout: the safe area has no decoration of its own.
248 }
249
250 fn on_click(&self) -> Option<Msg> {
251 None
252 }
253}
254
255#[cfg(test)]
256mod tests {
257 use super::*;
258 use crate::{Container, Text};
259 use frus_core::{Size, WindowInsets};
260
261 fn phone() -> MediaQuery {
262 MediaQuery::new(Size::new(360.0, 780.0))
263 .with_insets(WindowInsets::bars(Insets::new(28.0, 0.0, 16.0, 0.0)))
264 }
265
266 /// The padding is asked for **under the surface**, because that is the question:
267 /// milestone 417 made a safe area resolve when it is asked rather than when it was
268 /// built, so that a `MediaScope` above it can change the answer. Asking outside any
269 /// surface is asking about a screen that is not there.
270 #[test]
271 fn it_pads_by_the_occupied_edges() {
272 phone().scope(|| {
273 let area = SafeArea::<()>::new(Container::new());
274 assert_eq!(area.padding().top, 28.0);
275 assert_eq!(area.padding().bottom, 16.0);
276 assert_eq!(area.padding().left, 0.0);
277 });
278 }
279
280 #[test]
281 fn on_a_bare_surface_it_is_a_no_op() {
282 let area = SafeArea::<()>::new(Container::new());
283 assert_eq!(area.padding(), Insets::ZERO);
284 }
285
286 #[test]
287 fn an_unselected_edge_is_left_free() {
288 phone().scope(|| {
289 let area = SafeArea::<()>::new(Container::new()).edges(Edges::ALL.without_bottom());
290 assert_eq!(area.padding().top, 28.0);
291 assert_eq!(
292 area.padding().bottom,
293 0.0,
294 "the content should run under the gesture handle"
295 );
296 });
297 }
298
299 #[test]
300 fn the_minimum_is_a_floor_and_not_an_addition() {
301 phone().scope(|| {
302 let area = SafeArea::<()>::new(Container::new()).minimum(Insets::uniform(20.0));
303 assert_eq!(area.padding().top, 28.0, "28 already clears 20");
304 assert_eq!(area.padding().bottom, 20.0, "16 is raised to 20");
305 assert_eq!(area.padding().left, 20.0, "nothing occupied, so the floor");
306 });
307 }
308
309 fn with_keyboard() -> MediaQuery {
310 phone().with_insets(WindowInsets::from_baseline(
311 Insets::new(28.0, 0.0, 16.0, 0.0),
312 Insets::new(28.0, 0.0, 320.0, 0.0),
313 ))
314 }
315
316 #[test]
317 fn the_keyboard_is_avoided_only_when_asked() {
318 let with_keyboard = with_keyboard();
319 // **Zero**, not sixteen. The navigation bar is under the keyboard, so there is
320 // nothing left at the bottom to stay clear of, and padding by it as well would
321 // leave a strip of nothing between the content and the keys.
322 with_keyboard.scope(|| {
323 let ignoring = SafeArea::<()>::new(Container::new());
324 assert_eq!(ignoring.padding().bottom, 0.0);
325 let avoiding = SafeArea::<()>::new(Container::new()).avoid_keyboard();
326 assert_eq!(avoiding.padding().bottom, 320.0);
327 });
328 }
329
330 /// The bottom padding going to zero is right for nearly every screen and wrong for
331 /// one holding a flexible child, which would grow by the bar's height the moment a
332 /// field was tapped and shrink again when the keyboard closed. Asked to, the safe
333 /// area keeps the intrusion that does not move.
334 #[test]
335 fn the_bottom_can_be_kept_still_while_the_keyboard_comes_and_goes() {
336 let bottom = |mq: MediaQuery| {
337 mq.scope(|| {
338 SafeArea::<()>::new(Container::new())
339 .maintain_bottom_view_padding()
340 .padding()
341 .bottom
342 })
343 };
344 assert_eq!(bottom(phone()), 16.0);
345 assert_eq!(
346 bottom(with_keyboard()),
347 16.0,
348 "the same, keyboard or no keyboard"
349 );
350 // And it is the *view* padding it keeps, not the keyboard: the content still
351 // sits behind the keys rather than being lifted above them.
352 assert!(bottom(with_keyboard()) < 320.0);
353 }
354
355 #[test]
356 fn a_nested_build_does_not_avoid_the_same_notch_twice() {
357 let inner_padding = std::cell::Cell::new(Insets::ZERO);
358 phone().scope(|| {
359 SafeArea::<()>::build(|_| {
360 let inner = SafeArea::<()>::new(Container::new());
361 inner_padding.set(inner.padding());
362 inner
363 })
364 });
365 assert_eq!(
366 inner_padding.get(),
367 Insets::ZERO,
368 "the outer SafeArea consumed the padding"
369 );
370 }
371
372 #[test]
373 fn build_hands_the_consumed_surface_to_the_closure() {
374 let seen = std::cell::Cell::new(Insets::uniform(-1.0));
375 phone().scope(|| {
376 SafeArea::<()>::build(|mq| {
377 seen.set(mq.padding);
378 Text::new("x")
379 })
380 });
381 assert_eq!(seen.get(), Insets::ZERO);
382 }
383
384 #[test]
385 fn build_with_only_consumes_the_edges_it_insets() {
386 let seen = std::cell::Cell::new(Insets::ZERO);
387 phone().scope(|| {
388 SafeArea::<()>::build_with(Edges::ALL.without_bottom(), Insets::ZERO, false, |mq| {
389 seen.set(mq.padding);
390 Text::new("x")
391 })
392 });
393 assert_eq!(seen.get().top, 0.0);
394 assert_eq!(
395 seen.get().bottom,
396 16.0,
397 "an edge left free is still there for a descendant to use"
398 );
399 }
400
401 const MARK: frus_core::Color = frus_core::Color::rgb(0.9, 0.1, 0.5);
402
403 /// The marked block's box in `root`, built and walked under the phone surface.
404 fn marked_in(root: &dyn Widget<()>) -> Rect {
405 let ui = phone().scope(|| {
406 crate::build_ui(
407 root,
408 Size::new(360.0, 780.0),
409 &crate::Runtime::default(),
410 &Theme::default(),
411 )
412 });
413 ui.scene()
414 .primitives()
415 .iter()
416 .find_map(|p| match p {
417 frus_core::Primitive::Rect { rect, color, .. } if *color == MARK => Some(*rect),
418 _ => None,
419 })
420 .expect("the marked block is drawn")
421 }
422
423 /// **`SafeArea::new` consumes what it pads**, as `build` always did (milestone 504).
424 ///
425 /// It used to pad and say nothing, on the grounds that its child was built before it
426 /// existed. But the surface is resolved during the walk, so a scope around the child
427 /// can still tell the subtree — and a `SafeArea` that did not left a status bar for a
428 /// navigation bar under it to clear a second time.
429 #[test]
430 fn a_nested_new_does_not_avoid_the_same_notch_twice() {
431 let root = SafeArea::new(SafeArea::new(Container::new().height(10.0).color(MARK)));
432 assert_eq!(marked_in(&root).y, 28.0, "the status bar once, not twice");
433 }
434
435 /// Only the edges it insets are consumed: one left free is still there for a
436 /// descendant to clear.
437 #[test]
438 fn an_edge_left_free_is_left_for_a_descendant() {
439 let root =
440 SafeArea::new(SafeArea::new(Container::new().height(10.0).color(MARK))).edges(Edges {
441 top: false,
442 ..Edges::ALL
443 });
444 assert_eq!(
445 marked_in(&root).y,
446 28.0,
447 "the inner one still found the status bar the outer one left free"
448 );
449 }
450
451 #[test]
452 fn the_padding_reaches_the_layout_style() {
453 phone().scope(|| {
454 let area = SafeArea::<()>::new(Container::new());
455 assert_eq!(Widget::<()>::style(&area).padding.top, 28.0);
456 });
457 }
458}