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 \
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}