Skip to main content

use_color/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use std::{error::Error, fmt};
5
6pub mod prelude;
7
8#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
9pub struct Rgb {
10    pub red: u8,
11    pub green: u8,
12    pub blue: u8,
13}
14
15impl Rgb {
16    #[must_use]
17    pub const fn new(red: u8, green: u8, blue: u8) -> Self {
18        Self { red, green, blue }
19    }
20
21    #[must_use]
22    pub const fn is_grayscale(self) -> bool {
23        self.red == self.green && self.green == self.blue
24    }
25
26    #[must_use]
27    pub fn to_hex_rgb(self) -> String {
28        format!("#{:02X}{:02X}{:02X}", self.red, self.green, self.blue)
29    }
30
31    #[must_use]
32    pub fn relative_luminance(self) -> f64 {
33        0.2126 * srgb_channel_to_linear(self.red)
34            + 0.7152 * srgb_channel_to_linear(self.green)
35            + 0.0722 * srgb_channel_to_linear(self.blue)
36    }
37}
38
39pub const BLACK: Rgb = Rgb::new(0, 0, 0);
40pub const WHITE: Rgb = Rgb::new(255, 255, 255);
41pub const RED: Rgb = Rgb::new(255, 0, 0);
42pub const GREEN: Rgb = Rgb::new(0, 255, 0);
43pub const BLUE: Rgb = Rgb::new(0, 0, 255);
44
45#[derive(Clone, Debug, PartialEq, Eq)]
46pub enum HexColorError {
47    InvalidLength(usize),
48    InvalidCharacter,
49}
50
51impl fmt::Display for HexColorError {
52    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
53        match self {
54            Self::InvalidLength(length) => {
55                write!(formatter, "hex color must contain 6 digits, got {length}")
56            },
57            Self::InvalidCharacter => formatter.write_str("hex color contains invalid digits"),
58        }
59    }
60}
61
62impl Error for HexColorError {}
63
64pub fn parse_hex_rgb(input: &str) -> Result<Rgb, HexColorError> {
65    let value = input.strip_prefix('#').unwrap_or(input);
66
67    if value.len() != 6 {
68        return Err(HexColorError::InvalidLength(value.len()));
69    }
70
71    let red = parse_hex_pair(&value[0..2])?;
72    let green = parse_hex_pair(&value[2..4])?;
73    let blue = parse_hex_pair(&value[4..6])?;
74
75    Ok(Rgb::new(red, green, blue))
76}
77
78fn parse_hex_pair(pair: &str) -> Result<u8, HexColorError> {
79    u8::from_str_radix(pair, 16).map_err(|_| HexColorError::InvalidCharacter)
80}
81
82fn srgb_channel_to_linear(channel: u8) -> f64 {
83    let srgb = f64::from(channel) / 255.0;
84
85    if srgb <= 0.04045 {
86        srgb / 12.92
87    } else {
88        ((srgb + 0.055) / 1.055).powf(2.4)
89    }
90}
91
92#[cfg(test)]
93mod tests {
94    use super::{BLACK, HexColorError, Rgb, WHITE, parse_hex_rgb};
95
96    #[test]
97    fn parses_hex_colors() {
98        let color = parse_hex_rgb("#3366CC").expect("hex color should parse");
99
100        assert_eq!(color, Rgb::new(0x33, 0x66, 0xCC));
101        assert_eq!(color.to_hex_rgb(), "#3366CC");
102    }
103
104    #[test]
105    fn rejects_invalid_hex_colors() {
106        assert_eq!(parse_hex_rgb("#FFF"), Err(HexColorError::InvalidLength(3)));
107        assert_eq!(
108            parse_hex_rgb("#GG0000"),
109            Err(HexColorError::InvalidCharacter)
110        );
111    }
112
113    #[test]
114    fn detects_grayscale_and_luminance() {
115        assert!(Rgb::new(80, 80, 80).is_grayscale());
116        assert!(!Rgb::new(80, 81, 80).is_grayscale());
117        assert!(WHITE.relative_luminance() > BLACK.relative_luminance());
118    }
119}