auto_palette/
theme.rs

1use std::{fmt::Debug, str::FromStr};
2
3use crate::{
4    color::Gamut,
5    math::{normalize, FloatNumber},
6    Error,
7    Swatch,
8};
9
10/// The theme representation for scoring the swatches.
11/// The definition of the themes is based on the PCCS (Practical Color Co-ordinate System) color theory.
12/// @see [Practical Color Coordinate System - Wikipedia](https://en.wikipedia.org/wiki/Practical_Color_Coordinate_System)
13///
14/// # Examples
15/// ```
16/// use std::str::FromStr;
17///
18/// use auto_palette::Theme;
19///
20/// let theme = Theme::from_str("vivid").unwrap();
21/// assert_eq!(theme, Theme::Vivid);
22/// ```
23#[derive(Debug, Clone, Copy, PartialEq)]
24pub enum Theme {
25    /// The theme selects the swatches based on the population of the swatches.
26    /// The swatches are scored based on the population of the swatches.
27    #[deprecated(since = "0.8.0", note = "Use Palette::find_swatches() instead.")]
28    Basic,
29    /// The theme selects the swatches based on the moderate chroma and lightness.
30    /// The high chroma and lightness swatches are scored higher.
31    Colorful,
32    /// The theme selects the swatches based on the high chroma and moderate lightness.
33    /// The high chroma and lightness swatches are scored higher.
34    Vivid,
35    /// The theme selects the swatches based on the low chroma and moderate lightness.
36    /// The low chroma and lightness swatches are scored higher.
37    Muted,
38    /// The theme selects the swatches based on the moderate chroma and high lightness.
39    /// The moderate chroma and high lightness swatches are scored higher.
40    Light,
41    /// The theme selects the swatches based on the low chroma and low lightness.
42    /// The low chroma and lightness swatches are scored higher.
43    Dark,
44}
45
46impl Theme {
47    /// The maximum lightness value for the theme scoring.
48    const MAX_LIGHTNESS: u8 = 100;
49
50    /// Scores the swatch based on the theme.
51    ///
52    /// # Type Parameters
53    /// * `T` - The float number type.
54    ///
55    /// # Arguments
56    /// * `swatch` - The swatch to score.
57    ///
58    /// # Returns
59    /// The score of the swatch.
60    #[inline]
61    #[must_use]
62    pub(crate) fn score<T>(&self, swatch: &Swatch<T>) -> T
63    where
64        T: FloatNumber,
65    {
66        let params = match self {
67            #[allow(deprecated)]
68            Theme::Basic => {
69                return swatch.ratio();
70            }
71            Theme::Colorful => ThemeParams {
72                mean_chroma: T::from_f64(0.75),
73                sigma_chroma: T::from_f64(0.18),
74                mean_lightness: T::from_f64(0.60),
75                sigma_lightness: T::from_f64(0.20),
76            },
77            Theme::Vivid => ThemeParams {
78                mean_chroma: T::from_f64(0.90),
79                sigma_chroma: T::from_f64(0.10),
80                mean_lightness: T::from_f64(0.70),
81                sigma_lightness: T::from_f64(0.20),
82            },
83            Theme::Muted => ThemeParams {
84                mean_chroma: T::from_f64(0.20),
85                sigma_chroma: T::from_f64(0.15),
86                mean_lightness: T::from_f64(0.40),
87                sigma_lightness: T::from_f64(0.15),
88            },
89            Theme::Light => ThemeParams {
90                mean_chroma: T::from_f64(0.60),
91                sigma_chroma: T::from_f64(0.15),
92                mean_lightness: T::from_f64(0.85),
93                sigma_lightness: T::from_f64(0.15),
94            },
95            Theme::Dark => ThemeParams {
96                mean_chroma: T::from_f64(0.25),
97                sigma_chroma: T::from_f64(0.15),
98                mean_lightness: T::from_f64(0.15),
99                sigma_lightness: T::from_f64(0.15),
100            },
101        };
102
103        let color = swatch.color();
104        let max_chroma = Gamut::default().max_chroma(color.hue());
105        let chroma = normalize(color.chroma(), T::zero(), max_chroma);
106        let lightness = normalize(
107            color.lightness(),
108            T::zero(),
109            T::from_u8(Self::MAX_LIGHTNESS),
110        );
111
112        let dc = (chroma - params.mean_chroma) / params.sigma_chroma;
113        let dl = (lightness - params.mean_lightness) / params.sigma_lightness;
114        (T::from_f64(-0.5) * (dc * dc + dl * dl)).exp()
115    }
116}
117
118impl FromStr for Theme {
119    type Err = Error;
120
121    fn from_str(s: &str) -> Result<Self, Self::Err> {
122        match s.to_lowercase().as_str() {
123            #[allow(deprecated)]
124            "basic" => Ok(Theme::Basic),
125            "colorful" => Ok(Theme::Colorful),
126            "vivid" => Ok(Theme::Vivid),
127            "muted" => Ok(Theme::Muted),
128            "light" => Ok(Theme::Light),
129            "dark" => Ok(Theme::Dark),
130            _ => Err(Error::UnsupportedTheme {
131                name: s.to_string(),
132            }),
133        }
134    }
135}
136
137/// The mean and standard deviation parameters for the theme scoring.
138///
139/// # Type Parameters
140/// * `T` - The float number type.
141#[derive(Debug, PartialEq)]
142struct ThemeParams<T>
143where
144    T: FloatNumber,
145{
146    /// The mean chroma value for the theme.
147    mean_chroma: T,
148
149    /// The standard deviation of the chroma values for the theme.
150    sigma_chroma: T,
151
152    /// The mean lightness value for the theme.
153    mean_lightness: T,
154
155    /// The standard deviation of the lightness values for the theme.
156    sigma_lightness: T,
157}
158
159#[cfg(test)]
160mod tests {
161    use std::str::FromStr;
162
163    use rstest::rstest;
164
165    use super::*;
166    use crate::{assert_approx_eq, color::Color};
167
168    #[test]
169    fn test_score_basic() {
170        // Act
171        let color: Color<f64> = Color::from_str("#ff0080").unwrap();
172        let swatch = Swatch::new(color, (32, 64), 256, 0.25);
173        let actual = Theme::Basic.score(&swatch);
174
175        // Assert
176        assert_approx_eq!(actual, 0.25);
177    }
178
179    #[rstest]
180    #[case::black("#000000", 0.000001)]
181    #[case::gray("#808080", 0.000162)]
182    #[case::white("#ffffff", 0.000023)]
183    #[case::red("#ff0000", 0.359991)]
184    #[case::green("#00ff00", 0.145718)]
185    #[case::blue("#0000ff", 0.146085)]
186    #[case::yellow("#ffff00", 0.067976)]
187    #[case::cyan("#00ffff", 0.113646)]
188    #[case::magenta("#ff00ff", 0.381121)]
189    #[case::orange("#ff8000", 0.358194)]
190    #[case::purple("#8000ff", 0.241719)]
191    #[case::lime("#80ff00", 0.124594)]
192    fn test_score_colorful(#[case] hex: &str, #[case] expected: f64) {
193        // Act
194        let color: Color<f64> = Color::from_str(hex).unwrap();
195        let swatch = Swatch::new(color, (32, 64), 256, 0.5);
196        let actual = Theme::Colorful.score(&swatch);
197
198        // Assert
199        assert_approx_eq!(actual, expected);
200    }
201
202    #[rstest]
203    #[case::black("#000000", 0.0)]
204    #[case::gray("#808080", 0.0)]
205    #[case::white("#ffffff", 0.0)]
206    #[case::red("#ff0000", 0.426884)]
207    #[case::green("#00ff00", 0.409349)]
208    #[case::blue("#0000ff", 0.102639)]
209    #[case::yellow("#ffff00", 0.241562)]
210    #[case::cyan("#00ffff", 0.347395)]
211    #[case::magenta("#ff00ff", 0.539526)]
212    #[case::orange("#ff8000", 0.599979)]
213    #[case::purple("#8000ff", 0.210622)]
214    #[case::lime("#80ff00", 0.369553)]
215    fn test_score_vivid(#[case] hex: &str, #[case] expected: f64) {
216        // Act
217        let color: Color<f64> = Color::from_str(hex).unwrap();
218        let swatch = Swatch::new(color, (32, 64), 256, 0.5);
219        let actual = Theme::Vivid.score(&swatch);
220
221        // Assert
222        assert_approx_eq!(actual, expected);
223    }
224
225    #[rstest]
226    #[case::black("#000000", 0.011743)]
227    #[case::gray("#808080", 0.273211)]
228    #[case::white("#ffffff", 0.000138)]
229    #[case::red("#ff0000", 0.0)]
230    #[case::green("#00ff00", 0.0)]
231    #[case::blue("#0000ff", 0.0)]
232    #[case::yellow("#ffff00", 0.0)]
233    #[case::cyan("#00ffff", 0.0)]
234    #[case::magenta("#ff00ff", 0.0)]
235    #[case::orange("#ff8000", 0.0)]
236    #[case::purple("#8000ff", 0.0)]
237    #[case::lime("#80ff00", 0.0)]
238    fn test_score_muted(#[case] hex: &str, #[case] expected: f64) {
239        // Act
240        let color: Color<f64> = Color::from_str(hex).unwrap();
241        let swatch = Swatch::new(color, (32, 64), 256, 0.5);
242        let actual = Theme::Muted.score(&swatch);
243
244        // Assert
245        assert_approx_eq!(actual, expected);
246    }
247
248    #[rstest]
249    #[case::black("#000000", 0.0)]
250    #[case::gray("#808080", 0.000037)]
251    #[case::white("#ffffff", 0.000204)]
252    #[case::red("#ff0000", 0.003035)]
253    #[case::green("#00ff00", 0.028094)]
254    #[case::blue("#0000ff", 0.000059)]
255    #[case::yellow("#ffff00", 0.020589)]
256    #[case::cyan("#00ffff", 0.026287)]
257    #[case::magenta("#ff00ff", 0.007381)]
258    #[case::orange("#ff8000", 0.013962)]
259    #[case::purple("#8000ff", 0.000380)]
260    #[case::lime("#80ff00", 0.027076)]
261    fn test_score_light(#[case] hex: &str, #[case] expected: f64) {
262        // Act
263        let color: Color<f64> = Color::from_str(hex).unwrap();
264        let swatch = Swatch::new(color, (32, 64), 256, 0.5);
265        let actual = Theme::Light.score(&swatch);
266
267        // Assert
268        assert_approx_eq!(actual, expected);
269    }
270
271    #[rstest]
272    #[case::black("#000000", 0.151239)]
273    #[case::gray("#808080", 0.009136)]
274    #[case::white("#ffffff", 0.0)]
275    #[case::red("#ff0000", 0.0)]
276    #[case::green("#00ff00", 0.0)]
277    #[case::blue("#0000ff", 0.000001)]
278    #[case::yellow("#ffff00", 0.0)]
279    #[case::cyan("#00ffff", 0.0)]
280    #[case::magenta("#ff00ff", 0.0)]
281    #[case::orange("#ff8000", 0.0)]
282    #[case::purple("#8000ff", 0.0)]
283    #[case::lime("#80ff00", 0.0)]
284    fn test_score_dark(#[case] hex: &str, #[case] expected: f64) {
285        // Act
286        let color: Color<f64> = Color::from_str(hex).unwrap();
287        let swatch = Swatch::new(color, (32, 64), 256, 0.5);
288        let actual = Theme::Dark.score(&swatch);
289
290        // Assert
291        assert_approx_eq!(actual, expected);
292    }
293
294    #[rstest]
295    #[case::basic("basic", Theme::Basic)]
296    #[case::colorful("colorful", Theme::Colorful)]
297    #[case::vivid("vivid", Theme::Vivid)]
298    #[case::muted("muted", Theme::Muted)]
299    #[case::light("light", Theme::Light)]
300    #[case::dark("dark", Theme::Dark)]
301    #[case::basic_upper("BASIC", Theme::Basic)]
302    #[case::colorful_upper("COLORFUL", Theme::Colorful)]
303    #[case::vivid_upper("VIVID", Theme::Vivid)]
304    #[case::muted_upper("MUTED", Theme::Muted)]
305    #[case::light_upper("LIGHT", Theme::Light)]
306    #[case::dark_upper("DARK", Theme::Dark)]
307    #[case::basic_capitalized("Basic", Theme::Basic)]
308    #[case::colorful_capitalized("Colorful", Theme::Colorful)]
309    #[case::vivid_capitalized("Vivid", Theme::Vivid)]
310    #[case::muted_capitalized("Muted", Theme::Muted)]
311    #[case::light_capitalized("Light", Theme::Light)]
312    #[case::dark_capitalized("Dark", Theme::Dark)]
313    fn test_from_str(#[case] str: &str, #[case] expected: Theme) {
314        // Act
315        let actual = Theme::from_str(str);
316
317        // Assert
318        assert!(actual.is_ok());
319        assert_eq!(actual.unwrap(), expected);
320    }
321
322    #[rstest]
323    #[case::empty("")]
324    #[case::invalid("unknown")]
325    fn test_from_str_error(#[case] str: &str) {
326        // Act
327        let actual = Theme::from_str(str);
328
329        // Assert
330        assert!(actual.is_err());
331        assert_eq!(
332            actual.unwrap_err().to_string(),
333            format!("Unsupported theme specified: '{}'", str),
334        );
335    }
336}