material-color-utils 0.1.3

Color libraries for Google's Material You
Documentation
use crate::contrast::contrast_utils::Contrast;
use crate::dynamic::color_spec::SpecVersion;
use crate::dynamic::contrast_curve::ContrastCurve;
use crate::dynamic::dynamic_scheme::DynamicScheme;
use crate::dynamic::tone_delta_pair::ToneDeltaPair;
use crate::hct::hct_color::Hct;
use crate::palettes::tonal_palette::TonalPalette;
use crate::utils::color_utils::Argb;
use std::fmt;
use std::fmt::Debug;
use std::sync::Arc;

pub type DynamicColorFunction<T> = Arc<dyn Fn(&DynamicScheme) -> T + Send + Sync>;

pub struct ContrastConstraints {
    pub background: DynamicColorFunction<Option<Arc<DynamicColor>>>,
    pub contrast_curve: DynamicColorFunction<Option<ContrastCurve>>,
    pub second_background: Option<DynamicColorFunction<Option<Arc<DynamicColor>>>>,
}

/// A color that adjusts itself based on UI state, represented by `DynamicScheme`.
pub struct DynamicColor {
    pub name: String,
    pub palette: DynamicColorFunction<TonalPalette>,
    pub is_background: bool,
    pub tone: DynamicColorFunction<f64>,
    pub chroma_multiplier: Option<DynamicColorFunction<f64>>,
    pub tone_delta_pair: Option<DynamicColorFunction<Option<ToneDeltaPair>>>,
    pub opacity: Option<DynamicColorFunction<Option<f64>>>,
    pub contrast: Option<ContrastConstraints>,
}

impl Debug for DynamicColor {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("DynamicColor")
            .field("name", &self.name)
            .field("is_background", &self.is_background)
            .field("palette", &"<function>")
            .field("tone", &"<function>")
            .field("has_contrast_constraints", &self.contrast.is_some())
            .field(
                "chroma_multiplier",
                &self.chroma_multiplier.as_ref().map(|_| "<function>"),
            )
            .field(
                "tone_delta_pair",
                &self.tone_delta_pair.as_ref().map(|_| "<function>"),
            )
            .field("opacity", &self.opacity.as_ref().map(|_| "<function>"))
            .finish()
    }
}

impl DynamicColor {
    pub fn new(
        name: String,
        palette: DynamicColorFunction<TonalPalette>,
        is_background: bool,
        tone: Option<DynamicColorFunction<f64>>,
        chroma_multiplier: Option<DynamicColorFunction<f64>>,
        tone_delta_pair: Option<DynamicColorFunction<Option<ToneDeltaPair>>>,
        opacity: Option<DynamicColorFunction<Option<f64>>>,
        contrast: Option<ContrastConstraints>,
    ) -> Self {
        // Default tone logic: If tone is not provided, try to derive it from background
        let tone = tone.unwrap_or_else(|| {
            // Capture only what we need from contrast to keep the closure Send/Sync
            let bg_func = contrast.as_ref().map(|c| Arc::clone(&c.background));

            Arc::new(move |scheme| {
                if let Some(ref bg) = bg_func
                    && let Some(bg_color) = bg(scheme)
                {
                    return bg_color.get_tone(scheme);
                }
                50.0
            })
        });

        Self {
            name,
            palette,
            is_background,
            tone,
            chroma_multiplier,
            tone_delta_pair,
            opacity,
            contrast,
        }
    }

    #[must_use]
    pub fn get_argb(&self, scheme: &DynamicScheme) -> Argb {
        scheme.get_argb(self)
    }

    #[must_use]
    pub fn get_hct(&self, scheme: &DynamicScheme) -> Hct {
        scheme.get_hct(self)
    }

    #[must_use]
    pub fn get_tone(&self, scheme: &DynamicScheme) -> f64 {
        scheme.get_tone(self)
    }

    /// Create a `DynamicColor` from an ARGB hex code.
    #[must_use]
    pub fn from_argb(name: &str, argb: Argb) -> Self {
        let hct = Hct::from_argb(argb);
        let palette = TonalPalette::from_argb(argb);
        Self::new(
            name.to_string(),
            Arc::new(move |_| palette.clone()),
            false,
            Some(Arc::new(move |_| hct.tone())),
            None, // No contrast constraints for static ARGB
            None,
            None,
            None,
        )
    }

    #[must_use]
    pub fn extend_spec_version(
        &self,
        spec_version: SpecVersion,
        extended_color: &Self,
    ) -> Arc<Self> {
        Self::validate_extended_color(self, spec_version, extended_color);

        let this_palette = self.palette.clone();
        let ext_palette = extended_color.palette.clone();
        let palette = Arc::new(move |scheme: &DynamicScheme| {
            if scheme.spec_version >= spec_version {
                (ext_palette)(scheme)
            } else {
                (this_palette)(scheme)
            }
        });

        let this_tone = self.tone.clone();
        let ext_tone = extended_color.tone.clone();
        let tone = Arc::new(move |scheme: &DynamicScheme| {
            if scheme.spec_version >= spec_version {
                (ext_tone)(scheme)
            } else {
                (this_tone)(scheme)
            }
        });

        // Grouped Contrast Logic
        // We create a function for each field inside the new ContrastConstraints
        let background = {
            let this_bg = self.contrast.as_ref().map(|c| c.background.clone());
            let ext_bg = extended_color
                .contrast
                .as_ref()
                .map(|c| c.background.clone());
            Arc::new(move |scheme: &DynamicScheme| {
                if scheme.spec_version >= spec_version {
                    ext_bg.as_ref().and_then(|f| f(scheme))
                } else {
                    this_bg.as_ref().and_then(|f| f(scheme))
                }
            })
        };

        let contrast_curve = {
            let this_curve = self.contrast.as_ref().map(|c| c.contrast_curve.clone());
            let ext_curve = extended_color
                .contrast
                .as_ref()
                .map(|c| c.contrast_curve.clone());
            Arc::new(move |scheme: &DynamicScheme| {
                if scheme.spec_version >= spec_version {
                    ext_curve.as_ref().and_then(|f| f(scheme))
                } else {
                    this_curve.as_ref().and_then(|f| f(scheme))
                }
            })
        };

        let second_background = {
            let this_bg2 = self
                .contrast
                .as_ref()
                .and_then(|c| c.second_background.clone());
            let ext_bg2 = extended_color
                .contrast
                .as_ref()
                .and_then(|c| c.second_background.clone());
            Arc::new(move |scheme: &DynamicScheme| {
                if scheme.spec_version >= spec_version {
                    ext_bg2.as_ref().and_then(|f| f(scheme))
                } else {
                    this_bg2.as_ref().and_then(|f| f(scheme))
                }
            })
        };

        let contrast = Some(ContrastConstraints {
            background,
            contrast_curve,
            second_background: Some(second_background),
        });

        // Independent options
        let this_chroma = self.chroma_multiplier.clone();
        let ext_chroma = extended_color.chroma_multiplier.clone();
        let chroma_multiplier = Arc::new(move |scheme: &DynamicScheme| {
            if scheme.spec_version >= spec_version {
                ext_chroma.as_ref().map_or(1.0, |f| f(scheme))
            } else {
                this_chroma.as_ref().map_or(1.0, |f| f(scheme))
            }
        });

        let this_delta = self.tone_delta_pair.clone();
        let ext_delta = extended_color.tone_delta_pair.clone();
        let tone_delta_pair = Arc::new(move |scheme: &DynamicScheme| {
            if scheme.spec_version >= spec_version {
                ext_delta.as_ref().and_then(|f| f(scheme))
            } else {
                this_delta.as_ref().and_then(|f| f(scheme))
            }
        });

        let this_opacity = self.opacity.clone();
        let ext_opacity = extended_color.opacity.clone();
        let opacity = Arc::new(move |scheme: &DynamicScheme| {
            if scheme.spec_version >= spec_version {
                ext_opacity.as_ref().and_then(|f| f(scheme))
            } else {
                this_opacity.as_ref().and_then(|f| f(scheme))
            }
        });

        Arc::new(Self::new(
            self.name.clone(),
            palette,
            self.is_background,
            Some(tone),
            Some(chroma_multiplier),
            Some(tone_delta_pair),
            Some(opacity),
            contrast,
        ))
    }

    fn validate_extended_color(&self, spec_version: SpecVersion, extended_color: &Self) {
        assert!(
            self.name == extended_color.name,
            "Attempting to extend color {} with color {} of different name for spec version {:?}.",
            self.name,
            extended_color.name,
            spec_version
        );
        assert!(
            self.is_background == extended_color.is_background,
            "Attempting to extend color {} as a {} with color {} as a {} for spec version {:?}.",
            self.name,
            if self.is_background {
                "background"
            } else {
                "foreground"
            },
            extended_color.name,
            if extended_color.is_background {
                "background"
            } else {
                "foreground"
            },
            spec_version
        );
    }

    #[must_use]
    pub fn foreground_tone(bg_tone: f64, ratio: f64) -> f64 {
        let lighter_tone = Contrast::lighter_unsafe(bg_tone, ratio);
        let darker_tone = Contrast::darker_unsafe(bg_tone, ratio);
        let lighter_ratio = Contrast::ratio_of_tones(lighter_tone, bg_tone);
        let darker_ratio = Contrast::ratio_of_tones(darker_tone, bg_tone);
        let prefer_lighter = Self::tone_prefers_light_foreground(bg_tone);

        if prefer_lighter {
            let negligible_difference = (lighter_ratio - darker_ratio).abs() < 0.1
                && lighter_ratio < ratio
                && darker_ratio < ratio;
            if lighter_ratio >= ratio || lighter_ratio >= darker_ratio || negligible_difference {
                lighter_tone
            } else {
                darker_tone
            }
        } else if darker_ratio >= ratio || darker_ratio >= lighter_ratio {
            darker_tone
        } else {
            lighter_tone
        }
    }

    #[must_use]
    pub fn enable_light_foreground(tone: f64) -> f64 {
        if Self::tone_prefers_light_foreground(tone) && !Self::tone_allows_light_foreground(tone) {
            49.0
        } else {
            tone
        }
    }

    /// People prefer white foregrounds on ~T60-70.
    #[must_use]
    pub fn tone_prefers_light_foreground(tone: f64) -> bool {
        tone.round() < 60.0
    }

    /// Tones less than ~T50 always permit white at 4.5 contrast.
    #[must_use]
    pub fn tone_allows_light_foreground(tone: f64) -> bool {
        tone.round() <= 49.0
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_from_argb() {
        let color = DynamicColor::from_argb("test", Argb(0xff00ff00));
        assert_eq!(color.name, "test");
        // HCT for 0xff00ff00 (pure green) is roughly hue 142, chroma 107, tone 88
        let _hct = Hct::from_argb(Argb(0xff00ff00));
        // We can't easily test the closures without a scheme, but we can check initial tone logic
    }

    #[test]
    fn test_foreground_tone() {
        // T90 background, 4.5 ratio -> T42.5 (roughly)
        let fg = DynamicColor::foreground_tone(90.0, 4.5);
        assert!(fg < 45.0 && fg > 40.0);

        // T10 background, 4.5 ratio -> T54.6 (roughly)
        let fg = DynamicColor::foreground_tone(10.0, 4.5);
        assert!(fg > 50.0 && fg < 60.0);
    }

    #[test]
    fn test_tone_preferences() {
        assert!(DynamicColor::tone_prefers_light_foreground(59.0));
        assert!(!DynamicColor::tone_prefers_light_foreground(61.0));
        assert!(DynamicColor::tone_allows_light_foreground(49.0));
        assert!(!DynamicColor::tone_allows_light_foreground(50.0));
    }
}