1use 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
22pub const MATERIAL_COLOR_UTILITIES_VERSION: &str =
24 "Google Material Color Utilities bd811e4ca2ab94c4ce3cadc89c19b1820b9704b3 (phone)";
25
26pub const MATERIAL_HCT_SOLVER_VERSION: &str = "material-colors 0.4.2";
28
29#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
33pub enum MaterialColorSpec {
34 #[default]
35 Spec2021,
36 Spec2025,
37 Spec2026,
38}
39
40impl MaterialColorSpec {
41 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#[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#[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#[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#[derive(Clone, Debug, PartialEq, Eq)]
156pub struct MaterialTonalPalette {
157 tones: [Option<Color>; 101],
158}
159
160impl MaterialTonalPalette {
161 pub const ANDROID_SYSTEM_TONES: [u8; 13] = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 95, 99, 100];
163 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 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#[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 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 pub fn from_seed(seed: Color, style: MaterialPaletteStyle) -> Self {
281 Self::from_seed_with_spec(seed, style, false, MaterialColorSpec::default())
282 }
283
284 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 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 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 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 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 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 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 #[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 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}