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`] 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    fn scroll_behavior_override(&self) -> Option<crate::ScrollBehavior> {
131        self.inner.scroll_behavior_override()
132    }
133    /// Forwarded: a wrapper around a form is still that form (milestone 512).
134    fn autofill_group(&self) -> bool {
135        self.inner.autofill_group()
136    }
137    /// Forwarded: the area's bar is the area's, whatever wraps it.
138    fn area_toolbar(
139        &self,
140        context: crate::ToolbarContext,
141    ) -> Option<Option<&dyn crate::widget::Widget<Msg>>> {
142        self.inner.area_toolbar(context)
143    }
144});
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149    use crate::flex::Flex;
150    use crate::runtime::Runtime;
151    use crate::theme::Theme;
152    use crate::ui::build_ui;
153    use frus_core::{Insets, Size};
154
155    #[derive(Clone, Debug, PartialEq)]
156    enum Msg {}
157
158    /// Reads `MediaQuery::of()` at the moment the walk builds it, which is what a deferred
159    /// subtree — an `AppBar`, a `ThemeBuilder` — does.
160    fn probe(seen: std::rc::Rc<std::cell::Cell<Insets>>) -> impl Widget<Msg> {
161        crate::ThemeBuilder::new(move |_: &Theme| {
162            seen.set(MediaQuery::of().padding);
163            crate::container::Container::<Msg>::new()
164                .width(10.0)
165                .height(10.0)
166        })
167    }
168
169    /// A phone-shaped surface: a status bar at the top, a gesture handle at the bottom.
170    fn surface() -> MediaQuery {
171        let padding = Insets::new(48.0, 0.0, 24.0, 0.0);
172        MediaQuery::new(Size::new(200.0, 200.0)).with_insets(frus_core::WindowInsets {
173            padding,
174            view_insets: Insets::ZERO,
175            view_padding: padding,
176        })
177    }
178
179    #[test]
180    fn a_scope_narrows_the_surface_its_subtree_is_built_under() {
181        // The point of the milestone: the widget that knows what to consume is not the one
182        // doing the constructing, so the swap has to happen during the **walk**.
183        let seen = std::rc::Rc::new(std::cell::Cell::new(Insets::ZERO));
184        let scoped = MediaScope::tweak(
185            |mq: &mut MediaQuery| mq.padding.top = 0.0,
186            probe(seen.clone()),
187        );
188        let root = Flex::<Msg>::column()
189            .width(200.0)
190            .height(200.0)
191            .child(scoped);
192        surface().scope(|| {
193            build_ui(
194                &root,
195                Size::new(200.0, 200.0),
196                &Runtime::default(),
197                &Theme::default(),
198            )
199        });
200        assert_eq!(
201            seen.get(),
202            Insets::new(0.0, 0.0, 24.0, 0.0),
203            "the top was consumed for the subtree, the bottom left alone"
204        );
205    }
206
207    #[test]
208    fn without_a_scope_a_subtree_sees_the_frames_own_surface() {
209        let seen = std::rc::Rc::new(std::cell::Cell::new(Insets::ZERO));
210        let root = Flex::<Msg>::column()
211            .width(200.0)
212            .height(200.0)
213            .child(probe(seen.clone()));
214        surface().scope(|| {
215            build_ui(
216                &root,
217                Size::new(200.0, 200.0),
218                &Runtime::default(),
219                &Theme::default(),
220            )
221        });
222        assert_eq!(seen.get(), Insets::new(48.0, 0.0, 24.0, 0.0));
223    }
224
225    #[test]
226    fn scopes_compose_from_the_inside_out() {
227        // Each starts from what it inherits, so an inner one consuming the bottom keeps the
228        // outer one's consumed top. Written out because the alternative — the inner one
229        // starting from the frame's surface — reads identically at the call site and is
230        // wrong in a way nothing would say out loud.
231        let seen = std::rc::Rc::new(std::cell::Cell::new(Insets::ZERO));
232        let inner = MediaScope::tweak(
233            |mq: &mut MediaQuery| mq.padding.bottom = 0.0,
234            probe(seen.clone()),
235        );
236        let outer = MediaScope::tweak(|mq: &mut MediaQuery| mq.padding.top = 0.0, inner);
237        let root = Flex::<Msg>::column()
238            .width(200.0)
239            .height(200.0)
240            .child(outer);
241        surface().scope(|| {
242            build_ui(
243                &root,
244                Size::new(200.0, 200.0),
245                &Runtime::default(),
246                &Theme::default(),
247            )
248        });
249        assert_eq!(seen.get(), Insets::ZERO);
250    }
251
252    #[test]
253    fn a_scoped_surface_is_part_of_the_relayout_fingerprint() {
254        // The cache keys on a fingerprint of the walk, and a scoped surface is part of that
255        // walk. If it were not hashed, two subtrees given different descriptions would
256        // share a fingerprint and the second would keep the first's geometry — the same
257        // trap `theme_override` documents beside it.
258        let runtime = Runtime::default();
259        let height = |top: f32| {
260            let scoped = MediaScope::tweak(
261                move |mq: &mut MediaQuery| mq.padding.top = top,
262                crate::safearea::SafeArea::<Msg>::new(
263                    crate::container::Container::<Msg>::new()
264                        .width(10.0)
265                        .height(10.0)
266                        .color(frus_core::Color::rgb8(255, 0, 128)),
267                ),
268            );
269            let root = Flex::<Msg>::column()
270                .width(200.0)
271                .height(200.0)
272                .child(scoped);
273            surface().scope(|| {
274                build_ui(&root, Size::new(200.0, 200.0), &runtime, &Theme::default())
275                    .scene()
276                    .primitives()
277                    .iter()
278                    .find_map(|p| match p {
279                        frus_core::Primitive::Rect { rect, .. } => Some(rect.y),
280                        _ => None,
281                    })
282            })
283        };
284        let first = height(40.0);
285        let second = height(0.0);
286        assert_ne!(
287            first, second,
288            "a scoped surface must not be served from the cache of a different one"
289        );
290        assert_eq!(height(40.0), first, "and back again");
291    }
292
293    /// **A component inside a scope is built for the scope** (milestone 642). A wrapper and
294    /// the component it holds are one node, and the component used to be built before the
295    /// node's scope was in force: a page in `MediaScope::tweak` read the whole window.
296    #[test]
297    fn a_component_inside_a_scope_is_built_for_it() {
298        let seen = std::rc::Rc::new(std::cell::Cell::new(None));
299        let page = {
300            let seen = seen.clone();
301            crate::Component::stateless(move |_: &crate::BuildContext| -> Box<dyn Widget> {
302                seen.set(Some(MediaQuery::of().padding));
303                Box::new(crate::container::Container::new().width(10.0).height(10.0))
304            })
305        };
306        let root = Flex::column()
307            .width(200.0)
308            .height(200.0)
309            .child(MediaScope::tweak(
310                |mq: &mut MediaQuery| mq.padding.top = 0.0,
311                page,
312            ));
313        surface().scope(|| {
314            crate::build_deferred(&root, &Theme::default(), &Runtime::default());
315        });
316        assert_eq!(
317            seen.get(),
318            Some(Insets::new(0.0, 0.0, 24.0, 0.0)),
319            "built under the scope's surface, not the frame's"
320        );
321    }
322}