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    // The pair-slicing below indexes by byte, so anything that is not an
70    // even number of ASCII hex digits has to be rejected BEFORE it runs:
71    // an odd length slices past the end and a multibyte char slices
72    // mid-boundary, and both panic. `BadHex` was already the right
73    // answer, it just came after the slice. The yaml path treats a colour
74    // parse failure as "warn and use the default", so a panic there took
75    // the whole flow down.
76    if !hex.len().is_multiple_of(2) || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
77        return Err(ColorParseError::BadHex(hex.to_string()));
78    }
79    let bytes: Result<Vec<u8>, _> = (0..hex.len())
80        .step_by(2)
81        .map(|i| u8::from_str_radix(&hex[i..i + 2], 16))
82        .collect();
83    let bytes = bytes.map_err(|_| ColorParseError::BadHex(hex.to_string()))?;
84    match bytes.len() {
85        3 => Ok(Color::rgb(bytes[0], bytes[1], bytes[2])),
86        4 => Ok(Color::rgba(bytes[0], bytes[1], bytes[2], bytes[3])),
87        _ => Err(ColorParseError::BadHex(hex.to_string())),
88    }
89}
90
91fn parse_rgb_fn(inner: &str) -> Result<Color, ColorParseError> {
92    let parts: Vec<&str> = inner.split(',').map(|s| s.trim()).collect();
93    if parts.len() != 3 {
94        return Err(ColorParseError::BadRgb(inner.to_string()));
95    }
96    let r = parts[0]
97        .parse()
98        .map_err(|_| ColorParseError::BadRgb(inner.to_string()))?;
99    let g = parts[1]
100        .parse()
101        .map_err(|_| ColorParseError::BadRgb(inner.to_string()))?;
102    let b = parts[2]
103        .parse()
104        .map_err(|_| ColorParseError::BadRgb(inner.to_string()))?;
105    Ok(Color::rgb(r, g, b))
106}
107
108fn parse_rgba_fn(inner: &str) -> Result<Color, ColorParseError> {
109    let parts: Vec<&str> = inner.split(',').map(|s| s.trim()).collect();
110    if parts.len() != 4 {
111        return Err(ColorParseError::BadRgb(inner.to_string()));
112    }
113    let r = parts[0]
114        .parse()
115        .map_err(|_| ColorParseError::BadRgb(inner.to_string()))?;
116    let g = parts[1]
117        .parse()
118        .map_err(|_| ColorParseError::BadRgb(inner.to_string()))?;
119    let b = parts[2]
120        .parse()
121        .map_err(|_| ColorParseError::BadRgb(inner.to_string()))?;
122    let a_f: f32 = parts[3]
123        .parse()
124        .map_err(|_| ColorParseError::BadRgb(inner.to_string()))?;
125    let a = (a_f.clamp(0.0, 1.0) * 255.0) as u8;
126    Ok(Color::rgba(r, g, b, a))
127}
128
129fn parse_named(s: &str) -> Result<Color, ColorParseError> {
130    match s.to_lowercase().as_str() {
131        "red" => Ok(Color::RED),
132        "green" => Ok(Color::GREEN),
133        "blue" => Ok(Color::BLUE),
134        "yellow" => Ok(Color::YELLOW),
135        "cyan" => Ok(Color::CYAN),
136        "magenta" => Ok(Color::MAGENTA),
137        "white" => Ok(Color::WHITE),
138        "black" => Ok(Color::BLACK),
139        "orange" => Ok(Color::ORANGE),
140        "purple" => Ok(Color::PURPLE),
141        "pink" => Ok(Color::PINK),
142        "gray" | "grey" => Ok(Color::GRAY),
143        "expected" | "success" => Ok(Color::GREEN),
144        "actual" | "error" => Ok(Color::RED),
145        "hint" => Ok(Color::YELLOW),
146        other => Err(ColorParseError::UnknownName(other.to_string())),
147    }
148}
149
150#[derive(Debug, Error)]
151pub enum ColorParseError {
152    #[error("unknown color name: {0}")]
153    UnknownName(String),
154    #[error("bad hex color: {0}")]
155    BadHex(String),
156    #[error("bad rgb/rgba color: {0}")]
157    BadRgb(String),
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163
164    #[test]
165    fn named() {
166        assert_eq!(Color::parse("red").unwrap(), Color::RED);
167        assert_eq!(Color::parse("EXPECTED").unwrap(), Color::GREEN);
168    }
169
170    #[test]
171    fn hex_short() {
172        assert_eq!(Color::parse("#FF0000").unwrap(), Color::RED);
173        assert_eq!(Color::parse("#00CC00").unwrap(), Color::rgb(0, 204, 0));
174    }
175
176    #[test]
177    fn hex_with_alpha() {
178        assert_eq!(
179            Color::parse("#FF000080").unwrap(),
180            Color::rgba(255, 0, 0, 128)
181        );
182    }
183
184    #[test]
185    fn rgb_fn() {
186        assert_eq!(Color::parse("rgb(255, 0, 0)").unwrap(), Color::RED);
187    }
188
189    #[test]
190    fn rgba_fn() {
191        let c = Color::parse("rgba(255, 0, 0, 0.5)").unwrap();
192        assert_eq!(c.r, 255);
193        assert_eq!(c.a, 127);
194    }
195
196    #[test]
197    fn unknown_name_errors() {
198        assert!(matches!(
199            Color::parse("neverheard"),
200            Err(ColorParseError::UnknownName(_))
201        ));
202    }
203}