Skip to main content

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}