Skip to main content

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}