frus_widgets/scaffoldinfo.rs
1//! [`ScaffoldInfo`]: **what the shell knows and its slots do not.**
2//!
3//! An [`AppBar`](crate::AppBar) is handed to a [`Scaffold`](crate::Scaffold) already
4//! built, so it cannot see the shell it is about to stand in. The reference's does, through
5//! the context: `Scaffold.of(context)` tells a bar whether the screen has a drawer, which
6//! is how a bar with no `leading` grows a menu button on a screen that has a menu and
7//! stays empty on one that does not (`app_bar.dart:1010`).
8//!
9//! This is that context, in the shape frus already uses twice: an ambient value the walk
10//! installs on the way down, like [`MediaQuery`](crate::MediaQuery) and the theme, and a
11//! [`Widget`] hook — [`Widget::scaffold_override`] — for the node that imposes one.
12//!
13//! It carries **messages**, which is the part that needed care: the shell knows what opens
14//! its drawer and the bar does not, and the two are generic over different type parameters
15//! as far as the ambient is concerned. The message is held as `Rc<dyn Any>` and handed back
16//! only to a caller that names the type it was put in with. A bar whose `Msg` is not the
17//! shell's asks and is told nothing, which is the honest answer: it could not have sent
18//! that message anyway.
19//!
20//! ```ignore
21//! ScaffoldScope::new(ScaffoldInfo::default().with_drawer(Msg::ToggleMenu), shell)
22//! ```
23
24use std::any::Any;
25use std::cell::RefCell;
26use std::hash::{Hash, Hasher};
27use std::rc::Rc;
28
29use crate::widget::Widget;
30
31/// What the [`Scaffold`](crate::Scaffold) around a widget knows about itself.
32///
33/// Empty outside a shell — which is not the same as "a shell with nothing in it", but the
34/// two want the same answers here, and a bar outside a shell has no drawer to open either
35/// way.
36#[derive(Clone, Default)]
37pub struct ScaffoldInfo {
38 /// The message that opens or closes the leading drawer, if the shell has one.
39 drawer: Option<Rc<dyn Any>>,
40 /// The same for the trailing drawer.
41 end_drawer: Option<Rc<dyn Any>>,
42}
43
44impl ScaffoldInfo {
45 /// The one in force here — empty outside every [`ScaffoldInfo::scope`].
46 #[inline(never)]
47 #[must_use]
48 pub fn of() -> Self {
49 AMBIENT.with(|a| a.borrow().clone())
50 }
51
52 /// Records a leading drawer and the message that toggles it.
53 #[must_use]
54 pub fn with_drawer<Msg: Clone + 'static>(mut self, toggle: Msg) -> Self {
55 self.drawer = Some(Rc::new(toggle));
56 self
57 }
58
59 /// Records a trailing drawer and the message that toggles it.
60 #[must_use]
61 pub fn with_end_drawer<Msg: Clone + 'static>(mut self, toggle: Msg) -> Self {
62 self.end_drawer = Some(Rc::new(toggle));
63 self
64 }
65
66 /// Whether the shell has a drawer on the leading edge.
67 #[must_use]
68 pub fn has_drawer(&self) -> bool {
69 self.drawer.is_some()
70 }
71
72 /// Whether the shell has a drawer on the trailing edge.
73 #[must_use]
74 pub fn has_end_drawer(&self) -> bool {
75 self.end_drawer.is_some()
76 }
77
78 /// The message that toggles the leading drawer, for a widget whose message type it
79 /// **is**. `None` for anyone else: a widget cannot send a message of another tree's
80 /// type, so being told about it would only invite a cast.
81 #[must_use]
82 pub fn drawer_toggle<Msg: Clone + 'static>(&self) -> Option<Msg> {
83 self.drawer.as_ref()?.downcast_ref::<Msg>().cloned()
84 }
85
86 /// The same for the trailing drawer.
87 #[must_use]
88 pub fn end_drawer_toggle<Msg: Clone + 'static>(&self) -> Option<Msg> {
89 self.end_drawer.as_ref()?.downcast_ref::<Msg>().cloned()
90 }
91
92 /// Puts this in force until the guard is dropped.
93 #[inline(never)]
94 #[must_use]
95 pub fn install(self) -> ScaffoldGuard {
96 ScaffoldGuard(AMBIENT.with(|a| a.replace(self)))
97 }
98
99 /// Runs `body` with this in force, and puts back what was there.
100 pub fn scope<R>(self, body: impl FnOnce() -> R) -> R {
101 let _guard = self.install();
102 body()
103 }
104
105 /// The part of this that changes what a slot **composes**, for the relayout
106 /// fingerprint.
107 ///
108 /// Whether there is a drawer decides whether a bar grows a button, which is a
109 /// different row and a different geometry. *Which* message opens it does not: two
110 /// shells with different messages and the same drawers lay out identically, and
111 /// hashing the message would mean hashing a pointer that moves every frame.
112 pub(crate) fn shape_hash<H: Hasher>(&self, hasher: &mut H) {
113 self.has_drawer().hash(hasher);
114 self.has_end_drawer().hash(hasher);
115 }
116}
117
118/// Puts back the previous ambient shell when dropped — including while a panic unwinds.
119pub struct ScaffoldGuard(ScaffoldInfo);
120
121impl Drop for ScaffoldGuard {
122 #[inline(never)]
123 fn drop(&mut self) {
124 AMBIENT.with(|a| a.replace(std::mem::take(&mut self.0)));
125 }
126}
127
128thread_local! {
129 /// The shell in force on this thread. A `RefCell` rather than a `Cell` because the
130 /// value is not `Copy`: it holds the messages, and a message is whatever the
131 /// application says it is.
132 static AMBIENT: RefCell<ScaffoldInfo> = const { RefCell::new(ScaffoldInfo {
133 drawer: None,
134 end_drawer: None,
135 }) };
136}
137
138/// A **shell for one subtree**: the counterpart of [`MediaScope`](crate::MediaScope), one
139/// milestone later and for the other thing a shell knows.
140///
141/// A **transparent wrapper**: it lays out, paints and answers exactly
142/// as its child does. The only thing it adds is what the subtree below it reads from
143/// [`ScaffoldInfo::of`] — including a subtree deferred until
144/// [`Widget::build_themed`](crate::Widget::build_themed), which is where an
145/// [`AppBar`](crate::AppBar) is composed and therefore the whole point.
146pub struct ScaffoldScope<Msg = crate::callback::Callback> {
147 info: ScaffoldInfo,
148 inner: Box<dyn Widget<Msg>>,
149}
150
151impl<Msg> ScaffoldScope<Msg> {
152 /// Tells `child`'s subtree what shell it is in.
153 pub fn new(info: ScaffoldInfo, child: impl Widget<Msg> + 'static) -> Self {
154 Self {
155 info,
156 inner: Box::new(child),
157 }
158 }
159
160 /// Transparent: it does not change the box its child is.
161 fn restyle(&self, base: frus_layout::Style) -> frus_layout::Style {
162 base
163 }
164}
165
166crate::transparent::forward_transparent!(ScaffoldScope {
167 fn key(&self) -> Option<u64> {
168 self.inner.key()
169 }
170 fn positioned(&self) -> Option<crate::positioned::Positioning> {
171 self.inner.positioned()
172 }
173 fn theme_override(&self, inherited: &crate::theme::Theme) -> Option<Box<crate::theme::Theme>> {
174 self.inner.theme_override(inherited)
175 }
176 fn media_override(&self, inherited: crate::MediaQuery) -> Option<crate::MediaQuery> {
177 self.inner.media_override(inherited)
178 }
179 /// The shell this scope names — unless the child is itself a scope, in which case the
180 /// **inner** one wins, exactly as a nested `Scaffold` wins in the reference.
181 fn scaffold_override(&self) -> Option<ScaffoldInfo> {
182 Some(
183 self.inner
184 .scaffold_override()
185 .unwrap_or_else(|| self.info.clone()),
186 )
187 }
188 fn scroll_behavior_override(&self) -> Option<crate::ScrollBehavior> {
189 self.inner.scroll_behavior_override()
190 }
191 /// Forwarded: a scope around a form is still that form (milestone 512).
192 fn autofill_group(&self) -> bool {
193 self.inner.autofill_group()
194 }
195 /// Forwarded: the area's bar is the area's, whatever wraps it.
196 fn area_toolbar(
197 &self,
198 context: crate::ToolbarContext,
199 ) -> Option<Option<&dyn crate::widget::Widget<Msg>>> {
200 self.inner.area_toolbar(context)
201 }
202});
203
204#[cfg(test)]
205mod tests {
206 use super::*;
207 use crate::{build_ui, Container, Runtime, Size, Theme};
208
209 #[derive(Clone, Debug, PartialEq)]
210 enum Msg {
211 Menu,
212 }
213
214 /// Another tree's message type, which the shell's message is not.
215 #[derive(Clone, Debug, PartialEq)]
216 enum Other {
217 #[allow(dead_code)]
218 Menu,
219 }
220
221 /// Outside a shell there is no shell, and the answer says so rather than inventing one.
222 #[test]
223 fn there_is_no_shell_until_one_says_so() {
224 let bare = ScaffoldInfo::of();
225 assert!(!bare.has_drawer() && !bare.has_end_drawer());
226 assert_eq!(bare.drawer_toggle::<Msg>(), None);
227 }
228
229 /// The message comes back to the tree it was put in for, and to nobody else.
230 #[test]
231 fn a_message_is_handed_back_only_to_the_type_it_was_put_in_with() {
232 ScaffoldInfo::default().with_drawer(Msg::Menu).scope(|| {
233 let info = ScaffoldInfo::of();
234 assert!(info.has_drawer());
235 assert_eq!(info.drawer_toggle::<Msg>(), Some(Msg::Menu));
236 // A tree of another message type is told nothing, because it could not
237 // have sent this message anyway.
238 assert_eq!(info.drawer_toggle::<Other>(), None);
239 });
240 // And the scope is closed behind it.
241 assert!(!ScaffoldInfo::of().has_drawer());
242 }
243
244 /// The scope reaches the subtree through the walk, not only the call that installs it —
245 /// which is what an already-built app bar needs.
246 #[test]
247 fn a_scope_reaches_the_subtree_the_walk_visits() {
248 let seen = std::rc::Rc::new(std::cell::Cell::new(false));
249 let probe = {
250 let seen = seen.clone();
251 crate::ThemeBuilder::<Msg>::boxed(move |_| {
252 seen.set(ScaffoldInfo::of().has_drawer());
253 Box::new(Container::new().color(frus_core::Color::WHITE))
254 })
255 };
256 let tree = ScaffoldScope::new(ScaffoldInfo::default().with_drawer(Msg::Menu), probe);
257 build_ui(
258 &tree,
259 Size::new(100.0, 100.0),
260 &Runtime::default(),
261 &Theme::default(),
262 );
263 assert!(seen.get(), "the deferred build did not see the shell");
264 }
265
266 /// Two shells that differ in what they hold must not share a layout: a drawer is a
267 /// button in the bar, and a button is a different row. The cache keys on a fingerprint
268 /// of the walk, so the shell has to be in it — the same trap `theme_override` and
269 /// `media_override` each document beside themselves.
270 #[test]
271 fn the_shell_is_part_of_the_relayout_fingerprint() {
272 // One runtime across both builds, so the second really does consult the first's
273 // cache rather than starting from nothing.
274 let runtime = Runtime::default();
275 let height = |drawer: bool| {
276 let probe = crate::ThemeBuilder::<Msg>::boxed(move |_| {
277 // A shell with a drawer composes a taller box, the way a bar with a menu
278 // button composes a different row.
279 let tall = if ScaffoldInfo::of().has_drawer() {
280 40.0
281 } else {
282 10.0
283 };
284 Box::new(
285 Container::new()
286 .width(10.0)
287 .height(tall)
288 .color(frus_core::Color::rgb8(255, 0, 128)),
289 )
290 });
291 let info = if drawer {
292 ScaffoldInfo::default().with_drawer(Msg::Menu)
293 } else {
294 ScaffoldInfo::default()
295 };
296 let tree = ScaffoldScope::new(info, probe);
297 build_ui(&tree, Size::new(100.0, 100.0), &runtime, &Theme::default())
298 .scene()
299 .primitives()
300 .iter()
301 .find_map(|p| match p {
302 frus_core::Primitive::Rect { rect, .. } => Some(rect.height),
303 _ => None,
304 })
305 };
306 let with = height(true);
307 let without = height(false);
308 assert_ne!(
309 with, without,
310 "a shell with a drawer was served from the cache of one without"
311 );
312 assert_eq!(height(true), with, "and back again");
313 }
314}