use peniko::Color;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum Brightness {
#[default]
Light,
Dark,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ColorScheme {
pub primary: Color,
pub on_primary: Color,
pub primary_container: Color,
pub on_primary_container: Color,
pub primary_fixed: Color,
pub primary_fixed_dim: Color,
pub on_primary_fixed: Color,
pub on_primary_fixed_variant: Color,
pub secondary: Color,
pub on_secondary: Color,
pub secondary_container: Color,
pub on_secondary_container: Color,
pub secondary_fixed: Color,
pub secondary_fixed_dim: Color,
pub on_secondary_fixed: Color,
pub on_secondary_fixed_variant: Color,
pub tertiary: Color,
pub on_tertiary: Color,
pub tertiary_container: Color,
pub on_tertiary_container: Color,
pub tertiary_fixed: Color,
pub tertiary_fixed_dim: Color,
pub on_tertiary_fixed: Color,
pub on_tertiary_fixed_variant: Color,
pub error: Color,
pub on_error: Color,
pub error_container: Color,
pub on_error_container: Color,
pub surface: Color,
pub on_surface: Color,
pub on_surface_variant: Color,
pub surface_dim: Color,
pub surface_bright: Color,
pub surface_container_lowest: Color,
pub surface_container_low: Color,
pub surface_container: Color,
pub surface_container_high: Color,
pub surface_container_highest: Color,
pub outline: Color,
pub outline_variant: Color,
pub shadow: Color,
pub scrim: Color,
pub inverse_surface: Color,
pub inverse_on_surface: Color,
pub inverse_primary: Color,
pub surface_tint: Color,
}
impl ColorScheme {
pub fn with_accent(self, accent: Color) -> Self {
let on_primary = select_ink_by_luminance(accent);
let primary_container = blend_accent_over_surface(accent, self.surface);
Self {
primary: accent,
on_primary,
primary_container,
on_primary_container: accent,
..self
}
}
pub const fn neutral_light() -> Self {
const ACCENT_LIGHT: Color = Color::from_rgb8(0x3D, 0x5A, 0x73);
const ACCENT_DARK: Color = Color::from_rgb8(0x8F, 0xB4, 0xD1);
Self {
primary: ACCENT_LIGHT,
on_primary: Color::from_rgb8(0xFF, 0xFF, 0xFF),
primary_container: Color::from_rgb8(0xE3, 0xE9, 0xEE),
on_primary_container: Color::from_rgb8(0x1B, 0x2A, 0x35),
primary_fixed: ACCENT_LIGHT,
primary_fixed_dim: ACCENT_DARK,
on_primary_fixed: Color::from_rgb8(0xFF, 0xFF, 0xFF),
on_primary_fixed_variant: Color::from_rgb8(0x2E, 0x47, 0x59),
secondary: Color::from_rgb8(0x6B, 0x6B, 0x6B),
on_secondary: Color::from_rgb8(0xFF, 0xFF, 0xFF),
secondary_container: Color::from_rgb8(0xE4, 0xE4, 0xE4),
on_secondary_container: Color::from_rgb8(0x2B, 0x2B, 0x2B),
secondary_fixed: Color::from_rgb8(0xE4, 0xE4, 0xE4),
secondary_fixed_dim: Color::from_rgb8(0xC7, 0xC7, 0xC7),
on_secondary_fixed: Color::from_rgb8(0x2B, 0x2B, 0x2B),
on_secondary_fixed_variant: Color::from_rgb8(0x4A, 0x4A, 0x4A),
tertiary: Color::from_rgb8(0x4A, 0x4A, 0x4A),
on_tertiary: Color::from_rgb8(0xFF, 0xFF, 0xFF),
tertiary_container: Color::from_rgb8(0xD6, 0xD6, 0xD6),
on_tertiary_container: Color::from_rgb8(0x26, 0x26, 0x26),
tertiary_fixed: Color::from_rgb8(0xD6, 0xD6, 0xD6),
tertiary_fixed_dim: Color::from_rgb8(0xB8, 0xB8, 0xB8),
on_tertiary_fixed: Color::from_rgb8(0x26, 0x26, 0x26),
on_tertiary_fixed_variant: Color::from_rgb8(0x3D, 0x3D, 0x3D),
error: Color::from_rgb8(0xB3, 0x26, 0x1E),
on_error: Color::from_rgb8(0xFF, 0xFF, 0xFF),
error_container: Color::from_rgb8(0xF9, 0xDE, 0xDC),
on_error_container: Color::from_rgb8(0x41, 0x0E, 0x0B),
surface: Color::from_rgb8(0xFA, 0xFA, 0xFA),
on_surface: Color::from_rgb8(0x1A, 0x1A, 0x1A),
on_surface_variant: Color::from_rgb8(0x5C, 0x5C, 0x5C),
surface_dim: Color::from_rgb8(0xE8, 0xE8, 0xE8),
surface_bright: Color::from_rgb8(0xFF, 0xFF, 0xFF),
surface_container_lowest: Color::from_rgb8(0xFF, 0xFF, 0xFF),
surface_container_low: Color::from_rgb8(0xF5, 0xF5, 0xF5),
surface_container: Color::from_rgb8(0xEF, 0xEF, 0xEF),
surface_container_high: Color::from_rgb8(0xE7, 0xE7, 0xE7),
surface_container_highest: Color::from_rgb8(0xDF, 0xDF, 0xDF),
outline: Color::from_rgb8(0x8A, 0x8A, 0x8A),
outline_variant: Color::from_rgb8(0xD0, 0xD0, 0xD0),
shadow: Color::from_rgb8(0x00, 0x00, 0x00),
scrim: Color::from_rgb8(0x00, 0x00, 0x00),
inverse_surface: Color::from_rgb8(0x2B, 0x2B, 0x2B),
inverse_on_surface: Color::from_rgb8(0xF5, 0xF5, 0xF5),
inverse_primary: ACCENT_DARK,
surface_tint: ACCENT_LIGHT,
}
}
pub const fn neutral_dark() -> Self {
const ACCENT_LIGHT: Color = Color::from_rgb8(0x3D, 0x5A, 0x73);
const ACCENT_DARK: Color = Color::from_rgb8(0x8F, 0xB4, 0xD1);
Self {
primary: ACCENT_DARK,
on_primary: Color::from_rgb8(0x16, 0x23, 0x2C),
primary_container: Color::from_rgb8(0x22, 0x34, 0x41),
on_primary_container: Color::from_rgb8(0xC7, 0xDC, 0xEA),
primary_fixed: ACCENT_LIGHT,
primary_fixed_dim: ACCENT_DARK,
on_primary_fixed: Color::from_rgb8(0xFF, 0xFF, 0xFF),
on_primary_fixed_variant: Color::from_rgb8(0x2E, 0x47, 0x59),
secondary: Color::from_rgb8(0xB0, 0xB0, 0xB0),
on_secondary: Color::from_rgb8(0x1E, 0x1E, 0x1E),
secondary_container: Color::from_rgb8(0x3A, 0x3A, 0x3A),
on_secondary_container: Color::from_rgb8(0xE4, 0xE4, 0xE4),
secondary_fixed: Color::from_rgb8(0xE4, 0xE4, 0xE4),
secondary_fixed_dim: Color::from_rgb8(0xC7, 0xC7, 0xC7),
on_secondary_fixed: Color::from_rgb8(0x2B, 0x2B, 0x2B),
on_secondary_fixed_variant: Color::from_rgb8(0x4A, 0x4A, 0x4A),
tertiary: Color::from_rgb8(0xB8, 0xB8, 0xB8),
on_tertiary: Color::from_rgb8(0x1E, 0x1E, 0x1E),
tertiary_container: Color::from_rgb8(0x38, 0x38, 0x38),
on_tertiary_container: Color::from_rgb8(0xD6, 0xD6, 0xD6),
tertiary_fixed: Color::from_rgb8(0xD6, 0xD6, 0xD6),
tertiary_fixed_dim: Color::from_rgb8(0xB8, 0xB8, 0xB8),
on_tertiary_fixed: Color::from_rgb8(0x26, 0x26, 0x26),
on_tertiary_fixed_variant: Color::from_rgb8(0x3D, 0x3D, 0x3D),
error: Color::from_rgb8(0xF2, 0xB8, 0xB5),
on_error: Color::from_rgb8(0x60, 0x14, 0x10),
error_container: Color::from_rgb8(0x8C, 0x1D, 0x18),
on_error_container: Color::from_rgb8(0xF9, 0xDE, 0xDC),
surface: Color::from_rgb8(0x12, 0x12, 0x12),
on_surface: Color::from_rgb8(0xF2, 0xF2, 0xF2),
on_surface_variant: Color::from_rgb8(0xB0, 0xB0, 0xB0),
surface_dim: Color::from_rgb8(0x12, 0x12, 0x12),
surface_bright: Color::from_rgb8(0x38, 0x38, 0x38),
surface_container_lowest: Color::from_rgb8(0x0B, 0x0B, 0x0B),
surface_container_low: Color::from_rgb8(0x1C, 0x1C, 0x1C),
surface_container: Color::from_rgb8(0x20, 0x20, 0x20),
surface_container_high: Color::from_rgb8(0x2A, 0x2A, 0x2A),
surface_container_highest: Color::from_rgb8(0x35, 0x35, 0x35),
outline: Color::from_rgb8(0x8A, 0x8A, 0x8A),
outline_variant: Color::from_rgb8(0x47, 0x47, 0x47),
shadow: Color::from_rgb8(0x00, 0x00, 0x00),
scrim: Color::from_rgb8(0x00, 0x00, 0x00),
inverse_surface: Color::from_rgb8(0xE6, 0xE6, 0xE6),
inverse_on_surface: Color::from_rgb8(0x2B, 0x2B, 0x2B),
inverse_primary: ACCENT_LIGHT,
surface_tint: ACCENT_DARK,
}
}
}
fn relative_luminance(color: Color) -> f64 {
let [r, g, b, _] = color.to_rgba8().to_u8_array();
let r = linearize_srgb_channel(r as f64 / 255.0);
let g = linearize_srgb_channel(g as f64 / 255.0);
let b = linearize_srgb_channel(b as f64 / 255.0);
0.2126 * r + 0.7152 * g + 0.0722 * b
}
fn linearize_srgb_channel(c: f64) -> f64 {
if c <= 0.03928 {
c / 12.92
} else {
((c + 0.055) / 1.055).powf(2.4)
}
}
fn select_ink_by_luminance(color: Color) -> Color {
if relative_luminance(color) < 0.5 {
Color::from_rgb8(0xFF, 0xFF, 0xFF)
} else {
Color::from_rgb8(0x1D, 0x1B, 0x20)
}
}
fn blend_accent_over_surface(accent: Color, surface: Color) -> Color {
const ACCENT_ALPHA: f64 = 0.10;
let [a_r, a_g, a_b, _] = accent.to_rgba8().to_u8_array();
let [s_r, s_g, s_b, _] = surface.to_rgba8().to_u8_array();
let a_r = a_r as f64 / 255.0;
let a_g = a_g as f64 / 255.0;
let a_b = a_b as f64 / 255.0;
let s_r = s_r as f64 / 255.0;
let s_g = s_g as f64 / 255.0;
let s_b = s_b as f64 / 255.0;
let blend_channel = |a: f64, s: f64| -> u8 {
let result = a * ACCENT_ALPHA + s * (1.0 - ACCENT_ALPHA);
(result * 255.0).round() as u8
};
Color::from_rgb8(
blend_channel(a_r, s_r),
blend_channel(a_g, s_g),
blend_channel(a_b, s_b),
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hex_round_trip() {
let c = Color::from_rgb8(0x67, 0x50, 0xA4);
let [r, g, b, a] = c.to_rgba8().to_u8_array();
assert_eq!((r, g, b, a), (0x67, 0x50, 0xA4, 0xFF));
}
#[test]
fn deprecated_roles_absent() {
let _ = ColorScheme::neutral_light();
}
#[test]
fn with_accent_changes_exactly_four_roles() {
let base = ColorScheme::neutral_dark();
let red = Color::from_rgb8(0xFF, 0x00, 0x00);
let modified = base.with_accent(red);
assert_eq!(modified.primary, red);
assert_eq!(modified.on_primary_container, red);
assert_ne!(modified.on_primary, base.on_primary);
assert_ne!(modified.primary_container, base.primary_container);
assert_eq!(modified.secondary, base.secondary);
assert_eq!(modified.on_secondary, base.on_secondary);
assert_eq!(modified.secondary_container, base.secondary_container);
assert_eq!(modified.on_secondary_container, base.on_secondary_container);
assert_eq!(modified.secondary_fixed, base.secondary_fixed);
assert_eq!(modified.secondary_fixed_dim, base.secondary_fixed_dim);
assert_eq!(modified.on_secondary_fixed, base.on_secondary_fixed);
assert_eq!(
modified.on_secondary_fixed_variant,
base.on_secondary_fixed_variant
);
assert_eq!(modified.tertiary, base.tertiary);
assert_eq!(modified.on_tertiary, base.on_tertiary);
assert_eq!(modified.tertiary_container, base.tertiary_container);
assert_eq!(modified.on_tertiary_container, base.on_tertiary_container);
assert_eq!(modified.tertiary_fixed, base.tertiary_fixed);
assert_eq!(modified.tertiary_fixed_dim, base.tertiary_fixed_dim);
assert_eq!(modified.on_tertiary_fixed, base.on_tertiary_fixed);
assert_eq!(
modified.on_tertiary_fixed_variant,
base.on_tertiary_fixed_variant
);
assert_eq!(modified.error, base.error);
assert_eq!(modified.on_error, base.on_error);
assert_eq!(modified.error_container, base.error_container);
assert_eq!(modified.on_error_container, base.on_error_container);
assert_eq!(modified.surface, base.surface);
assert_eq!(modified.on_surface, base.on_surface);
assert_eq!(modified.on_surface_variant, base.on_surface_variant);
assert_eq!(modified.surface_dim, base.surface_dim);
assert_eq!(modified.surface_bright, base.surface_bright);
assert_eq!(
modified.surface_container_lowest,
base.surface_container_lowest
);
assert_eq!(modified.surface_container_low, base.surface_container_low);
assert_eq!(modified.surface_container, base.surface_container);
assert_eq!(modified.surface_container_high, base.surface_container_high);
assert_eq!(
modified.surface_container_highest,
base.surface_container_highest
);
assert_eq!(modified.outline, base.outline);
assert_eq!(modified.outline_variant, base.outline_variant);
assert_eq!(modified.shadow, base.shadow);
assert_eq!(modified.scrim, base.scrim);
assert_eq!(modified.inverse_surface, base.inverse_surface);
assert_eq!(modified.inverse_on_surface, base.inverse_on_surface);
assert_eq!(modified.inverse_primary, base.inverse_primary);
assert_eq!(modified.surface_tint, base.surface_tint);
assert_eq!(modified.primary_fixed, base.primary_fixed);
assert_eq!(modified.primary_fixed_dim, base.primary_fixed_dim);
assert_eq!(modified.on_primary_fixed, base.on_primary_fixed);
assert_eq!(
modified.on_primary_fixed_variant,
base.on_primary_fixed_variant
);
}
#[test]
fn on_primary_contrast_for_dark_and_light_accents() {
let base = ColorScheme::neutral_dark();
let dark_accent = Color::from_rgb8(0x80, 0x00, 0x00);
let dark_scheme = base.with_accent(dark_accent);
assert_eq!(
dark_scheme.on_primary,
Color::from_rgb8(0xFF, 0xFF, 0xFF),
"Dark accent should pair with white ink"
);
let light_accent = Color::from_rgb8(0xFF, 0xFF, 0x00);
let light_scheme = base.with_accent(light_accent);
assert_eq!(
light_scheme.on_primary,
Color::from_rgb8(0x1D, 0x1B, 0x20),
"Light accent should pair with dark ink"
);
}
fn contrast_ratio(a: Color, b: Color) -> f64 {
let la = relative_luminance(a);
let lb = relative_luminance(b);
let (hi, lo) = if la >= lb { (la, lb) } else { (lb, la) };
(hi + 0.05) / (lo + 0.05)
}
#[test]
fn neutral_surface_on_surface_contrast_is_legible() {
const AA_NORMAL_TEXT: f64 = 4.5;
let light = ColorScheme::neutral_light();
assert!(
contrast_ratio(light.surface, light.on_surface) >= AA_NORMAL_TEXT,
"neutral_light surface/on_surface contrast too low"
);
let dark = ColorScheme::neutral_dark();
assert!(
contrast_ratio(dark.surface, dark.on_surface) >= AA_NORMAL_TEXT,
"neutral_dark surface/on_surface contrast too low"
);
}
#[test]
fn neutral_light_and_dark_are_distinct() {
let light = ColorScheme::neutral_light();
let dark = ColorScheme::neutral_dark();
assert_ne!(light.surface, dark.surface);
assert_ne!(light.on_surface, dark.on_surface);
assert_ne!(light.primary, dark.primary);
}
#[test]
fn neutral_carries_exactly_one_accent_family() {
fn is_achromatic(c: Color) -> bool {
let [r, g, b, _] = c.to_rgba8().to_u8_array();
r == g && g == b
}
for scheme in [ColorScheme::neutral_light(), ColorScheme::neutral_dark()] {
assert!(!is_achromatic(scheme.primary), "primary must be the accent");
assert!(is_achromatic(scheme.secondary));
assert!(is_achromatic(scheme.tertiary));
assert!(is_achromatic(scheme.surface));
assert!(is_achromatic(scheme.on_surface));
}
}
#[test]
fn neutral_fixed_roles_are_brightness_invariant() {
let light = ColorScheme::neutral_light();
let dark = ColorScheme::neutral_dark();
assert_eq!(light.primary_fixed, dark.primary_fixed);
assert_eq!(light.primary_fixed_dim, dark.primary_fixed_dim);
assert_eq!(
light.on_primary_fixed_variant,
dark.on_primary_fixed_variant
);
assert_eq!(light.secondary_fixed, dark.secondary_fixed);
assert_eq!(light.tertiary_fixed, dark.tertiary_fixed);
}
#[test]
fn neutral_is_const_constructible() {
const LIGHT: ColorScheme = ColorScheme::neutral_light();
const DARK: ColorScheme = ColorScheme::neutral_dark();
assert_ne!(LIGHT.surface, DARK.surface);
}
}