Skip to main content

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}