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}