akari_theme/
color.rs

1use crate::Error;
2use std::fmt;
3use std::str::FromStr;
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6pub struct Rgb {
7    pub r: u8,
8    pub g: u8,
9    pub b: u8,
10}
11
12impl FromStr for Rgb {
13    type Err = Error;
14
15    fn from_str(s: &str) -> Result<Self, Self::Err> {
16        let hex = s.strip_prefix('#').unwrap_or(s);
17
18        if !hex.is_ascii() || hex.len() != 6 {
19            return Err(Error::InvalidHex(hex.to_string()));
20        }
21
22        let parse = |range: std::ops::Range<usize>| u8::from_str_radix(&hex[range], 16);
23
24        match (parse(0..2), parse(2..4), parse(4..6)) {
25            (Ok(r), Ok(g), Ok(b)) => Ok(Self { r, g, b }),
26            _ => Err(Error::InvalidHex(hex.to_string())),
27        }
28    }
29}
30
31impl fmt::Display for Rgb {
32    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
33        write!(f, "#{:02X}{:02X}{:02X}", self.r, self.g, self.b)
34    }
35}
36
37impl Rgb {
38    #[must_use]
39    pub const fn as_floats(self) -> (f64, f64, f64) {
40        (
41            self.r as f64 / 255.0,
42            self.g as f64 / 255.0,
43            self.b as f64 / 255.0,
44        )
45    }
46
47    /// Returns [r, g, b] as f32 values in 0.0-1.0 range.
48    ///
49    /// Useful for GPU APIs like wgpu that expect f32 colors.
50    #[must_use]
51    pub const fn to_array(self) -> [f32; 3] {
52        [
53            self.r as f32 / 255.0,
54            self.g as f32 / 255.0,
55            self.b as f32 / 255.0,
56        ]
57    }
58
59    /// Returns [r, g, b, a] as f32 values with alpha = 1.0.
60    ///
61    /// Useful for GPU APIs like wgpu that expect f32 RGBA colors.
62    #[must_use]
63    pub const fn to_array_with_alpha(self) -> [f32; 4] {
64        [
65            self.r as f32 / 255.0,
66            self.g as f32 / 255.0,
67            self.b as f32 / 255.0,
68            1.0,
69        ]
70    }
71
72    #[must_use]
73    pub fn to_array_string(self) -> String {
74        format!("[{}, {}, {}]", self.r, self.g, self.b)
75    }
76
77    /// Lighten the color by increasing lightness in HSL space.
78    ///
79    /// `factor` of 0.0 returns the original color, 1.0 returns white.
80    /// The lightness is increased proportionally to the remaining headroom.
81    #[must_use]
82    pub fn lighten(self, factor: f64) -> Self {
83        let factor = factor.clamp(0.0, 1.0);
84        let (h, s, l) = self.to_hsl();
85        let new_l = l + (1.0 - l) * factor;
86        Self::from_hsl(h, s, new_l)
87    }
88
89    /// Darken the color by decreasing lightness in HSL space.
90    ///
91    /// `factor` of 0.0 returns the original color, 1.0 returns black.
92    /// The lightness is decreased proportionally to the current lightness.
93    #[must_use]
94    pub fn darken(self, factor: f64) -> Self {
95        let factor = factor.clamp(0.0, 1.0);
96        let (h, s, l) = self.to_hsl();
97        let new_l = l * (1.0 - factor);
98        Self::from_hsl(h, s, new_l)
99    }
100
101    /// Adjust lightness by an absolute amount in HSL space.
102    ///
103    /// Positive values brighten, negative values dim.
104    /// The amount is added directly to lightness (0.0 to 1.0 scale).
105    #[must_use]
106    pub fn brighten(self, amount: f64) -> Self {
107        let (h, s, l) = self.to_hsl();
108        let new_l = (l + amount).clamp(0.0, 1.0);
109        Self::from_hsl(h, s, new_l)
110    }
111
112    /// Mix two colors together.
113    ///
114    /// `factor` of 0.0 returns self, 1.0 returns other.
115    #[must_use]
116    pub fn mix(self, other: Self, factor: f64) -> Self {
117        let factor = factor.clamp(0.0, 1.0);
118        Self {
119            r: Self::blend_channel(self.r, other.r, factor),
120            g: Self::blend_channel(self.g, other.g, factor),
121            b: Self::blend_channel(self.b, other.b, factor),
122        }
123    }
124
125    /// Convert RGB to HSL.
126    ///
127    /// Returns (hue, saturation, lightness) where:
128    /// - hue: 0.0 to 360.0
129    /// - saturation: 0.0 to 1.0
130    /// - lightness: 0.0 to 1.0
131    fn to_hsl(self) -> (f64, f64, f64) {
132        let (r, g, b) = self.as_floats();
133
134        let max = r.max(g).max(b);
135        let min = r.min(g).min(b);
136        let l = (max + min) / 2.0;
137
138        if (max - min).abs() < f64::EPSILON {
139            return (0.0, 0.0, l);
140        }
141
142        let d = max - min;
143        let s = if l > 0.5 {
144            d / (2.0 - max - min)
145        } else {
146            d / (max + min)
147        };
148
149        let h = if (max - r).abs() < f64::EPSILON {
150            let mut h = (g - b) / d;
151            if g < b {
152                h += 6.0;
153            }
154            h
155        } else if (max - g).abs() < f64::EPSILON {
156            (b - r) / d + 2.0
157        } else {
158            (r - g) / d + 4.0
159        };
160
161        (h * 60.0, s, l)
162    }
163
164    /// Convert HSL to RGB.
165    fn from_hsl(h: f64, s: f64, l: f64) -> Self {
166        if s.abs() < f64::EPSILON {
167            let v = (l * 255.0).round() as u8;
168            return Self { r: v, g: v, b: v };
169        }
170
171        let q = if l < 0.5 {
172            l * (1.0 + s)
173        } else {
174            l + s - l * s
175        };
176        let p = 2.0 * l - q;
177        let h = h / 360.0;
178
179        let r = Self::hue_to_rgb(p, q, h + 1.0 / 3.0);
180        let g = Self::hue_to_rgb(p, q, h);
181        let b = Self::hue_to_rgb(p, q, h - 1.0 / 3.0);
182
183        Self {
184            r: (r * 255.0).round() as u8,
185            g: (g * 255.0).round() as u8,
186            b: (b * 255.0).round() as u8,
187        }
188    }
189
190    fn hue_to_rgb(p: f64, q: f64, t: f64) -> f64 {
191        let t = t.rem_euclid(1.0);
192
193        if t < 1.0 / 6.0 {
194            p + (q - p) * 6.0 * t
195        } else if t < 1.0 / 2.0 {
196            q
197        } else if t < 2.0 / 3.0 {
198            p + (q - p) * (2.0 / 3.0 - t) * 6.0
199        } else {
200            p
201        }
202    }
203
204    fn blend_channel(from: u8, to: u8, factor: f64) -> u8 {
205        let from = from as f64;
206        let to = to as f64;
207        (from + (to - from) * factor).round() as u8
208    }
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214
215    fn approx_eq(a: f64, b: f64) -> bool {
216        (a - b).abs() < 0.001
217    }
218
219    #[test]
220    fn parse_with_hash() {
221        let rgb: Rgb = "#E26A3B".parse().unwrap();
222        assert_eq!(rgb.r, 226);
223        assert_eq!(rgb.g, 106);
224        assert_eq!(rgb.b, 59);
225    }
226
227    #[test]
228    fn parse_without_hash() {
229        let rgb: Rgb = "E26A3B".parse().unwrap();
230        assert_eq!(rgb.r, 226);
231        assert_eq!(rgb.g, 106);
232        assert_eq!(rgb.b, 59);
233    }
234
235    #[test]
236    fn parse_black() {
237        let rgb: Rgb = "#000000".parse().unwrap();
238        assert_eq!(rgb, Rgb { r: 0, g: 0, b: 0 });
239    }
240
241    #[test]
242    fn parse_white() {
243        let rgb: Rgb = "#FFFFFF".parse().unwrap();
244        assert_eq!(
245            rgb,
246            Rgb {
247                r: 255,
248                g: 255,
249                b: 255
250            }
251        );
252    }
253
254    #[test]
255    fn parse_lowercase() {
256        let rgb: Rgb = "#aabbcc".parse().unwrap();
257        assert_eq!(rgb.r, 170);
258        assert_eq!(rgb.g, 187);
259        assert_eq!(rgb.b, 204);
260    }
261
262    #[test]
263    fn parse_invalid_length_short() {
264        assert!("#FFF".parse::<Rgb>().is_err());
265    }
266
267    #[test]
268    fn parse_invalid_length_long() {
269        assert!("#FFFFFFFF".parse::<Rgb>().is_err());
270    }
271
272    #[test]
273    fn parse_invalid_chars() {
274        assert!("#GGGGGG".parse::<Rgb>().is_err());
275    }
276
277    #[test]
278    fn parse_empty() {
279        assert!("".parse::<Rgb>().is_err());
280    }
281
282    #[test]
283    fn parse_non_ascii() {
284        assert!("#ABCDEF".parse::<Rgb>().is_err());
285    }
286
287    #[test]
288    fn as_floats_black() {
289        let rgb = Rgb { r: 0, g: 0, b: 0 };
290        let (r, g, b) = rgb.as_floats();
291        assert!(approx_eq(r, 0.0));
292        assert!(approx_eq(g, 0.0));
293        assert!(approx_eq(b, 0.0));
294    }
295
296    #[test]
297    fn as_floats_white() {
298        let rgb = Rgb {
299            r: 255,
300            g: 255,
301            b: 255,
302        };
303        let (r, g, b) = rgb.as_floats();
304        assert!(approx_eq(r, 1.0));
305        assert!(approx_eq(g, 1.0));
306        assert!(approx_eq(b, 1.0));
307    }
308
309    #[test]
310    fn to_array_string_format() {
311        let rgb = Rgb {
312            r: 226,
313            g: 106,
314            b: 59,
315        };
316        assert_eq!(rgb.to_array_string(), "[226, 106, 59]");
317    }
318
319    #[test]
320    fn display_uppercase() {
321        let rgb = Rgb {
322            r: 226,
323            g: 106,
324            b: 59,
325        };
326        assert_eq!(rgb.to_string(), "#E26A3B");
327    }
328
329    #[test]
330    fn display_with_leading_zeros() {
331        let rgb = Rgb { r: 1, g: 2, b: 3 };
332        assert_eq!(rgb.to_string(), "#010203");
333    }
334
335    #[test]
336    fn lighten_zero_unchanged() {
337        let rgb = Rgb {
338            r: 100,
339            g: 100,
340            b: 100,
341        };
342        assert_eq!(rgb.lighten(0.0), rgb);
343    }
344
345    #[test]
346    fn lighten_full_becomes_white() {
347        let rgb = Rgb {
348            r: 100,
349            g: 100,
350            b: 100,
351        };
352        assert_eq!(
353            rgb.lighten(1.0),
354            Rgb {
355                r: 255,
356                g: 255,
357                b: 255
358            }
359        );
360    }
361
362    #[test]
363    fn lighten_half() {
364        let rgb = Rgb {
365            r: 100,
366            g: 100,
367            b: 100,
368        };
369        // 100 + (255 - 100) * 0.5 = 100 + 77.5 = 177.5 -> 178
370        assert_eq!(
371            rgb.lighten(0.5),
372            Rgb {
373                r: 178,
374                g: 178,
375                b: 178
376            }
377        );
378    }
379
380    #[test]
381    fn darken_zero_unchanged() {
382        let rgb = Rgb {
383            r: 100,
384            g: 100,
385            b: 100,
386        };
387        assert_eq!(rgb.darken(0.0), rgb);
388    }
389
390    #[test]
391    fn darken_full_becomes_black() {
392        let rgb = Rgb {
393            r: 100,
394            g: 100,
395            b: 100,
396        };
397        assert_eq!(rgb.darken(1.0), Rgb { r: 0, g: 0, b: 0 });
398    }
399
400    #[test]
401    fn darken_half() {
402        let rgb = Rgb {
403            r: 100,
404            g: 100,
405            b: 100,
406        };
407        // 100 + (0 - 100) * 0.5 = 100 - 50 = 50
408        assert_eq!(
409            rgb.darken(0.5),
410            Rgb {
411                r: 50,
412                g: 50,
413                b: 50
414            }
415        );
416    }
417
418    #[test]
419    fn lighten_clamps_factor() {
420        let rgb = Rgb {
421            r: 100,
422            g: 100,
423            b: 100,
424        };
425        // Factor > 1.0 should be clamped to 1.0
426        assert_eq!(
427            rgb.lighten(2.0),
428            Rgb {
429                r: 255,
430                g: 255,
431                b: 255
432            }
433        );
434    }
435
436    #[test]
437    fn darken_clamps_negative_factor() {
438        let rgb = Rgb {
439            r: 100,
440            g: 100,
441            b: 100,
442        };
443        // Factor < 0.0 should be clamped to 0.0
444        assert_eq!(rgb.darken(-0.5), rgb);
445    }
446
447    #[test]
448    fn brighten_zero_unchanged() {
449        let rgb = Rgb {
450            r: 100,
451            g: 100,
452            b: 100,
453        };
454        assert_eq!(rgb.brighten(0.0), rgb);
455    }
456
457    #[test]
458    fn brighten_positive_increases_lightness() {
459        let rgb = Rgb {
460            r: 100,
461            g: 100,
462            b: 100,
463        };
464        let brightened = rgb.brighten(0.2);
465        // Lightness increases, so RGB values should increase
466        assert!(brightened.r > rgb.r);
467        assert!(brightened.g > rgb.g);
468        assert!(brightened.b > rgb.b);
469    }
470
471    #[test]
472    fn brighten_negative_decreases_lightness() {
473        let rgb = Rgb {
474            r: 100,
475            g: 100,
476            b: 100,
477        };
478        let dimmed = rgb.brighten(-0.2);
479        // Lightness decreases, so RGB values should decrease
480        assert!(dimmed.r < rgb.r);
481        assert!(dimmed.g < rgb.g);
482        assert!(dimmed.b < rgb.b);
483    }
484
485    #[test]
486    fn brighten_clamps_to_white() {
487        let rgb = Rgb {
488            r: 200,
489            g: 200,
490            b: 200,
491        };
492        let result = rgb.brighten(1.0);
493        assert_eq!(
494            result,
495            Rgb {
496                r: 255,
497                g: 255,
498                b: 255
499            }
500        );
501    }
502
503    #[test]
504    fn brighten_clamps_to_black() {
505        let rgb = Rgb {
506            r: 50,
507            g: 50,
508            b: 50,
509        };
510        let result = rgb.brighten(-1.0);
511        assert_eq!(result, Rgb { r: 0, g: 0, b: 0 });
512    }
513
514    #[test]
515    fn mix_zero_returns_self() {
516        let a = Rgb {
517            r: 100,
518            g: 100,
519            b: 100,
520        };
521        let b = Rgb {
522            r: 200,
523            g: 200,
524            b: 200,
525        };
526        assert_eq!(a.mix(b, 0.0), a);
527    }
528
529    #[test]
530    fn mix_one_returns_other() {
531        let a = Rgb {
532            r: 100,
533            g: 100,
534            b: 100,
535        };
536        let b = Rgb {
537            r: 200,
538            g: 200,
539            b: 200,
540        };
541        assert_eq!(a.mix(b, 1.0), b);
542    }
543
544    #[test]
545    fn mix_half() {
546        let a = Rgb {
547            r: 100,
548            g: 100,
549            b: 100,
550        };
551        let b = Rgb {
552            r: 200,
553            g: 200,
554            b: 200,
555        };
556        // 100 + (200 - 100) * 0.5 = 150
557        assert_eq!(
558            a.mix(b, 0.5),
559            Rgb {
560                r: 150,
561                g: 150,
562                b: 150
563            }
564        );
565    }
566
567    fn approx_eq_f32(a: f32, b: f32) -> bool {
568        (a - b).abs() < 0.001
569    }
570
571    #[test]
572    fn to_array_black() {
573        let rgb = Rgb { r: 0, g: 0, b: 0 };
574        let arr = rgb.to_array();
575        assert!(approx_eq_f32(arr[0], 0.0));
576        assert!(approx_eq_f32(arr[1], 0.0));
577        assert!(approx_eq_f32(arr[2], 0.0));
578    }
579
580    #[test]
581    fn to_array_white() {
582        let rgb = Rgb {
583            r: 255,
584            g: 255,
585            b: 255,
586        };
587        let arr = rgb.to_array();
588        assert!(approx_eq_f32(arr[0], 1.0));
589        assert!(approx_eq_f32(arr[1], 1.0));
590        assert!(approx_eq_f32(arr[2], 1.0));
591    }
592
593    #[test]
594    fn to_array_with_alpha_black() {
595        let rgb = Rgb { r: 0, g: 0, b: 0 };
596        let arr = rgb.to_array_with_alpha();
597        assert!(approx_eq_f32(arr[0], 0.0));
598        assert!(approx_eq_f32(arr[1], 0.0));
599        assert!(approx_eq_f32(arr[2], 0.0));
600        assert!(approx_eq_f32(arr[3], 1.0));
601    }
602
603    #[test]
604    fn to_array_with_alpha_white() {
605        let rgb = Rgb {
606            r: 255,
607            g: 255,
608            b: 255,
609        };
610        let arr = rgb.to_array_with_alpha();
611        assert!(approx_eq_f32(arr[0], 1.0));
612        assert!(approx_eq_f32(arr[1], 1.0));
613        assert!(approx_eq_f32(arr[2], 1.0));
614        assert!(approx_eq_f32(arr[3], 1.0));
615    }
616}