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}