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 reached this tree before any layout pass did. Call `build_deferred(tree, &theme)` on it first — milestone 415."
133 );
134 }
135 self.built.get().map(Vec::as_slice).unwrap_or(&[])
136 }
137
138 fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
139
140 fn on_click(&self) -> Option<Msg> {
141 None
142 }
143
144 fn debug_name(&self) -> &'static str {
145 "ThemeBuilder"
146 }
147}
148
149#[cfg(test)]
150mod tests {
151 use super::*;
152 use crate::container::Container;
153 use crate::ui::build_ui;
154 use crate::{Runtime, WidgetId};
155 use frus_core::Color;
156
157 #[derive(Clone, Debug, PartialEq)]
158 enum Msg {}
159
160 /// The point of the widget: a subtree that could not be decided until the theme was
161 /// known. The same builder under two themes produces two different trees.
162 #[test]
163 fn the_subtree_is_built_from_the_ambient_theme() {
164 let under = |theme: Theme| {
165 // A subtree whose *shape* comes from the theme, which is the case no
166 // paint-time or style-time hook can serve.
167 let builder: ThemeBuilder<Msg> =
168 ThemeBuilder::new(|t: &Theme| Container::new().width(t.radius).height(10.0));
169 Widget::<Msg>::build_themed(&builder, &theme);
170 let children = Widget::<Msg>::children(&builder);
171 assert_eq!(children.len(), 1, "exactly one child, always");
172 children[0].style().width
173 };
174 let sharp = Theme {
175 radius: 0.0,
176 ..Theme::default()
177 };
178 let round = Theme {
179 radius: 24.0,
180 ..Theme::default()
181 };
182 assert_eq!(under(sharp), frus_layout::Dimension::Length(0.0));
183 assert_eq!(under(round), frus_layout::Dimension::Length(24.0));
184 }
185
186 /// A traversal that arrives before the layout pass must not see an empty node. Asking
187 /// for the style builds the subtree, so a bare `natural_size` or a test is not a
188 /// different answer from a real frame.
189 #[test]
190 fn asking_for_the_style_builds_it() {
191 let builder: ThemeBuilder<Msg> =
192 ThemeBuilder::new(|_: &Theme| Container::new().width(120.0).height(40.0));
193 let style = Widget::<Msg>::style_themed(&builder, &Theme::default());
194 assert_eq!(style.width, frus_layout::Dimension::Length(120.0));
195 assert_eq!(
196 Widget::<Msg>::children(&builder).len(),
197 1,
198 "and the child is there afterwards"
199 );
200 }
201
202 /// Transparent: the builder has no box of its own, so the child lands exactly where
203 /// it would have without it.
204 #[test]
205 fn the_builder_takes_no_room() {
206 let bare: Container<Msg> = Container::new()
207 .width(200.0)
208 .height(50.0)
209 .color(Color::rgb(1.0, 0.0, 0.0));
210 let wrapped: ThemeBuilder<Msg> = ThemeBuilder::new(|_: &Theme| {
211 Container::new()
212 .width(200.0)
213 .height(50.0)
214 .color(Color::rgb(1.0, 0.0, 0.0))
215 });
216 let rect_of = |w: &dyn Widget<Msg>| {
217 let runtime = Runtime::default();
218 let ui = build_ui(
219 w,
220 frus_core::Size::new(400.0, 300.0),
221 &runtime,
222 &Theme::default(),
223 );
224 ui.scene()
225 .primitives()
226 .iter()
227 .find_map(|p| match p {
228 frus_core::Primitive::Rect { rect, color, .. } if color.r > 0.5 => Some(*rect),
229 _ => None,
230 })
231 .expect("the child painted")
232 };
233 assert_eq!(rect_of(&bare), rect_of(&wrapped));
234 }
235
236 /// Built **once**, not once per traversal: the closure runs a single time however
237 /// many passes go over the node, which is what lets the subtree keep retained state.
238 #[test]
239 fn the_subtree_is_built_once() {
240 use std::rc::Rc;
241 let calls = Rc::new(std::cell::Cell::new(0));
242 let seen = calls.clone();
243 let builder: ThemeBuilder<Msg> = ThemeBuilder::new(move |_: &Theme| {
244 seen.set(seen.get() + 1);
245 Container::new().width(1.0)
246 });
247 let theme = Theme::default();
248 for _ in 0..3 {
249 Widget::<Msg>::build_themed(&builder, &theme);
250 let _ = Widget::<Msg>::style_themed(&builder, &theme);
251 let _ = Widget::<Msg>::children(&builder);
252 }
253 assert_eq!(calls.get(), 1);
254 }
255
256 /// The subtree keeps a **stable identity** across frames, which is what separates this
257 /// from `LayoutBuilder`: the child's id is the builder's child id, and it does not
258 /// move because the builder was rebuilt.
259 #[test]
260 fn the_child_keeps_its_identity() {
261 let id_of = || {
262 let builder: ThemeBuilder<Msg> =
263 ThemeBuilder::new(|_: &Theme| Container::new().width(1.0));
264 Widget::<Msg>::build_themed(&builder, &Theme::default());
265 let child = &Widget::<Msg>::children(&builder)[0];
266 crate::ui::child_id(WidgetId::from_u64(7), 0, child.as_ref())
267 };
268 assert_eq!(id_of(), id_of());
269 }
270}