frust_theme/builder.rs
1//! [`ThemeBuilder`]: the `defineTheme`/`copyWith` analog for composing a
2//! [`Theme`](crate::theme::Theme) from a baseline plus layered edits.
3//!
4//! A theme starts from a baseline — [`Theme::neutral`](crate::theme::Theme::neutral),
5//! the language-free floor, or any other already-built `Theme` (a design
6//! system's own baseline; this is also how a design system assembles that
7//! baseline in the first place) — then layers edits through
8//! [`Theme::builder`](crate::theme::Theme::builder)
9//! in **application order** — whichever call runs last for a given group wins
10//! (`ThemeExtensions`' own last-write-wins `insert` for extensions, mirrored
11//! here at the group level):
12//!
13//! 1. **baseline** — the `Theme` passed to [`Theme::builder`](crate::theme::Theme::builder).
14//! 2. **whole-group swaps** — [`ThemeBuilder::colors_light`]/[`colors_dark`](ThemeBuilder::colors_dark)/
15//! [`type_scale`](ThemeBuilder::type_scale)/[`shape`](ThemeBuilder::shape)/
16//! [`elevation`](ThemeBuilder::elevation)/[`motion`](ThemeBuilder::motion)/
17//! [`glass`](ThemeBuilder::glass) replace a whole group's value outright.
18//! 3. **per-token closure edits** — the `map_*` counterpart of each group
19//! setter above (`map_colors_light`/`map_colors_dark`/`map_type_scale`/
20//! `map_shape`/`map_elevation`/`map_motion`/`map_glass`) hands the
21//! *current* group value to an `FnOnce(T) -> T`, so a caller writes a
22//! Rust struct-update (`..`) edit instead of restating every field.
23//! 4. **extensions** — [`ThemeBuilder::extension`] inserts or replaces a
24//! typed extension (see [`crate::extensions`]), same replace-by-`TypeId`
25//! semantics as [`ThemeExtensions::insert`](crate::extensions::ThemeExtensions::insert).
26//!
27//! There's deliberately no separate "whole-value setter vs. closure editor,
28//! same method name" overload: Rust has no method overloading, so each group
29//! gets two distinctly-named methods (the whole-value setter and its `map_*`
30//! closure counterpart) rather than one method accepting either shape.
31//!
32//! [`ThemeBuilder::build`] does no validation — tokens are data (v1; see
33//! module docs above), so `build()` simply returns the accumulated `Theme`.
34//!
35//! # Examples
36//!
37//! Baseline + a whole-group color swap + a per-token shape edit:
38//!
39//! ```
40//! use frust_theme::{ColorScheme, DesignLanguage, ShapeScale, Theme};
41//! use peniko::Color;
42//!
43//! const BRAND: Color = Color::from_rgb8(0xFF, 0x6A, 0x00);
44//!
45//! let theme = Theme::builder(Theme::neutral())
46//! .colors_dark(ColorScheme {
47//! primary: BRAND,
48//! ..ColorScheme::neutral_dark()
49//! }) // whole-group swap
50//! .map_shape(|s| ShapeScale { medium: 8.0, ..s }) // per-token closure edit
51//! .design_language(DesignLanguage::Custom("acme"))
52//! .build();
53//!
54//! assert_eq!(theme.dark.primary, BRAND);
55//! assert_eq!(theme.shape.medium, 8.0);
56//! // Every other shape token is untouched (struct-update `..` above).
57//! assert_eq!(theme.shape.large, ShapeScale::neutral().large);
58//! ```
59//!
60//! Attaching a typed extension (see [`crate::extensions`]):
61//!
62//! ```
63//! use frust_theme::Theme;
64//!
65//! #[derive(Debug, Clone, PartialEq)]
66//! struct BrandTokens {
67//! logo_glow: bool,
68//! }
69//!
70//! let theme = Theme::builder(Theme::neutral())
71//! .extension(BrandTokens { logo_glow: true })
72//! .build();
73//!
74//! assert_eq!(theme.extension::<BrandTokens>(), Some(&BrandTokens { logo_glow: true }));
75//! ```
76
77use std::any::Any;
78
79use crate::color::{Brightness, ColorScheme};
80use crate::elevation::Elevation;
81use crate::glass::GlassScale;
82use crate::motion::MotionScheme;
83use crate::shape::ShapeScale;
84use crate::theme::{DesignLanguage, Theme};
85use crate::typography::TypeScale;
86
87/// A layered builder over a [`Theme`] baseline — see the module docs for the
88/// full precedence order. Every method takes/returns `Self` by value so calls
89/// chain (`Theme::builder(base).colors_dark(..).map_shape(..).build()`).
90#[derive(Clone, Debug)]
91pub struct ThemeBuilder {
92 theme: Theme,
93}
94
95impl ThemeBuilder {
96 /// Start a builder from `base` — typically
97 /// [`Theme::neutral`](crate::theme::Theme::neutral) or a design system's
98 /// own baseline, but any already-built `Theme` works (e.g. re-deriving
99 /// one theme from another).
100 pub fn new(base: Theme) -> Self {
101 Self { theme: base }
102 }
103
104 // -- light color scheme -------------------------------------------------
105
106 /// Whole-group swap: replace the light [`ColorScheme`] outright.
107 pub fn colors_light(mut self, scheme: ColorScheme) -> Self {
108 self.theme.light = scheme;
109 self
110 }
111
112 /// Per-token closure edit: hand the current light [`ColorScheme`] to `f`,
113 /// keeping whatever fields it doesn't overwrite (Rust struct-update `..`
114 /// ergonomics).
115 pub fn map_colors_light(mut self, f: impl FnOnce(ColorScheme) -> ColorScheme) -> Self {
116 self.theme.light = f(self.theme.light);
117 self
118 }
119
120 // -- dark color scheme ---------------------------------------------------
121
122 /// Whole-group swap: replace the dark [`ColorScheme`] outright.
123 pub fn colors_dark(mut self, scheme: ColorScheme) -> Self {
124 self.theme.dark = scheme;
125 self
126 }
127
128 /// Per-token closure edit: hand the current dark [`ColorScheme`] to `f`.
129 pub fn map_colors_dark(mut self, f: impl FnOnce(ColorScheme) -> ColorScheme) -> Self {
130 self.theme.dark = f(self.theme.dark);
131 self
132 }
133
134 // -- type scale -----------------------------------------------------------
135
136 /// Whole-group swap: replace the [`TypeScale`] outright.
137 pub fn type_scale(mut self, scale: TypeScale) -> Self {
138 self.theme.type_scale = scale;
139 self
140 }
141
142 /// Per-token closure edit: hand the current [`TypeScale`] to `f`.
143 pub fn map_type_scale(mut self, f: impl FnOnce(TypeScale) -> TypeScale) -> Self {
144 self.theme.type_scale = f(self.theme.type_scale);
145 self
146 }
147
148 // -- shape scale -----------------------------------------------------------
149
150 /// Whole-group swap: replace the [`ShapeScale`] outright.
151 pub fn shape(mut self, shape: ShapeScale) -> Self {
152 self.theme.shape = shape;
153 self
154 }
155
156 /// Per-token closure edit: hand the current [`ShapeScale`] to `f`.
157 pub fn map_shape(mut self, f: impl FnOnce(ShapeScale) -> ShapeScale) -> Self {
158 self.theme.shape = f(self.theme.shape);
159 self
160 }
161
162 // -- elevation ---------------------------------------------------------
163
164 /// Whole-group swap: replace the [`Elevation`] table outright.
165 pub fn elevation(mut self, elevation: Elevation) -> Self {
166 self.theme.elevation = elevation;
167 self
168 }
169
170 /// Per-token closure edit: hand the current [`Elevation`] table to `f`.
171 pub fn map_elevation(mut self, f: impl FnOnce(Elevation) -> Elevation) -> Self {
172 self.theme.elevation = f(self.theme.elevation);
173 self
174 }
175
176 // -- motion --------------------------------------------------------------
177
178 /// Whole-group swap: replace the [`MotionScheme`] outright.
179 pub fn motion(mut self, motion: MotionScheme) -> Self {
180 self.theme.motion = motion;
181 self
182 }
183
184 /// Per-token closure edit: hand the current [`MotionScheme`] to `f`.
185 pub fn map_motion(mut self, f: impl FnOnce(MotionScheme) -> MotionScheme) -> Self {
186 self.theme.motion = f(self.theme.motion);
187 self
188 }
189
190 // -- glass ---------------------------------------------------------------
191
192 /// Whole-group swap: replace the [`GlassScale`] outright.
193 pub fn glass(mut self, glass: GlassScale) -> Self {
194 self.theme.glass = glass;
195 self
196 }
197
198 /// Per-token closure edit: hand the current [`GlassScale`] to `f`.
199 pub fn map_glass(mut self, f: impl FnOnce(GlassScale) -> GlassScale) -> Self {
200 self.theme.glass = f(self.theme.glass);
201 self
202 }
203
204 // -- scalars ---------------------------------------------------------------
205
206 /// Set the active [`Brightness`] (which of `light`/`dark` [`Theme::scheme`](crate::theme::Theme::scheme)
207 /// selects).
208 pub fn brightness(mut self, brightness: Brightness) -> Self {
209 self.theme.brightness = brightness;
210 self
211 }
212
213 /// Set the [`DesignLanguage`] tag. Purely a tag (see
214 /// [`crate::theme::DesignLanguage`]'s docs) — this does not itself swap
215 /// any token group; pair it with the whole-group swaps above when
216 /// actually changing baselines.
217 pub fn design_language(mut self, design_language: DesignLanguage) -> Self {
218 self.theme.design_language = design_language;
219 self
220 }
221
222 // -- extensions ------------------------------------------------------------
223
224 /// Insert or replace a typed extension (see [`crate::extensions`]) —
225 /// replace-by-`TypeId`, same semantics as
226 /// [`ThemeExtensions::insert`](crate::extensions::ThemeExtensions::insert).
227 pub fn extension<T: Any + Send + Sync>(mut self, ext: T) -> Self {
228 self.theme.extensions.insert(ext);
229 self
230 }
231
232 /// Finish building, returning the accumulated [`Theme`]. No validation
233 /// pass in v1 — tokens are data (see module docs).
234 pub fn build(self) -> Theme {
235 self.theme
236 }
237}
238
239#[cfg(test)]
240mod tests {
241 use super::*;
242 use crate::status::StatusPalette;
243
244 const BRAND: peniko::Color = peniko::Color::from_rgb8(0xFF, 0x6A, 0x00);
245
246 #[test]
247 fn round_trip_is_field_for_field_identical() {
248 // `Theme::builder(x).build() == x`, for the framework baseline and
249 // for a design-system-shaped theme built over it (a differently
250 // tagged, differently coloured value — so the round-trip isn't
251 // proved against one shape only).
252 let base = Theme::neutral();
253 let rebuilt = Theme::builder(base.clone()).build();
254 assert_eq!(rebuilt, base);
255
256 let design_system = Theme::builder(Theme::neutral())
257 .design_language(DesignLanguage::Cupertino)
258 .map_colors_light(|c| ColorScheme {
259 primary: BRAND,
260 ..c
261 })
262 .build();
263 assert_ne!(design_system, base);
264 let rebuilt = Theme::builder(design_system.clone()).build();
265 assert_eq!(rebuilt, design_system);
266 }
267
268 #[test]
269 fn whole_group_swap_replaces_only_that_group() {
270 let base = Theme::neutral();
271 let swapped_dark = ColorScheme {
272 primary: BRAND,
273 ..ColorScheme::neutral_dark()
274 };
275 let theme = Theme::builder(base.clone())
276 .colors_dark(swapped_dark)
277 .build();
278
279 assert_eq!(theme.dark.primary, BRAND);
280 // Nothing else moved.
281 assert_eq!(theme.light, base.light);
282 assert_eq!(theme.shape, base.shape);
283 assert_eq!(theme.motion, base.motion);
284 assert_eq!(theme.elevation, base.elevation);
285 assert_eq!(theme.type_scale, base.type_scale);
286 assert_eq!(theme.glass, base.glass);
287 assert_eq!(theme.brightness, base.brightness);
288 assert_eq!(theme.design_language, base.design_language);
289 }
290
291 #[test]
292 fn closure_edit_changes_only_the_named_token() {
293 let base = Theme::neutral();
294 let theme = Theme::builder(base.clone())
295 .map_shape(|s| ShapeScale { medium: 8.0, ..s })
296 .build();
297
298 assert_eq!(theme.shape.medium, 8.0);
299 assert_eq!(theme.shape.large, base.shape.large);
300 assert_eq!(theme.shape.none, base.shape.none);
301 // Untouched groups still match the baseline.
302 assert_eq!(theme.light, base.light);
303 assert_eq!(theme.motion, base.motion);
304 }
305
306 #[test]
307 fn extension_insert_round_trips() {
308 #[derive(Debug, Clone, PartialEq)]
309 struct AppTokens {
310 brand_name: &'static str,
311 }
312
313 let theme = Theme::builder(Theme::neutral())
314 .extension(AppTokens { brand_name: "Acme" })
315 .build();
316
317 assert_eq!(
318 theme.extension::<AppTokens>(),
319 Some(&AppTokens { brand_name: "Acme" })
320 );
321 // The pre-attached StatusPalette extension (from the baseline)
322 // survives alongside the newly-inserted one.
323 assert_eq!(
324 theme.extension::<StatusPalette>(),
325 Some(&StatusPalette::neutral())
326 );
327 }
328
329 #[test]
330 fn extension_replace_by_type_id_last_write_wins() {
331 #[derive(Debug, Clone, PartialEq)]
332 struct Marker(u32);
333
334 let theme = Theme::builder(Theme::neutral())
335 .extension(Marker(1))
336 .extension(Marker(2))
337 .build();
338
339 assert_eq!(theme.extension::<Marker>(), Some(&Marker(2)));
340 }
341
342 #[test]
343 fn application_order_is_last_write_wins_per_group() {
344 // Two whole-group swaps to the same group: the later call wins.
345 let first = ShapeScale {
346 medium: 8.0,
347 ..ShapeScale::neutral()
348 };
349 let second = ShapeScale {
350 medium: 16.0,
351 ..ShapeScale::neutral()
352 };
353 let theme = Theme::builder(Theme::neutral())
354 .shape(first)
355 .shape(second)
356 .build();
357 assert_eq!(theme.shape.medium, 16.0);
358
359 // A closure edit after a whole-group swap sees the swapped value.
360 let theme = Theme::builder(Theme::neutral())
361 .shape(first)
362 .map_shape(|s| ShapeScale { large: 99.0, ..s })
363 .build();
364 assert_eq!(theme.shape.medium, 8.0);
365 assert_eq!(theme.shape.large, 99.0);
366 }
367
368 #[test]
369 fn brightness_and_design_language_setters_apply() {
370 let theme = Theme::builder(Theme::neutral())
371 .brightness(Brightness::Dark)
372 .design_language(DesignLanguage::Cupertino)
373 .build();
374 assert_eq!(theme.brightness, Brightness::Dark);
375 assert_eq!(theme.design_language, DesignLanguage::Cupertino);
376 assert_eq!(theme.scheme(), &theme.dark);
377 }
378}