Skip to main content

stipple_render/
color.rs

1use oxideav_core::Rgba;
2
3/// A straight (non-premultiplied) 8-bit-per-channel sRGB color.
4///
5/// Matches `oxideav-core`'s `Rgba` model; [`Color::to_oxideav`] is a
6/// zero-cost bridge used when building a scene.
7#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
8pub struct Color {
9    pub r: u8,
10    pub g: u8,
11    pub b: u8,
12    pub a: u8,
13}
14
15impl Color {
16    pub const TRANSPARENT: Self = Self {
17        r: 0,
18        g: 0,
19        b: 0,
20        a: 0,
21    };
22    pub const BLACK: Self = Self::rgb(0, 0, 0);
23    pub const WHITE: Self = Self::rgb(255, 255, 255);
24
25    #[inline]
26    pub const fn rgb(r: u8, g: u8, b: u8) -> Self {
27        Self { r, g, b, a: 255 }
28    }
29
30    #[inline]
31    pub const fn rgba(r: u8, g: u8, b: u8, a: u8) -> Self {
32        Self { r, g, b, a }
33    }
34
35    /// Parse `#RGB`, `#RGBA`, `#RRGGBB`, or `#RRGGBBAA` (leading `#`
36    /// optional). Returns `None` on any malformed input.
37    pub fn from_hex(s: &str) -> Option<Self> {
38        let s = s.strip_prefix('#').unwrap_or(s);
39        let h = |i: usize| u8::from_str_radix(&s[i..i + 2], 16).ok();
40        let n = |i: usize| u8::from_str_radix(&s[i..i + 1], 16).ok().map(|v| v * 17);
41        match s.len() {
42            3 => Some(Self::rgb(n(0)?, n(1)?, n(2)?)),
43            4 => Some(Self::rgba(n(0)?, n(1)?, n(2)?, n(3)?)),
44            6 => Some(Self::rgb(h(0)?, h(2)?, h(4)?)),
45            8 => Some(Self::rgba(h(0)?, h(2)?, h(4)?, h(6)?)),
46            _ => None,
47        }
48    }
49
50    /// Returns a copy with the alpha channel replaced.
51    #[inline]
52    pub const fn with_alpha(self, a: u8) -> Self {
53        Self { a, ..self }
54    }
55
56    /// Linear blend toward `other` by `t` (0.0 = self, 1.0 = other), per
57    /// channel including alpha. `t` is clamped to `[0, 1]`.
58    pub fn mix(self, other: Color, t: f64) -> Color {
59        let t = t.clamp(0.0, 1.0);
60        let lerp = |a: u8, b: u8| (a as f64 + (b as f64 - a as f64) * t).round() as u8;
61        Color {
62            r: lerp(self.r, other.r),
63            g: lerp(self.g, other.g),
64            b: lerp(self.b, other.b),
65            a: lerp(self.a, other.a),
66        }
67    }
68
69    /// Blend `amount` (0..=1) toward white — for hover/active tints.
70    pub fn lighten(self, amount: f64) -> Color {
71        self.mix(Color::rgb(255, 255, 255).with_alpha(self.a), amount)
72    }
73
74    /// Blend `amount` (0..=1) toward black.
75    pub fn darken(self, amount: f64) -> Color {
76        self.mix(Color::rgb(0, 0, 0).with_alpha(self.a), amount)
77    }
78
79    /// Perceptual luminance in `[0, 1]` (ITU-R BT.709 weights), for picking a
80    /// readable on-color.
81    pub fn luminance(self) -> f64 {
82        (0.2126 * self.r as f64 + 0.7152 * self.g as f64 + 0.0722 * self.b as f64) / 255.0
83    }
84
85    /// Black or white, whichever contrasts better with `self`.
86    pub fn on_color(self) -> Color {
87        if self.luminance() > 0.5 {
88            Color::BLACK
89        } else {
90            Color::WHITE
91        }
92    }
93
94    /// Bridge to the `oxideav-core` color used by the scene graph.
95    #[inline]
96    pub const fn to_oxideav(self) -> Rgba {
97        Rgba {
98            r: self.r,
99            g: self.g,
100            b: self.b,
101            a: self.a,
102        }
103    }
104}
105
106impl From<Color> for Rgba {
107    #[inline]
108    fn from(c: Color) -> Rgba {
109        c.to_oxideav()
110    }
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116
117    #[test]
118    fn hex_parsing() {
119        assert_eq!(Color::from_hex("#ff0000"), Some(Color::rgb(255, 0, 0)));
120        assert_eq!(Color::from_hex("0f0"), Some(Color::rgb(0, 255, 0)));
121        assert_eq!(
122            Color::from_hex("#11223344"),
123            Some(Color::rgba(0x11, 0x22, 0x33, 0x44))
124        );
125        assert_eq!(Color::from_hex("#abc"), Some(Color::rgb(0xaa, 0xbb, 0xcc)));
126        assert_eq!(Color::from_hex("zzz"), None);
127        assert_eq!(Color::from_hex("#12345"), None);
128    }
129
130    #[test]
131    fn mix_lighten_darken() {
132        let c = Color::rgb(100, 100, 100);
133        assert_eq!(
134            c.mix(Color::rgb(200, 200, 200), 0.5),
135            Color::rgb(150, 150, 150)
136        );
137        assert_eq!(c.mix(Color::rgb(200, 200, 200), 0.0), c);
138        assert_eq!(
139            Color::rgb(100, 100, 100).lighten(0.5),
140            Color::rgb(178, 178, 178)
141        );
142        assert_eq!(
143            Color::rgb(100, 100, 100).darken(0.5),
144            Color::rgb(50, 50, 50)
145        );
146    }
147
148    #[test]
149    fn on_color_contrasts() {
150        assert_eq!(Color::WHITE.on_color(), Color::BLACK);
151        assert_eq!(Color::rgb(20, 20, 20).on_color(), Color::WHITE);
152    }
153}