Skip to main content

identicon_rs/theme/
mod.rs

1use std::sync::Arc;
2
3use error::ThemeError;
4
5use crate::{color::RGB, map_values::map_values};
6
7/// Theme Errors
8///
9/// Identicon Errors can wrap these errors
10pub mod error;
11
12/// Trait defining requirements for an identicon theme
13pub trait Theme {
14    /// This should return the main color within the identicon image
15    fn main_color(&self, hash: &[u8]) -> Result<RGB, ThemeError>;
16
17    /// This should return the background color within the identicon image
18    fn background_color(&self, hash: &[u8]) -> Result<RGB, ThemeError>;
19}
20
21/// Simple selection theme struct
22///
23/// This will choose a predefined main color and background color based on the hash.
24///
25/// Both the main and background colors are defined as a `Vec<RGB>`.
26///
27/// Implements [Theme]
28pub struct Selection {
29    /// A vector of input colors to choose from based on the input hash.
30    /// This can be a vector of one value to allow for constant image colors.
31    main: Vec<RGB>,
32
33    /// A vector of background colors to choose from based on the input hash.
34    /// This can be a vector of one value to allow for constant backgrounds.
35    background: Vec<RGB>,
36}
37
38impl Selection {
39    /// Generates a new Selection theme
40    ///
41    /// `main` is used as the colors to select from for the main image color.
42    /// `background` is used as the possible colors of the background.
43    pub fn new(main: Vec<RGB>, background: Vec<RGB>) -> Result<Selection, ThemeError> {
44        let theme = Selection { main, background };
45        theme.validate().map(|_| theme)
46    }
47
48    fn validate(&self) -> Result<(), ThemeError> {
49        if self.main.is_empty() {
50            Err(ThemeError::ThemeValidationError(
51                "main color selection is empty".to_string(),
52            ))
53        } else if self.background.is_empty() {
54            Err(ThemeError::ThemeValidationError(
55                "background color selection is empty".to_string(),
56            ))
57        } else {
58            Ok(())
59        }
60    }
61}
62
63impl Theme for Selection {
64    fn main_color(&self, hash: &[u8]) -> Result<RGB, ThemeError> {
65        if self.main.is_empty() {
66            Err(ThemeError::ThemeValidationError(
67                "main color selection is empty".to_string(),
68            ))
69        } else {
70            let index = hash[0 % hash.len()] as usize % self.main.len();
71            Ok(self.main[index])
72        }
73    }
74
75    fn background_color(&self, hash: &[u8]) -> Result<RGB, ThemeError> {
76        if self.background.is_empty() {
77            Err(ThemeError::ThemeValidationError(
78                "background color selection is empty".to_string(),
79            ))
80        } else {
81            let index = hash[2 % hash.len()] as usize % self.background.len();
82            Ok(self.background[index])
83        }
84    }
85}
86
87/// Complex HSL Range theme struct
88///
89/// This will generate a main color within the defined HSL Range.
90///
91/// The background color is based on a predefined `Vec<RGB>` and the color is selected by the hash value.
92///
93/// Implements [Theme]
94pub struct HSLRange {
95    /// The minimum hue
96    /// A value between 0.0 and 360.0
97    hue_min: f32,
98
99    /// The maximum hue
100    /// A value between 0.0 and 360.0
101    hue_max: f32,
102
103    /// The minimum saturation
104    /// A value between 0.0 and 100.0 as a percent.
105    /// e.g. 75.0 will become 0.750 in a HSL calculation.
106    saturation_min: f32,
107
108    /// The maximum saturation
109    /// A value between 0.0 and 100.0 as a percent.
110    /// e.g. 75.0 will become 0.750 in a HSL calculation.
111    saturation_max: f32,
112
113    /// The minimum lightness
114    /// A value between 0.0 and 100.0 as a percent.
115    /// e.g. 75.0 will become 0.750 in a HSL calculation.
116    lightness_min: f32,
117
118    /// The maximum lightness
119    /// A value between 0.0 and 100.0 as a percent.
120    /// e.g. 75.0 will become 0.750 in a HSL calculation.
121    lightness_max: f32,
122
123    /// A vector of background colors to choose from based on the input hash.
124    /// This can be a vector of one value to allow for constant backgrounds.
125    // background: Vec<RGB>,
126    background: Vec<RGB>,
127}
128
129impl HSLRange {
130    /// Generates a new HSLRange theme
131    ///
132    /// `hue_min` and `hue_max` are the range of possible hue values.
133    /// They are expected to be between 0.0 and 360.0
134    ///
135    /// `saturation_min` and `saturation_max` are the range of possible saturation values.
136    /// They are expected to be between 0.0 and 100.0
137    ///
138    /// `lightness_min` and `lightness_max` are the range of possible lightness values.
139    /// They are expected to be between 0.0 and 100.0
140    ///
141    /// `background` is used as the possible colors of the background.
142    pub fn new(
143        hue_min: f32,
144        hue_max: f32,
145        saturation_min: f32,
146        saturation_max: f32,
147        lightness_min: f32,
148        lightness_max: f32,
149        background: Vec<RGB>,
150    ) -> Result<HSLRange, ThemeError> {
151        let theme = HSLRange {
152            hue_min,
153            hue_max,
154            saturation_min,
155            saturation_max,
156            lightness_min,
157            lightness_max,
158            background,
159        };
160
161        theme.validate().map(|_| theme)
162    }
163
164    fn validate(&self) -> Result<(), ThemeError> {
165        if self.hue_max < self.hue_min {
166            Err(ThemeError::ThemeValidationError(
167                "hue_max must be larger than hue_min".to_string(),
168            ))
169        } else if self.saturation_max < self.saturation_min {
170            Err(ThemeError::ThemeValidationError(
171                "saturation_max must be larger than saturation_min".to_string(),
172            ))
173        } else if self.lightness_max < self.lightness_min {
174            Err(ThemeError::ThemeValidationError(
175                "lightness_max must be larger than lightness_min".to_string(),
176            ))
177        } else {
178            Ok(())
179        }
180    }
181}
182
183impl Theme for HSLRange {
184    fn main_color(&self, hash: &[u8]) -> Result<RGB, ThemeError> {
185        // Validate the fields
186        self.validate()?;
187
188        // Compute hash for hue space in larger bitspace
189        let hue_hash = ((hash[0 % hash.len()] as u16) << 8) | hash[1 % hash.len()] as u16;
190
191        // Compute HSL values
192        let hash_hue = map_values(
193            hue_hash as f32,
194            u16::MIN as f32,
195            u16::MAX as f32,
196            self.hue_min,
197            self.hue_max,
198        );
199
200        // Handle 0 degree hue is equivalent to 360 degree hue
201        let hue = hash_hue % 360.0;
202
203        // Saturation should be between 0.5 and 0.75 for pastel colors
204        let saturation = map_values(
205            hash[2 % hash.len()] as f32,
206            u8::MIN as f32,
207            u8::MAX as f32,
208            self.saturation_min,
209            self.saturation_max,
210        ) / 100.0;
211
212        // Lightness should be between 0.6 and 0.70 for pastel colors
213        let lightness = map_values(
214            hash[3 % hash.len()] as f32,
215            u8::MIN as f32,
216            u8::MAX as f32,
217            self.lightness_min,
218            self.lightness_max,
219        ) / 100.0;
220
221        // Convert HSL to RGB
222        let chroma = (1.0 - ((2.0 * lightness) - 1.0).abs()) * saturation;
223        let hue_prime = hue / 60.0;
224        let x = chroma * (1.0 - ((hue_prime % 2.0) - 1.0).abs());
225
226        // Get Prime RGB Values
227        let (r_prime, g_prime, b_prime) = match hue_prime {
228            0.0..1.0 => (chroma, x, 0.0),
229            1.0..2.0 => (x, chroma, 0.0),
230            2.0..3.0 => (0.0, chroma, x),
231            3.0..4.0 => (0.0, x, chroma),
232            4.0..5.0 => (x, 0.0, chroma),
233            5.0..=6.0 => (chroma, 0.0, x),
234            // This should not occur as the hue is between 0 and 360, which casts down to between 0-6
235            _ => (0.0, 0.0, 0.0),
236        };
237
238        // Lightness modifier
239        let m = lightness - chroma * 0.5;
240
241        let red = (r_prime + m) * 255.0;
242        let green = (g_prime + m) * 255.0;
243        let blue = (b_prime + m) * 255.0;
244
245        Ok(RGB {
246            red: red as u8,
247            green: green as u8,
248            blue: blue as u8,
249        })
250    }
251
252    fn background_color(&self, hash: &[u8]) -> Result<RGB, ThemeError> {
253        if self.background.is_empty() {
254            Err(ThemeError::ThemeValidationError(
255                "background color selection is empty".to_string(),
256            ))
257        } else {
258            let index = hash[2 % hash.len()] as usize % self.background.len();
259            Ok(self.background[index])
260        }
261    }
262}
263
264/// The default theme
265///
266/// This is a muted pastel theme.
267/// The original color theme, before theme customization existed.
268pub fn default_theme() -> Arc<dyn Theme + Send + Sync> {
269    Arc::new(HSLRange {
270        hue_min: 0.0,
271        hue_max: 360.0,
272        saturation_min: 50.0,
273        saturation_max: 75.0,
274        lightness_min: 60.0,
275        lightness_max: 70.0,
276        background: vec![RGB {
277            red: 240,
278            green: 240,
279            blue: 240,
280        }],
281    })
282}
283
284/// The default theme
285///
286/// This is a muted pastel theme.
287/// The original color theme, before theme customization existed.
288pub fn pastel_selection_theme() -> Arc<dyn Theme + Send + Sync> {
289    let main = vec![
290        RGB {
291            red: 255,
292            green: 173,
293            blue: 173,
294        },
295        RGB {
296            red: 255,
297            green: 214,
298            blue: 165,
299        },
300        RGB {
301            red: 253,
302            green: 255,
303            blue: 182,
304        },
305        RGB {
306            red: 202,
307            green: 255,
308            blue: 191,
309        },
310        RGB {
311            red: 155,
312            green: 246,
313            blue: 255,
314        },
315        RGB {
316            red: 160,
317            green: 196,
318            blue: 255,
319        },
320        RGB {
321            red: 189,
322            green: 178,
323            blue: 255,
324        },
325        RGB {
326            red: 255,
327            green: 198,
328            blue: 255,
329        },
330    ];
331    let background = vec![RGB {
332        red: 240,
333        green: 240,
334        blue: 240,
335    }];
336
337    Arc::new(Selection { main, background })
338}
339
340#[cfg(test)]
341mod tests {
342    use std::sync::Arc;
343
344    use crate::{color::RGB, hash};
345
346    use super::{HSLRange, Selection, Theme, default_theme, pastel_selection_theme};
347    const CONSISTENCY_STRING_1: &str = "TEST CONSISTENCY";
348    const CONSISTENCY_STRING_2: &str = "TEST CONSISTENCY ALTERNATE";
349    const CONSISTENCY_STRING_3: &str = "CONSISTENCY TEST INPUT";
350
351    fn test_theme_consistency(
352        input: &str,
353        theme: Arc<dyn Theme>,
354        expected_main_color: RGB,
355        expected_background_color: RGB,
356    ) {
357        let hash = hash::hash_value(input);
358
359        let main_color = theme
360            .main_color(&hash)
361            .expect("could not generate main color");
362        let background_color = theme
363            .background_color(&hash)
364            .expect("could not generate background color");
365
366        assert_eq!(expected_main_color, main_color);
367        assert_eq!(expected_background_color, background_color);
368    }
369
370    #[test]
371    fn hsl_range_theme_consistency() {
372        let expected_main_color: RGB = (116, 93, 222).into();
373        let expected_background_color: RGB = (240, 240, 240).into();
374
375        test_theme_consistency(
376            CONSISTENCY_STRING_1,
377            default_theme(),
378            expected_main_color,
379            expected_background_color,
380        );
381
382        let expected_main_color: RGB = (94, 225, 227).into();
383        let expected_background_color: RGB = (240, 240, 240).into();
384
385        test_theme_consistency(
386            CONSISTENCY_STRING_2,
387            default_theme(),
388            expected_main_color,
389            expected_background_color,
390        );
391    }
392
393    #[test]
394    fn hsl_range_theme_multiple_background_consistency() {
395        let theme = Arc::new(HSLRange {
396            hue_min: 0.0,
397            hue_max: 100.0,
398            saturation_min: 0.0,
399            saturation_max: 100.0,
400            lightness_min: 0.0,
401            lightness_max: 100.0,
402            background: vec![(0, 0, 0).into(), (255, 255, 255).into()],
403        });
404
405        let expected_main_color: RGB = (67, 77, 16).into();
406        let expected_background_color: RGB = (0, 0, 0).into();
407
408        test_theme_consistency(
409            CONSISTENCY_STRING_1,
410            theme.clone(),
411            expected_main_color,
412            expected_background_color,
413        );
414
415        let expected_main_color: RGB = (232, 253, 218).into();
416        let expected_background_color: RGB = (255, 255, 255).into();
417
418        test_theme_consistency(
419            CONSISTENCY_STRING_3,
420            theme,
421            expected_main_color,
422            expected_background_color,
423        );
424    }
425
426    #[test]
427    fn hsl_theme_validation() {
428        let theme = HSLRange::new(
429            0.0,
430            360.0,
431            0.0,
432            100.0,
433            0.0,
434            100.0,
435            vec![RGB {
436                red: 255,
437                green: 255,
438                blue: 255,
439            }],
440        );
441        assert!(theme.is_ok());
442
443        let theme = HSLRange::new(
444            360.0,
445            0.0,
446            0.0,
447            100.0,
448            0.0,
449            100.0,
450            vec![RGB {
451                red: 255,
452                green: 255,
453                blue: 255,
454            }],
455        );
456        assert!(theme.is_err());
457
458        let theme = HSLRange::new(
459            0.0,
460            360.0,
461            100.0,
462            50.0,
463            0.0,
464            100.0,
465            vec![RGB {
466                red: 255,
467                green: 255,
468                blue: 255,
469            }],
470        );
471        assert!(theme.is_err());
472
473        let theme = HSLRange::new(
474            0.0,
475            360.0,
476            0.0,
477            100.0,
478            100.0,
479            50.0,
480            vec![RGB {
481                red: 255,
482                green: 255,
483                blue: 255,
484            }],
485        );
486        assert!(theme.is_err());
487    }
488
489    #[test]
490    fn selection_theme_consistency() {
491        let expected_main_color: RGB = (253, 255, 182).into();
492        let expected_background_color: RGB = (240, 240, 240).into();
493
494        test_theme_consistency(
495            CONSISTENCY_STRING_1,
496            pastel_selection_theme(),
497            expected_main_color,
498            expected_background_color,
499        );
500
501        let expected_main_color: RGB = (255, 173, 173).into();
502        let expected_background_color: RGB = (240, 240, 240).into();
503
504        test_theme_consistency(
505            CONSISTENCY_STRING_2,
506            pastel_selection_theme(),
507            expected_main_color,
508            expected_background_color,
509        );
510    }
511
512    #[test]
513    fn selection_theme_multiple_background_consistency() {
514        let theme = Arc::new(Selection {
515            main: vec![(0, 0, 0).into()],
516            background: vec![(0, 0, 0).into(), (255, 255, 255).into()],
517        });
518
519        let expected_main_color: RGB = (0, 0, 0).into();
520        let expected_background_color: RGB = (0, 0, 0).into();
521
522        test_theme_consistency(
523            CONSISTENCY_STRING_1,
524            theme.clone(),
525            expected_main_color,
526            expected_background_color,
527        );
528
529        let expected_main_color: RGB = (0, 0, 0).into();
530        let expected_background_color: RGB = (255, 255, 255).into();
531
532        test_theme_consistency(
533            CONSISTENCY_STRING_3,
534            theme,
535            expected_main_color,
536            expected_background_color,
537        );
538    }
539
540    #[test]
541    fn selection_theme_validation() {
542        let theme = Selection::new(
543            vec![],
544            vec![RGB {
545                red: 255,
546                green: 255,
547                blue: 255,
548            }],
549        );
550        assert!(theme.is_err());
551
552        let theme = Selection::new(
553            vec![RGB {
554                red: 255,
555                green: 255,
556                blue: 255,
557            }],
558            vec![],
559        );
560        assert!(theme.is_err());
561
562        let theme = Selection::new(
563            vec![RGB {
564                red: 255,
565                green: 255,
566                blue: 255,
567            }],
568            vec![RGB {
569                red: 0,
570                green: 0,
571                blue: 0,
572            }],
573        );
574        assert!(theme.is_ok());
575    }
576}