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}