frus_widgets/themebuilder.rs
1//! [`ThemeBuilder`]: builds its subtree **from the ambient theme**.
2//!
3//! Milestone 309 gave the framework `caller ?? theme ?? framework`, and milestone 310
4//! carried it into layout. Both work by handing a widget the theme when it is asked how
5//! it looks or how big it is. That covers everything a theme decides *about a widget*,
6//! and nothing a theme decides *about a composition*.
7//!
8//! An application bar is the example that forced this. Whether it centres its title
9//! changes **which children exist and in what order** — a centred title is a spring, a
10//! title and another spring; a flush one is a title after the leading. By the time
11//! `paint` is called the row has already been assembled, so no amount of theme-at-paint
12//! can answer it. And an `AppBar` is put together by a builder that never sees a theme in
13//! the first place.
14//!
15//! ```ignore
16//! ThemeBuilder::new(|theme| {
17//! AppBar::new()
18//! .title(Text::new("Inbox"))
19//! .center_title(theme.widgets.app_bar.center_title.unwrap_or(platform()))
20//! .build()
21//! })
22//! ```
23//!
24//! ## Why this is not `LayoutBuilder`
25//!
26//! [`LayoutBuilder`](crate::LayoutBuilder) also builds late, and its documentation is
27//! blunt about the price: the content is rebuilt every frame from a **box**, so it has no
28//! retained state — no persistent focus, no deferred overlays. A field inside one would
29//! lose the caret between frames.
30//!
31//! A theme is not a box. It is the same from frame to frame unless the application
32//! changes it, and when it changes, the tree is rebuilt anyway. So this builds **once per
33//! instance**, the subtree keeps its positional identity, and retained state survives —
34//! which is the whole reason an application bar can live in one and keep its overflow
35//! menu open.
36
37use std::cell::{OnceCell, RefCell};
38
39use frus_core::{Rect, Scene};
40use frus_layout::Style;
41
42use crate::interaction::Status;
43use crate::theme::Theme;
44use crate::widget::Widget;
45
46/// What a [`ThemeBuilder`] holds until the theme arrives: a composition that has not been
47/// made yet.
48type Deferred<Msg> = Box<dyn FnOnce(&Theme) -> Box<dyn Widget<Msg>>>;
49
50/// Builds its child from the ambient theme (`theme → widget`).
51///
52/// Transparent otherwise: it has no box, no paint and no identity of its own, so the
53/// child sits exactly where the builder does.
54pub struct ThemeBuilder<Msg = crate::callback::Callback> {
55 /// Taken and run the first time the layout pass reaches this node. `FnOnce`, not
56 /// `Fn`: what it captures is usually a builder holding boxed widgets, which cannot be
57 /// produced twice.
58 build: RefCell<Option<Deferred<Msg>>>,
59 /// The built child, as the one-element slice [`Widget::children`] has to hand back.
60 built: OnceCell<Vec<Box<dyn Widget<Msg>>>>,
61}
62
63impl<Msg: 'static> ThemeBuilder<Msg> {
64 /// Defers a subtree until the ambient theme is known.
65 pub fn new<W: Widget<Msg> + 'static>(build: impl FnOnce(&Theme) -> W + 'static) -> Self {
66 Self {
67 build: RefCell::new(Some(Box::new(move |theme| {
68 Box::new(build(theme)) as Box<dyn Widget<Msg>>
69 }))),
70 built: OnceCell::new(),
71 }
72 }
73
74 /// Defers an **already boxed** subtree, which is what a builder's `build()` returns.
75 pub fn boxed(build: impl FnOnce(&Theme) -> Box<dyn Widget<Msg>> + 'static) -> Self {
76 Self {
77 build: RefCell::new(Some(Box::new(build))),
78 built: OnceCell::new(),
79 }
80 }
81
82 /// The child, building it if the layout pass has not already.
83 ///
84 /// Every traversal goes through here rather than reading the cell directly, so a
85 /// caller that reaches this node without the layout pass having been down it first —
86 /// a bare `natural_size`, a test — gets a built subtree rather than an empty one.
87 fn child(&self, theme: &Theme) -> &[Box<dyn Widget<Msg>>] {
88 if self.built.get().is_none() {
89 let built = match self.build.borrow_mut().take() {
90 Some(build) => vec![build(theme)],
91 // Already taken and stored by another call: the `get` below finds it.
92 None => Vec::new(),
93 };
94 let _ = self.built.set(built);
95 }
96 self.built.get().map(Vec::as_slice).unwrap_or(&[])
97 }
98}
99
100impl<Msg: Clone + 'static> Widget<Msg> for ThemeBuilder<Msg> {
101 fn build_themed(&self, theme: &Theme) {
102 self.child(theme);
103 }
104
105 fn style(&self) -> Style {
106 Style::default()
107 }
108
109 fn style_themed(&self, theme: &Theme) -> Style {
110 // The builder is **transparent**: it takes the child's box, so putting one around
111 // a widget does not change where that widget lands.
112 self.child(theme)
113 .first()
114 .map(|c| c.style_themed(theme))
115 .unwrap_or_default()
116 }
117
118 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
119 // Unbuilt, this is empty — which is why `build_themed` runs on the way down in
120 // the layout pass, before anything reads it. A traversal that arrives first
121 // should call `crate::build_deferred`, not this.
122 //
123 // That rule was a comment for three milestones and the shell broke it: a tree
124 // rebuilt outside a frame, on a burst of keystrokes, was read straight away and
125 // every deferred subtree in it answered "no children" — so nothing inside one
126 // could be found, focused, or scrolled to. It is a tripwire now, because the
127 // failure it guards against is **silence**, and silence is what a reader of this
128 // code would have to notice on their own.
129 #[cfg(debug_assertions)]
130 if self.built.get().is_none() && self.build.borrow().is_some() {
131 panic!(
132 "a ThemeBuilder's children were read before it was built: a traversal \
133 reached this tree before any layout pass did. Call `build_deferred(tree, \
134 &theme)` on it first — milestone 415."
135 );
136 }
137 self.built.get().map(Vec::as_slice).unwrap_or(&[])
138 }
139
140 fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
141
142 fn on_click(&self) -> Option<Msg> {
143 None
144 }
145
146 fn debug_name(&self) -> &'static str {
147 "ThemeBuilder"
148 }
149}
150
151#[cfg(test)]
152mod tests {
153 use super::*;
154 use crate::container::Container;
155 use crate::ui::build_ui;
156 use crate::{Runtime, WidgetId};
157 use frus_core::Color;
158
159 #[derive(Clone, Debug, PartialEq)]
160 enum Msg {}
161
162 /// The point of the widget: a subtree that could not be decided until the theme was
163 /// known. The same builder under two themes produces two different trees.
164 #[test]
165 fn the_subtree_is_built_from_the_ambient_theme() {
166 let under = |theme: Theme| {
167 // A subtree whose *shape* comes from the theme, which is the case no
168 // paint-time or style-time hook can serve.
169 let builder: ThemeBuilder<Msg> =
170 ThemeBuilder::new(|t: &Theme| Container::new().width(t.radius).height(10.0));
171 Widget::<Msg>::build_themed(&builder, &theme);
172 let children = Widget::<Msg>::children(&builder);
173 assert_eq!(children.len(), 1, "exactly one child, always");
174 children[0].style().width
175 };
176 let sharp = Theme {
177 radius: 0.0,
178 ..Theme::default()
179 };
180 let round = Theme {
181 radius: 24.0,
182 ..Theme::default()
183 };
184 assert_eq!(under(sharp), frus_layout::Dimension::Length(0.0));
185 assert_eq!(under(round), frus_layout::Dimension::Length(24.0));
186 }
187
188 /// A traversal that arrives before the layout pass must not see an empty node. Asking
189 /// for the style builds the subtree, so a bare `natural_size` or a test is not a
190 /// different answer from a real frame.
191 #[test]
192 fn asking_for_the_style_builds_it() {
193 let builder: ThemeBuilder<Msg> =
194 ThemeBuilder::new(|_: &Theme| Container::new().width(120.0).height(40.0));
195 let style = Widget::<Msg>::style_themed(&builder, &Theme::default());
196 assert_eq!(style.width, frus_layout::Dimension::Length(120.0));
197 assert_eq!(
198 Widget::<Msg>::children(&builder).len(),
199 1,
200 "and the child is there afterwards"
201 );
202 }
203
204 /// Transparent: the builder has no box of its own, so the child lands exactly where
205 /// it would have without it.
206 #[test]
207 fn the_builder_takes_no_room() {
208 let bare: Container<Msg> = Container::new()
209 .width(200.0)
210 .height(50.0)
211 .color(Color::rgb(1.0, 0.0, 0.0));
212 let wrapped: ThemeBuilder<Msg> = ThemeBuilder::new(|_: &Theme| {
213 Container::new()
214 .width(200.0)
215 .height(50.0)
216 .color(Color::rgb(1.0, 0.0, 0.0))
217 });
218 let rect_of = |w: &dyn Widget<Msg>| {
219 let runtime = Runtime::default();
220 let ui = build_ui(
221 w,
222 frus_core::Size::new(400.0, 300.0),
223 &runtime,
224 &Theme::default(),
225 );
226 ui.scene()
227 .primitives()
228 .iter()
229 .find_map(|p| match p {
230 frus_core::Primitive::Rect { rect, color, .. } if color.r > 0.5 => Some(*rect),
231 _ => None,
232 })
233 .expect("the child painted")
234 };
235 assert_eq!(rect_of(&bare), rect_of(&wrapped));
236 }
237
238 /// Built **once**, not once per traversal: the closure runs a single time however
239 /// many passes go over the node, which is what lets the subtree keep retained state.
240 #[test]
241 fn the_subtree_is_built_once() {
242 use std::rc::Rc;
243 let calls = Rc::new(std::cell::Cell::new(0));
244 let seen = calls.clone();
245 let builder: ThemeBuilder<Msg> = ThemeBuilder::new(move |_: &Theme| {
246 seen.set(seen.get() + 1);
247 Container::new().width(1.0)
248 });
249 let theme = Theme::default();
250 for _ in 0..3 {
251 Widget::<Msg>::build_themed(&builder, &theme);
252 let _ = Widget::<Msg>::style_themed(&builder, &theme);
253 let _ = Widget::<Msg>::children(&builder);
254 }
255 assert_eq!(calls.get(), 1);
256 }
257
258 /// The subtree keeps a **stable identity** across frames, which is what separates this
259 /// from `LayoutBuilder`: the child's id is the builder's child id, and it does not
260 /// move because the builder was rebuilt.
261 #[test]
262 fn the_child_keeps_its_identity() {
263 let id_of = || {
264 let builder: ThemeBuilder<Msg> =
265 ThemeBuilder::new(|_: &Theme| Container::new().width(1.0));
266 Widget::<Msg>::build_themed(&builder, &Theme::default());
267 let child = &Widget::<Msg>::children(&builder)[0];
268 crate::ui::child_id(WidgetId::from_u64(7), 0, child.as_ref())
269 };
270 assert_eq!(id_of(), id_of());
271 }
272}