Skip to main content

frus_widgets/
themed.rs

1//! [`Themed`]: a **theme for one subtree**, not for the frame.
2//!
3//! Milestone 309 gave the theme per-widget defaults — an application can say *every card
4//! is flat* once. It could still only say it once, for everything: there was one theme per
5//! frame, so a dark panel on a light page had to be written out colour by colour at every
6//! call site inside it, which is the framework failing at the exact point a theme was
7//! supposed to help.
8//!
9//! ```ignore
10//! Themed::new(Theme::dark(), sidebar())        // this subtree is dark
11//!
12//! Themed::tweak(|t| t.widgets.card.elevation = Some(0.0), settings_page())
13//! ```
14//!
15//! [`Themed::tweak`] is the form that gets used: it receives the theme **inherited from
16//! above** and changes part of it, so a panel can be flat without also deciding the
17//! application's colours. It has to be a closure rather than a value because the ambient
18//! theme is not known when the tree is built — the walk resolves it on the way down, which
19//! is the same reason the reference reads its theme from the *context* and not from the
20//! constructor.
21//!
22//! It is a **transparent wrapper**: it lays out, paints and answers exactly as its child
23//! does (see [`crate::transparent`]). The only thing it adds is the theme its subtree
24//! inherits — for **layout as much as paint**, so a themed subtree can hold thinner
25//! dividers and not merely differently coloured ones.
26//!
27//! Nesting works the way it reads: each `Themed` starts from the theme it inherits, so an
28//! inner one changing the ink colour keeps the outer one's dark scheme.
29
30use crate::theme::Theme;
31use crate::widget::Widget;
32
33/// Where a [`Themed`]'s theme comes from.
34enum Source {
35    /// A theme entire, ignoring what it inherits. Boxed: a `Theme` dwarfs a closure, and
36    /// this enum sits in every `Themed` in the tree.
37    Data(Box<Theme>),
38    /// A change applied to the theme inherited from above.
39    Tweak(Box<dyn Fn(&mut Theme)>),
40}
41
42/// Applies a theme to its child and everything under it.
43pub struct Themed<Msg = crate::callback::Callback> {
44    source: Source,
45    inner: Box<dyn Widget<Msg>>,
46}
47
48impl<Msg> Themed<Msg> {
49    /// Replaces the theme wholesale for `child`'s subtree — a page that is dark whatever
50    /// the application around it is.
51    pub fn new(theme: Theme, child: impl Widget<Msg> + 'static) -> Self {
52        Self {
53            source: Source::Data(Box::new(theme)),
54            inner: Box::new(child),
55        }
56    }
57
58    /// **Changes part** of the inherited theme for `child`'s subtree, which is what an
59    /// application usually wants: this section's cards are flat, or its ink is the brand
60    /// colour, and everything else stays as the application decided.
61    ///
62    /// The closure runs during the walk, on the theme inherited at that point — nested
63    /// tweaks compose, and a tweak under a [`Themed::new`] starts from that one.
64    pub fn tweak(change: impl Fn(&mut Theme) + 'static, child: impl Widget<Msg> + 'static) -> Self {
65        Self {
66            source: Source::Tweak(Box::new(change)),
67            inner: Box::new(child),
68        }
69    }
70}
71
72impl<Msg> Themed<Msg> {
73    /// A theme does reach sizes and spacing, but through the child's own
74    /// `style_themed`; this wrapper adds nothing on top of it.
75    fn restyle(&self, base: frus_layout::Style) -> frus_layout::Style {
76        base
77    }
78}
79
80crate::transparent::forward_transparent!(Themed {
81    /// A theme says nothing about identity: a keyed widget wrapped in a theme keeps its key.
82    fn key(&self) -> Option<u64> {
83        self.inner.key()
84    }
85
86    /// Forwarded: a theme is not a place.
87    fn positioned(&self) -> Option<crate::positioned::Positioning> {
88        self.inner.positioned()
89    }
90
91    /// The one thing a `Themed` does not delegate: the theme it exists to impose.
92    ///
93    /// It still asks its child, because a transparent wrapper **is** its child: two
94    /// nested `Themed`s are one node in the tree, and the inner one would otherwise never
95    /// be asked at all. Applied outer first, so the inner one sees what it inherits —
96    /// which is what nesting means when it is written out.
97    fn theme_override(&self, inherited: &Theme) -> Option<Box<Theme>> {
98        let mine = match &self.source {
99            Source::Data(theme) => (**theme).clone(),
100            Source::Tweak(change) => {
101                let mut theme = inherited.clone();
102                change(&mut theme);
103                theme
104            }
105        };
106        Some(self.inner.theme_override(&mine).unwrap_or_else(|| Box::new(mine)))
107    }
108
109    /// Forwarded: a theme says nothing about the surface.
110    fn media_override(&self, inherited: crate::MediaQuery) -> Option<crate::MediaQuery> {
111        self.inner.media_override(inherited)
112    }
113    fn scaffold_override(&self) -> Option<crate::ScaffoldInfo> {
114        self.inner.scaffold_override()
115    }
116    fn scroll_behavior_override(&self) -> Option<crate::ScrollBehavior> {
117        self.inner.scroll_behavior_override()
118    }
119    /// Forwarded: a wrapper around a form is still that form (milestone 512).
120    fn autofill_group(&self) -> bool {
121        self.inner.autofill_group()
122    }
123    /// Forwarded: the area's bar is the area's, whatever wraps it.
124    fn area_toolbar(
125        &self,
126        context: crate::ToolbarContext,
127    ) -> Option<Option<&dyn crate::widget::Widget<Msg>>> {
128        self.inner.area_toolbar(context)
129    }
130});
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135    use crate::card::CARD_MARGIN;
136    use crate::divider::Divider;
137    use crate::flex::Flex;
138    use crate::runtime::Runtime;
139    use crate::ui::build_ui;
140    use frus_core::{Color, Primitive, Rect, Size};
141
142    #[derive(Clone, Debug, PartialEq)]
143    enum Msg {}
144
145    /// The crisp boxes a tree paints, in paint order (shadows excluded).
146    fn boxes(tree: &dyn Widget<Msg>) -> Vec<(Rect, Color)> {
147        build_ui(
148            tree,
149            Size::new(200.0, 200.0),
150            &Runtime::default(),
151            &Theme::default(),
152        )
153        .scene()
154        .primitives()
155        .iter()
156        .filter_map(|p| match p {
157            Primitive::Rect {
158                rect, color, blur, ..
159            } if *blur == 0.0 => Some((*rect, *color)),
160            _ => None,
161        })
162        .collect()
163    }
164
165    #[test]
166    fn a_subtree_theme_reaches_paint() {
167        let ink = Color::rgb8(255, 0, 128);
168        let tree = Flex::<Msg>::column()
169            .width(200.0)
170            .height(200.0)
171            .child(Divider::new())
172            .child(Themed::tweak(
173                move |t| t.widgets.divider.color = Some(ink),
174                Divider::new(),
175            ));
176        let painted = boxes(&tree);
177        assert_eq!(painted.len(), 2, "two dividers");
178        assert_ne!(painted[0].1, ink, "the first keeps the theme it inherited");
179        assert_eq!(painted[1].1, ink, "the second takes the subtree's");
180    }
181
182    #[test]
183    fn a_subtree_theme_reaches_layout() {
184        // The half that a paint-only theme would miss. Two dividers stacked: the second
185        // starts where the first's *box* ended, so a themed height moves it.
186        let tree = |themed: bool| {
187            let second: Box<dyn Widget<Msg>> = if themed {
188                Box::new(Themed::tweak(
189                    |t| t.widgets.divider.height = Some(40.0),
190                    Divider::new(),
191                ))
192            } else {
193                Box::new(Divider::new())
194            };
195            Flex::<Msg>::column()
196                .width(200.0)
197                .height(200.0)
198                .child(second)
199                .child(Divider::new())
200        };
201        let plain = boxes(&tree(false));
202        let themed = boxes(&tree(true));
203        assert!(
204            themed[1].0.y > plain[1].0.y,
205            "the themed divider's box is taller, so the next one is pushed down: \
206             {:?} against {:?}",
207            themed[1].0,
208            plain[1].0
209        );
210    }
211
212    #[test]
213    fn the_theme_is_restored_on_the_way_out() {
214        // A sibling *after* a themed subtree must be untouched — the walk's theme is a
215        // stack, not a switch. This is the failure that would look like "the theme leaks
216        // downward from wherever it was set".
217        let ink = Color::rgb8(255, 0, 128);
218        let tree = Flex::<Msg>::column()
219            .width(200.0)
220            .height(200.0)
221            .child(Themed::tweak(
222                move |t| t.widgets.divider.color = Some(ink),
223                Divider::new(),
224            ))
225            .child(Divider::new());
226        let painted = boxes(&tree);
227        assert_eq!(painted[0].1, ink);
228        assert_ne!(painted[1].1, ink, "the sibling after it is not themed");
229    }
230
231    #[test]
232    fn nesting_starts_from_what_it_inherits() {
233        // `tweak` changes *part* of the theme it is handed, so an inner one keeps what
234        // the outer one set. A `Themed` that started from the default instead would
235        // silently undo its parent.
236        let ink = Color::rgb8(255, 0, 128);
237        let tree = Flex::<Msg>::column()
238            .width(200.0)
239            .height(200.0)
240            .child(Themed::tweak(
241                move |t| t.widgets.divider.color = Some(ink),
242                Themed::tweak(|t| t.widgets.divider.height = Some(40.0), Divider::new()),
243            ));
244        let painted = boxes(&tree);
245        assert_eq!(painted[0].1, ink, "the outer theme's colour survives");
246        assert_eq!(painted[0].0.y, 19.5, "and the inner theme's height applies");
247    }
248
249    #[test]
250    fn a_whole_theme_replaces_rather_than_merges() {
251        // `new` is the other half: it ignores what it inherits. An outer tweak must not
252        // survive it.
253        let ink = Color::rgb8(255, 0, 128);
254        let tree = Flex::<Msg>::column()
255            .width(200.0)
256            .height(200.0)
257            .child(Themed::tweak(
258                move |t| t.widgets.divider.color = Some(ink),
259                Themed::new(Theme::default(), Divider::new()),
260            ));
261        assert_ne!(boxes(&tree)[0].1, ink);
262    }
263
264    #[test]
265    fn an_overlay_keeps_the_theme_it_was_declared_under() {
266        // An overlay is painted long after the walk has left the node that declared it.
267        // Without the theme travelling with it, a dialog opened from inside a themed
268        // section would come out in the application's theme instead of the section's —
269        // and only when it opened, which is the worst place to find out.
270        let ink = Color::rgb8(255, 0, 128);
271        let panel = Flex::<Msg>::column()
272            .width(100.0)
273            .height(20.0)
274            .child(Divider::new());
275        let tree = Flex::<Msg>::column()
276            .width(200.0)
277            .height(200.0)
278            .child(Themed::tweak(
279                move |t| t.widgets.divider.color = Some(ink),
280                crate::portal::OverlayPortal::new(crate::Container::new().width(20.0).height(20.0))
281                    .overlay(panel, crate::portal::Placement::Center),
282            ));
283        assert!(
284            boxes(&tree).iter().any(|(_, color)| *color == ink),
285            "the overlay's divider takes the theme of the subtree that declared it"
286        );
287    }
288
289    #[test]
290    fn it_is_transparent_to_layout() {
291        // The wrapper must not become a box of its own: a themed card sits exactly where
292        // the same card would, or every layout it is dropped into shifts.
293        let plain = boxes(
294            &Flex::<Msg>::column()
295                .width(200.0)
296                .height(200.0)
297                .child(crate::card::Card::new()),
298        );
299        let wrapped = boxes(
300            &Flex::<Msg>::column()
301                .width(200.0)
302                .height(200.0)
303                .child(Themed::tweak(|_| {}, crate::card::Card::new())),
304        );
305        assert_eq!(plain[0].0.x, CARD_MARGIN);
306        assert_eq!(plain[0].0, wrapped[0].0, "same box, wrapped or not");
307    }
308
309    /// **A component inside a themed subtree is built with its theme** (milestone 642): what
310    /// it reads from `cx.theme()` is the subtree's, as what it lays out and paints with is.
311    #[test]
312    fn a_component_inside_a_themed_subtree_is_built_with_its_theme() {
313        let ink = Color::rgb8(255, 0, 128);
314        let seen = std::rc::Rc::new(std::cell::Cell::new(None));
315        let probe = {
316            let seen = seen.clone();
317            crate::Component::stateless(move |cx: &crate::BuildContext| -> Box<dyn Widget> {
318                seen.set(Some(cx.theme().widgets.divider.color));
319                Box::new(Divider::new())
320            })
321        };
322        let root = Themed::tweak(move |t| t.widgets.divider.color = Some(ink), probe);
323        crate::build_deferred(&root, &Theme::default(), &Runtime::default());
324        assert_eq!(seen.get(), Some(Some(ink)), "the subtree's theme");
325    }
326}