Skip to main content

tessera_components/
theme.rs

1//! Material theme primitives for color, typography, and shape.
2//!
3//! ## Usage
4//!
5//! Provide app-wide defaults for Material components.
6
7use material_color_utilities::{
8    dynamiccolor::{DynamicSchemeBuilder, MaterialDynamicColors, SpecVersion, Variant},
9    hct::Hct,
10};
11use tessera_ui::{
12    CallbackWith, Color, Dp, RenderSlot, provide_context, remember, tessera, use_context,
13};
14
15use crate::shape_def::{RoundedCorner, Shape};
16
17const DEFAULT_SEED_ARGB: u32 = 0xFF6750A4;
18
19/// Ambient content color used by text and icons when no explicit tint is
20/// provided.
21#[derive(Clone, PartialEq, Copy, Debug)]
22pub struct ContentColor {
23    /// Current content color used by text/icons when no explicit tint is
24    /// provided.
25    pub current: Color,
26}
27
28impl Default for ContentColor {
29    fn default() -> Self {
30        ContentColor {
31            current: Color::BLACK,
32        }
33    }
34}
35
36/// Standard Material 3 alpha values used for state layers and disabled content.
37pub struct MaterialAlpha;
38
39impl MaterialAlpha {
40    /// Alpha for hover state layers.
41    pub const HOVER: f32 = 0.08;
42    /// Alpha for pressed state layers.
43    pub const PRESSED: f32 = 0.1;
44    /// Alpha for focused state layers.
45    pub const FOCUSED: f32 = 0.1;
46    /// Alpha for dragged state layers.
47    pub const DRAGGED: f32 = 0.16;
48    /// Alpha for disabled containers (e.g., filled controls).
49    pub const DISABLED_CONTAINER: f32 = 0.12;
50    /// Alpha for disabled content (text/icons) placed on disabled containers.
51    pub const DISABLED_CONTENT: f32 = 0.38;
52}
53
54/// Colors used for text selection highlights and handles.
55#[derive(Clone, PartialEq, Copy, Debug)]
56pub struct TextSelectionColors {
57    /// Color of the selection handle (when rendered).
58    pub handle: Color,
59    /// Background color of the selection highlight.
60    pub background: Color,
61}
62
63impl TextSelectionColors {
64    /// Creates selection colors based on the current Material scheme.
65    pub fn from_scheme(scheme: &MaterialColorScheme) -> Self {
66        Self {
67            handle: scheme.primary,
68            background: scheme.primary.with_alpha(0.35),
69        }
70    }
71}
72
73/// Returns the matching content color for a background color in the scheme.
74///
75/// When the background color does not match a known scheme color, this returns
76/// `None`.
77pub fn content_color_for(background: Color, scheme: &MaterialColorScheme) -> Option<Color> {
78    scheme.content_color_for(background)
79}
80
81/// Font family, metrics, weight, and tracking for a single text style.
82#[derive(Clone, PartialEq, Copy, Debug)]
83pub struct TextStyle {
84    /// Font size in density-independent pixels (dp).
85    pub font_size: Dp,
86    /// Optional line height override in density-independent pixels (dp).
87    pub line_height: Option<Dp>,
88    /// CSS-compatible font weight, from 1 to 1000.
89    pub font_weight: u16,
90    /// Extra spacing between letters in density-independent pixels.
91    pub letter_spacing: Dp,
92    /// Optional named font family; `None` uses the default sans-serif family.
93    pub font_family: Option<&'static str>,
94}
95
96impl Default for TextStyle {
97    fn default() -> Self {
98        Self {
99            font_size: Dp(16.0),
100            line_height: Some(Dp(24.0)),
101            font_weight: 400,
102            letter_spacing: Dp(0.0),
103            font_family: None,
104        }
105    }
106}
107
108/// Material typography scale used by components to resolve default text styles.
109#[derive(Clone, PartialEq, Copy, Debug)]
110pub struct MaterialTypography {
111    /// Large display text.
112    pub display_large: TextStyle,
113    /// Medium display text.
114    pub display_medium: TextStyle,
115    /// Small display text.
116    pub display_small: TextStyle,
117    /// Large headline text.
118    pub headline_large: TextStyle,
119    /// Medium headline text.
120    pub headline_medium: TextStyle,
121    /// Small headline text.
122    pub headline_small: TextStyle,
123    /// Large title text.
124    pub title_large: TextStyle,
125    /// Medium title text.
126    pub title_medium: TextStyle,
127    /// Small title text.
128    pub title_small: TextStyle,
129    /// Large body text.
130    pub body_large: TextStyle,
131    /// Medium body text.
132    pub body_medium: TextStyle,
133    /// Small body text.
134    pub body_small: TextStyle,
135    /// Large label text.
136    pub label_large: TextStyle,
137    /// Medium label text.
138    pub label_medium: TextStyle,
139    /// Small label text.
140    pub label_small: TextStyle,
141}
142
143impl Default for MaterialTypography {
144    fn default() -> Self {
145        Self {
146            display_large: TextStyle {
147                font_size: Dp(57.0),
148                line_height: Some(Dp(64.0)),
149                font_weight: 400,
150                letter_spacing: Dp(-0.2),
151                font_family: None,
152            },
153            display_medium: TextStyle {
154                font_size: Dp(45.0),
155                line_height: Some(Dp(52.0)),
156                font_weight: 400,
157                letter_spacing: Dp(0.0),
158                font_family: None,
159            },
160            display_small: TextStyle {
161                font_size: Dp(36.0),
162                line_height: Some(Dp(44.0)),
163                font_weight: 400,
164                letter_spacing: Dp(0.0),
165                font_family: None,
166            },
167            headline_large: TextStyle {
168                font_size: Dp(32.0),
169                line_height: Some(Dp(40.0)),
170                font_weight: 400,
171                letter_spacing: Dp(0.0),
172                font_family: None,
173            },
174            headline_medium: TextStyle {
175                font_size: Dp(28.0),
176                line_height: Some(Dp(36.0)),
177                font_weight: 400,
178                letter_spacing: Dp(0.0),
179                font_family: None,
180            },
181            headline_small: TextStyle {
182                font_size: Dp(24.0),
183                line_height: Some(Dp(32.0)),
184                font_weight: 400,
185                letter_spacing: Dp(0.0),
186                font_family: None,
187            },
188            title_large: TextStyle {
189                font_size: Dp(22.0),
190                line_height: Some(Dp(28.0)),
191                font_weight: 400,
192                letter_spacing: Dp(0.0),
193                font_family: None,
194            },
195            title_medium: TextStyle {
196                font_size: Dp(16.0),
197                line_height: Some(Dp(24.0)),
198                font_weight: 500,
199                letter_spacing: Dp(0.2),
200                font_family: None,
201            },
202            title_small: TextStyle {
203                font_size: Dp(14.0),
204                line_height: Some(Dp(20.0)),
205                font_weight: 500,
206                letter_spacing: Dp(0.1),
207                font_family: None,
208            },
209            body_large: TextStyle {
210                font_size: Dp(16.0),
211                line_height: Some(Dp(24.0)),
212                font_weight: 400,
213                letter_spacing: Dp(0.5),
214                font_family: None,
215            },
216            body_medium: TextStyle {
217                font_size: Dp(14.0),
218                line_height: Some(Dp(20.0)),
219                font_weight: 400,
220                letter_spacing: Dp(0.2),
221                font_family: None,
222            },
223            body_small: TextStyle {
224                font_size: Dp(12.0),
225                line_height: Some(Dp(16.0)),
226                font_weight: 400,
227                letter_spacing: Dp(0.4),
228                font_family: None,
229            },
230            label_large: TextStyle {
231                font_size: Dp(14.0),
232                line_height: Some(Dp(20.0)),
233                font_weight: 500,
234                letter_spacing: Dp(0.1),
235                font_family: None,
236            },
237            label_medium: TextStyle {
238                font_size: Dp(12.0),
239                line_height: Some(Dp(16.0)),
240                font_weight: 500,
241                letter_spacing: Dp(0.5),
242                font_family: None,
243            },
244            label_small: TextStyle {
245                font_size: Dp(11.0),
246                line_height: Some(Dp(16.0)),
247                font_weight: 500,
248                letter_spacing: Dp(0.5),
249                font_family: None,
250            },
251        }
252    }
253}
254
255impl MaterialTypography {
256    /// Returns the emphasized type scale with stronger weights and tracking.
257    pub fn emphasized(&self) -> Self {
258        let mut scale = *self;
259        for style in [
260            &mut scale.display_large,
261            &mut scale.display_medium,
262            &mut scale.display_small,
263            &mut scale.headline_large,
264            &mut scale.headline_medium,
265            &mut scale.headline_small,
266            &mut scale.title_large,
267            &mut scale.body_large,
268            &mut scale.body_medium,
269            &mut scale.body_small,
270        ] {
271            style.font_weight = 500;
272        }
273        for style in [
274            &mut scale.title_medium,
275            &mut scale.title_small,
276            &mut scale.label_large,
277            &mut scale.label_medium,
278            &mut scale.label_small,
279        ] {
280            style.font_weight = 700;
281        }
282        scale.display_large.letter_spacing = Dp(0.0);
283        scale.body_large.letter_spacing = Dp(0.15);
284        scale.body_medium.letter_spacing = Dp(0.25);
285        scale.title_medium.letter_spacing = Dp(0.15);
286        scale
287    }
288}
289
290/// Material shape scale used by components to resolve default container shapes.
291#[derive(Clone, PartialEq, Copy, Debug)]
292pub struct MaterialShapes {
293    /// Extra small container shape.
294    pub extra_small: Shape,
295    /// Small container shape.
296    pub small: Shape,
297    /// Medium container shape.
298    pub medium: Shape,
299    /// Large container shape.
300    pub large: Shape,
301    /// Increased large container shape for prominent medium-sized controls.
302    pub large_increased: Shape,
303    /// Extra large container shape.
304    pub extra_large: Shape,
305}
306
307impl Default for MaterialShapes {
308    fn default() -> Self {
309        let rounded = |radius| {
310            let corner = RoundedCorner::manual(Dp(radius), 2.0);
311            Shape::RoundedRectangle {
312                top_left: corner,
313                top_right: corner,
314                bottom_left: corner,
315                bottom_right: corner,
316            }
317        };
318        Self {
319            extra_small: rounded(4.0),
320            small: rounded(8.0),
321            medium: rounded(12.0),
322            large: rounded(16.0),
323            large_increased: rounded(20.0),
324            extra_large: rounded(28.0),
325        }
326    }
327}
328
329/// Material theme container holding the three primary Material 3 theme
330/// primitives.
331#[derive(Clone, PartialEq, Debug, Default)]
332pub struct MaterialTheme {
333    /// Color scheme used by Material components.
334    pub color_scheme: MaterialColorScheme,
335    /// Typography scale used by text-based components.
336    pub typography: MaterialTypography,
337    /// Shape scale used by container components.
338    pub shapes: MaterialShapes,
339}
340
341impl MaterialTheme {
342    /// Create a theme from an explicit color scheme, using default typography
343    /// and shapes.
344    pub fn from_color_scheme(color_scheme: MaterialColorScheme) -> Self {
345        Self {
346            color_scheme,
347            ..Self::default()
348        }
349    }
350
351    /// Create a theme from a linear sRGB seed color.
352    pub fn from_seed(seed: Color, is_dark: bool) -> Self {
353        Self::from_color_scheme(scheme_from_seed(seed, is_dark))
354    }
355}
356
357/// # material_theme
358///
359/// Provides a Material theme context (color scheme, typography, shapes) to
360/// descendants.
361///
362/// ## Usage
363///
364/// Wrap your app (or a subtree) to configure defaults for Material components.
365/// The theme producer reads current state on each rebuild and updates
366/// descendants when its result changes.
367///
368/// ## Parameters
369///
370/// - `theme` — optional theme producer for descendants.
371/// - `child` — optional content rendered with the provided theme.
372///
373/// ## Examples
374///
375/// ```
376/// use tessera_components::theme::{
377///     MaterialColorScheme, MaterialTheme, MaterialTypography, material_theme,
378/// };
379/// use tessera_ui::{Color, tessera};
380///
381/// #[tessera]
382/// fn app() {
383///     let scheme = MaterialColorScheme::light_from_seed(Color::from_rgb(0.4, 0.3, 0.6));
384///     let typography = MaterialTypography::default();
385///
386///     material_theme()
387///         .theme(move || MaterialTheme {
388///             color_scheme: scheme.clone(),
389///             typography,
390///             ..MaterialTheme::default()
391///         })
392///         .child(|| {
393///             // Your UI here.
394///         });
395/// }
396/// ```
397#[tessera]
398pub fn material_theme(theme: Option<CallbackWith<(), MaterialTheme>>, child: Option<RenderSlot>) {
399    let theme = theme.unwrap_or_else(|| CallbackWith::new(|()| MaterialTheme::default()));
400    let child = child.unwrap_or_else(RenderSlot::empty);
401    let value = theme.call(());
402    let next = (value.clone(), child);
403    let data = remember(|| next.clone());
404    let pending = remember(|| None::<MaterialTheme>);
405    if data.with(|current| current.0 != next.0) {
406        pending.set(Some(next.0.clone()));
407    }
408    if data.with(|current| current != &next) {
409        data.set(next);
410    }
411    let content = RenderSlot::new(move || {
412        if pending.with(Option::is_some) {
413            let value = pending
414                .with_mut(Option::take)
415                .expect("pending theme update");
416            use_context::<MaterialTheme>()
417                .expect("Material theme must be provided")
418                .set(value);
419        }
420        let child = data.with(|(_, child)| *child);
421        child.render();
422    });
423    provide_context(move || value, move || content.render());
424}
425
426/// A Material Design color scheme, which can be light or dark,
427/// produced from a seed color.
428///
429/// The default is a light Tonal Spot scheme using the 2025 color specification
430/// and the sRGB seed `#6750A4`.
431#[derive(Clone, PartialEq, Debug)]
432pub struct MaterialColorScheme {
433    /// Indicates if the scheme is dark mode (`true`) or light mode (`false`).
434    pub is_dark: bool,
435    /// The primary color of the scheme.
436    pub primary: Color,
437    /// Color used for content on top of `primary`.
438    pub on_primary: Color,
439    /// A container color for `primary`.
440    pub primary_container: Color,
441    /// Color used for content on top of `primary_container`.
442    pub on_primary_container: Color,
443    /// The secondary color of the scheme.
444    pub secondary: Color,
445    /// Color used for content on top of `secondary`.
446    pub on_secondary: Color,
447    /// A container color for `secondary`.
448    pub secondary_container: Color,
449    /// Color used for content on top of `secondary_container`.
450    pub on_secondary_container: Color,
451    /// The tertiary color of the scheme.
452    pub tertiary: Color,
453    /// Color used for content on top of `tertiary`.
454    pub on_tertiary: Color,
455    /// A container color for `tertiary`.
456    pub tertiary_container: Color,
457    /// Color used for content on top of `tertiary_container`.
458    pub on_tertiary_container: Color,
459    /// The error color of the scheme.
460    pub error: Color,
461    /// Color used for content on top of `error`.
462    pub on_error: Color,
463    /// A container color for `error`.
464    pub error_container: Color,
465    /// Color used for content on top of `error_container`.
466    pub on_error_container: Color,
467    /// The background color of the scheme.
468    pub background: Color,
469    /// Color used for content on top of `background`.
470    pub on_background: Color,
471    /// The surface color of the scheme.
472    pub surface: Color,
473    /// Color used for content on top of `surface`.
474    pub on_surface: Color,
475    /// A variant of the surface color.
476    pub surface_variant: Color,
477    /// Color used for content on top of `surface_variant`.
478    pub on_surface_variant: Color,
479    /// The outline color.
480    pub outline: Color,
481    /// A variant of the outline color.
482    pub outline_variant: Color,
483    /// The shadow color.
484    pub shadow: Color,
485    /// The scrim color.
486    pub scrim: Color,
487    /// Tint color used by tonal elevation overlays on surfaces.
488    pub surface_tint: Color,
489    /// Brighter surface color used by expressive surface roles.
490    pub surface_bright: Color,
491    /// Dimmer surface color used by expressive surface roles.
492    pub surface_dim: Color,
493    /// An inverse of the surface color.
494    pub inverse_surface: Color,
495    /// Color used for content on top of `inverse_surface`.
496    pub inverse_on_surface: Color,
497    /// An inverse of the primary color.
498    pub inverse_primary: Color,
499    /// A container color for surfaces.
500    pub surface_container: Color,
501    /// A high container color for surfaces.
502    pub surface_container_high: Color,
503    /// A low container color for surfaces.
504    pub surface_container_highest: Color,
505    /// A low container color for surfaces.
506    pub surface_container_low: Color,
507    /// A lowest container color for surfaces.
508    pub surface_container_lowest: Color,
509    /// Fixed primary color role that maintains the same tone in light/dark
510    /// themes.
511    pub primary_fixed: Color,
512    /// Fixed primary dim color role that maintains the same tone in light/dark
513    /// themes.
514    pub primary_fixed_dim: Color,
515    /// Content color used on `primary_fixed` and `primary_fixed_dim`.
516    pub on_primary_fixed: Color,
517    /// Lower-emphasis content color used on `primary_fixed` roles.
518    pub on_primary_fixed_variant: Color,
519    /// Fixed secondary color role that maintains the same tone in light/dark
520    /// themes.
521    pub secondary_fixed: Color,
522    /// Fixed secondary dim color role that maintains the same tone in
523    /// light/dark themes.
524    pub secondary_fixed_dim: Color,
525    /// Content color used on `secondary_fixed` and `secondary_fixed_dim`.
526    pub on_secondary_fixed: Color,
527    /// Lower-emphasis content color used on `secondary_fixed` roles.
528    pub on_secondary_fixed_variant: Color,
529    /// Fixed tertiary color role that maintains the same tone in light/dark
530    /// themes.
531    pub tertiary_fixed: Color,
532    /// Fixed tertiary dim color role that maintains the same tone in light/dark
533    /// themes.
534    pub tertiary_fixed_dim: Color,
535    /// Content color used on `tertiary_fixed` and `tertiary_fixed_dim`.
536    pub on_tertiary_fixed: Color,
537    /// Lower-emphasis content color used on `tertiary_fixed` roles.
538    pub on_tertiary_fixed_variant: Color,
539}
540
541impl MaterialColorScheme {
542    /// Generates a light color scheme derived from a linear sRGB seed color.
543    pub fn light_from_seed(seed: Color) -> Self {
544        scheme_from_seed(seed, false)
545    }
546
547    /// Generates a dark color scheme derived from a linear sRGB seed color.
548    pub fn dark_from_seed(seed: Color) -> Self {
549        scheme_from_seed(seed, true)
550    }
551
552    /// Returns the matching content color for a background color in this
553    /// scheme.
554    ///
555    /// When the background color does not match a known scheme color, this
556    /// returns `None`.
557    pub fn content_color_for(&self, background: Color) -> Option<Color> {
558        if background == self.primary {
559            Some(self.on_primary)
560        } else if background == self.secondary {
561            Some(self.on_secondary)
562        } else if background == self.tertiary {
563            Some(self.on_tertiary)
564        } else if background == self.background {
565            Some(self.on_background)
566        } else if background == self.error {
567            Some(self.on_error)
568        } else if background == self.primary_container {
569            Some(self.on_primary_container)
570        } else if background == self.secondary_container {
571            Some(self.on_secondary_container)
572        } else if background == self.tertiary_container {
573            Some(self.on_tertiary_container)
574        } else if background == self.error_container {
575            Some(self.on_error_container)
576        } else if background == self.inverse_surface {
577            Some(self.inverse_on_surface)
578        } else if background == self.surface {
579            Some(self.on_surface)
580        } else if background == self.surface_variant {
581            Some(self.on_surface_variant)
582        } else if background == self.surface_bright
583            || background == self.surface_container
584            || background == self.surface_container_high
585            || background == self.surface_container_highest
586            || background == self.surface_container_low
587            || background == self.surface_container_lowest
588            || background == self.surface_dim
589        {
590            Some(self.on_surface)
591        } else if background == self.primary_fixed || background == self.primary_fixed_dim {
592            Some(self.on_primary_fixed)
593        } else if background == self.secondary_fixed || background == self.secondary_fixed_dim {
594            Some(self.on_secondary_fixed)
595        } else if background == self.tertiary_fixed || background == self.tertiary_fixed_dim {
596            Some(self.on_tertiary_fixed)
597        } else {
598            None
599        }
600    }
601
602    /// Computes the surface tonal color at an elevation.
603    pub fn surface_color_at_elevation(&self, elevation: Dp) -> Color {
604        if elevation.0 == 0.0 {
605            return self.surface;
606        }
607        let alpha = ((4.5 * (elevation.0 + 1.0).ln()) + 2.0) / 100.0;
608        self.surface.blend_over(self.surface_tint, alpha as f32)
609    }
610
611    /// Returns `surface_color_at_elevation` for the scheme surface, otherwise
612    /// returns `background`.
613    pub fn surface_color_at_elevation_for(&self, background: Color, elevation: Dp) -> Color {
614        if background == self.surface {
615            self.surface_color_at_elevation(elevation)
616        } else {
617            background
618        }
619    }
620}
621
622impl Default for MaterialColorScheme {
623    fn default() -> Self {
624        MaterialColorScheme::light_from_seed(argb_to_color(DEFAULT_SEED_ARGB))
625    }
626}
627
628fn scheme_from_seed(seed: Color, is_dark: bool) -> MaterialColorScheme {
629    let scheme = DynamicSchemeBuilder::default()
630        .source_color_hct(Hct::from_int(color_to_argb(seed)))
631        .variant(Variant::TonalSpot)
632        .spec_version(SpecVersion::Spec2025)
633        .is_dark(is_dark)
634        .build();
635    let dynamic_colors = MaterialDynamicColors::new();
636
637    MaterialColorScheme {
638        is_dark,
639        primary: argb_to_color(dynamic_colors.primary().get_argb(&scheme)),
640        on_primary: argb_to_color(dynamic_colors.on_primary().get_argb(&scheme)),
641        primary_container: argb_to_color(dynamic_colors.primary_container().get_argb(&scheme)),
642        on_primary_container: argb_to_color(
643            dynamic_colors.on_primary_container().get_argb(&scheme),
644        ),
645        secondary: argb_to_color(dynamic_colors.secondary().get_argb(&scheme)),
646        on_secondary: argb_to_color(dynamic_colors.on_secondary().get_argb(&scheme)),
647        secondary_container: argb_to_color(dynamic_colors.secondary_container().get_argb(&scheme)),
648        on_secondary_container: argb_to_color(
649            dynamic_colors.on_secondary_container().get_argb(&scheme),
650        ),
651        tertiary: argb_to_color(dynamic_colors.tertiary().get_argb(&scheme)),
652        on_tertiary: argb_to_color(dynamic_colors.on_tertiary().get_argb(&scheme)),
653        tertiary_container: argb_to_color(dynamic_colors.tertiary_container().get_argb(&scheme)),
654        on_tertiary_container: argb_to_color(
655            dynamic_colors.on_tertiary_container().get_argb(&scheme),
656        ),
657        error: argb_to_color(dynamic_colors.error().get_argb(&scheme)),
658        on_error: argb_to_color(dynamic_colors.on_error().get_argb(&scheme)),
659        error_container: argb_to_color(dynamic_colors.error_container().get_argb(&scheme)),
660        on_error_container: argb_to_color(dynamic_colors.on_error_container().get_argb(&scheme)),
661        background: argb_to_color(dynamic_colors.background().get_argb(&scheme)),
662        on_background: argb_to_color(dynamic_colors.on_background().get_argb(&scheme)),
663        surface: argb_to_color(dynamic_colors.surface().get_argb(&scheme)),
664        on_surface: argb_to_color(dynamic_colors.on_surface().get_argb(&scheme)),
665        surface_variant: argb_to_color(dynamic_colors.surface_variant().get_argb(&scheme)),
666        on_surface_variant: argb_to_color(dynamic_colors.on_surface_variant().get_argb(&scheme)),
667        outline: argb_to_color(dynamic_colors.outline().get_argb(&scheme)),
668        outline_variant: argb_to_color(dynamic_colors.outline_variant().get_argb(&scheme)),
669        shadow: argb_to_color(dynamic_colors.shadow().get_argb(&scheme)),
670        scrim: argb_to_color(dynamic_colors.scrim().get_argb(&scheme)),
671        surface_tint: argb_to_color(dynamic_colors.surface_tint().get_argb(&scheme)),
672        inverse_surface: argb_to_color(dynamic_colors.inverse_surface().get_argb(&scheme)),
673        inverse_on_surface: argb_to_color(dynamic_colors.inverse_on_surface().get_argb(&scheme)),
674        inverse_primary: argb_to_color(dynamic_colors.inverse_primary().get_argb(&scheme)),
675        surface_bright: argb_to_color(dynamic_colors.surface_bright().get_argb(&scheme)),
676        surface_dim: argb_to_color(dynamic_colors.surface_dim().get_argb(&scheme)),
677        surface_container: argb_to_color(dynamic_colors.surface_container().get_argb(&scheme)),
678        surface_container_high: argb_to_color(
679            dynamic_colors.surface_container_high().get_argb(&scheme),
680        ),
681        surface_container_highest: argb_to_color(
682            dynamic_colors.surface_container_highest().get_argb(&scheme),
683        ),
684        surface_container_low: argb_to_color(
685            dynamic_colors.surface_container_low().get_argb(&scheme),
686        ),
687        surface_container_lowest: argb_to_color(
688            dynamic_colors.surface_container_lowest().get_argb(&scheme),
689        ),
690        primary_fixed: argb_to_color(dynamic_colors.primary_fixed().get_argb(&scheme)),
691        primary_fixed_dim: argb_to_color(dynamic_colors.primary_fixed_dim().get_argb(&scheme)),
692        on_primary_fixed: argb_to_color(dynamic_colors.on_primary_fixed().get_argb(&scheme)),
693        on_primary_fixed_variant: argb_to_color(
694            dynamic_colors.on_primary_fixed_variant().get_argb(&scheme),
695        ),
696        secondary_fixed: argb_to_color(dynamic_colors.secondary_fixed().get_argb(&scheme)),
697        secondary_fixed_dim: argb_to_color(dynamic_colors.secondary_fixed_dim().get_argb(&scheme)),
698        on_secondary_fixed: argb_to_color(dynamic_colors.on_secondary_fixed().get_argb(&scheme)),
699        on_secondary_fixed_variant: argb_to_color(
700            dynamic_colors
701                .on_secondary_fixed_variant()
702                .get_argb(&scheme),
703        ),
704        tertiary_fixed: argb_to_color(dynamic_colors.tertiary_fixed().get_argb(&scheme)),
705        tertiary_fixed_dim: argb_to_color(dynamic_colors.tertiary_fixed_dim().get_argb(&scheme)),
706        on_tertiary_fixed: argb_to_color(dynamic_colors.on_tertiary_fixed().get_argb(&scheme)),
707        on_tertiary_fixed_variant: argb_to_color(
708            dynamic_colors.on_tertiary_fixed_variant().get_argb(&scheme),
709        ),
710    }
711}
712
713fn linear_to_srgb_channel(v: f32) -> f32 {
714    let v = v.clamp(0.0, 1.0);
715    if v <= 0.003_130_8 {
716        v * 12.92
717    } else {
718        1.055 * v.powf(1.0 / 2.4) - 0.055
719    }
720}
721
722fn srgb_to_linear_channel(v: f32) -> f32 {
723    let v = v.clamp(0.0, 1.0);
724    if v <= 0.04045 {
725        v / 12.92
726    } else {
727        ((v + 0.055) / 1.055).powf(2.4)
728    }
729}
730
731fn color_to_argb(color: Color) -> u32 {
732    let r = (linear_to_srgb_channel(color.r) * 255.0 + 0.5) as u32;
733    let g = (linear_to_srgb_channel(color.g) * 255.0 + 0.5) as u32;
734    let b = (linear_to_srgb_channel(color.b) * 255.0 + 0.5) as u32;
735    let a = (color.a.clamp(0.0, 1.0) * 255.0 + 0.5) as u32;
736    (a << 24) | (r << 16) | (g << 8) | b
737}
738
739fn argb_to_color(argb: u32) -> Color {
740    let a = ((argb >> 24) & 0xFF) as f32 / 255.0;
741    let r_srgb = ((argb >> 16) & 0xFF) as f32 / 255.0;
742    let g_srgb = ((argb >> 8) & 0xFF) as f32 / 255.0;
743    let b_srgb = (argb & 0xFF) as f32 / 255.0;
744    Color::new(
745        srgb_to_linear_channel(r_srgb),
746        srgb_to_linear_channel(g_srgb),
747        srgb_to_linear_channel(b_srgb),
748        a,
749    )
750}
751
752#[cfg(test)]
753mod tests {
754    use tessera_ui::Color;
755
756    use super::MaterialColorScheme;
757
758    fn assert_reference_roles(scheme: MaterialColorScheme, expected: [[f32; 3]; 12]) {
759        let actual = [
760            ("primary", scheme.primary),
761            ("on_primary", scheme.on_primary),
762            ("primary_container", scheme.primary_container),
763            ("on_primary_container", scheme.on_primary_container),
764            ("secondary", scheme.secondary),
765            ("secondary_container", scheme.secondary_container),
766            ("on_secondary_container", scheme.on_secondary_container),
767            ("tertiary", scheme.tertiary),
768            ("surface", scheme.surface),
769            ("surface_container_low", scheme.surface_container_low),
770            ("on_surface", scheme.on_surface),
771            ("outline", scheme.outline),
772        ];
773        for ((role, actual), expected) in actual.into_iter().zip(expected) {
774            for (actual, expected) in [actual.r, actual.g, actual.b].into_iter().zip(expected) {
775                assert!(
776                    (actual - expected).abs() < 0.000_001,
777                    "{role}: expected {expected}, got {actual}"
778                );
779            }
780            assert_eq!(actual.a, 1.0, "{role} must be opaque");
781        }
782    }
783
784    #[test]
785    fn default_light_roles_match_material_2025_reference() {
786        assert_reference_roles(
787            MaterialColorScheme::default(),
788            [
789                [0.130136477, 0.095307467, 0.250158285],
790                [0.982250550, 0.930110858, 1.000000000],
791                [0.658374817, 0.545724461, 0.982250550],
792                [0.066625939, 0.045186204, 0.149959790],
793                [0.122138772, 0.107023103, 0.165132195],
794                [0.806952258, 0.730460740, 0.938685728],
795                [0.090841711, 0.078187422, 0.124771818],
796                [0.198069320, 0.084376212, 0.162029376],
797                [0.982250550, 0.930110858, 0.991102097],
798                [0.938685728, 0.879622397, 0.955973353],
799                [0.034339807, 0.030713444, 0.042311411],
800                [0.205078736, 0.191201683, 0.226965874],
801            ],
802        );
803    }
804
805    #[test]
806    fn seeded_dark_roles_match_material_2025_reference() {
807        assert_reference_roles(
808            MaterialColorScheme::dark_from_seed(Color::from_rgb(
809                0.135633335,
810                0.080219820,
811                0.371237695,
812            )),
813            [
814                [0.610495571, 0.527115126, 0.838799012],
815                [0.057805430, 0.042311411, 0.114435374],
816                [0.095307467, 0.074213568, 0.168269400],
817                [0.814846572, 0.730460740, 1.000000000],
818                [0.597201788, 0.539479489, 0.708375780],
819                [0.048171824, 0.039546235, 0.072271851],
820                [0.552011402, 0.496932995, 0.658374817],
821                [1.000000000, 0.623960392, 0.863157213],
822                [0.004776953, 0.004024717, 0.006048833],
823                [0.006995410, 0.006048833, 0.009134059],
824                [0.822785754, 0.768151147, 0.863157213],
825                [0.187820772, 0.171441101, 0.208636870],
826            ],
827        );
828    }
829}