Skip to main content

smix_annotate/
color.rs

1//! Color parsing + palette.
2
3use thiserror::Error;
4
5/// RGBA color (0-255 per channel).
6#[derive(Clone, Copy, Debug, PartialEq, Eq)]
7pub struct Color {
8    pub r: u8,
9    pub g: u8,
10    pub b: u8,
11    pub a: u8,
12}
13
14impl Color {
15    pub const RED: Color = Color::rgb(255, 0, 0);
16    pub const GREEN: Color = Color::rgb(0, 200, 0);
17    pub const BLUE: Color = Color::rgb(0, 100, 255);
18    pub const YELLOW: Color = Color::rgb(255, 220, 0);
19    pub const CYAN: Color = Color::rgb(0, 220, 220);
20    pub const MAGENTA: Color = Color::rgb(220, 0, 220);
21    pub const WHITE: Color = Color::rgb(255, 255, 255);
22    pub const BLACK: Color = Color::rgb(0, 0, 0);
23    pub const ORANGE: Color = Color::rgb(255, 140, 0);
24    pub const PURPLE: Color = Color::rgb(160, 32, 240);
25    pub const PINK: Color = Color::rgb(255, 105, 180);
26    pub const GRAY: Color = Color::rgb(128, 128, 128);
27
28    /// Semantic — for "the expected value / anchor" markers.
29    pub const EXPECTED: Color = Color::GREEN;
30    /// Semantic — for "the observed / mismatched" markers.
31    pub const ACTUAL: Color = Color::RED;
32    /// Semantic — for hint / suggestion overlays.
33    pub const HINT: Color = Color::YELLOW;
34    /// Semantic — for error state.
35    pub const ERROR: Color = Color::RED;
36    /// Semantic — for success state.
37    pub const SUCCESS: Color = Color::GREEN;
38
39    pub const fn rgb(r: u8, g: u8, b: u8) -> Self {
40        Self { r, g, b, a: 255 }
41    }
42
43    pub const fn rgba(r: u8, g: u8, b: u8, a: u8) -> Self {
44        Self { r, g, b, a }
45    }
46
47    pub fn to_rgba(self) -> [u8; 4] {
48        [self.r, self.g, self.b, self.a]
49    }
50
51    /// Parse from a string spec — named color, `#RRGGBB`, `#RRGGBBAA`,
52    /// or `rgba(r,g,b,a)`.
53    pub fn parse(s: &str) -> Result<Self, ColorParseError> {
54        let s = s.trim();
55        if let Some(hex) = s.strip_prefix('#') {
56            return parse_hex(hex);
57        }
58        if s.starts_with("rgb(") && s.ends_with(')') {
59            return parse_rgb_fn(&s[4..s.len() - 1]);
60        }
61        if s.starts_with("rgba(") && s.ends_with(')') {
62            return parse_rgba_fn(&s[5..s.len() - 1]);
63        }
64        parse_named(s)
65    }
66}
67
68fn parse_hex(hex: &str) -> Result<Color, ColorParseError> {
69    let bytes: Result<Vec<u8>, _> = (0..hex.len())
70        .step_by(2)
71        .map(|i| u8::from_str_radix(&hex[i..i + 2], 16))
72        .collect();
73    let bytes = bytes.map_err(|_| ColorParseError::BadHex(hex.to_string()))?;
74    match bytes.len() {
75        3 => Ok(Color::rgb(bytes[0], bytes[1], bytes[2])),
76        4 => Ok(Color::rgba(bytes[0], bytes[1], bytes[2], bytes[3])),
77        _ => Err(ColorParseError::BadHex(hex.to_string())),
78    }
79}
80
81fn parse_rgb_fn(inner: &str) -> Result<Color, ColorParseError> {
82    let parts: Vec<&str> = inner.split(',').map(|s| s.trim()).collect();
83    if parts.len() != 3 {
84        return Err(ColorParseError::BadRgb(inner.to_string()));
85    }
86    let r = parts[0]
87        .parse()
88        .map_err(|_| ColorParseError::BadRgb(inner.to_string()))?;
89    let g = parts[1]
90        .parse()
91        .map_err(|_| ColorParseError::BadRgb(inner.to_string()))?;
92    let b = parts[2]
93        .parse()
94        .map_err(|_| ColorParseError::BadRgb(inner.to_string()))?;
95    Ok(Color::rgb(r, g, b))
96}
97
98fn parse_rgba_fn(inner: &str) -> Result<Color, ColorParseError> {
99    let parts: Vec<&str> = inner.split(',').map(|s| s.trim()).collect();
100    if parts.len() != 4 {
101        return Err(ColorParseError::BadRgb(inner.to_string()));
102    }
103    let r = parts[0]
104        .parse()
105        .map_err(|_| ColorParseError::BadRgb(inner.to_string()))?;
106    let g = parts[1]
107        .parse()
108        .map_err(|_| ColorParseError::BadRgb(inner.to_string()))?;
109    let b = parts[2]
110        .parse()
111        .map_err(|_| ColorParseError::BadRgb(inner.to_string()))?;
112    let a_f: f32 = parts[3]
113        .parse()
114        .map_err(|_| ColorParseError::BadRgb(inner.to_string()))?;
115    let a = (a_f.clamp(0.0, 1.0) * 255.0) as u8;
116    Ok(Color::rgba(r, g, b, a))
117}
118
119fn parse_named(s: &str) -> Result<Color, ColorParseError> {
120    match s.to_lowercase().as_str() {
121        "red" => Ok(Color::RED),
122        "green" => Ok(Color::GREEN),
123        "blue" => Ok(Color::BLUE),
124        "yellow" => Ok(Color::YELLOW),
125        "cyan" => Ok(Color::CYAN),
126        "magenta" => Ok(Color::MAGENTA),
127        "white" => Ok(Color::WHITE),
128        "black" => Ok(Color::BLACK),
129        "orange" => Ok(Color::ORANGE),
130        "purple" => Ok(Color::PURPLE),
131        "pink" => Ok(Color::PINK),
132        "gray" | "grey" => Ok(Color::GRAY),
133        "expected" | "success" => Ok(Color::GREEN),
134        "actual" | "error" => Ok(Color::RED),
135        "hint" => Ok(Color::YELLOW),
136        other => Err(ColorParseError::UnknownName(other.to_string())),
137    }
138}
139
140#[derive(Debug, Error)]
141pub enum ColorParseError {
142    #[error("unknown color name: {0}")]
143    UnknownName(String),
144    #[error("bad hex color: {0}")]
145    BadHex(String),
146    #[error("bad rgb/rgba color: {0}")]
147    BadRgb(String),
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    #[test]
155    fn named() {
156        assert_eq!(Color::parse("red").unwrap(), Color::RED);
157        assert_eq!(Color::parse("EXPECTED").unwrap(), Color::GREEN);
158    }
159
160    #[test]
161    fn hex_short() {
162        assert_eq!(Color::parse("#FF0000").unwrap(), Color::RED);
163        assert_eq!(Color::parse("#00CC00").unwrap(), Color::rgb(0, 204, 0));
164    }
165
166    #[test]
167    fn hex_with_alpha() {
168        assert_eq!(
169            Color::parse("#FF000080").unwrap(),
170            Color::rgba(255, 0, 0, 128)
171        );
172    }
173
174    #[test]
175    fn rgb_fn() {
176        assert_eq!(Color::parse("rgb(255, 0, 0)").unwrap(), Color::RED);
177    }
178
179    #[test]
180    fn rgba_fn() {
181        let c = Color::parse("rgba(255, 0, 0, 0.5)").unwrap();
182        assert_eq!(c.r, 255);
183        assert_eq!(c.a, 127);
184    }
185
186    #[test]
187    fn unknown_name_errors() {
188        assert!(matches!(
189            Color::parse("neverheard"),
190            Err(ColorParseError::UnknownName(_))
191        ));
192    }
193}