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}
386
387impl MediaQuery {
388 /// The value [`of`](Self::of) returns outside any scope: a zero-size surface, no
389 /// scaling, nothing to avoid.
390 pub const UNSET: Self = Self {
391 size: Size {
392 width: 0.0,
393 height: 0.0,
394 },
395 device_pixel_ratio: 1.0,
396 density: 1.0,
397 padding: Insets::ZERO,
398 view_insets: Insets::ZERO,
399 view_padding: Insets::ZERO,
400 text_scaler: 1.0,
401 platform_brightness: Brightness::Light,
402 accessibility: Accessibility::NONE,
403 system_gesture_insets: Insets::ZERO,
404 };
405
406 /// A description of a bare surface of `size`, with no insets — the desktop case,
407 /// and the starting point for building one by hand in a test.
408 pub fn new(size: Size) -> Self {
409 Self {
410 size,
411 ..Self::UNSET
412 }
413 }
414
415 /// Sets the DPI scale.
416 pub fn with_device_pixel_ratio(mut self, ratio: f32) -> Self {
417 self.device_pixel_ratio = ratio;
418 self
419 }
420
421 /// Sets the application zoom factor.
422 pub fn with_density(mut self, density: f32) -> Self {
423 self.density = density;
424 self
425 }
426
427 /// Sets the user's text-size setting. Clamped at zero, since a scale that shrank
428 /// text to nothing would be a setting nobody could undo.
429 pub fn with_text_scaler(mut self, scaler: f32) -> Self {
430 self.text_scaler = scaler.max(0.0);
431 self
432 }
433
434 /// Sets the platform's own light/dark preference.
435 pub fn with_platform_brightness(mut self, brightness: Brightness) -> Self {
436 self.platform_brightness = brightness;
437 self
438 }
439
440 /// Sets the accessibility settings the platform reports.
441 pub fn with_accessibility(mut self, accessibility: Accessibility) -> Self {
442 self.accessibility = accessibility;
443 self
444 }
445
446 /// Sets the edges a system gesture starts from.
447 pub fn with_system_gesture_insets(mut self, insets: Insets) -> Self {
448 self.system_gesture_insets = insets;
449 self
450 }
451
452 /// A font size with the user's text-size setting applied.
453 ///
454 /// The reference calls this `TextScaler.scale`, and it is a **function** there
455 /// rather than a multiplication because a platform may scale non-linearly — large
456 /// sizes growing less than small ones, so a heading does not run off the screen when
457 /// body text is made readable. Ours is linear; the shape of the call is the one that
458 /// can become non-linear without every caller changing.
459 pub fn scaled(&self, size: f32) -> f32 {
460 size * self.text_scaler
461 }
462
463 /// Sets both kinds of inset at once, from what the shell reports.
464 pub fn with_insets(mut self, insets: WindowInsets) -> Self {
465 self.padding = insets.padding;
466 self.view_insets = insets.view_insets;
467 self.view_padding = insets.view_padding;
468 self
469 }
470
471 /// The area to keep content out of: the per-side **maximum** of the two kinds of
472 /// inset. The keyboard covers the navigation bar rather than stacking on top of
473 /// it, so they are combined with `max` and not with `+`.
474 pub fn safe(&self) -> Insets {
475 WindowInsets {
476 padding: self.padding,
477 view_insets: self.view_insets,
478 view_padding: self.view_padding,
479 }
480 .safe()
481 }
482
483 /// Which way round the surface is.
484 pub fn orientation(&self) -> Orientation {
485 Orientation::from_size(self.size.width, self.size.height)
486 }
487
488 /// The width breakpoint the surface falls into.
489 pub fn size_class(&self) -> SizeClass {
490 SizeClass::from_width(self.size.width)
491 }
492
493 /// The same description with the selected edges' [`padding`](Self::padding)
494 /// zeroed — what a widget that has just *consumed* that padding hands to its
495 /// descendants, so they do not inset for the same notch a second time.
496 /// Mixes into `hasher` everything about this surface that can change a **measurement**.
497 ///
498 /// The relayout cache fingerprints the walk, and a scoped surface is part of that walk
499 /// (milestone 417): two subtrees given different descriptions must not share a
500 /// fingerprint, or the second would keep the first's geometry.
501 ///
502 /// The colours are left out on purpose — `platform_brightness` and the accessibility
503 /// flags change how a frame is *painted*, and paint is not what this cache stores.
504 pub fn measure_hash<H: std::hash::Hasher>(&self, hasher: &mut H) {
505 use std::hash::Hash;
506 for n in [
507 self.size.width,
508 self.size.height,
509 self.device_pixel_ratio,
510 self.density,
511 self.text_scaler,
512 ] {
513 n.to_bits().hash(hasher);
514 }
515 for insets in [
516 self.padding,
517 self.view_insets,
518 self.view_padding,
519 self.system_gesture_insets,
520 ] {
521 for n in [insets.top, insets.right, insets.bottom, insets.left] {
522 n.to_bits().hash(hasher);
523 }
524 }
525 }
526
527 /// The query as a descendant sees it once `edges` of the padding have been **consumed** by
528 /// a widget that already made room for them — an app bar takes the top, say. What was
529 /// consumed is taken off the padding and off the view padding (floored at zero), so nothing
530 /// below insets for it a second time.
531 pub fn remove_padding(mut self, edges: Edges) -> Self {
532 let consumed = edges.select(self.padding);
533 // The **view** padding loses the same amount, floored at zero. Without this a
534 // descendant asking for the intrusion that does not move would be told about
535 // one its parent has already dealt with, and would inset for the notch twice
536 // on the one path that was meant to be immune to the keyboard.
537 self.view_padding = Insets::new(
538 (self.view_padding.top - consumed.top).max(0.0),
539 (self.view_padding.right - consumed.right).max(0.0),
540 (self.view_padding.bottom - consumed.bottom).max(0.0),
541 (self.view_padding.left - consumed.left).max(0.0),
542 );
543 self.padding = Insets::new(
544 self.padding.top - consumed.top,
545 self.padding.right - consumed.right,
546 self.padding.bottom - consumed.bottom,
547 self.padding.left - consumed.left,
548 );
549 self
550 }
551
552 /// The same description with the selected edges' [`view_insets`](Self::view_insets)
553 /// zeroed — the keyboard equivalent of [`remove_padding`](Self::remove_padding).
554 pub fn remove_view_insets(mut self, edges: Edges) -> Self {
555 let consumed = edges.select(self.view_insets);
556 // Same reasoning as [`remove_padding`](Self::remove_padding): a keyboard a
557 // parent has already made room for is not one its children should hear about,
558 // through either inset.
559 self.view_padding = Insets::new(
560 (self.view_padding.top - consumed.top).max(0.0),
561 (self.view_padding.right - consumed.right).max(0.0),
562 (self.view_padding.bottom - consumed.bottom).max(0.0),
563 (self.view_padding.left - consumed.left).max(0.0),
564 );
565 self.view_insets = Insets::new(
566 self.view_insets.top - consumed.top,
567 self.view_insets.right - consumed.right,
568 self.view_insets.bottom - consumed.bottom,
569 self.view_insets.left - consumed.left,
570 );
571 self
572 }
573
574 /// The description currently in force, or [`UNSET`](Self::UNSET) outside any
575 /// scope.
576 pub fn of() -> MediaQuery {
577 AMBIENT.with(|a| a.get())
578 }
579
580 /// Is a surface actually **described**? False outside every
581 /// [`scope`](Self::scope), where [`of`](Self::of) answers
582 /// [`UNSET`](Self::UNSET) — a surface of no size.
583 ///
584 /// Any widget that sizes itself from the ambient description needs this, because
585 /// a width of zero is not a narrow screen: it is the absence of one, and the two
586 /// want opposite answers. A bar folds everything into its menu at zero and nothing
587 /// at all when there is no screen to fold against.
588 pub fn is_described(&self) -> bool {
589 self.size.width > 0.0 && self.size.height > 0.0
590 }
591
592 /// Runs `f` with `self` as the ambient description, and restores whatever was in
593 /// force before — including when `f` panics, so one bad frame cannot leave a
594 /// stale surface installed for every frame after it.
595 ///
596 /// **For a subtree**, where a closure is the right shape: a widget changing the
597 /// description its children see. A *frame* wants [`install`](Self::install) instead,
598 /// because a frame does not fit in a closure — see there for why that matters.
599 pub fn scope<R>(self, f: impl FnOnce() -> R) -> R {
600 let guard = self.install();
601 let out = f();
602 drop(guard);
603 out
604 }
605
606 /// Installs `self` as the ambient description **until the returned guard is dropped**,
607 /// and restores whatever was in force before — panic or not.
608 ///
609 /// # Why this exists as well as [`scope`](Self::scope)
610 ///
611 /// A closure bounds the description to one call, and a *frame* is not one call. The
612 /// widgets are built, then measured and laid out, then painted, and **all three**
613 /// resolve sizes. The shell wrapped only the build for four milestones: the reader's
614 /// font size reached a real device and moved not one pixel, because the two steps that
615 /// decide how big text actually is ran outside the closure (milestone 407).
616 ///
617 /// The description and the reader's font size are installed **together, by this one
618 /// call**, and that is the point. They were briefly two guards that had to agree with
619 /// each other, which is the same bug with an extra step: whoever holds one and forgets
620 /// the other gets a layout measured at one size and painted at another, with nothing to
621 /// say so.
622 #[must_use = "the surface is uninstalled the moment the guard is dropped"]
623 pub fn install(self) -> SurfaceGuard {
624 let previous = AMBIENT.with(|a| a.replace(self));
625 // The reader's font size travels with the description, because it **is** part of
626 // the description and because installing it anywhere else would be one more thing
627 // to remember. It lives in `frus-core` rather than here: the only place a size
628 // becomes a number is `TextStyle::resolved`, and that is below this crate.
629 SurfaceGuard {
630 _scale: frus_core::install_text_scale(self.text_scaler),
631 _surface: Restore(previous),
632 }
633 }
634}
635
636/// Holds a surface installed — see [`MediaQuery::install`]. Dropping it puts back the
637/// description **and** the reader's font size that were in force before.
638///
639/// The two live in one guard so that they cannot be held for different lengths of time,
640/// which is exactly how they came apart in milestone 407.
641#[must_use = "the surface is uninstalled the moment the guard is dropped"]
642pub struct SurfaceGuard {
643 /// Held for its `Drop` and never read, hence the name. Declared **first**, so it is
644 /// dropped first: the reverse of the order the two were installed in.
645 _scale: frus_core::TextScaleGuard,
646 /// Held for its `Drop` and never read. See `_scale` for the ordering.
647 _surface: Restore,
648}
649
650thread_local! {
651 /// The description in force on this thread. `Cell`, not `RefCell`: the value is
652 /// `Copy` and every access is a whole get or a whole set, so there is no borrow to
653 /// get wrong.
654 static AMBIENT: Cell<MediaQuery> = const { Cell::new(MediaQuery::UNSET) };
655}
656
657/// Puts back the previous ambient description when dropped — including while a panic
658/// unwinds.
659struct Restore(MediaQuery);
660
661impl Drop for Restore {
662 fn drop(&mut self) {
663 AMBIENT.with(|a| a.set(self.0));
664 }
665}
666
667#[cfg(test)]
668mod tests {
669 use super::*;
670
671 fn phone() -> MediaQuery {
672 MediaQuery::new(Size::new(360.0, 780.0))
673 .with_device_pixel_ratio(3.0)
674 .with_insets(WindowInsets::bars(Insets::new(28.0, 0.0, 16.0, 0.0)))
675 }
676
677 #[test]
678 fn outside_a_scope_the_surface_is_unset_rather_than_a_panic() {
679 assert_eq!(MediaQuery::of(), MediaQuery::UNSET);
680 }
681
682 #[test]
683 fn a_scope_is_visible_inside_and_gone_outside() {
684 let seen = phone().scope(MediaQuery::of);
685 assert_eq!(seen.size.width, 360.0);
686 assert_eq!(seen.padding.top, 28.0);
687 assert_eq!(MediaQuery::of(), MediaQuery::UNSET);
688 }
689
690 #[test]
691 fn scopes_nest_and_unwind_in_order() {
692 phone().scope(|| {
693 assert_eq!(MediaQuery::of().size.width, 360.0);
694 MediaQuery::new(Size::new(1200.0, 800.0)).scope(|| {
695 assert_eq!(MediaQuery::of().size.width, 1200.0);
696 });
697 // The inner scope put the outer one back, not `UNSET`.
698 assert_eq!(MediaQuery::of().size.width, 360.0);
699 });
700 }
701
702 #[test]
703 fn a_panicking_scope_still_restores_the_previous_surface() {
704 let caught = std::panic::catch_unwind(|| {
705 phone().scope(|| panic!("the view exploded"));
706 });
707 assert!(caught.is_err());
708 assert_eq!(
709 MediaQuery::of(),
710 MediaQuery::UNSET,
711 "a panic inside a scope must not leave its surface installed"
712 );
713 }
714
715 #[test]
716 fn removing_padding_leaves_the_other_edges_alone() {
717 let mq = phone().remove_padding(Edges::ALL.without_bottom());
718 assert_eq!(mq.padding.top, 0.0);
719 assert_eq!(mq.padding.bottom, 16.0, "the bottom was not consumed");
720 }
721
722 #[test]
723 fn the_keyboard_and_the_navigation_bar_do_not_stack() {
724 // Derived the way the shell derives it: a 16 px bar, then 320 px of occlusion
725 // measured from the window edge. Hand-writing the three insets would let a test
726 // describe a surface no platform can report.
727 let mq = phone().with_insets(WindowInsets::from_baseline(
728 Insets::new(28.0, 0.0, 16.0, 0.0),
729 Insets::new(28.0, 0.0, 320.0, 0.0),
730 ));
731 assert_eq!(mq.safe().bottom, 320.0, "not 336");
732 assert_eq!(mq.safe().top, 28.0);
733 }
734
735 /// The three insets, and what each is for.
736 ///
737 /// `view_padding` is the bar whether or not anything covers it; `view_insets` is the
738 /// occlusion from the window edge; `padding` is what is **left** to avoid, which is
739 /// nothing at the bottom once the keyboard is over the bar. Padding a screen by the
740 /// bar as well would leave a strip of nothing above the keys.
741 #[test]
742 fn the_padding_is_what_the_keyboard_has_not_already_covered() {
743 let bar = Insets::new(28.0, 0.0, 16.0, 0.0);
744 let shut = phone().with_insets(WindowInsets::bars(bar));
745 assert_eq!(shut.padding.bottom, 16.0);
746 assert_eq!(shut.view_padding.bottom, 16.0);
747 assert_eq!(shut.view_insets.bottom, 0.0);
748
749 let open = phone().with_insets(WindowInsets::from_baseline(
750 bar,
751 Insets::new(28.0, 0.0, 320.0, 0.0),
752 ));
753 assert_eq!(
754 open.padding.bottom, 0.0,
755 "the bar is covered, so nothing avoids it"
756 );
757 assert_eq!(open.view_padding.bottom, 16.0, "the bar has not moved");
758 assert_eq!(open.view_insets.bottom, 320.0);
759 // The top is untouched by any of it: a notch is a notch.
760 assert_eq!(open.padding.top, 28.0);
761 assert_eq!(open.view_padding.top, 28.0);
762 }
763
764 /// Consuming the padding consumes as much of the **view** padding, or a widget
765 /// inside a safe area would inset for the notch a second time on the one path that
766 /// was meant to be immune to the keyboard.
767 #[test]
768 fn consuming_the_padding_consumes_the_view_padding_with_it() {
769 let mq = phone()
770 .with_insets(WindowInsets::bars(Insets::new(28.0, 0.0, 16.0, 0.0)))
771 .remove_padding(Edges::ALL);
772 assert_eq!(mq.padding, Insets::ZERO);
773 assert_eq!(mq.view_padding, Insets::ZERO);
774 }
775
776 #[test]
777 fn orientation_and_size_class_come_from_the_surface() {
778 assert_eq!(phone().orientation(), Orientation::Portrait);
779 assert_eq!(phone().size_class(), SizeClass::Compact);
780 let desk = MediaQuery::new(Size::new(1440.0, 900.0));
781 assert_eq!(desk.orientation(), Orientation::Landscape);
782 assert_eq!(desk.size_class(), SizeClass::Expanded);
783 }
784
785 #[test]
786 fn a_square_surface_counts_as_portrait() {
787 let square = MediaQuery::new(Size::new(500.0, 500.0));
788 assert_eq!(square.orientation(), Orientation::Portrait);
789 }
790
791 /// **The platform answers, and the application overrides only what it spoke for.**
792 ///
793 /// The ordering is the point. These settings belong to the person using the device,
794 /// so an application that says nothing must not be able to overrule them by accident —
795 /// and before this the application was the only source, so silence and "off" were the
796 /// same sentence.
797 #[test]
798 fn an_application_that_says_nothing_leaves_the_user_alone() {
799 let user = Accessibility {
800 disable_animations: true,
801 accessible_navigation: true,
802 ..Accessibility::NONE
803 };
804 assert_eq!(
805 user.with_overrides(AccessibilityOverrides::NONE),
806 user,
807 "an application with no settings screen changes nothing"
808 );
809 }
810
811 /// And an application that speaks for **one** setting speaks for one setting.
812 ///
813 /// This is what a plain `Accessibility` could not express: forcing *reduce motion* off
814 /// used to mean also declaring that the user runs no screen reader, because a `false`
815 /// and a silence were the same value.
816 #[test]
817 fn speaking_for_one_setting_does_not_speak_for_the_others() {
818 let user = Accessibility {
819 disable_animations: true,
820 accessible_navigation: true,
821 always_use_24_hour_format: true,
822 ..Accessibility::NONE
823 };
824 let resolved = user.with_overrides(AccessibilityOverrides::NONE.disable_animations(false));
825 assert!(!resolved.disable_animations, "the application had its say");
826 assert!(
827 resolved.accessible_navigation,
828 "and said nothing about the screen reader"
829 );
830 assert!(resolved.always_use_24_hour_format, "nor about the clock");
831 }
832
833 /// A surface installed with [`MediaQuery::scope`] puts the reader's font size where
834 /// [`frus_core::TextStyle::resolved`] reads it — the whole point of carrying the
835 /// number. Without this the platform could report it and nothing would change.
836 #[test]
837 fn a_described_surface_hands_the_readers_font_size_to_the_text() {
838 let phone = MediaQuery::new(Size::new(400.0, 800.0)).with_text_scaler(1.5);
839 let plain = frus_core::TextStyle::new(16.0);
840 assert_eq!(
841 plain.resolved().size,
842 16.0,
843 "outside a surface, nothing moves"
844 );
845 phone.scope(|| {
846 assert_eq!(plain.resolved().size, 24.0);
847 });
848 assert_eq!(plain.resolved().size, 16.0, "and it is put back afterwards");
849 }
850
851 /// **The description and the reader's font size are installed by one call.**
852 ///
853 /// They were briefly two guards that had to agree with each other, which is the same
854 /// bug with an extra step: whoever holds one and forgets the other gets a layout
855 /// measured at one size and painted at another.
856 #[test]
857 fn one_guard_installs_the_whole_surface() {
858 let phone = MediaQuery::new(Size::new(360.0, 780.0)).with_text_scaler(1.5);
859 assert_eq!(frus_core::text_scale(), 1.0);
860 assert!(!MediaQuery::of().is_described());
861 {
862 let _held = phone.install();
863 assert_eq!(frus_core::text_scale(), 1.5, "the font size is in force");
864 assert!(MediaQuery::of().is_described(), "and so is the description");
865 }
866 assert_eq!(frus_core::text_scale(), 1.0, "both are put back");
867 assert!(!MediaQuery::of().is_described());
868 }
869
870 /// A guard **outlives a closure**, which is the whole reason it exists: a frame builds,
871 /// then lays out, then paints, and all three resolve sizes.
872 #[test]
873 fn a_guard_holds_past_the_call_that_made_it() {
874 let built = {
875 let held = MediaQuery::new(Size::new(400.0, 300.0))
876 .with_text_scaler(2.0)
877 .install();
878 let size = frus_core::TextStyle::new(16.0).resolved().size;
879 // The "layout" and the "paint" of this frame: after the widgets were built,
880 // and still inside the surface.
881 let laid_out = frus_core::TextStyle::new(16.0).resolved().size;
882 drop(held);
883 (size, laid_out)
884 };
885 assert_eq!(built, (32.0, 32.0));
886 assert_eq!(frus_core::TextStyle::new(16.0).resolved().size, 16.0);
887 }
888
889 /// Installing one **inside** another nests and unwinds in order, so a widget can still
890 /// change the description its own subtree sees.
891 #[test]
892 fn surfaces_nest_and_unwind_in_order() {
893 let outer = MediaQuery::new(Size::new(400.0, 800.0)).with_text_scaler(1.5);
894 let inner = MediaQuery::new(Size::new(200.0, 400.0)).with_text_scaler(3.0);
895 let _o = outer.install();
896 assert_eq!(frus_core::text_scale(), 1.5);
897 {
898 let _i = inner.install();
899 assert_eq!(frus_core::text_scale(), 3.0);
900 assert_eq!(MediaQuery::of().size.width, 200.0);
901 }
902 assert_eq!(frus_core::text_scale(), 1.5, "the outer surface is back");
903 assert_eq!(MediaQuery::of().size.width, 400.0);
904 }
905}