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