Skip to main content

frus_widgets/
mediascope.rs

1//! [`MediaScope`]: a **surface for one subtree**, not for the frame.
2//!
3//! The shell installs one description per frame — the window's size, the intrusions the
4//! platform reported, the reader's font setting — and every widget reads it with
5//! [`MediaQuery::of`]. Until milestone 417 that description could only be narrowed **where
6//! a widget is constructed**: [`SafeArea::build`](crate::SafeArea::build) takes a closure,
7//! runs it under a narrowed surface, and keeps what it returns.
8//!
9//! That is the wrong end for a shell. A [`Scaffold`](crate::Scaffold) is handed slots that
10//! are **already built**, so it could not tell its app bar's subtree "the status bar is not
11//! yours to worry about" — it could only inset the bar from outside, which is one switch
12//! where the reference has two: there the shell makes the slot tall enough and the bar
13//! decides for itself whether to consume the status bar. The cost was not cosmetic. An
14//! [`AppBar`](crate::AppBar) used **outside** a shell drew under the status bar, because
15//! nothing insetted it and it would not inset itself.
16//!
17//! ```ignore
18//! MediaScope::tweak(|mq| mq.padding.top = 0.0, app_bar)   // not yours to worry about
19//! ```
20//!
21//! [`MediaScope::tweak`] is the form that gets used, for the same reason
22//! [`Themed::tweak`](crate::Themed::tweak) is: it receives the description **inherited from
23//! above** and changes part of it, so a scope can consume one edge without also inventing a
24//! screen size. It has to be a closure rather than a value because the ambient surface is
25//! not known when the tree is built — the walk resolves it on the way down.
26//!
27//! It is a **transparent wrapper**: it lays out, paints and answers exactly as its child
28//! does (see [`crate::transparent`]). The only thing it adds is the surface its subtree
29//! inherits — for **layout as much as paint**, and for the composition of anything deferred
30//! below it, since [`Widget::build_themed`] runs under the swap.
31//!
32//! Nesting works the way it reads: each scope starts from what it inherits, so an inner one
33//! removing the bottom padding keeps the outer one's removed top.
34
35use crate::media::MediaQuery;
36use crate::widget::Widget;
37
38/// Where a [`MediaScope`]'s description comes from.
39enum Source {
40    /// A description entire, ignoring what it inherits.
41    Data(MediaQuery),
42    /// A change applied to the description inherited from above.
43    Tweak(Box<dyn Fn(&mut MediaQuery)>),
44}
45
46/// Describes the surface for its child and everything under it.
47pub struct MediaScope<Msg = crate::callback::Callback> {
48    source: Source,
49    inner: Box<dyn Widget<Msg>>,
50}
51
52impl<Msg> MediaScope<Msg> {
53    /// Replaces the description wholesale for `child`'s subtree.
54    ///
55    /// For the rare case where a subtree is being laid out against something that is not
56    /// the window at all — a preview of a phone screen inside a desktop tool, a thumbnail
57    /// of a page. Everything else wants [`tweak`](Self::tweak).
58    pub fn new(media: MediaQuery, child: impl Widget<Msg> + 'static) -> Self {
59        Self {
60            source: Source::Data(media),
61            inner: Box::new(child),
62        }
63    }
64
65    /// **Changes part** of the inherited description for `child`'s subtree, which is what a
66    /// shell wants: this slot's top intrusion has already been dealt with, and everything
67    /// else stays as the platform reported it.
68    ///
69    /// The closure runs during the walk, on the description inherited at that point —
70    /// nested scopes compose, and a scope under a [`MediaScope::new`] starts from that one.
71    pub fn tweak(
72        change: impl Fn(&mut MediaQuery) + 'static,
73        child: impl Widget<Msg> + 'static,
74    ) -> Self {
75        Self {
76            source: Source::Tweak(Box::new(change)),
77            inner: Box::new(child),
78        }
79    }
80}
81
82impl<Msg> MediaScope<Msg> {
83    /// A description does reach sizes — a `SafeArea` below one pads by what it was told —
84    /// but through the child's own `style_themed`, under the swap. This wrapper adds
85    /// nothing on top of it.
86    fn restyle(&self, base: frus_layout::Style) -> frus_layout::Style {
87        base
88    }
89}
90
91crate::transparent::forward_transparent!(MediaScope {
92    /// A surface says nothing about identity: a keyed widget in a scope keeps its key.
93    fn key(&self) -> Option<u64> {
94        self.inner.key()
95    }
96
97    /// Forwarded: a description is not a place.
98    fn positioned(&self) -> Option<crate::positioned::Positioning> {
99        self.inner.positioned()
100    }
101
102    /// Forwarded: a surface says nothing about the theme.
103    fn theme_override(
104        &self,
105        inherited: &crate::theme::Theme,
106    ) -> Option<Box<crate::theme::Theme>> {
107        self.inner.theme_override(inherited)
108    }
109
110    /// The one thing a `MediaScope` does not delegate: the surface it exists to impose.
111    ///
112    /// It still asks its child, because a transparent wrapper **is** its child: two nested
113    /// scopes are one node in the tree, and the inner one would otherwise never be asked at
114    /// all. Applied outer first, so the inner one sees what it inherits — which is what
115    /// nesting means when it is written out.
116    fn media_override(&self, inherited: MediaQuery) -> Option<MediaQuery> {
117        let mine = match &self.source {
118            Source::Data(media) => *media,
119            Source::Tweak(change) => {
120                let mut media = inherited;
121                change(&mut media);
122                media
123            }
124        };
125        Some(self.inner.media_override(mine).unwrap_or(mine))
126    }
127    fn scaffold_override(&self) -> Option<crate::ScaffoldInfo> {
128        self.inner.scaffold_override()
129    }
130    /// Forwarded: a wrapper around a form is still that form (milestone 512).
131    fn autofill_group(&self) -> bool {
132        self.inner.autofill_group()
133    }
134});
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139    use crate::flex::Flex;
140    use crate::runtime::Runtime;
141    use crate::theme::Theme;
142    use crate::ui::build_ui;
143    use frus_core::{Insets, Size};
144
145    #[derive(Clone, Debug, PartialEq)]
146    enum Msg {}
147
148    /// Reads `MediaQuery::of()` at the moment the walk builds it, which is what a deferred
149    /// subtree — an `AppBar`, a `ThemeBuilder` — does.
150    fn probe(seen: std::rc::Rc<std::cell::Cell<Insets>>) -> impl Widget<Msg> {
151        crate::ThemeBuilder::new(move |_: &Theme| {
152            seen.set(MediaQuery::of().padding);
153            crate::container::Container::<Msg>::new()
154                .width(10.0)
155                .height(10.0)
156        })
157    }
158
159    /// A phone-shaped surface: a status bar at the top, a gesture handle at the bottom.
160    fn surface() -> MediaQuery {
161        let padding = Insets::new(48.0, 0.0, 24.0, 0.0);
162        MediaQuery::new(Size::new(200.0, 200.0)).with_insets(frus_core::WindowInsets {
163            padding,
164            view_insets: Insets::ZERO,
165            view_padding: padding,
166        })
167    }
168
169    #[test]
170    fn a_scope_narrows_the_surface_its_subtree_is_built_under() {
171        // The point of the milestone: the widget that knows what to consume is not the one
172        // doing the constructing, so the swap has to happen during the **walk**.
173        let seen = std::rc::Rc::new(std::cell::Cell::new(Insets::ZERO));
174        let scoped = MediaScope::tweak(
175            |mq: &mut MediaQuery| mq.padding.top = 0.0,
176            probe(seen.clone()),
177        );
178        let root = Flex::<Msg>::column()
179            .width(200.0)
180            .height(200.0)
181            .child(scoped);
182        surface().scope(|| {
183            build_ui(
184                &root,
185                Size::new(200.0, 200.0),
186                &Runtime::default(),
187                &Theme::default(),
188            )
189        });
190        assert_eq!(
191            seen.get(),
192            Insets::new(0.0, 0.0, 24.0, 0.0),
193            "the top was consumed for the subtree, the bottom left alone"
194        );
195    }
196
197    #[test]
198    fn without_a_scope_a_subtree_sees_the_frames_own_surface() {
199        let seen = std::rc::Rc::new(std::cell::Cell::new(Insets::ZERO));
200        let root = Flex::<Msg>::column()
201            .width(200.0)
202            .height(200.0)
203            .child(probe(seen.clone()));
204        surface().scope(|| {
205            build_ui(
206                &root,
207                Size::new(200.0, 200.0),
208                &Runtime::default(),
209                &Theme::default(),
210            )
211        });
212        assert_eq!(seen.get(), Insets::new(48.0, 0.0, 24.0, 0.0));
213    }
214
215    #[test]
216    fn scopes_compose_from_the_inside_out() {
217        // Each starts from what it inherits, so an inner one consuming the bottom keeps the
218        // outer one's consumed top. Written out because the alternative — the inner one
219        // starting from the frame's surface — reads identically at the call site and is
220        // wrong in a way nothing would say out loud.
221        let seen = std::rc::Rc::new(std::cell::Cell::new(Insets::ZERO));
222        let inner = MediaScope::tweak(
223            |mq: &mut MediaQuery| mq.padding.bottom = 0.0,
224            probe(seen.clone()),
225        );
226        let outer = MediaScope::tweak(|mq: &mut MediaQuery| mq.padding.top = 0.0, inner);
227        let root = Flex::<Msg>::column()
228            .width(200.0)
229            .height(200.0)
230            .child(outer);
231        surface().scope(|| {
232            build_ui(
233                &root,
234                Size::new(200.0, 200.0),
235                &Runtime::default(),
236                &Theme::default(),
237            )
238        });
239        assert_eq!(seen.get(), Insets::ZERO);
240    }
241
242    #[test]
243    fn a_scoped_surface_is_part_of_the_relayout_fingerprint() {
244        // The cache keys on a fingerprint of the walk, and a scoped surface is part of that
245        // walk. If it were not hashed, two subtrees given different descriptions would
246        // share a fingerprint and the second would keep the first's geometry — the same
247        // trap `theme_override` documents beside it.
248        let runtime = Runtime::default();
249        let height = |top: f32| {
250            let scoped = MediaScope::tweak(
251                move |mq: &mut MediaQuery| mq.padding.top = top,
252                crate::safearea::SafeArea::<Msg>::new(
253                    crate::container::Container::<Msg>::new()
254                        .width(10.0)
255                        .height(10.0)
256                        .color(frus_core::Color::rgb8(255, 0, 128)),
257                ),
258            );
259            let root = Flex::<Msg>::column()
260                .width(200.0)
261                .height(200.0)
262                .child(scoped);
263            surface().scope(|| {
264                build_ui(&root, Size::new(200.0, 200.0), &runtime, &Theme::default())
265                    .scene()
266                    .primitives()
267                    .iter()
268                    .find_map(|p| match p {
269                        frus_core::Primitive::Rect { rect, .. } => Some(rect.y),
270                        _ => None,
271                    })
272            })
273        };
274        let first = height(40.0);
275        let second = height(0.0);
276        assert_ne!(
277            first, second,
278            "a scoped surface must not be served from the cache of a different one"
279        );
280        assert_eq!(height(40.0), first, "and back again");
281    }
282}