Skip to main content

frust_theme/
theme.rs

1//! [`Theme`]: the aggregate design-token bundle an app wires through to its
2//! widget tree. The value is delivered two ways: to app code through
3//! the reactive context (`use_context::<Theme>()` via the facade), and to
4//! widgets through the type-erased `PaintCtx`/`LayoutCtx` theme slot — the
5//! [`Theme::from_paint_ctx`]/[`Theme::from_layout_ctx`] wrappers below recover
6//! it in one line.
7
8use std::any::Any;
9
10use frust_core::widget::{LayoutCtx, PaintCtx};
11use frust_text::TextStyle;
12
13use crate::color::{Brightness, ColorScheme};
14use crate::elevation::Elevation;
15use crate::extensions::ThemeExtensions;
16use crate::glass::GlassScale;
17use crate::motion::MotionScheme;
18use crate::shape::ShapeScale;
19use crate::status::StatusPalette;
20use crate::typography::TypeScale;
21
22/// Which design language assembled a [`Theme`] — a `Theme` itself stays a
23/// single, language-agnostic aggregate struct; this is just a tag app/shell
24/// code can branch on (e.g. to pick per-platform interaction affordances),
25/// not a second `Theme` type. No constructor in this crate produces anything
26/// but the derived default: a design system sets its own tag through
27/// [`ThemeBuilder::design_language`](crate::builder::ThemeBuilder::design_language)
28/// while assembling its baseline, from its own crate.
29///
30/// The tag is identity, not behavior: a design system styles itself entirely
31/// via `Theme`'s tokens plus `Theme::extensions` (see
32/// [`ThemeExtensions`](crate::extensions::ThemeExtensions)) — this enum just
33/// lets a host/widget recognize which system is active, or deliberately
34/// ignore an unrecognized one. `#[non_exhaustive]` and [`Custom`](Self::Custom)
35/// together mean a new built-in variant, or a third-party system tagging
36/// itself, is never a breaking change for downstream code: the built-in
37/// `==`-based branch site (`frust-widgets`' slider) already treats anything
38/// that isn't `Cupertino` as the neutral path by construction, so an
39/// unrecognized `Custom` id falls through safely. Two `Custom` tags compare
40/// equal by string content, not by pointer/interning identity.
41#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
42#[non_exhaustive]
43pub enum DesignLanguage {
44    /// Material 3 (Android/cross-platform baseline). Default — the tag
45    /// `Theme::neutral` carries for want of a neutral variant (see that
46    /// constructor's caveat), and the one `frust-material` sets explicitly.
47    #[default]
48    Material3,
49    /// Cupertino (iOS) — the tag `frust-cupertino` sets.
50    Cupertino,
51    /// Glyph — the tag `frust-glyph` sets.
52    Glyph,
53    /// A third-party design system's identity tag — the id is the system's
54    /// stable name (e.g. `"yaru"`). Carried so hosts/widgets that branch on
55    /// language can recognize (or deliberately ignore) an external system;
56    /// the built-in `==` branch site (the slider) treats any `Custom` as the
57    /// neutral path by construction. Compared by string content —
58    /// `Custom("yaru") == Custom("yaru")` even across two distinct
59    /// `&'static str` allocations with the same bytes.
60    Custom(&'static str),
61}
62
63/// A full design-token bundle: paired light/dark color schemes, the type
64/// scale, shape scale, elevation table, and motion scheme, plus which
65/// brightness is currently active.
66#[derive(Clone, Debug, PartialEq)]
67pub struct Theme {
68    pub light: ColorScheme,
69    pub dark: ColorScheme,
70    pub type_scale: TypeScale,
71    pub shape: ShapeScale,
72    pub elevation: Elevation,
73    pub motion: MotionScheme,
74    /// The glass material scale. [`GlassScale::opaque_material`] is the only
75    /// recipe this crate constructs (and what [`Theme::neutral`] carries); a
76    /// design system with translucent chrome supplies its own through
77    /// [`ThemeBuilder::glass`](crate::builder::ThemeBuilder::glass). Consumed
78    /// by chrome widgets that branch on
79    /// [`GlassMaterial::is_opaque`](crate::glass::GlassMaterial::is_opaque).
80    pub glass: GlassScale,
81    pub brightness: Brightness,
82    pub design_language: DesignLanguage,
83    /// The no-lock-in typed extension slot (a Flutter
84    /// `ThemeExtension` analog) — see `crate::extensions` and
85    /// [`Theme::extension`]. `Arc`-backed internally, so cloning a `Theme`
86    /// (required at both delivery paths — the process-global override slot
87    /// and the reactive `provide_context` copy) stays cheap regardless of
88    /// how many extensions are attached.
89    pub extensions: ThemeExtensions,
90}
91
92impl Theme {
93    /// The neutral, design-language-free baseline theme — the **only**
94    /// `Theme` this crate constructs, and the floor every shell falls back to
95    /// when no design system seeded one via `set_default_theme` (see
96    /// `docs/ARCHITECTURE.md`'s Theme delivery). Material, Cupertino, and
97    /// Glyph each assemble their own baseline in their own plugin crate.
98    ///
99    /// Composes: a plain grayscale surface/on-surface ramp plus one
100    /// restrained slate-blue accent
101    /// ([`ColorScheme::neutral_light`]/[`ColorScheme::neutral_dark`]); a
102    /// numeric type scale resolved against a generic system-font stack with
103    /// **no bundled font bytes referenced** ([`TypeScale::neutral`]); the
104    /// [`ShapeScale::neutral`]/[`Elevation::neutral`] value tables — the M3
105    /// numbers reused rather than re-authored, since neither is actually
106    /// M3-branded in *value* (`Elevation::neutral`'s own module docs call
107    /// its shadow math "TUNABLE, not an M3-published spec", and
108    /// [`GlassScale::opaque_material`] already reuses `Elevation::neutral`
109    /// the same way); a no-overshoot [`MotionScheme::neutral`]; and
110    /// [`GlassScale::opaque_material`] (already neutral). Attaches
111    /// [`StatusPalette::neutral`], since success/warning/info are a
112    /// functional signal, not a design-language "look" — the same reasoning
113    /// `neutral_light`/`neutral_dark` use to keep `error` real red instead of
114    /// grayscaling it too.
115    ///
116    /// Starts in [`Brightness::Light`].
117    ///
118    /// **Caveat — `design_language` is [`DesignLanguage::Material3`] here**,
119    /// the derived default, despite this baseline carrying no Material
120    /// identity: the enum has no neutral variant and is not reshaped by this
121    /// constructor. Branch on the tokens you actually need, not on this field,
122    /// when handed a `neutral()` theme.
123    ///
124    /// Not `const`: `ThemeExtensions`' `HashMap` construction isn't
125    /// const-evaluable.
126    pub fn neutral() -> Self {
127        let mut extensions = ThemeExtensions::new();
128        extensions.insert(StatusPalette::neutral());
129        Self {
130            light: ColorScheme::neutral_light(),
131            dark: ColorScheme::neutral_dark(),
132            type_scale: TypeScale::neutral(&TextStyle::default()),
133            shape: ShapeScale::neutral(),
134            elevation: Elevation::neutral(),
135            motion: MotionScheme::neutral(),
136            glass: GlassScale::opaque_material(),
137            brightness: Brightness::Light,
138            design_language: DesignLanguage::default(),
139            extensions,
140        }
141    }
142
143    /// The active [`ColorScheme`] — `light` or `dark`, selected by
144    /// `self.brightness`.
145    pub fn scheme(&self) -> &ColorScheme {
146        match self.brightness {
147            Brightness::Light => &self.light,
148            Brightness::Dark => &self.dark,
149        }
150    }
151
152    /// Force this theme's [`Brightness`] while keeping every other token —
153    /// a builder over the `brightness` field, meant to be chained onto a
154    /// baseline constructor (which hardcodes `Brightness::Light`) before
155    /// handing the result to `set_app_theme`.
156    ///
157    /// Framework footgun this closes: `set_app_theme`
158    /// stores its argument as the override-wins theme (the override-wins
159    /// rule — an app-set theme always beats further OS appearance reports,
160    /// intentionally). A caller that forces a design language via
161    /// `set_app_theme(some_baseline())` therefore also silently pins
162    /// brightness to `Light` forever, discarding whatever OS night-mode state
163    /// was live a moment before. `some_baseline().with_brightness(live)`
164    /// forces the design language without discarding brightness.
165    pub fn with_brightness(mut self, brightness: Brightness) -> Self {
166        self.brightness = brightness;
167        self
168    }
169
170    /// Recover the active theme from a widget's [`PaintCtx`], or `None` if none
171    /// was threaded into the paint pass (a supported state — a pre-theme app or
172    /// a bare-core test).
173    ///
174    /// A one-line convenience wrapper over
175    /// [`PaintCtx::theme_as::<Theme>()`](frust_core::widget::PaintCtx::theme_as)
176    /// so a themed widget writes `Theme::from_paint_ctx(ctx)` in its `paint`.
177    pub fn from_paint_ctx<'a>(ctx: &'a PaintCtx<'_>) -> Option<&'a Theme> {
178        ctx.theme_as::<Theme>()
179    }
180
181    /// Recover the active theme from a widget's [`LayoutCtx`], or `None` if none
182    /// was threaded into the layout pass. The layout-pass mirror of
183    /// [`Theme::from_paint_ctx`].
184    pub fn from_layout_ctx<'a>(ctx: &'a LayoutCtx<'_>) -> Option<&'a Theme> {
185        ctx.theme_as::<Theme>()
186    }
187
188    /// Recover a typed extension previously attached via
189    /// `self.extensions.insert::<T>(..)`, or `None` if nothing of that type
190    /// was ever attached — see `crate::extensions`' module docs for the
191    /// no-lock-in rationale. A one-line convenience wrapper over
192    /// [`ThemeExtensions::get`].
193    pub fn extension<T: Any + Send + Sync>(&self) -> Option<&T> {
194        self.extensions.get::<T>()
195    }
196
197    /// Start a [`crate::builder::ThemeBuilder`] over `self` as the baseline —
198    /// the `defineTheme`/`copyWith` analog. See
199    /// `crate::builder`'s module docs for the full layered-precedence
200    /// contract (baseline → whole-group swaps → per-token closure edits →
201    /// extensions).
202    pub fn builder(base: Theme) -> crate::builder::ThemeBuilder {
203        crate::builder::ThemeBuilder::new(base)
204    }
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210
211    #[test]
212    fn scheme_selects_by_brightness() {
213        let mut theme = Theme::neutral();
214        assert_eq!(theme.scheme(), &theme.light);
215
216        theme.brightness = Brightness::Dark;
217        assert_eq!(theme.scheme(), &theme.dark);
218    }
219
220    #[test]
221    fn from_paint_ctx_recovers_a_threaded_theme() {
222        use frust_core::widget::PaintCtx;
223        use peniko::kurbo::{Point, Size};
224
225        let theme = Theme::neutral();
226        let ctx = PaintCtx::new(Point::ZERO, Size::new(1.0, 1.0)).with_theme(&theme);
227        assert_eq!(Theme::from_paint_ctx(&ctx), Some(&theme));
228
229        // No theme threaded in → `None`, never a panic.
230        let bare = PaintCtx::new(Point::ZERO, Size::new(1.0, 1.0));
231        assert!(Theme::from_paint_ctx(&bare).is_none());
232    }
233
234    #[test]
235    fn from_layout_ctx_recovers_a_threaded_theme() {
236        use frust_core::widget::LayoutCtx;
237
238        let theme = Theme::neutral();
239        let ctx = LayoutCtx::new().with_theme(&theme);
240        assert_eq!(Theme::from_layout_ctx(&ctx), Some(&theme));
241
242        let bare = LayoutCtx::new();
243        assert!(Theme::from_layout_ctx(&bare).is_none());
244    }
245
246    #[test]
247    fn design_language_defaults_to_material3() {
248        assert_eq!(DesignLanguage::default(), DesignLanguage::Material3);
249    }
250
251    #[test]
252    fn with_brightness_forces_design_language_without_discarding_brightness() {
253        // Regression: forcing a design language by handing `set_app_theme` a
254        // design system's baseline used to silently reset brightness to
255        // `Light` even when the caller's live brightness was `Dark`. The two
256        // themes below stand in for a design system's own baseline — built
257        // inline here since none is constructible from this crate any more,
258        // and deliberately carrying DIFFERENT tags so the assertion that the
259        // tag survives is not vacuous.
260        let material = Theme::builder(Theme::neutral())
261            .design_language(DesignLanguage::Material3)
262            .build();
263        let cupertino = Theme::builder(Theme::neutral())
264            .design_language(DesignLanguage::Cupertino)
265            .build();
266        assert_ne!(material.design_language, cupertino.design_language);
267
268        let theme = material.clone().with_brightness(Brightness::Dark);
269        assert_eq!(theme.brightness, Brightness::Dark);
270        assert_eq!(theme.design_language, DesignLanguage::Material3);
271        assert_eq!(theme.scheme(), &theme.dark);
272
273        let theme = cupertino.with_brightness(Brightness::Dark);
274        assert_eq!(theme.brightness, Brightness::Dark);
275        assert_eq!(theme.design_language, DesignLanguage::Cupertino);
276        assert_eq!(theme.scheme(), &theme.dark);
277
278        // Light stays light — the builder isn't a one-way flip.
279        let theme = material.with_brightness(Brightness::Light);
280        assert_eq!(theme.brightness, Brightness::Light);
281    }
282
283    #[test]
284    fn custom_extension_type_round_trips() {
285        // A custom user type round-trips
286        // insert -> get, alongside the pre-attached `StatusPalette`.
287        #[derive(Debug, PartialEq)]
288        struct AppTokens {
289            brand_name: &'static str,
290        }
291
292        let mut theme = Theme::neutral();
293        assert!(theme.extension::<AppTokens>().is_none());
294
295        theme.extensions.insert(AppTokens { brand_name: "Acme" });
296        assert_eq!(
297            theme.extension::<AppTokens>(),
298            Some(&AppTokens { brand_name: "Acme" })
299        );
300
301        // The pre-attached extension is unaffected by inserting another type.
302        use crate::status::StatusPalette;
303        assert_eq!(
304            theme.extension::<StatusPalette>(),
305            Some(&StatusPalette::neutral())
306        );
307    }
308
309    #[test]
310    fn theme_extensions_field_survives_clone() {
311        #[derive(Debug, PartialEq)]
312        struct Marker;
313
314        let mut theme = Theme::neutral();
315        theme.extensions.insert(Marker);
316
317        let cloned = theme.clone();
318        assert_eq!(cloned.extension::<Marker>(), Some(&Marker));
319    }
320
321    // ---- Neutral baseline --------------------------------------------
322
323    #[test]
324    fn neutral_populates_every_role_in_both_brightnesses() {
325        // A fully-populated `Theme`, no placeholder — every field below
326        // constructs and round-trips through the aggregate untouched.
327        let theme = Theme::neutral();
328        assert_eq!(theme.light, ColorScheme::neutral_light());
329        assert_eq!(theme.dark, ColorScheme::neutral_dark());
330        assert_eq!(theme.shape, ShapeScale::neutral());
331        assert_eq!(theme.elevation, Elevation::neutral());
332        assert_eq!(theme.motion, MotionScheme::neutral());
333        assert_eq!(theme.glass, GlassScale::opaque_material());
334        assert_eq!(theme.brightness, Brightness::Light);
335    }
336
337    #[test]
338    fn neutral_with_brightness_selects_both_schemes() {
339        // Both brightnesses of `neutral()` are legible and distinct — the
340        // dark scheme is reachable the same way every other baseline's is.
341        let light = Theme::neutral();
342        assert_eq!(light.scheme(), &light.light);
343
344        let dark = Theme::neutral().with_brightness(Brightness::Dark);
345        assert_eq!(dark.scheme(), &dark.dark);
346        assert_ne!(dark.scheme().surface, light.scheme().surface);
347    }
348
349    #[test]
350    fn neutral_attaches_the_neutral_status_palette_extension() {
351        use crate::status::StatusPalette;
352        // Success/warning/info are a functional signal, not a "look" — the
353        // same reasoning `error` stays real red in `ColorScheme::neutral_*`
354        // (see that constructor's doc comment).
355        assert_eq!(
356            Theme::neutral().extension::<StatusPalette>(),
357            Some(&StatusPalette::neutral())
358        );
359    }
360
361    #[test]
362    fn neutral_design_language_stays_the_default() {
363        // The enum has no neutral variant, so this baseline carries the
364        // derived default (see the constructor's own caveat) rather than
365        // claiming a design language it doesn't have.
366        assert_eq!(Theme::neutral().design_language, DesignLanguage::default());
367    }
368
369    #[test]
370    fn neutral_type_scale_references_no_bundled_font() {
371        // Exercised through the full `Theme` (see `crate::typography`'s own
372        // tests for the focused check).
373        let theme = Theme::neutral();
374        assert!(matches!(
375            theme.type_scale.body_large.family,
376            frust_text::FontFamily::NamedWithGeneric(_)
377        ));
378    }
379
380    #[test]
381    fn neutral_survives_clone_and_extends_independently() {
382        let theme = Theme::neutral();
383        let cloned = theme.clone();
384        assert_eq!(cloned, theme);
385    }
386
387    // ---- DesignLanguage::Custom ---------------------------------------
388
389    #[test]
390    fn custom_design_language_compares_by_content() {
391        assert_eq!(DesignLanguage::Custom("x"), DesignLanguage::Custom("x"));
392        assert_ne!(DesignLanguage::Custom("x"), DesignLanguage::Custom("y"));
393        assert_ne!(DesignLanguage::Custom("x"), DesignLanguage::Material3);
394    }
395
396    #[test]
397    fn custom_design_language_round_trips_through_the_builder() {
398        let theme = Theme::builder(Theme::neutral())
399            .design_language(DesignLanguage::Custom("sample"))
400            .build();
401        assert_eq!(theme.design_language, DesignLanguage::Custom("sample"));
402    }
403}