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}