Skip to main content

ferroui_themes_material/
dynamic_color.rs

1// Copyright 2021 The Android Open Source Project (Android tonal role mapping).
2// Copyright 2026 FerroUI contributors (Rust adapters).
3// SPDX-License-Identifier: Apache-2.0
4//! HCT seed schemes and lossless imports of Android dynamic color palettes.
5//!
6//! Seed schemes follow versioned Google Material Color Utilities phone specs at
7//! bd811e4ca2ab94c4ce3cadc89c19b1820b9704b3, using `material-colors` 0.4.2 for HCT.
8//! Android imports follow AndroidX Material3 `DynamicTonalPalette.android.kt` at
9//! e4d933c98df7baa7ea2b83eea6e086915961e0ff. See `docs/porting/material-dynamic-color.md`
10//! for the distinction between seed algorithm revisions and system color imports.
11
12use crate::{MaterialColorRole, MaterialColorScheme};
13use ferroui_base::media::Color;
14use material_colors::color::Argb;
15use material_colors::dynamic_color::Variant;
16use material_colors::palette::TonalPalette;
17use std::fmt;
18
19#[path = "dynamic_color_spec.rs"]
20mod spec;
21
22/// Exact upstream source used for palette, tone-delta, and role-contrast rules.
23pub const MATERIAL_COLOR_UTILITIES_VERSION: &str =
24    "Google Material Color Utilities bd811e4ca2ab94c4ce3cadc89c19b1820b9704b3 (phone)";
25
26/// The separately pinned implementation of the CAM16/HCT gamut solver.
27pub const MATERIAL_HCT_SOLVER_VERSION: &str = "material-colors 0.4.2";
28
29/// Source-spec selection for locally generated phone schemes. The default is
30/// exactly Google's `DynamicScheme.DEFAULT_SPEC_VERSION` at the pinned commit.
31/// This never changes imported Android colors or the AndroidX baseline schemes.
32#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
33pub enum MaterialColorSpec {
34    #[default]
35    Spec2021,
36    Spec2025,
37    Spec2026,
38}
39
40impl MaterialColorSpec {
41    /// Apply Google's supported-variant fallback. The 2026 spec introduces a
42    /// separate CMF multi-source variant, which this single-seed API does not
43    /// expose. For these nine phone styles its results are identical to 2025.
44    pub const fn effective_for(self, style: MaterialPaletteStyle) -> Self {
45        match (self, style) {
46            (Self::Spec2021, _) => Self::Spec2021,
47            (
48                _,
49                MaterialPaletteStyle::Neutral
50                | MaterialPaletteStyle::TonalSpot
51                | MaterialPaletteStyle::Vibrant
52                | MaterialPaletteStyle::Expressive,
53            ) => Self::Spec2025,
54            _ => Self::Spec2021,
55        }
56    }
57}
58
59/// Material Color Utilities palette variants. This is independent of the UI motion scheme.
60#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
61pub enum MaterialPaletteStyle {
62    Monochrome,
63    Neutral,
64    #[default]
65    TonalSpot,
66    Vibrant,
67    Expressive,
68    Fidelity,
69    Content,
70    Rainbow,
71    FruitSalad,
72}
73
74impl MaterialPaletteStyle {
75    pub const ALL: [Self; 9] = [
76        Self::Monochrome,
77        Self::Neutral,
78        Self::TonalSpot,
79        Self::Vibrant,
80        Self::Expressive,
81        Self::Fidelity,
82        Self::Content,
83        Self::Rainbow,
84        Self::FruitSalad,
85    ];
86
87    const fn variant(self) -> Variant {
88        match self {
89            Self::Monochrome => Variant::Monochrome,
90            Self::Neutral => Variant::Neutral,
91            Self::TonalSpot => Variant::TonalSpot,
92            Self::Vibrant => Variant::Vibrant,
93            Self::Expressive => Variant::Expressive,
94            Self::Fidelity => Variant::Fidelity,
95            Self::Content => Variant::Content,
96            Self::Rainbow => Variant::Rainbow,
97            Self::FruitSalad => Variant::FruitSalad,
98        }
99    }
100}
101
102/// Inputs for a generated dynamic scheme. Contrast is in [-1, 1]: zero is standard,
103/// 0.5 is medium, and 1 is high contrast. NaN/infinite/out-of-range values are rejected.
104#[derive(Clone, Copy, Debug, Default, PartialEq)]
105pub struct MaterialDynamicColorOptions {
106    pub dark: bool,
107    pub contrast_level: f64,
108    pub style: MaterialPaletteStyle,
109}
110
111#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
112pub enum MaterialPaletteFamily {
113    Primary,
114    Secondary,
115    Tertiary,
116    Neutral,
117    NeutralVariant,
118}
119
120/// A failed import identifies the exact missing tone or role; it never substitutes
121/// an unrelated color or interpolates missing platform tones in RGB.
122#[derive(Clone, Debug, PartialEq)]
123pub enum MaterialDynamicColorError {
124    InvalidContrast(f64),
125    InvalidHueOrChroma {
126        hue: f64,
127        chroma: f64,
128    },
129    InvalidTone(u8),
130    DuplicateTone(u8),
131    MissingTone {
132        palette: MaterialPaletteFamily,
133        tone: u8,
134    },
135    MissingSystemRole(MaterialColorRole),
136}
137
138impl fmt::Display for MaterialDynamicColorError {
139    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
140        match self {
141            Self::InvalidContrast(value) => write!(f, "Material contrast must be finite and in [-1, 1], got {value}"),
142            Self::InvalidHueOrChroma { hue, chroma } => write!(f, "Material hue must be finite and chroma finite/nonnegative, got hue {hue}, chroma {chroma}"),
143            Self::InvalidTone(tone) => write!(f, "Material tone must be in [0, 100], got {tone}"),
144            Self::DuplicateTone(tone) => write!(f, "Material tone {tone} was supplied more than once"),
145            Self::MissingTone { palette, tone } => write!(f, "Android {palette:?} palette is missing tone {tone}"),
146            Self::MissingSystemRole(role) => write!(f, "Android dynamic scheme is missing {}", role.name()),
147        }
148    }
149}
150
151impl std::error::Error for MaterialDynamicColorError {}
152
153/// An imported or HCT-generated tonal palette. Imported ARGB values are preserved
154/// exactly. Sparse palettes are supported, but scheme creation fails for missing tones.
155#[derive(Clone, Debug, PartialEq, Eq)]
156pub struct MaterialTonalPalette {
157    tones: [Option<Color>; 101],
158}
159
160impl MaterialTonalPalette {
161    /// Tones exposed as the thirteen Android 12 system accent/neutral resources.
162    pub const ANDROID_SYSTEM_TONES: [u8; 13] = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 95, 99, 100];
163    /// The complete neutral range read or calculated by AndroidX Material3.
164    pub const ANDROID_NEUTRAL_TONES: [u8; 24] = [
165        0, 4, 6, 10, 12, 17, 20, 22, 24, 30, 40, 50, 60, 70, 80, 87, 90, 92, 94, 95, 96, 98, 99,
166        100,
167    ];
168
169    pub fn from_tones(
170        tones: impl IntoIterator<Item = (u8, Color)>,
171    ) -> Result<Self, MaterialDynamicColorError> {
172        let mut result = Self { tones: [None; 101] };
173        for (tone, color) in tones {
174            let Some(slot) = result.tones.get_mut(usize::from(tone)) else {
175                return Err(MaterialDynamicColorError::InvalidTone(tone));
176            };
177            if slot.replace(color).is_some() {
178                return Err(MaterialDynamicColorError::DuplicateTone(tone));
179            }
180        }
181        Ok(result)
182    }
183
184    pub fn from_colors(tones: [Color; 101]) -> Self {
185        Self {
186            tones: tones.map(Some),
187        }
188    }
189
190    /// Generates all integer tones using the actual HCT gamut solver. Hue is
191    /// normalized modulo 360; no RGB blending or fixed channel offsets are used.
192    pub fn from_hct(hue: f64, chroma: f64) -> Result<Self, MaterialDynamicColorError> {
193        if !hue.is_finite() || !chroma.is_finite() || chroma < 0.0 {
194            return Err(MaterialDynamicColorError::InvalidHueOrChroma { hue, chroma });
195        }
196        Ok(Self::from_mcu(&TonalPalette::of(
197            hue.rem_euclid(360.0),
198            chroma,
199        )))
200    }
201
202    pub fn tone(&self, tone: u8) -> Option<Color> {
203        self.tones.get(usize::from(tone)).copied().flatten()
204    }
205
206    fn from_mcu(palette: &TonalPalette) -> Self {
207        Self::from_colors(std::array::from_fn(|tone| {
208            from_argb(spec::palette_argb(palette, tone as f64, 1.0))
209        }))
210    }
211}
212
213/// All five Android tonal palettes, including the full neutral and neutral-variant
214/// ranges. The Android 31–33 mapping deliberately uses neutral-variant surfaces,
215/// as AndroidX does, rather than the older system's lower-chroma neutral palette.
216#[derive(Clone, Debug, PartialEq, Eq)]
217pub struct MaterialDynamicPalette {
218    pub primary: MaterialTonalPalette,
219    pub secondary: MaterialTonalPalette,
220    pub tertiary: MaterialTonalPalette,
221    pub neutral: MaterialTonalPalette,
222    pub neutral_variant: MaterialTonalPalette,
223}
224
225impl MaterialDynamicPalette {
226    /// Imports the complete AndroidX tonal palette. A platform bridge supplies
227    /// the system tones and the additional neutral tones calculated by AndroidX
228    /// `Color.setLuminance`; values are never regenerated on this path.
229    pub fn from_android31_colors(
230        mut resolve: impl FnMut(MaterialPaletteFamily, u8) -> Option<Color>,
231    ) -> Result<Self, MaterialDynamicColorError> {
232        fn import(
233            family: MaterialPaletteFamily,
234            tones: &[u8],
235            resolve: &mut impl FnMut(MaterialPaletteFamily, u8) -> Option<Color>,
236        ) -> Result<MaterialTonalPalette, MaterialDynamicColorError> {
237            let mut result = MaterialTonalPalette { tones: [None; 101] };
238            for &tone in tones {
239                result.tones[usize::from(tone)] = Some(resolve(family, tone).ok_or(
240                    MaterialDynamicColorError::MissingTone {
241                        palette: family,
242                        tone,
243                    },
244                )?);
245            }
246            Ok(result)
247        }
248        use MaterialPaletteFamily as F;
249        Ok(Self {
250            primary: import(
251                F::Primary,
252                &MaterialTonalPalette::ANDROID_SYSTEM_TONES,
253                &mut resolve,
254            )?,
255            secondary: import(
256                F::Secondary,
257                &MaterialTonalPalette::ANDROID_SYSTEM_TONES,
258                &mut resolve,
259            )?,
260            tertiary: import(
261                F::Tertiary,
262                &MaterialTonalPalette::ANDROID_SYSTEM_TONES,
263                &mut resolve,
264            )?,
265            neutral: import(
266                F::Neutral,
267                &MaterialTonalPalette::ANDROID_NEUTRAL_TONES,
268                &mut resolve,
269            )?,
270            neutral_variant: import(
271                F::NeutralVariant,
272                &MaterialTonalPalette::ANDROID_NEUTRAL_TONES,
273                &mut resolve,
274            )?,
275        })
276    }
277
278    /// Generates five full HCT palettes locally. To reproduce a device wallpaper
279    /// scheme exactly, import its system palettes or direct color roles instead.
280    pub fn from_seed(seed: Color, style: MaterialPaletteStyle) -> Self {
281        Self::from_seed_with_spec(seed, style, false, MaterialColorSpec::default())
282    }
283
284    /// Generates all five tone palettes for a source specification. In the 2025
285    /// phone spec some palette chromas depend on dark mode, so it is explicit.
286    pub fn from_seed_with_spec(
287        seed: Color,
288        style: MaterialPaletteStyle,
289        dark: bool,
290        source_spec: MaterialColorSpec,
291    ) -> Self {
292        let scheme = spec::palettes(
293            to_seed_argb(seed),
294            style.variant(),
295            dark,
296            0.0,
297            source_spec.effective_for(style),
298        );
299        Self {
300            primary: MaterialTonalPalette::from_mcu(&scheme.primary_palette),
301            secondary: MaterialTonalPalette::from_mcu(&scheme.secondary_palette),
302            tertiary: MaterialTonalPalette::from_mcu(&scheme.tertiary_palette),
303            neutral: MaterialTonalPalette::from_mcu(&scheme.neutral_palette),
304            neutral_variant: MaterialTonalPalette::from_mcu(&scheme.neutral_variant_palette),
305        }
306    }
307
308    pub fn palette(&self, family: MaterialPaletteFamily) -> &MaterialTonalPalette {
309        match family {
310            MaterialPaletteFamily::Primary => &self.primary,
311            MaterialPaletteFamily::Secondary => &self.secondary,
312            MaterialPaletteFamily::Tertiary => &self.tertiary,
313            MaterialPaletteFamily::Neutral => &self.neutral,
314            MaterialPaletteFamily::NeutralVariant => &self.neutral_variant,
315        }
316    }
317
318    /// Exact role selection from `dynamicLightColorScheme31` /
319    /// `dynamicDarkColorScheme31` in the pinned AndroidX source. Fixed roles do
320    /// not change with light/dark mode. Error roles retain the AndroidX defaults.
321    pub fn scheme_android31(
322        &self,
323        dark: bool,
324    ) -> Result<MaterialColorScheme, MaterialDynamicColorError> {
325        let mut scheme = if dark {
326            MaterialColorScheme::dark()
327        } else {
328            MaterialColorScheme::light()
329        };
330        for role in MaterialColorRole::ALL {
331            if let Some((palette, tone)) = android31_tone(role, dark) {
332                let color = self
333                    .palette(palette)
334                    .tone(tone)
335                    .ok_or(MaterialDynamicColorError::MissingTone { palette, tone })?;
336                scheme.set_color(role, color);
337            }
338        }
339        Ok(scheme)
340    }
341}
342
343impl MaterialColorScheme {
344    /// Standard-contrast Tonal Spot scheme, generated with CAM16/HCT. Seed alpha
345    /// is ignored, matching the opaque source-color contract of Material utilities.
346    pub fn from_seed(seed: Color, dark: bool) -> Self {
347        Self::from_seed_with_options(
348            seed,
349            MaterialDynamicColorOptions {
350                dark,
351                ..Default::default()
352            },
353        )
354        .expect("zero is a valid Material contrast level")
355    }
356
357    /// Generates all 48 roles, including fixed and surface-container roles, with
358    /// the Tonal Spot palette and an explicit contrast level in [-1, 1].
359    pub fn from_seed_color(
360        seed: Color,
361        dark: bool,
362        contrast_level: f64,
363    ) -> Result<Self, MaterialDynamicColorError> {
364        Self::from_seed_with_options(
365            seed,
366            MaterialDynamicColorOptions {
367                dark,
368                contrast_level,
369                ..Default::default()
370            },
371        )
372    }
373
374    pub fn from_seed_with_options(
375        seed: Color,
376        options: MaterialDynamicColorOptions,
377    ) -> Result<Self, MaterialDynamicColorError> {
378        Self::from_seed_with_spec(seed, options, MaterialColorSpec::default())
379    }
380
381    /// Generates all 48 roles using an explicit source specification and its
382    /// official phone-variant fallback. `Spec2025` enables the current expressive
383    /// palettes, chroma-sensitive surfaces, and independent tone constraints.
384    pub fn from_seed_with_spec(
385        seed: Color,
386        options: MaterialDynamicColorOptions,
387        source_spec: MaterialColorSpec,
388    ) -> Result<Self, MaterialDynamicColorError> {
389        if !options.contrast_level.is_finite() || !(-1.0..=1.0).contains(&options.contrast_level) {
390            return Err(MaterialDynamicColorError::InvalidContrast(
391                options.contrast_level,
392            ));
393        }
394        let source_spec = source_spec.effective_for(options.style);
395        let scheme = spec::palettes(
396            to_seed_argb(seed),
397            options.style.variant(),
398            options.dark,
399            options.contrast_level,
400            source_spec,
401        );
402        let resolver = spec::RoleResolver::new(&scheme, source_spec);
403        Ok(Self::new(
404            MaterialColorRole::ALL.map(|role| from_argb(resolver.argb(role))),
405        ))
406    }
407
408    /// Losslessly imports Android 14/API 34+ roles, including system high-contrast
409    /// adjustments. The host resolves the corresponding `mc3_light_scheme` or
410    /// `mc3_dark_scheme` attributes. As in AndroidX, error roles and scrim retain
411    /// their baseline defaults when absent; supplied overrides are preserved.
412    /// Every other role is required. This method does not query Android or JNI.
413    pub fn from_android_system_roles(
414        dark: bool,
415        mut resolve: impl FnMut(MaterialColorRole) -> Option<Color>,
416    ) -> Result<Self, MaterialDynamicColorError> {
417        let mut scheme = if dark { Self::dark() } else { Self::light() };
418        for role in MaterialColorRole::ALL {
419            if let Some(color) = resolve(role) {
420                scheme.set_color(role, color);
421            } else if !matches!(
422                role,
423                MaterialColorRole::Error
424                    | MaterialColorRole::OnError
425                    | MaterialColorRole::ErrorContainer
426                    | MaterialColorRole::OnErrorContainer
427                    | MaterialColorRole::Scrim
428            ) {
429                return Err(MaterialDynamicColorError::MissingSystemRole(role));
430            }
431        }
432        Ok(scheme)
433    }
434}
435
436fn to_seed_argb(color: Color) -> Argb {
437    Argb::new(255, color.r, color.g, color.b)
438}
439fn from_argb(color: Argb) -> Color {
440    Color::from_argb(color.alpha, color.red, color.green, color.blue)
441}
442
443fn android31_tone(role: MaterialColorRole, dark: bool) -> Option<(MaterialPaletteFamily, u8)> {
444    use MaterialColorRole as R;
445    use MaterialPaletteFamily as F;
446    let accent = if dark { 80 } else { 40 };
447    let on_accent = if dark { 20 } else { 100 };
448    let container = if dark { 30 } else { 90 };
449    let on_container = if dark { 90 } else { 10 };
450    Some(match role {
451        R::Primary | R::SurfaceTint => (F::Primary, accent),
452        R::OnPrimary => (F::Primary, on_accent),
453        R::PrimaryContainer => (F::Primary, container),
454        R::OnPrimaryContainer => (F::Primary, on_container),
455        R::InversePrimary => (F::Primary, if dark { 40 } else { 80 }),
456        R::Secondary => (F::Secondary, accent),
457        R::OnSecondary => (F::Secondary, on_accent),
458        R::SecondaryContainer => (F::Secondary, container),
459        R::OnSecondaryContainer => (F::Secondary, on_container),
460        R::Tertiary => (F::Tertiary, accent),
461        R::OnTertiary => (F::Tertiary, on_accent),
462        R::TertiaryContainer => (F::Tertiary, container),
463        R::OnTertiaryContainer => (F::Tertiary, on_container),
464        R::PrimaryFixed => (F::Primary, 90),
465        R::PrimaryFixedDim => (F::Primary, 80),
466        R::OnPrimaryFixed => (F::Primary, 10),
467        R::OnPrimaryFixedVariant => (F::Primary, 30),
468        R::SecondaryFixed => (F::Secondary, 90),
469        R::SecondaryFixedDim => (F::Secondary, 80),
470        R::OnSecondaryFixed => (F::Secondary, 10),
471        R::OnSecondaryFixedVariant => (F::Secondary, 30),
472        R::TertiaryFixed => (F::Tertiary, 90),
473        R::TertiaryFixedDim => (F::Tertiary, 80),
474        R::OnTertiaryFixed => (F::Tertiary, 10),
475        R::OnTertiaryFixedVariant => (F::Tertiary, 30),
476        R::Background | R::Surface => (F::NeutralVariant, if dark { 6 } else { 98 }),
477        R::OnBackground | R::OnSurface => (F::NeutralVariant, if dark { 90 } else { 10 }),
478        R::SurfaceVariant => (F::NeutralVariant, if dark { 30 } else { 90 }),
479        R::OnSurfaceVariant => (F::NeutralVariant, if dark { 80 } else { 30 }),
480        R::InverseSurface => (F::NeutralVariant, if dark { 90 } else { 20 }),
481        R::InverseOnSurface => (F::NeutralVariant, if dark { 20 } else { 95 }),
482        R::Outline => (F::NeutralVariant, if dark { 60 } else { 50 }),
483        R::OutlineVariant => (F::NeutralVariant, if dark { 30 } else { 80 }),
484        R::Scrim => (F::NeutralVariant, 0),
485        R::SurfaceBright => (F::NeutralVariant, if dark { 24 } else { 98 }),
486        R::SurfaceDim => (F::NeutralVariant, if dark { 6 } else { 87 }),
487        R::SurfaceContainer => (F::NeutralVariant, if dark { 12 } else { 94 }),
488        R::SurfaceContainerHigh => (F::NeutralVariant, if dark { 17 } else { 92 }),
489        R::SurfaceContainerHighest => (F::NeutralVariant, if dark { 22 } else { 90 }),
490        R::SurfaceContainerLow => (F::NeutralVariant, if dark { 10 } else { 96 }),
491        R::SurfaceContainerLowest => (F::NeutralVariant, if dark { 4 } else { 100 }),
492        R::Error | R::OnError | R::ErrorContainer | R::OnErrorContainer => return None,
493    })
494}
495
496#[cfg(test)]
497mod tests {
498    use super::*;
499
500    fn assert_reference_spec(specification: MaterialColorSpec, fixture_name: &str) {
501        let csv = include_str!("tests/fixtures/dynamic-color-phone.csv");
502        let mut lines = csv.lines();
503        let header: Vec<_> = lines.next().unwrap().split(',').collect();
504        for (column, role) in header[5..].iter().zip(MaterialColorRole::ALL) {
505            assert_eq!(*column, role.name(), "fixture role order");
506        }
507        let mut schemes = 0;
508        for line in lines {
509            let row: Vec<_> = line.split(',').collect();
510            if row[0] != fixture_name {
511                continue;
512            }
513            let style = match row[2] {
514                "MONOCHROME" => MaterialPaletteStyle::Monochrome,
515                "NEUTRAL" => MaterialPaletteStyle::Neutral,
516                "TONAL_SPOT" => MaterialPaletteStyle::TonalSpot,
517                "VIBRANT" => MaterialPaletteStyle::Vibrant,
518                "EXPRESSIVE" => MaterialPaletteStyle::Expressive,
519                "FIDELITY" => MaterialPaletteStyle::Fidelity,
520                "CONTENT" => MaterialPaletteStyle::Content,
521                "RAINBOW" => MaterialPaletteStyle::Rainbow,
522                "FRUIT_SALAD" => MaterialPaletteStyle::FruitSalad,
523                other => panic!("unknown reference style {other}"),
524            };
525            let seed = Color::from_uint32(u32::from_str_radix(row[1], 16).unwrap());
526            let options = MaterialDynamicColorOptions {
527                style,
528                dark: row[3] == "1",
529                contrast_level: row[4].parse().unwrap(),
530            };
531            let actual =
532                MaterialColorScheme::from_seed_with_spec(seed, options, specification).unwrap();
533            assert_eq!(row.len(), 53);
534            for (role, expected) in MaterialColorRole::ALL.into_iter().zip(&row[5..]) {
535                let expected = u32::from_str_radix(expected, 16).unwrap();
536                assert_eq!(
537                    actual.color(role).to_uint32(),
538                    expected,
539                    "Google MCU {specification:?} seed={} {options:?} {role:?}",
540                    row[1]
541                );
542            }
543            schemes += 1;
544        }
545        assert_eq!(schemes, 1728, "16 seeds × 9 styles × 2 modes × 6 contrasts");
546    }
547
548    #[test]
549    fn current_2021_spec_matches_all_82944_independent_reference_argbs() {
550        assert_reference_spec(MaterialColorSpec::Spec2021, "2021");
551    }
552
553    #[test]
554    fn expressive_2025_spec_matches_all_82944_independent_reference_argbs() {
555        assert_reference_spec(MaterialColorSpec::Spec2025, "2025");
556    }
557
558    #[test]
559    fn source_default_and_2026_phone_fallback_match_the_upstream_contract() {
560        assert_eq!(MaterialColorSpec::default(), MaterialColorSpec::Spec2021);
561        let seed = Color::from_uint32(0xff4285f4);
562        for style in MaterialPaletteStyle::ALL {
563            let effective = if matches!(
564                style,
565                MaterialPaletteStyle::Neutral
566                    | MaterialPaletteStyle::TonalSpot
567                    | MaterialPaletteStyle::Vibrant
568                    | MaterialPaletteStyle::Expressive
569            ) {
570                MaterialColorSpec::Spec2025
571            } else {
572                MaterialColorSpec::Spec2021
573            };
574            assert_eq!(MaterialColorSpec::Spec2025.effective_for(style), effective);
575            assert_eq!(MaterialColorSpec::Spec2026.effective_for(style), effective);
576            for dark in [false, true] {
577                let options = MaterialDynamicColorOptions {
578                    style,
579                    dark,
580                    contrast_level: 0.25,
581                };
582                assert_eq!(
583                    MaterialColorScheme::from_seed_with_options(seed, options).unwrap(),
584                    MaterialColorScheme::from_seed_with_spec(
585                        seed,
586                        options,
587                        MaterialColorSpec::Spec2021
588                    )
589                    .unwrap()
590                );
591                assert_eq!(
592                    MaterialColorScheme::from_seed_with_spec(
593                        seed,
594                        options,
595                        MaterialColorSpec::Spec2026
596                    )
597                    .unwrap(),
598                    MaterialColorScheme::from_seed_with_spec(
599                        seed,
600                        options,
601                        MaterialColorSpec::Spec2025
602                    )
603                    .unwrap()
604                );
605            }
606        }
607    }
608
609    #[test]
610    fn black_fidelity_keeps_the_reference_gray_container_foreground() {
611        for style in [
612            MaterialPaletteStyle::Fidelity,
613            MaterialPaletteStyle::Content,
614        ] {
615            let options = MaterialDynamicColorOptions {
616                style,
617                dark: false,
618                contrast_level: 0.0,
619            };
620            let scheme = MaterialColorScheme::from_seed_with_options(
621                Color::from_uint32(0xff000000),
622                options,
623            )
624            .unwrap();
625            // The dependency's black HCT tone is -2.22e-16 due to fused multiply-add.
626            // Passing that to contrast's range validation incorrectly selects white.
627            assert_eq!(
628                scheme
629                    .color(MaterialColorRole::OnPrimaryContainer)
630                    .to_uint32(),
631                0xff848484
632            );
633            assert_eq!(
634                scheme
635                    .color(MaterialColorRole::OnTertiaryContainer)
636                    .to_uint32(),
637                0xff848484
638            );
639        }
640    }
641
642    // Independently generated by the pinned Google TypeScript implementation.
643    // This small readable golden supplements the complete multi-variant fixture.
644    #[test]
645    fn seed_schemes_match_official_default_contrast_goldens() {
646        let expected: [u32; 48] = [
647            0xfffdf7ff, 0xffba1a1a, 0xffffdad6, 0xfff5eff7, 0xffcfbdfe, 0xff322f35, 0xff1d1b20,
648            0xffffffff, 0xff93000a, 0xffffffff, 0xff4d3d75, 0xff201047, 0xff4d3d75, 0xffffffff,
649            0xff4a4458, 0xff1e192b, 0xff4a4458, 0xff1d1b20, 0xff49454e, 0xffffffff, 0xff633b48,
650            0xff31101d, 0xff633b48, 0xff7a757f, 0xffcac4cf, 0xff65558f, 0xffe9ddff, 0xffe9ddff,
651            0xffcfbdfe, 0xff000000, 0xff625b71, 0xffe8def8, 0xffe8def8, 0xffcbc2db, 0xfffdf7ff,
652            0xfffdf7ff, 0xfff2ecf4, 0xffece6ee, 0xffe6e0e9, 0xfff8f2fa, 0xffffffff, 0xffded8e0,
653            0xffe7e0eb, 0xff7e5260, 0xffffd9e3, 0xffffd9e3, 0xffefb8c8, 0xff65558f,
654        ];
655        let actual = MaterialColorScheme::from_seed(Color::from_uint32(0xff6750a4), false);
656        for (role, expected) in MaterialColorRole::ALL.into_iter().zip(expected) {
657            assert_eq!(actual.color(role).to_uint32(), expected, "{}", role.name());
658        }
659        let expected: [u32; 48] = [
660            0xff141218, 0xffffb4ab, 0xff93000a, 0xff322f35, 0xff65558f, 0xffe6e0e9, 0xffe6e0e9,
661            0xff690005, 0xffffdad6, 0xff36275d, 0xffe9ddff, 0xff201047, 0xff4d3d75, 0xff332d41,
662            0xffe8def8, 0xff1e192b, 0xff4a4458, 0xffe6e0e9, 0xffcac4cf, 0xff4a2532, 0xffffd9e3,
663            0xff31101d, 0xff633b48, 0xff948f99, 0xff49454e, 0xffcfbdfe, 0xff4d3d75, 0xffe9ddff,
664            0xffcfbdfe, 0xff000000, 0xffcbc2db, 0xff4a4458, 0xffe8def8, 0xffcbc2db, 0xff141218,
665            0xff3b383e, 0xff211f24, 0xff2b292f, 0xff36343a, 0xff1d1b20, 0xff0f0d13, 0xff141218,
666            0xff49454e, 0xffefb8c8, 0xff633b48, 0xffffd9e3, 0xffefb8c8, 0xffcfbdfe,
667        ];
668        let actual = MaterialColorScheme::from_seed(Color::from_uint32(0xff6750a4), true);
669        for (role, expected) in MaterialColorRole::ALL.into_iter().zip(expected) {
670            assert_eq!(actual.color(role).to_uint32(), expected, "{}", role.name());
671        }
672    }
673
674    fn luminance(color: Color) -> f64 {
675        let channel = |value: u8| {
676            let value = f64::from(value) / 255.0;
677            if value <= 0.04045 {
678                value / 12.92
679            } else {
680                ((value + 0.055) / 1.055).powf(2.4)
681            }
682        };
683        0.2126 * channel(color.r) + 0.7152 * channel(color.g) + 0.0722 * channel(color.b)
684    }
685
686    fn contrast(a: Color, b: Color) -> f64 {
687        let (a, b) = (luminance(a), luminance(b));
688        (a.max(b) + 0.05) / (a.min(b) + 0.05)
689    }
690
691    #[test]
692    fn high_contrast_schemes_meet_primary_foreground_pair_contrast() {
693        use MaterialColorRole as R;
694        let pairs = [
695            (R::OnPrimary, R::Primary),
696            (R::OnPrimaryContainer, R::PrimaryContainer),
697            (R::OnSecondary, R::Secondary),
698            (R::OnTertiary, R::Tertiary),
699            (R::OnError, R::Error),
700            (R::OnSurface, R::Surface),
701        ];
702        for seed in [
703            0xff6750a4, 0xffff0000, 0xff00ff00, 0xff0000ff, 0xff000000, 0xffffffff,
704        ] {
705            for dark in [false, true] {
706                let scheme =
707                    MaterialColorScheme::from_seed_color(Color::from_uint32(seed), dark, 1.0)
708                        .unwrap();
709                for (foreground, background) in pairs {
710                    let ratio = contrast(scheme.color(foreground), scheme.color(background));
711                    assert!(
712                        ratio >= 7.0,
713                        "{seed:#x} dark={dark} {foreground:?}/{background:?}: {ratio}"
714                    );
715                }
716            }
717        }
718    }
719
720    #[test]
721    fn every_palette_style_produces_complete_opaque_schemes() {
722        for style in MaterialPaletteStyle::ALL {
723            for dark in [false, true] {
724                let scheme = MaterialColorScheme::from_seed_with_options(
725                    Color::from_uint32(0x806750a4),
726                    MaterialDynamicColorOptions {
727                        style,
728                        dark,
729                        contrast_level: 0.0,
730                    },
731                )
732                .unwrap();
733                assert!(MaterialColorRole::ALL
734                    .into_iter()
735                    .all(|role| scheme.color(role).a == 255));
736                assert_ne!(
737                    scheme.color(MaterialColorRole::Primary),
738                    scheme.color(MaterialColorRole::OnPrimary)
739                );
740            }
741        }
742        assert_eq!(
743            MaterialColorScheme::from_seed(Color::from_uint32(0x006750a4), false),
744            MaterialColorScheme::from_seed(Color::from_uint32(0xff6750a4), false)
745        );
746    }
747
748    #[test]
749    fn invalid_contrast_and_tonal_inputs_fail_explicitly() {
750        for contrast in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, -1.01, 1.01] {
751            assert!(matches!(
752                MaterialColorScheme::from_seed_color(
753                    Color::from_uint32(0xff6750a4),
754                    false,
755                    contrast
756                ),
757                Err(MaterialDynamicColorError::InvalidContrast(_))
758            ));
759        }
760        let color = Color::from_uint32(0xff6750a4);
761        assert_eq!(
762            MaterialTonalPalette::from_tones([(101, color)]),
763            Err(MaterialDynamicColorError::InvalidTone(101))
764        );
765        assert_eq!(
766            MaterialTonalPalette::from_tones([(40, color), (40, color)]),
767            Err(MaterialDynamicColorError::DuplicateTone(40))
768        );
769        assert!(MaterialTonalPalette::from_hct(f64::NAN, 20.0).is_err());
770        assert!(MaterialTonalPalette::from_hct(100.0, -1.0).is_err());
771    }
772
773    fn imported_color(family: MaterialPaletteFamily, tone: u8) -> Color {
774        let family = match family {
775            MaterialPaletteFamily::Primary => 1,
776            MaterialPaletteFamily::Secondary => 2,
777            MaterialPaletteFamily::Tertiary => 3,
778            MaterialPaletteFamily::Neutral => 4,
779            MaterialPaletteFamily::NeutralVariant => 5,
780        };
781        // Deliberately retain nonopaque alpha to prove imports are lossless.
782        Color::from_argb(231, family, tone, 200)
783    }
784
785    #[test]
786    fn android31_import_uses_neutral_variant_and_preserves_fixed_colors() {
787        use MaterialColorRole as R;
788        use MaterialPaletteFamily as F;
789        let palette = MaterialDynamicPalette::from_android31_colors(|family, tone| {
790            Some(imported_color(family, tone))
791        })
792        .unwrap();
793        let light = palette.scheme_android31(false).unwrap();
794        let dark = palette.scheme_android31(true).unwrap();
795        assert_eq!(
796            light.color(R::Surface),
797            imported_color(F::NeutralVariant, 98)
798        );
799        assert_eq!(dark.color(R::Surface), imported_color(F::NeutralVariant, 6));
800        assert_eq!(
801            light.color(R::SurfaceContainerHighest),
802            imported_color(F::NeutralVariant, 90)
803        );
804        assert_eq!(
805            dark.color(R::SurfaceContainerLowest),
806            imported_color(F::NeutralVariant, 4)
807        );
808        assert_eq!(light.color(R::Primary), imported_color(F::Primary, 40));
809        assert_eq!(dark.color(R::Primary), imported_color(F::Primary, 80));
810        assert_eq!(
811            light.color(R::OnSecondaryContainer),
812            imported_color(F::Secondary, 10)
813        );
814        assert_eq!(
815            dark.color(R::OnSecondaryContainer),
816            imported_color(F::Secondary, 90)
817        );
818        for role in [
819            R::PrimaryFixed,
820            R::PrimaryFixedDim,
821            R::OnPrimaryFixed,
822            R::OnPrimaryFixedVariant,
823            R::SecondaryFixed,
824            R::SecondaryFixedDim,
825            R::OnSecondaryFixed,
826            R::OnSecondaryFixedVariant,
827            R::TertiaryFixed,
828            R::TertiaryFixedDim,
829            R::OnTertiaryFixed,
830            R::OnTertiaryFixedVariant,
831        ] {
832            assert_eq!(light.color(role), dark.color(role), "{role:?}");
833        }
834        assert_eq!(
835            light.color(R::Error),
836            MaterialColorScheme::light().color(R::Error)
837        );
838        assert_eq!(
839            dark.color(R::Error),
840            MaterialColorScheme::dark().color(R::Error)
841        );
842    }
843
844    #[test]
845    fn missing_android_tones_are_not_interpolated() {
846        let missing = MaterialDynamicPalette::from_android31_colors(|family, tone| {
847            if family == MaterialPaletteFamily::NeutralVariant && tone == 94 {
848                None
849            } else {
850                Some(imported_color(family, tone))
851            }
852        });
853        assert_eq!(
854            missing,
855            Err(MaterialDynamicColorError::MissingTone {
856                palette: MaterialPaletteFamily::NeutralVariant,
857                tone: 94,
858            })
859        );
860        let common = MaterialTonalPalette::from_tones(
861            MaterialTonalPalette::ANDROID_SYSTEM_TONES
862                .map(|tone| (tone, imported_color(MaterialPaletteFamily::Primary, tone))),
863        )
864        .unwrap();
865        let sparse = MaterialDynamicPalette {
866            primary: common.clone(),
867            secondary: common.clone(),
868            tertiary: common.clone(),
869            neutral: common.clone(),
870            neutral_variant: common,
871        };
872        assert!(matches!(
873            sparse.scheme_android31(false),
874            Err(MaterialDynamicColorError::MissingTone { tone: 98, .. })
875        ));
876    }
877
878    #[test]
879    fn android34_roles_keep_device_colors_and_require_complete_roles() {
880        let expected =
881            MaterialColorScheme::from_seed_color(Color::from_uint32(0xff0057b7), true, 1.0)
882                .unwrap();
883        let imported =
884            MaterialColorScheme::from_android_system_roles(true, |role| Some(expected.color(role)))
885                .unwrap();
886        assert_eq!(imported, expected);
887        assert_eq!(
888            MaterialColorScheme::from_android_system_roles(true, |role| {
889                if role == MaterialColorRole::SurfaceContainerHigh {
890                    None
891                } else {
892                    Some(expected.color(role))
893                }
894            }),
895            Err(MaterialDynamicColorError::MissingSystemRole(
896                MaterialColorRole::SurfaceContainerHigh
897            ))
898        );
899        let defaults = MaterialColorScheme::from_android_system_roles(true, |role| {
900            if matches!(
901                role,
902                MaterialColorRole::Error
903                    | MaterialColorRole::OnError
904                    | MaterialColorRole::ErrorContainer
905                    | MaterialColorRole::OnErrorContainer
906                    | MaterialColorRole::Scrim
907            ) {
908                None
909            } else {
910                Some(expected.color(role))
911            }
912        })
913        .unwrap();
914        assert_eq!(
915            defaults.color(MaterialColorRole::Error),
916            MaterialColorScheme::dark().color(MaterialColorRole::Error)
917        );
918        assert_eq!(
919            defaults.color(MaterialColorRole::Primary),
920            expected.color(MaterialColorRole::Primary)
921        );
922    }
923
924    #[test]
925    fn hct_palette_matches_published_blue_tones() {
926        let blue: material_colors::hct::Hct = Argb::from_u32(0xff0000ff).into();
927        let palette = MaterialTonalPalette::from_hct(blue.get_hue(), blue.get_chroma()).unwrap();
928        assert_eq!(palette.tone(0).unwrap().to_uint32(), 0xff000000);
929        assert_eq!(palette.tone(40).unwrap().to_uint32(), 0xff343dff);
930        assert_eq!(palette.tone(90).unwrap().to_uint32(), 0xffe0e0ff);
931        assert_eq!(palette.tone(100).unwrap().to_uint32(), 0xffffffff);
932        assert_eq!(palette.tone(101), None);
933    }
934}