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    /// Forwarded: a wrapper around a form is still that form (milestone 512).
117    fn autofill_group(&self) -> bool {
118        self.inner.autofill_group()
119    }
120});
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125    use crate::card::CARD_MARGIN;
126    use crate::divider::Divider;
127    use crate::flex::Flex;
128    use crate::runtime::Runtime;
129    use crate::ui::build_ui;
130    use frus_core::{Color, Primitive, Rect, Size};
131
132    #[derive(Clone, Debug, PartialEq)]
133    enum Msg {}
134
135    /// The crisp boxes a tree paints, in paint order (shadows excluded).
136    fn boxes(tree: &dyn Widget<Msg>) -> Vec<(Rect, Color)> {
137        build_ui(
138            tree,
139            Size::new(200.0, 200.0),
140            &Runtime::default(),
141            &Theme::default(),
142        )
143        .scene()
144        .primitives()
145        .iter()
146        .filter_map(|p| match p {
147            Primitive::Rect {
148                rect, color, blur, ..
149            } if *blur == 0.0 => Some((*rect, *color)),
150            _ => None,
151        })
152        .collect()
153    }
154
155    #[test]
156    fn a_subtree_theme_reaches_paint() {
157        let ink = Color::rgb8(255, 0, 128);
158        let tree = Flex::<Msg>::column()
159            .width(200.0)
160            .height(200.0)
161            .child(Divider::new())
162            .child(Themed::tweak(
163                move |t| t.widgets.divider.color = Some(ink),
164                Divider::new(),
165            ));
166        let painted = boxes(&tree);
167        assert_eq!(painted.len(), 2, "two dividers");
168        assert_ne!(painted[0].1, ink, "the first keeps the theme it inherited");
169        assert_eq!(painted[1].1, ink, "the second takes the subtree's");
170    }
171
172    #[test]
173    fn a_subtree_theme_reaches_layout() {
174        // The half that a paint-only theme would miss. Two dividers stacked: the second
175        // starts where the first's *box* ended, so a themed height moves it.
176        let tree = |themed: bool| {
177            let second: Box<dyn Widget<Msg>> = if themed {
178                Box::new(Themed::tweak(
179                    |t| t.widgets.divider.height = Some(40.0),
180                    Divider::new(),
181                ))
182            } else {
183                Box::new(Divider::new())
184            };
185            Flex::<Msg>::column()
186                .width(200.0)
187                .height(200.0)
188                .child(second)
189                .child(Divider::new())
190        };
191        let plain = boxes(&tree(false));
192        let themed = boxes(&tree(true));
193        assert!(
194            themed[1].0.y > plain[1].0.y,
195            "the themed divider's box is taller, so the next one is pushed down: \
196             {:?} against {:?}",
197            themed[1].0,
198            plain[1].0
199        );
200    }
201
202    #[test]
203    fn the_theme_is_restored_on_the_way_out() {
204        // A sibling *after* a themed subtree must be untouched — the walk's theme is a
205        // stack, not a switch. This is the failure that would look like "the theme leaks
206        // downward from wherever it was set".
207        let ink = Color::rgb8(255, 0, 128);
208        let tree = Flex::<Msg>::column()
209            .width(200.0)
210            .height(200.0)
211            .child(Themed::tweak(
212                move |t| t.widgets.divider.color = Some(ink),
213                Divider::new(),
214            ))
215            .child(Divider::new());
216        let painted = boxes(&tree);
217        assert_eq!(painted[0].1, ink);
218        assert_ne!(painted[1].1, ink, "the sibling after it is not themed");
219    }
220
221    #[test]
222    fn nesting_starts_from_what_it_inherits() {
223        // `tweak` changes *part* of the theme it is handed, so an inner one keeps what
224        // the outer one set. A `Themed` that started from the default instead would
225        // silently undo its parent.
226        let ink = Color::rgb8(255, 0, 128);
227        let tree = Flex::<Msg>::column()
228            .width(200.0)
229            .height(200.0)
230            .child(Themed::tweak(
231                move |t| t.widgets.divider.color = Some(ink),
232                Themed::tweak(|t| t.widgets.divider.height = Some(40.0), Divider::new()),
233            ));
234        let painted = boxes(&tree);
235        assert_eq!(painted[0].1, ink, "the outer theme's colour survives");
236        assert_eq!(painted[0].0.y, 19.5, "and the inner theme's height applies");
237    }
238
239    #[test]
240    fn a_whole_theme_replaces_rather_than_merges() {
241        // `new` is the other half: it ignores what it inherits. An outer tweak must not
242        // survive it.
243        let ink = Color::rgb8(255, 0, 128);
244        let tree = Flex::<Msg>::column()
245            .width(200.0)
246            .height(200.0)
247            .child(Themed::tweak(
248                move |t| t.widgets.divider.color = Some(ink),
249                Themed::new(Theme::default(), Divider::new()),
250            ));
251        assert_ne!(boxes(&tree)[0].1, ink);
252    }
253
254    #[test]
255    fn an_overlay_keeps_the_theme_it_was_declared_under() {
256        // An overlay is painted long after the walk has left the node that declared it.
257        // Without the theme travelling with it, a dialog opened from inside a themed
258        // section would come out in the application's theme instead of the section's —
259        // and only when it opened, which is the worst place to find out.
260        let ink = Color::rgb8(255, 0, 128);
261        let panel = Flex::<Msg>::column()
262            .width(100.0)
263            .height(20.0)
264            .child(Divider::new());
265        let tree = Flex::<Msg>::column()
266            .width(200.0)
267            .height(200.0)
268            .child(Themed::tweak(
269                move |t| t.widgets.divider.color = Some(ink),
270                crate::portal::OverlayPortal::new(crate::Container::new().width(20.0).height(20.0))
271                    .overlay(panel, crate::portal::Placement::Center),
272            ));
273        assert!(
274            boxes(&tree).iter().any(|(_, color)| *color == ink),
275            "the overlay's divider takes the theme of the subtree that declared it"
276        );
277    }
278
279    #[test]
280    fn it_is_transparent_to_layout() {
281        // The wrapper must not become a box of its own: a themed card sits exactly where
282        // the same card would, or every layout it is dropped into shifts.
283        let plain = boxes(
284            &Flex::<Msg>::column()
285                .width(200.0)
286                .height(200.0)
287                .child(crate::card::Card::new()),
288        );
289        let wrapped = boxes(
290            &Flex::<Msg>::column()
291                .width(200.0)
292                .height(200.0)
293                .child(Themed::tweak(|_| {}, crate::card::Card::new())),
294        );
295        assert_eq!(plain[0].0.x, CARD_MARGIN);
296        assert_eq!(plain[0].0, wrapped[0].0, "same box, wrapped or not");
297    }
298}