lib_misc_color/
lib.rs

1//! Unified color type with lazy conversion
2//!
3//! Colors are stored in their original format and only converted when needed.
4
5/// Unified color representation supporting multiple formats.
6/// Conversion happens lazily when accessing a specific format.
7#[derive(Debug, Clone, PartialEq)]
8pub enum Color {
9    /// RGB as bytes [0-255]
10    Rgb(u8, u8, u8),
11    /// RGBA as bytes [0-255]
12    Rgba(u8, u8, u8, u8),
13    /// RGB as normalized floats [0.0-1.0]
14    RgbFloat(f32, f32, f32),
15    /// RGBA as normalized floats [0.0-1.0]
16    RgbaFloat(f32, f32, f32, f32),
17    /// Hex color string (with or without #)
18    Hex(String),
19}
20
21impl Color {
22    // ─────────────────────────────────────────────────────────────────────────
23    // Constructors
24    // ─────────────────────────────────────────────────────────────────────────
25
26    /// Create from RGB bytes
27    #[inline]
28    pub const fn rgb(r: u8, g: u8, b: u8) -> Self {
29        Self::Rgb(r, g, b)
30    }
31
32    /// Create from RGBA bytes
33    #[inline]
34    pub const fn rgba(r: u8, g: u8, b: u8, a: u8) -> Self {
35        Self::Rgba(r, g, b, a)
36    }
37
38    /// Create from RGB floats [0.0-1.0]
39    #[inline]
40    pub const fn rgb_float(r: f32, g: f32, b: f32) -> Self {
41        Self::RgbFloat(r, g, b)
42    }
43
44    /// Create from RGBA floats [0.0-1.0]
45    #[inline]
46    pub const fn rgba_float(r: f32, g: f32, b: f32, a: f32) -> Self {
47        Self::RgbaFloat(r, g, b, a)
48    }
49
50    /// Create from hex string (with or without #)
51    #[inline]
52    pub fn hex(s: impl Into<String>) -> Self {
53        Self::Hex(s.into())
54    }
55
56    /// Parse hex string, returning None for invalid format
57    pub fn from_hex(s: &str) -> Option<Self> {
58        let s = s.strip_prefix('#').unwrap_or(s);
59        match s.len() {
60            // RGB shorthand: #RGB -> #RRGGBB
61            3 => {
62                let r = u8::from_str_radix(&s[0..1], 16).ok()?;
63                let g = u8::from_str_radix(&s[1..2], 16).ok()?;
64                let b = u8::from_str_radix(&s[2..3], 16).ok()?;
65                Some(Self::Rgb(r * 17, g * 17, b * 17))
66            }
67            // RGBA shorthand: #RGBA -> #RRGGBBAA
68            4 => {
69                let r = u8::from_str_radix(&s[0..1], 16).ok()?;
70                let g = u8::from_str_radix(&s[1..2], 16).ok()?;
71                let b = u8::from_str_radix(&s[2..3], 16).ok()?;
72                let a = u8::from_str_radix(&s[3..4], 16).ok()?;
73                Some(Self::Rgba(r * 17, g * 17, b * 17, a * 17))
74            }
75            // Full RGB: #RRGGBB
76            6 => {
77                let r = u8::from_str_radix(&s[0..2], 16).ok()?;
78                let g = u8::from_str_radix(&s[2..4], 16).ok()?;
79                let b = u8::from_str_radix(&s[4..6], 16).ok()?;
80                Some(Self::Rgb(r, g, b))
81            }
82            // Full RGBA: #RRGGBBAA
83            8 => {
84                let r = u8::from_str_radix(&s[0..2], 16).ok()?;
85                let g = u8::from_str_radix(&s[2..4], 16).ok()?;
86                let b = u8::from_str_radix(&s[4..6], 16).ok()?;
87                let a = u8::from_str_radix(&s[6..8], 16).ok()?;
88                Some(Self::Rgba(r, g, b, a))
89            }
90            _ => None,
91        }
92    }
93
94    // ─────────────────────────────────────────────────────────────────────────
95    // Lazy accessors (convert on read)
96    // ─────────────────────────────────────────────────────────────────────────
97
98    /// Get as RGB bytes tuple
99    pub fn as_rgb(&self) -> (u8, u8, u8) {
100        match self {
101            Self::Rgb(r, g, b) => (*r, *g, *b),
102            Self::Rgba(r, g, b, _) => (*r, *g, *b),
103            Self::RgbFloat(r, g, b) => (
104                (r.clamp(0.0, 1.0) * 255.0) as u8,
105                (g.clamp(0.0, 1.0) * 255.0) as u8,
106                (b.clamp(0.0, 1.0) * 255.0) as u8,
107            ),
108            Self::RgbaFloat(r, g, b, _) => (
109                (r.clamp(0.0, 1.0) * 255.0) as u8,
110                (g.clamp(0.0, 1.0) * 255.0) as u8,
111                (b.clamp(0.0, 1.0) * 255.0) as u8,
112            ),
113            Self::Hex(s) => Self::from_hex(s).map(|c| c.as_rgb()).unwrap_or((0, 0, 0)),
114        }
115    }
116
117    /// Get as RGBA bytes tuple
118    pub fn as_rgba(&self) -> (u8, u8, u8, u8) {
119        match self {
120            Self::Rgb(r, g, b) => (*r, *g, *b, 255),
121            Self::Rgba(r, g, b, a) => (*r, *g, *b, *a),
122            Self::RgbFloat(r, g, b) => (
123                (r.clamp(0.0, 1.0) * 255.0) as u8,
124                (g.clamp(0.0, 1.0) * 255.0) as u8,
125                (b.clamp(0.0, 1.0) * 255.0) as u8,
126                255,
127            ),
128            Self::RgbaFloat(r, g, b, a) => (
129                (r.clamp(0.0, 1.0) * 255.0) as u8,
130                (g.clamp(0.0, 1.0) * 255.0) as u8,
131                (b.clamp(0.0, 1.0) * 255.0) as u8,
132                (a.clamp(0.0, 1.0) * 255.0) as u8,
133            ),
134            Self::Hex(s) => Self::from_hex(s)
135                .map(|c| c.as_rgba())
136                .unwrap_or((0, 0, 0, 255)),
137        }
138    }
139
140    /// Get as RGB float array [0.0-1.0]
141    pub fn as_rgb_float(&self) -> [f32; 3] {
142        match self {
143            Self::Rgb(r, g, b) => [*r as f32 / 255.0, *g as f32 / 255.0, *b as f32 / 255.0],
144            Self::Rgba(r, g, b, _) => [*r as f32 / 255.0, *g as f32 / 255.0, *b as f32 / 255.0],
145            Self::RgbFloat(r, g, b) => [*r, *g, *b],
146            Self::RgbaFloat(r, g, b, _) => [*r, *g, *b],
147            Self::Hex(s) => Self::from_hex(s)
148                .map(|c| c.as_rgb_float())
149                .unwrap_or([0.0, 0.0, 0.0]),
150        }
151    }
152
153    /// Get as RGBA float array [0.0-1.0]
154    pub fn as_rgba_float(&self) -> [f32; 4] {
155        match self {
156            Self::Rgb(r, g, b) => [*r as f32 / 255.0, *g as f32 / 255.0, *b as f32 / 255.0, 1.0],
157            Self::Rgba(r, g, b, a) => [
158                *r as f32 / 255.0,
159                *g as f32 / 255.0,
160                *b as f32 / 255.0,
161                *a as f32 / 255.0,
162            ],
163            Self::RgbFloat(r, g, b) => [*r, *g, *b, 1.0],
164            Self::RgbaFloat(r, g, b, a) => [*r, *g, *b, *a],
165            Self::Hex(s) => Self::from_hex(s)
166                .map(|c| c.as_rgba_float())
167                .unwrap_or([0.0, 0.0, 0.0, 1.0]),
168        }
169    }
170
171    /// Get as hex string (always returns #RRGGBB or #RRGGBBAA)
172    pub fn as_hex(&self) -> String {
173        match self {
174            Self::Hex(s) => {
175                let s = s.strip_prefix('#').unwrap_or(s);
176                format!("#{s}")
177            }
178            _ => {
179                let (r, g, b, a) = self.as_rgba();
180                if a == 255 {
181                    format!("#{r:02x}{g:02x}{b:02x}")
182                } else {
183                    format!("#{r:02x}{g:02x}{b:02x}{a:02x}")
184                }
185            }
186        }
187    }
188
189    /// Get alpha value as float [0.0-1.0]
190    pub fn alpha(&self) -> f32 {
191        match self {
192            Self::Rgb(_, _, _) | Self::RgbFloat(_, _, _) => 1.0,
193            Self::Rgba(_, _, _, a) => *a as f32 / 255.0,
194            Self::RgbaFloat(_, _, _, a) => *a,
195            Self::Hex(s) => Self::from_hex(s).map(|c| c.alpha()).unwrap_or(1.0),
196        }
197    }
198
199    // ─────────────────────────────────────────────────────────────────────────
200    // Transformations (return new Color)
201    // ─────────────────────────────────────────────────────────────────────────
202
203    /// Create new color with specified alpha
204    pub fn with_alpha(&self, alpha: f32) -> Self {
205        let [r, g, b, _] = self.as_rgba_float();
206        Self::RgbaFloat(r, g, b, alpha.clamp(0.0, 1.0))
207    }
208
209    /// Lighten color by amount [0.0-1.0]
210    pub fn lighten(&self, amount: f32) -> Self {
211        let [r, g, b, a] = self.as_rgba_float();
212        Self::RgbaFloat(
213            (r + amount).min(1.0),
214            (g + amount).min(1.0),
215            (b + amount).min(1.0),
216            a,
217        )
218    }
219
220    /// Darken color by amount [0.0-1.0]
221    pub fn darken(&self, amount: f32) -> Self {
222        let [r, g, b, a] = self.as_rgba_float();
223        Self::RgbaFloat(
224            (r - amount).max(0.0),
225            (g - amount).max(0.0),
226            (b - amount).max(0.0),
227            a,
228        )
229    }
230
231    /// Mix with another color (0.0 = self, 1.0 = other)
232    pub fn mix(&self, other: &Self, t: f32) -> Self {
233        let [r1, g1, b1, a1] = self.as_rgba_float();
234        let [r2, g2, b2, a2] = other.as_rgba_float();
235        let t = t.clamp(0.0, 1.0);
236        Self::RgbaFloat(
237            r1 + (r2 - r1) * t,
238            g1 + (g2 - g1) * t,
239            b1 + (b2 - b1) * t,
240            a1 + (a2 - a1) * t,
241        )
242    }
243
244    /// Invert color (RGB only, alpha preserved)
245    pub fn invert(&self) -> Self {
246        let [r, g, b, a] = self.as_rgba_float();
247        Self::RgbaFloat(1.0 - r, 1.0 - g, 1.0 - b, a)
248    }
249
250    /// Convert to grayscale using luminance formula
251    pub fn grayscale(&self) -> Self {
252        let [r, g, b, a] = self.as_rgba_float();
253        let lum = 0.299 * r + 0.587 * g + 0.114 * b;
254        Self::RgbaFloat(lum, lum, lum, a)
255    }
256}
257
258// ─────────────────────────────────────────────────────────────────────────────
259// From implementations for ergonomic construction
260// ─────────────────────────────────────────────────────────────────────────────
261
262impl From<(u8, u8, u8)> for Color {
263    fn from((r, g, b): (u8, u8, u8)) -> Self {
264        Self::Rgb(r, g, b)
265    }
266}
267
268impl From<(u8, u8, u8, u8)> for Color {
269    fn from((r, g, b, a): (u8, u8, u8, u8)) -> Self {
270        Self::Rgba(r, g, b, a)
271    }
272}
273
274impl From<[u8; 3]> for Color {
275    fn from([r, g, b]: [u8; 3]) -> Self {
276        Self::Rgb(r, g, b)
277    }
278}
279
280impl From<[u8; 4]> for Color {
281    fn from([r, g, b, a]: [u8; 4]) -> Self {
282        Self::Rgba(r, g, b, a)
283    }
284}
285
286impl From<[f32; 3]> for Color {
287    fn from([r, g, b]: [f32; 3]) -> Self {
288        Self::RgbFloat(r, g, b)
289    }
290}
291
292impl From<[f32; 4]> for Color {
293    fn from([r, g, b, a]: [f32; 4]) -> Self {
294        Self::RgbaFloat(r, g, b, a)
295    }
296}
297
298impl From<&str> for Color {
299    fn from(s: &str) -> Self {
300        Self::from_hex(s).unwrap_or(Self::Hex(s.to_string()))
301    }
302}
303
304// ─────────────────────────────────────────────────────────────────────────────
305// Default
306// ─────────────────────────────────────────────────────────────────────────────
307
308impl Default for Color {
309    fn default() -> Self {
310        Self::Rgb(0, 0, 0)
311    }
312}
313
314// ─────────────────────────────────────────────────────────────────────────────
315// Tests
316// ─────────────────────────────────────────────────────────────────────────────
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321
322    #[test]
323    fn test_rgb_constructor() {
324        let c = Color::rgb(255, 128, 0);
325        assert_eq!(c.as_rgb(), (255, 128, 0));
326    }
327
328    #[test]
329    fn test_rgba_constructor() {
330        let c = Color::rgba(255, 128, 0, 128);
331        assert_eq!(c.as_rgba(), (255, 128, 0, 128));
332    }
333
334    #[test]
335    fn test_rgb_to_rgba() {
336        let c = Color::rgb(255, 128, 0);
337        assert_eq!(c.as_rgba(), (255, 128, 0, 255));
338    }
339
340    #[test]
341    fn test_float_to_bytes() {
342        let c = Color::rgba_float(1.0, 0.5, 0.0, 1.0);
343        let (r, g, b, a) = c.as_rgba();
344        assert_eq!(r, 255);
345        assert!((g as i32 - 127).abs() <= 1); // Allow rounding
346        assert_eq!(b, 0);
347        assert_eq!(a, 255);
348    }
349
350    #[test]
351    fn test_bytes_to_float() {
352        let c = Color::rgb(255, 0, 128);
353        let [r, g, b] = c.as_rgb_float();
354        assert!((r - 1.0).abs() < 0.01);
355        assert!((g - 0.0).abs() < 0.01);
356        assert!((b - 0.502).abs() < 0.01);
357    }
358
359    #[test]
360    fn test_hex_parsing_6_digit() {
361        let c = Color::from_hex("#ff8000").unwrap();
362        assert_eq!(c.as_rgb(), (255, 128, 0));
363    }
364
365    #[test]
366    fn test_hex_parsing_8_digit() {
367        let c = Color::from_hex("#ff800080").unwrap();
368        assert_eq!(c.as_rgba(), (255, 128, 0, 128));
369    }
370
371    #[test]
372    fn test_hex_parsing_3_digit() {
373        let c = Color::from_hex("#f80").unwrap();
374        assert_eq!(c.as_rgb(), (255, 136, 0));
375    }
376
377    #[test]
378    fn test_hex_parsing_without_hash() {
379        let c = Color::from_hex("ff8000").unwrap();
380        assert_eq!(c.as_rgb(), (255, 128, 0));
381    }
382
383    #[test]
384    fn test_as_hex() {
385        let c = Color::rgb(255, 128, 0);
386        assert_eq!(c.as_hex(), "#ff8000");
387    }
388
389    #[test]
390    fn test_as_hex_with_alpha() {
391        let c = Color::rgba(255, 128, 0, 128);
392        assert_eq!(c.as_hex(), "#ff800080");
393    }
394
395    #[test]
396    fn test_with_alpha() {
397        let c = Color::rgb(255, 128, 0).with_alpha(0.5);
398        assert!((c.alpha() - 0.5).abs() < 0.01);
399    }
400
401    #[test]
402    fn test_lighten() {
403        let c = Color::rgb(128, 128, 128).lighten(0.1);
404        let [r, g, b, _] = c.as_rgba_float();
405        assert!(r > 0.5);
406        assert!(g > 0.5);
407        assert!(b > 0.5);
408    }
409
410    #[test]
411    fn test_darken() {
412        let c = Color::rgb(128, 128, 128).darken(0.1);
413        let [r, g, b, _] = c.as_rgba_float();
414        assert!(r < 0.5);
415        assert!(g < 0.5);
416        assert!(b < 0.5);
417    }
418
419    #[test]
420    fn test_mix() {
421        let white = Color::rgb(255, 255, 255);
422        let black = Color::rgb(0, 0, 0);
423        let gray = white.mix(&black, 0.5);
424        let (r, g, b, _) = gray.as_rgba();
425        assert!((r as i32 - 127).abs() <= 1);
426        assert!((g as i32 - 127).abs() <= 1);
427        assert!((b as i32 - 127).abs() <= 1);
428    }
429
430    #[test]
431    fn test_invert() {
432        let c = Color::rgb(255, 0, 128).invert();
433        let (r, g, b, _) = c.as_rgba();
434        assert_eq!(r, 0);
435        assert_eq!(g, 255);
436        assert!((b as i32 - 127).abs() <= 1);
437    }
438
439    #[test]
440    fn test_grayscale() {
441        let c = Color::rgb(255, 0, 0).grayscale();
442        let [r, g, b, _] = c.as_rgba_float();
443        assert!((r - g).abs() < 0.01);
444        assert!((g - b).abs() < 0.01);
445    }
446
447    #[test]
448    fn test_from_tuple() {
449        let c: Color = (255, 128, 0).into();
450        assert_eq!(c.as_rgb(), (255, 128, 0));
451    }
452
453    #[test]
454    fn test_from_array() {
455        let c: Color = [0.5f32, 0.5, 0.5, 1.0].into();
456        assert!(matches!(c, Color::RgbaFloat(_, _, _, _)));
457    }
458
459    #[test]
460    fn test_from_str() {
461        let c: Color = "#ff8000".into();
462        assert_eq!(c.as_rgb(), (255, 128, 0));
463    }
464
465    #[test]
466    fn test_alpha_rgb() {
467        let c = Color::rgb(255, 128, 0);
468        assert!((c.alpha() - 1.0).abs() < 0.01);
469    }
470
471    #[test]
472    fn test_alpha_rgba() {
473        let c = Color::rgba(255, 128, 0, 128);
474        assert!((c.alpha() - 0.502).abs() < 0.01);
475    }
476
477    #[test]
478    fn test_clamp_float_values() {
479        let c = Color::rgba_float(1.5, -0.5, 0.5, 2.0);
480        let (r, g, b, a) = c.as_rgba();
481        assert_eq!(r, 255);
482        assert_eq!(g, 0);
483        assert_eq!(b, 127);
484        assert_eq!(a, 255);
485    }
486}