frus_widgets/media.rs
1//! [`MediaQuery`] — the **ambient** description of the surface being drawn on: its
2//! size, its pixel density, and the parts of it the system has already taken (bars,
3//! notch, soft keyboard).
4//!
5//! Until now this information reached an application only through
6//! `Application::on_insets`, which means every app that wanted to avoid a notch had to
7//! store the insets in its own state and thread them down by hand to whichever widget
8//! needed them. That is a lot of plumbing for a value that is the same everywhere in
9//! the tree.
10//!
11//! `MediaQuery` makes it **ambient** instead: the framework installs it around the
12//! call to `view`, and any widget built during that call reads it with
13//! [`MediaQuery::of`] — no argument to pass, no field to carry.
14//!
15//! ```ignore
16//! let mq = MediaQuery::of();
17//! if mq.orientation() == Orientation::Landscape {
18//! // the landscape arrangement
19//! }
20//! ```
21//!
22//! A subtree can be built against a **different** description with
23//! [`MediaQuery::scope`] — which is how [`crate::SafeArea`] stops its descendants from
24//! padding for the same notch twice.
25//!
26//! ## Why a scoped ambient, and not a `BuildContext`
27//!
28//! Widgets here are ordinary Rust values, built eagerly by `view`; there is no build
29//! context to look an inherited value up from. The scope is therefore **dynamic** —
30//! it covers whatever runs inside the closure — and it is restored on the way out even
31//! if that closure panics. Reading it is a thread-local read: no lock, no allocation.
32//!
33//! Outside a scope (a unit test constructing a widget directly, say) `of` returns
34//! [`MediaQuery::UNSET`], a zero-size surface with no insets, rather than panicking —
35//! a widget that reads it still builds, it simply has nothing to avoid.
36
37use std::cell::Cell;
38
39use frus_core::{Insets, Orientation, Size, SizeClass, WindowInsets};
40
41/// A set of edges, as four independent flags — which sides of a box an operation
42/// applies to. Used by [`crate::SafeArea`] to pick the edges it insets, and by
43/// [`MediaQuery::remove_padding`] to pick the ones it consumes.
44#[derive(Copy, Clone, Debug, PartialEq, Eq)]
45pub struct Edges {
46 /// The top edge.
47 pub top: bool,
48 /// The right edge.
49 pub right: bool,
50 /// The bottom edge.
51 pub bottom: bool,
52 /// The left edge.
53 pub left: bool,
54}
55
56impl Edges {
57 /// All four edges.
58 pub const ALL: Self = Self {
59 top: true,
60 right: true,
61 bottom: true,
62 left: true,
63 };
64
65 /// No edge at all.
66 pub const NONE: Self = Self {
67 top: false,
68 right: false,
69 bottom: false,
70 left: false,
71 };
72
73 /// Top and bottom only.
74 pub const VERTICAL: Self = Self {
75 top: true,
76 right: false,
77 bottom: true,
78 left: false,
79 };
80
81 /// Left and right only.
82 pub const HORIZONTAL: Self = Self {
83 top: false,
84 right: true,
85 bottom: false,
86 left: true,
87 };
88
89 /// The same set without its top edge.
90 pub const fn without_top(mut self) -> Self {
91 self.top = false;
92 self
93 }
94
95 /// The same set without its right edge.
96 pub const fn without_right(mut self) -> Self {
97 self.right = false;
98 self
99 }
100
101 /// The same set without its bottom edge.
102 pub const fn without_bottom(mut self) -> Self {
103 self.bottom = false;
104 self
105 }
106
107 /// The same set without its left edge.
108 pub const fn without_left(mut self) -> Self {
109 self.left = false;
110 self
111 }
112
113 /// Keeps `insets` on the selected edges and zeroes the others.
114 pub fn select(self, insets: Insets) -> Insets {
115 Insets::new(
116 if self.top { insets.top } else { 0.0 },
117 if self.right { insets.right } else { 0.0 },
118 if self.bottom { insets.bottom } else { 0.0 },
119 if self.left { insets.left } else { 0.0 },
120 )
121 }
122}
123
124impl Default for Edges {
125 fn default() -> Self {
126 Self::ALL
127 }
128}
129
130/// Whether a surface is showing a **light** or a **dark** interface.
131///
132/// The platform's own preference, which an application may follow or ignore. It is not
133/// the same question as which theme this application is using: a light application on a
134/// dark system is a choice, not a mistake.
135#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
136pub enum Brightness {
137 /// A light interface: dark content on a light surface.
138 #[default]
139 Light,
140 /// A dark interface: light content on a dark surface.
141 Dark,
142}
143
144impl Brightness {
145 /// Is this the dark one?
146 pub const fn is_dark(self) -> bool {
147 matches!(self, Brightness::Dark)
148 }
149
150 /// Is this the light one?
151 pub const fn is_light(self) -> bool {
152 matches!(self, Brightness::Light)
153 }
154
155 /// **The brightness content on this surface must have to be legible** — the opposite
156 /// of this one. A dark surface takes light content, and the other way round.
157 pub const fn inverted(self) -> Brightness {
158 match self {
159 Brightness::Light => Brightness::Dark,
160 Brightness::Dark => Brightness::Light,
161 }
162 }
163}
164
165/// The **accessibility settings** the platform reports about its user.
166///
167/// The reference carries these on `MediaQueryData` as separate booleans; they are one
168/// struct here because they arrive from one query and are read together, and because a
169/// widget that honours one usually honours its neighbours.
170///
171/// **Honoured by the framework**: [`disable_animations`](Self::disable_animations). The
172/// rest are reported for the application to act on — saying so is more useful than a
173/// field that looks obeyed and is not.
174#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
175pub struct Accessibility {
176 /// The user has asked for **heavier** text everywhere.
177 pub bold_text: bool,
178 /// The user has asked for **higher contrast** than the design's own.
179 pub high_contrast: bool,
180 /// The user has asked for motion to be reduced or removed.
181 ///
182 /// The framework honours this for its **implicit** animations — the ones a widget
183 /// starts by itself when a value changes — by completing them at once instead of
184 /// over time. The change still happens; it stops moving. That is what the setting
185 /// asks for, and skipping the change instead would leave the interface wrong rather
186 /// than still.
187 pub disable_animations: bool,
188 /// The user has asked for inverted colours.
189 pub invert_colors: bool,
190 /// Something is reading the screen aloud, or otherwise driving it without a pointer.
191 ///
192 /// A hint rather than an instruction: it is how an interface knows that a control
193 /// which only appears on hover will never appear.
194 pub accessible_navigation: bool,
195 /// The user's clock format — `true` for 24-hour, `false` for 12.
196 pub always_use_24_hour_format: bool,
197}
198
199impl Accessibility {
200 /// Nothing asked for: the settings of a user who has changed none of them.
201 pub const NONE: Self = Self {
202 bold_text: false,
203 high_contrast: false,
204 disable_animations: false,
205 invert_colors: false,
206 accessible_navigation: false,
207 always_use_24_hour_format: false,
208 };
209
210 /// The platform's answer with an application's [`AccessibilityOverrides`] laid over it.
211 #[must_use]
212 pub fn with_overrides(self, over: AccessibilityOverrides) -> Self {
213 Self {
214 bold_text: over.bold_text.unwrap_or(self.bold_text),
215 high_contrast: over.high_contrast.unwrap_or(self.high_contrast),
216 disable_animations: over.disable_animations.unwrap_or(self.disable_animations),
217 invert_colors: over.invert_colors.unwrap_or(self.invert_colors),
218 accessible_navigation: over
219 .accessible_navigation
220 .unwrap_or(self.accessible_navigation),
221 always_use_24_hour_format: over
222 .always_use_24_hour_format
223 .unwrap_or(self.always_use_24_hour_format),
224 }
225 }
226}
227
228/// What an application wants to say about [`Accessibility`] **instead of the platform**,
229/// one setting at a time.
230///
231/// Every field is an `Option`, and `None` is an answer: *this application has no opinion,
232/// use what the user's system reports*. That distinction is the whole point of the type.
233/// A plain `Accessibility` cannot make it — a `false` there is indistinguishable from
234/// silence, so an application that only wanted to force *reduce motion* would also be
235/// telling the framework that its user does not use a screen reader.
236///
237/// It is the same shape, and the same reason, as
238/// [`TextStyle`](frus_core::TextStyle) against
239/// [`ResolvedTextStyle`](frus_core::ResolvedTextStyle): one type asks, the other answers.
240///
241/// ```ignore
242/// fn accessibility(&self) -> AccessibilityOverrides {
243/// // A settings screen with a "reduce motion" switch of its own. Everything else
244/// // still comes from the platform.
245/// AccessibilityOverrides::NONE.disable_animations(self.reduce_motion)
246/// }
247/// ```
248#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
249pub struct AccessibilityOverrides {
250 /// Override [`Accessibility::bold_text`].
251 pub bold_text: Option<bool>,
252 /// Override [`Accessibility::high_contrast`].
253 pub high_contrast: Option<bool>,
254 /// Override [`Accessibility::disable_animations`].
255 pub disable_animations: Option<bool>,
256 /// Override [`Accessibility::invert_colors`].
257 pub invert_colors: Option<bool>,
258 /// Override [`Accessibility::accessible_navigation`].
259 pub accessible_navigation: Option<bool>,
260 /// Override [`Accessibility::always_use_24_hour_format`].
261 pub always_use_24_hour_format: Option<bool>,
262}
263
264impl AccessibilityOverrides {
265 /// **Nothing said**: every setting left to the platform.
266 pub const NONE: Self = Self {
267 bold_text: None,
268 high_contrast: None,
269 disable_animations: None,
270 invert_colors: None,
271 accessible_navigation: None,
272 always_use_24_hour_format: None,
273 };
274
275 /// Speak for [`Accessibility::bold_text`].
276 #[must_use]
277 pub const fn bold_text(mut self, on: bool) -> Self {
278 self.bold_text = Some(on);
279 self
280 }
281
282 /// Speak for [`Accessibility::high_contrast`].
283 #[must_use]
284 pub const fn high_contrast(mut self, on: bool) -> Self {
285 self.high_contrast = Some(on);
286 self
287 }
288
289 /// Speak for [`Accessibility::disable_animations`].
290 #[must_use]
291 pub const fn disable_animations(mut self, on: bool) -> Self {
292 self.disable_animations = Some(on);
293 self
294 }
295
296 /// Speak for [`Accessibility::invert_colors`].
297 #[must_use]
298 pub const fn invert_colors(mut self, on: bool) -> Self {
299 self.invert_colors = Some(on);
300 self
301 }
302
303 /// Speak for [`Accessibility::accessible_navigation`].
304 #[must_use]
305 pub const fn accessible_navigation(mut self, on: bool) -> Self {
306 self.accessible_navigation = Some(on);
307 self
308 }
309
310 /// Speak for [`Accessibility::always_use_24_hour_format`].
311 #[must_use]
312 pub const fn always_use_24_hour_format(mut self, on: bool) -> Self {
313 self.always_use_24_hour_format = Some(on);
314 self
315 }
316}
317
318/// The surface an interface is being built for.
319///
320/// Every length is in **logical** pixels, the same unit widget styles use, so a value
321/// read here can be handed straight to a padding or a size.
322#[derive(Copy, Clone, Debug, PartialEq)]
323pub struct MediaQuery {
324 /// The drawable surface, in logical px — the window's client area, or the whole
325 /// screen on a phone.
326 pub size: Size,
327 /// Physical pixels per logical pixel: the system's DPI scale.
328 pub device_pixel_ratio: f32,
329 /// The application's own zoom factor on top of the system scale (see
330 /// `Application::density`). `1.0` is neutral.
331 pub density: f32,
332 /// Occupied edges **still worth avoiding**: the intrusions, less whatever
333 /// [`view_insets`](Self::view_insets) already covers.
334 ///
335 /// Zero at the bottom while the keyboard is up, because the navigation bar it hides
336 /// is not an edge anything needs to stay clear of any more. This is what a
337 /// [`crate::SafeArea`] pads by, and it is why a screen does not keep a strip of
338 /// nothing between its content and the keys.
339 pub padding: Insets,
340 /// Transiently occupied edges — in practice the soft keyboard, at the bottom.
341 /// Measured from the window edge, so it already includes whatever bar it covers.
342 pub view_insets: Insets,
343 /// How much larger the **user** has asked text to be — the system's font-size
344 /// setting, not the application's zoom (that is [`density`](Self::density)).
345 ///
346 /// `1.0` is the platform's normal. It is the accessibility setting people actually
347 /// change: a phone's *Font size* slider goes to 1.3 on Android and past 3 with
348 /// *Larger Accessibility Sizes* on iOS, and a layout that ignores it is one a great
349 /// many people cannot read.
350 ///
351 /// **The framework scales text by it** since milestone 403: installing this surface
352 /// with [`MediaQuery::scope`](Self::scope) puts the factor where
353 /// [`TextStyle::resolved`](frus_core::TextStyle::resolved) reads it, and every size in
354 /// the framework passes through there. [`scaled`](Self::scaled) applies it to a number
355 /// of an application's own.
356 ///
357 /// Chrome that cannot grow caps it instead of obeying it — see
358 /// [`TextStyle::clamp_scale`](frus_core::TextStyle::clamp_scale), which is what an
359 /// app bar does with its title.
360 pub text_scaler: f32,
361 /// Whether the platform is currently showing a **dark** interface, independently of
362 /// what this application chose.
363 ///
364 /// The reference's `platformBrightness`. An application that follows the system
365 /// reads it; one that has its own switch ignores it.
366 pub platform_brightness: Brightness,
367 /// The accessibility settings the platform reports, which the framework and the
368 /// application both have a say in honouring.
369 pub accessibility: Accessibility,
370 /// The edges a **system gesture** starts from — a back swipe, a home swipe — which
371 /// a widget with a horizontal drag of its own has to keep clear of, or the two fight
372 /// over the same finger.
373 ///
374 /// The reference's `systemGestureInsets`. Usually wider than
375 /// [`padding`](Self::padding) at the sides and taller at the bottom.
376 pub system_gesture_insets: Insets,
377 /// The intrusions **ignoring** anything transient: what the notch and the bars take
378 /// whether or not the keyboard is over them.
379 ///
380 /// The one that does not move when the keyboard opens. A layout with a flexible
381 /// child would otherwise shift the moment the padding under it went to zero, which
382 /// is a whole screen twitching because somebody tapped a field — see
383 /// [`crate::SafeArea::maintain_bottom_view_padding`].
384 pub view_padding: Insets,
385 /// **The window's title bar line, when the application's content shares it**
386 /// (milestone 640): set by the shell on a desktop whose system allows it, while a
387 /// [`WindowMenuBar`](crate::WindowMenuBar) has a bar. Below the bar it is `None` again:
388 /// the line is the bar's.
389 pub title_bar: Option<crate::TitleBar>,
390}
391
392impl MediaQuery {
393 /// The value [`of`](Self::of) returns outside any scope: a zero-size surface, no
394 /// scaling, nothing to avoid.
395 pub const UNSET: Self = Self {
396 size: Size {
397 width: 0.0,
398 height: 0.0,
399 },
400 device_pixel_ratio: 1.0,
401 density: 1.0,
402 padding: Insets::ZERO,
403 view_insets: Insets::ZERO,
404 view_padding: Insets::ZERO,
405 title_bar: None,
406 text_scaler: 1.0,
407 platform_brightness: Brightness::Light,
408 accessibility: Accessibility::NONE,
409 system_gesture_insets: Insets::ZERO,
410 };
411
412 /// A description of a bare surface of `size`, with no insets — the desktop case,
413 /// and the starting point for building one by hand in a test.
414 pub fn new(size: Size) -> Self {
415 Self {
416 size,
417 ..Self::UNSET
418 }
419 }
420
421 /// Sets the DPI scale.
422 pub fn with_device_pixel_ratio(mut self, ratio: f32) -> Self {
423 self.device_pixel_ratio = ratio;
424 self
425 }
426
427 /// Sets the application zoom factor.
428 pub fn with_density(mut self, density: f32) -> Self {
429 self.density = density;
430 self
431 }
432
433 /// Sets the user's text-size setting. Clamped at zero, since a scale that shrank
434 /// text to nothing would be a setting nobody could undo.
435 pub fn with_text_scaler(mut self, scaler: f32) -> Self {
436 self.text_scaler = scaler.max(0.0);
437 self
438 }
439
440 /// Sets the platform's own light/dark preference.
441 pub fn with_platform_brightness(mut self, brightness: Brightness) -> Self {
442 self.platform_brightness = brightness;
443 self
444 }
445
446 /// Sets the accessibility settings the platform reports.
447 pub fn with_accessibility(mut self, accessibility: Accessibility) -> Self {
448 self.accessibility = accessibility;
449 self
450 }
451
452 /// Sets the edges a system gesture starts from.
453 pub fn with_system_gesture_insets(mut self, insets: Insets) -> Self {
454 self.system_gesture_insets = insets;
455 self
456 }
457
458 /// A font size with the user's text-size setting applied.
459 ///
460 /// The reference calls this `TextScaler.scale`, and it is a **function** there
461 /// rather than a multiplication because a platform may scale non-linearly — large
462 /// sizes growing less than small ones, so a heading does not run off the screen when
463 /// body text is made readable. Ours is linear; the shape of the call is the one that
464 /// can become non-linear without every caller changing.
465 pub fn scaled(&self, size: f32) -> f32 {
466 size * self.text_scaler
467 }
468
469 /// The title bar's line, shared with the application (milestone 640).
470 pub fn with_title_bar(mut self, title_bar: Option<crate::TitleBar>) -> Self {
471 self.title_bar = title_bar;
472 self
473 }
474
475 /// Sets both kinds of inset at once, from what the shell reports.
476 pub fn with_insets(mut self, insets: WindowInsets) -> Self {
477 self.padding = insets.padding;
478 self.view_insets = insets.view_insets;
479 self.view_padding = insets.view_padding;
480 self
481 }
482
483 /// The area to keep content out of: the per-side **maximum** of the two kinds of
484 /// inset. The keyboard covers the navigation bar rather than stacking on top of
485 /// it, so they are combined with `max` and not with `+`.
486 pub fn safe(&self) -> Insets {
487 WindowInsets {
488 padding: self.padding,
489 view_insets: self.view_insets,
490 view_padding: self.view_padding,
491 }
492 .safe()
493 }
494
495 /// Which way round the surface is.
496 pub fn orientation(&self) -> Orientation {
497 Orientation::from_size(self.size.width, self.size.height)
498 }
499
500 /// The width breakpoint the surface falls into.
501 pub fn size_class(&self) -> SizeClass {
502 SizeClass::from_width(self.size.width)
503 }
504
505 /// The same description with the selected edges' [`padding`](Self::padding)
506 /// zeroed — what a widget that has just *consumed* that padding hands to its
507 /// descendants, so they do not inset for the same notch a second time.
508 /// Mixes into `hasher` everything about this surface that can change a **measurement**.
509 ///
510 /// The relayout cache fingerprints the walk, and a scoped surface is part of that walk
511 /// (milestone 417): two subtrees given different descriptions must not share a
512 /// fingerprint, or the second would keep the first's geometry.
513 ///
514 /// The colours are left out on purpose — `platform_brightness` and the accessibility
515 /// flags change how a frame is *painted*, and paint is not what this cache stores.
516 pub fn measure_hash<H: std::hash::Hasher>(&self, hasher: &mut H) {
517 use std::hash::Hash;
518 for n in [
519 self.size.width,
520 self.size.height,
521 self.device_pixel_ratio,
522 self.density,
523 self.text_scaler,
524 ] {
525 n.to_bits().hash(hasher);
526 }
527 for insets in [
528 self.padding,
529 self.view_insets,
530 self.view_padding,
531 self.system_gesture_insets,
532 ] {
533 for n in [insets.top, insets.right, insets.bottom, insets.left] {
534 n.to_bits().hash(hasher);
535 }
536 }
537 }
538
539 /// The query as a descendant sees it once `edges` of the padding have been **consumed** by
540 /// a widget that already made room for them — an app bar takes the top, say. What was
541 /// consumed is taken off the padding and off the view padding (floored at zero), so nothing
542 /// below insets for it a second time.
543 pub fn remove_padding(mut self, edges: Edges) -> Self {
544 let consumed = edges.select(self.padding);
545 // The **view** padding loses the same amount, floored at zero. Without this a
546 // descendant asking for the intrusion that does not move would be told about
547 // one its parent has already dealt with, and would inset for the notch twice
548 // on the one path that was meant to be immune to the keyboard.
549 self.view_padding = Insets::new(
550 (self.view_padding.top - consumed.top).max(0.0),
551 (self.view_padding.right - consumed.right).max(0.0),
552 (self.view_padding.bottom - consumed.bottom).max(0.0),
553 (self.view_padding.left - consumed.left).max(0.0),
554 );
555 self.padding = Insets::new(
556 self.padding.top - consumed.top,
557 self.padding.right - consumed.right,
558 self.padding.bottom - consumed.bottom,
559 self.padding.left - consumed.left,
560 );
561 self
562 }
563
564 /// The same description with the selected edges' [`view_insets`](Self::view_insets)
565 /// zeroed — the keyboard equivalent of [`remove_padding`](Self::remove_padding).
566 pub fn remove_view_insets(mut self, edges: Edges) -> Self {
567 let consumed = edges.select(self.view_insets);
568 // Same reasoning as [`remove_padding`](Self::remove_padding): a keyboard a
569 // parent has already made room for is not one its children should hear about,
570 // through either inset.
571 self.view_padding = Insets::new(
572 (self.view_padding.top - consumed.top).max(0.0),
573 (self.view_padding.right - consumed.right).max(0.0),
574 (self.view_padding.bottom - consumed.bottom).max(0.0),
575 (self.view_padding.left - consumed.left).max(0.0),
576 );
577 self.view_insets = Insets::new(
578 self.view_insets.top - consumed.top,
579 self.view_insets.right - consumed.right,
580 self.view_insets.bottom - consumed.bottom,
581 self.view_insets.left - consumed.left,
582 );
583 self
584 }
585
586 /// The description currently in force, or [`UNSET`](Self::UNSET) outside any
587 /// scope.
588 #[inline(never)]
589 pub fn of() -> MediaQuery {
590 AMBIENT.with(|a| a.get())
591 }
592
593 /// Is a surface actually **described**? False outside every
594 /// [`scope`](Self::scope), where [`of`](Self::of) answers
595 /// [`UNSET`](Self::UNSET) — a surface of no size.
596 ///
597 /// Any widget that sizes itself from the ambient description needs this, because
598 /// a width of zero is not a narrow screen: it is the absence of one, and the two
599 /// want opposite answers. A bar folds everything into its menu at zero and nothing
600 /// at all when there is no screen to fold against.
601 pub fn is_described(&self) -> bool {
602 self.size.width > 0.0 && self.size.height > 0.0
603 }
604
605 /// Runs `f` with `self` as the ambient description, and restores whatever was in
606 /// force before — including when `f` panics, so one bad frame cannot leave a
607 /// stale surface installed for every frame after it.
608 ///
609 /// **For a subtree**, where a closure is the right shape: a widget changing the
610 /// description its children see. A *frame* wants [`install`](Self::install) instead,
611 /// because a frame does not fit in a closure — see there for why that matters.
612 pub fn scope<R>(self, f: impl FnOnce() -> R) -> R {
613 let guard = self.install();
614 let out = f();
615 drop(guard);
616 out
617 }
618
619 /// Installs `self` as the ambient description **until the returned guard is dropped**,
620 /// and restores whatever was in force before — panic or not.
621 ///
622 /// # Why this exists as well as [`scope`](Self::scope)
623 ///
624 /// A closure bounds the description to one call, and a *frame* is not one call. The
625 /// widgets are built, then measured and laid out, then painted, and **all three**
626 /// resolve sizes. The shell wrapped only the build for four milestones: the reader's
627 /// font size reached a real device and moved not one pixel, because the two steps that
628 /// decide how big text actually is ran outside the closure (milestone 407).
629 ///
630 /// The description and the reader's font size are installed **together, by this one
631 /// call**, and that is the point. They were briefly two guards that had to agree with
632 /// each other, which is the same bug with an extra step: whoever holds one and forgets
633 /// the other gets a layout measured at one size and painted at another, with nothing to
634 /// say so.
635 #[inline(never)]
636 #[must_use = "the surface is uninstalled the moment the guard is dropped"]
637 pub fn install(self) -> SurfaceGuard {
638 let previous = AMBIENT.with(|a| a.replace(self));
639 // The reader's font size travels with the description, because it **is** part of
640 // the description and because installing it anywhere else would be one more thing
641 // to remember. It lives in `frus-core` rather than here: the only place a size
642 // becomes a number is `TextStyle::resolved`, and that is below this crate.
643 SurfaceGuard {
644 _scale: frus_core::install_text_scale(self.text_scaler),
645 _surface: Restore(previous),
646 }
647 }
648}
649
650/// Holds a surface installed — see [`MediaQuery::install`]. Dropping it puts back the
651/// description **and** the reader's font size that were in force before.
652///
653/// The two live in one guard so that they cannot be held for different lengths of time,
654/// which is exactly how they came apart in milestone 407.
655#[must_use = "the surface is uninstalled the moment the guard is dropped"]
656pub struct SurfaceGuard {
657 /// Held for its `Drop` and never read, hence the name. Declared **first**, so it is
658 /// dropped first: the reverse of the order the two were installed in.
659 _scale: frus_core::TextScaleGuard,
660 /// Held for its `Drop` and never read. See `_scale` for the ordering.
661 _surface: Restore,
662}
663
664thread_local! {
665 /// The description in force on this thread. `Cell`, not `RefCell`: the value is
666 /// `Copy` and every access is a whole get or a whole set, so there is no borrow to
667 /// get wrong.
668 static AMBIENT: Cell<MediaQuery> = const { Cell::new(MediaQuery::UNSET) };
669}
670
671/// Puts back the previous ambient description when dropped — including while a panic
672/// unwinds.
673struct Restore(MediaQuery);
674
675impl Drop for Restore {
676 #[inline(never)]
677 fn drop(&mut self) {
678 AMBIENT.with(|a| a.set(self.0));
679 }
680}
681
682#[cfg(test)]
683mod tests {
684 use super::*;
685
686 fn phone() -> MediaQuery {
687 MediaQuery::new(Size::new(360.0, 780.0))
688 .with_device_pixel_ratio(3.0)
689 .with_insets(WindowInsets::bars(Insets::new(28.0, 0.0, 16.0, 0.0)))
690 }
691
692 #[test]
693 fn outside_a_scope_the_surface_is_unset_rather_than_a_panic() {
694 assert_eq!(MediaQuery::of(), MediaQuery::UNSET);
695 }
696
697 #[test]
698 fn a_scope_is_visible_inside_and_gone_outside() {
699 let seen = phone().scope(MediaQuery::of);
700 assert_eq!(seen.size.width, 360.0);
701 assert_eq!(seen.padding.top, 28.0);
702 assert_eq!(MediaQuery::of(), MediaQuery::UNSET);
703 }
704
705 #[test]
706 fn scopes_nest_and_unwind_in_order() {
707 phone().scope(|| {
708 assert_eq!(MediaQuery::of().size.width, 360.0);
709 MediaQuery::new(Size::new(1200.0, 800.0)).scope(|| {
710 assert_eq!(MediaQuery::of().size.width, 1200.0);
711 });
712 // The inner scope put the outer one back, not `UNSET`.
713 assert_eq!(MediaQuery::of().size.width, 360.0);
714 });
715 }
716
717 #[test]
718 fn a_panicking_scope_still_restores_the_previous_surface() {
719 let caught = std::panic::catch_unwind(|| {
720 phone().scope(|| panic!("the view exploded"));
721 });
722 assert!(caught.is_err());
723 assert_eq!(
724 MediaQuery::of(),
725 MediaQuery::UNSET,
726 "a panic inside a scope must not leave its surface installed"
727 );
728 }
729
730 #[test]
731 fn removing_padding_leaves_the_other_edges_alone() {
732 let mq = phone().remove_padding(Edges::ALL.without_bottom());
733 assert_eq!(mq.padding.top, 0.0);
734 assert_eq!(mq.padding.bottom, 16.0, "the bottom was not consumed");
735 }
736
737 #[test]
738 fn the_keyboard_and_the_navigation_bar_do_not_stack() {
739 // Derived the way the shell derives it: a 16 px bar, then 320 px of occlusion
740 // measured from the window edge. Hand-writing the three insets would let a test
741 // describe a surface no platform can report.
742 let mq = phone().with_insets(WindowInsets::from_baseline(
743 Insets::new(28.0, 0.0, 16.0, 0.0),
744 Insets::new(28.0, 0.0, 320.0, 0.0),
745 ));
746 assert_eq!(mq.safe().bottom, 320.0, "not 336");
747 assert_eq!(mq.safe().top, 28.0);
748 }
749
750 /// The three insets, and what each is for.
751 ///
752 /// `view_padding` is the bar whether or not anything covers it; `view_insets` is the
753 /// occlusion from the window edge; `padding` is what is **left** to avoid, which is
754 /// nothing at the bottom once the keyboard is over the bar. Padding a screen by the
755 /// bar as well would leave a strip of nothing above the keys.
756 #[test]
757 fn the_padding_is_what_the_keyboard_has_not_already_covered() {
758 let bar = Insets::new(28.0, 0.0, 16.0, 0.0);
759 let shut = phone().with_insets(WindowInsets::bars(bar));
760 assert_eq!(shut.padding.bottom, 16.0);
761 assert_eq!(shut.view_padding.bottom, 16.0);
762 assert_eq!(shut.view_insets.bottom, 0.0);
763
764 let open = phone().with_insets(WindowInsets::from_baseline(
765 bar,
766 Insets::new(28.0, 0.0, 320.0, 0.0),
767 ));
768 assert_eq!(
769 open.padding.bottom, 0.0,
770 "the bar is covered, so nothing avoids it"
771 );
772 assert_eq!(open.view_padding.bottom, 16.0, "the bar has not moved");
773 assert_eq!(open.view_insets.bottom, 320.0);
774 // The top is untouched by any of it: a notch is a notch.
775 assert_eq!(open.padding.top, 28.0);
776 assert_eq!(open.view_padding.top, 28.0);
777 }
778
779 /// Consuming the padding consumes as much of the **view** padding, or a widget
780 /// inside a safe area would inset for the notch a second time on the one path that
781 /// was meant to be immune to the keyboard.
782 #[test]
783 fn consuming_the_padding_consumes_the_view_padding_with_it() {
784 let mq = phone()
785 .with_insets(WindowInsets::bars(Insets::new(28.0, 0.0, 16.0, 0.0)))
786 .remove_padding(Edges::ALL);
787 assert_eq!(mq.padding, Insets::ZERO);
788 assert_eq!(mq.view_padding, Insets::ZERO);
789 }
790
791 #[test]
792 fn orientation_and_size_class_come_from_the_surface() {
793 assert_eq!(phone().orientation(), Orientation::Portrait);
794 assert_eq!(phone().size_class(), SizeClass::Compact);
795 let desk = MediaQuery::new(Size::new(1440.0, 900.0));
796 assert_eq!(desk.orientation(), Orientation::Landscape);
797 assert_eq!(desk.size_class(), SizeClass::Expanded);
798 }
799
800 #[test]
801 fn a_square_surface_counts_as_portrait() {
802 let square = MediaQuery::new(Size::new(500.0, 500.0));
803 assert_eq!(square.orientation(), Orientation::Portrait);
804 }
805
806 /// **The platform answers, and the application overrides only what it spoke for.**
807 ///
808 /// The ordering is the point. These settings belong to the person using the device,
809 /// so an application that says nothing must not be able to overrule them by accident —
810 /// and before this the application was the only source, so silence and "off" were the
811 /// same sentence.
812 #[test]
813 fn an_application_that_says_nothing_leaves_the_user_alone() {
814 let user = Accessibility {
815 disable_animations: true,
816 accessible_navigation: true,
817 ..Accessibility::NONE
818 };
819 assert_eq!(
820 user.with_overrides(AccessibilityOverrides::NONE),
821 user,
822 "an application with no settings screen changes nothing"
823 );
824 }
825
826 /// And an application that speaks for **one** setting speaks for one setting.
827 ///
828 /// This is what a plain `Accessibility` could not express: forcing *reduce motion* off
829 /// used to mean also declaring that the user runs no screen reader, because a `false`
830 /// and a silence were the same value.
831 #[test]
832 fn speaking_for_one_setting_does_not_speak_for_the_others() {
833 let user = Accessibility {
834 disable_animations: true,
835 accessible_navigation: true,
836 always_use_24_hour_format: true,
837 ..Accessibility::NONE
838 };
839 let resolved = user.with_overrides(AccessibilityOverrides::NONE.disable_animations(false));
840 assert!(!resolved.disable_animations, "the application had its say");
841 assert!(
842 resolved.accessible_navigation,
843 "and said nothing about the screen reader"
844 );
845 assert!(resolved.always_use_24_hour_format, "nor about the clock");
846 }
847
848 /// A surface installed with [`MediaQuery::scope`] puts the reader's font size where
849 /// [`frus_core::TextStyle::resolved`] reads it — the whole point of carrying the
850 /// number. Without this the platform could report it and nothing would change.
851 #[test]
852 fn a_described_surface_hands_the_readers_font_size_to_the_text() {
853 let phone = MediaQuery::new(Size::new(400.0, 800.0)).with_text_scaler(1.5);
854 let plain = frus_core::TextStyle::new(16.0);
855 assert_eq!(
856 plain.resolved().size,
857 16.0,
858 "outside a surface, nothing moves"
859 );
860 phone.scope(|| {
861 assert_eq!(plain.resolved().size, 24.0);
862 });
863 assert_eq!(plain.resolved().size, 16.0, "and it is put back afterwards");
864 }
865
866 /// **The description and the reader's font size are installed by one call.**
867 ///
868 /// They were briefly two guards that had to agree with each other, which is the same
869 /// bug with an extra step: whoever holds one and forgets the other gets a layout
870 /// measured at one size and painted at another.
871 #[test]
872 fn one_guard_installs_the_whole_surface() {
873 let phone = MediaQuery::new(Size::new(360.0, 780.0)).with_text_scaler(1.5);
874 assert_eq!(frus_core::text_scale(), 1.0);
875 assert!(!MediaQuery::of().is_described());
876 {
877 let _held = phone.install();
878 assert_eq!(frus_core::text_scale(), 1.5, "the font size is in force");
879 assert!(MediaQuery::of().is_described(), "and so is the description");
880 }
881 assert_eq!(frus_core::text_scale(), 1.0, "both are put back");
882 assert!(!MediaQuery::of().is_described());
883 }
884
885 /// A guard **outlives a closure**, which is the whole reason it exists: a frame builds,
886 /// then lays out, then paints, and all three resolve sizes.
887 #[test]
888 fn a_guard_holds_past_the_call_that_made_it() {
889 let built = {
890 let held = MediaQuery::new(Size::new(400.0, 300.0))
891 .with_text_scaler(2.0)
892 .install();
893 let size = frus_core::TextStyle::new(16.0).resolved().size;
894 // The "layout" and the "paint" of this frame: after the widgets were built,
895 // and still inside the surface.
896 let laid_out = frus_core::TextStyle::new(16.0).resolved().size;
897 drop(held);
898 (size, laid_out)
899 };
900 assert_eq!(built, (32.0, 32.0));
901 assert_eq!(frus_core::TextStyle::new(16.0).resolved().size, 16.0);
902 }
903
904 /// Installing one **inside** another nests and unwinds in order, so a widget can still
905 /// change the description its own subtree sees.
906 #[test]
907 fn surfaces_nest_and_unwind_in_order() {
908 let outer = MediaQuery::new(Size::new(400.0, 800.0)).with_text_scaler(1.5);
909 let inner = MediaQuery::new(Size::new(200.0, 400.0)).with_text_scaler(3.0);
910 let _o = outer.install();
911 assert_eq!(frus_core::text_scale(), 1.5);
912 {
913 let _i = inner.install();
914 assert_eq!(frus_core::text_scale(), 3.0);
915 assert_eq!(MediaQuery::of().size.width, 200.0);
916 }
917 assert_eq!(frus_core::text_scale(), 1.5, "the outer surface is back");
918 assert_eq!(MediaQuery::of().size.width, 400.0);
919 }
920}