Skip to main content

tinted_builder/scheme/
color.rs

1use palette::{rgb::Rgb, FromColor, GetHue, Hsl, IntoColor};
2use serde::{Deserialize, Serialize, Serializer};
3use std::fmt;
4use std::str::FromStr;
5
6use crate::error::TintedBuilderError;
7
8/// A normalized color with multiple representations used by templates.
9///
10/// Stores hex (lowercased, without the leading `#`), 8-bit RGB, and normalized decimal channels
11/// in `[0.0, 1.0]`. The custom `Serialize` implementation exposes template-friendly fields like
12/// `hex`, `hex-r/g/b`, `hex-bgr`, `rgb`, `rgb16`, and `dec` as documented in the spec.
13#[derive(Debug, Clone, Deserialize)]
14pub struct Color {
15    pub hex: (String, String, String),
16    pub rgb: (u8, u8, u8),
17    pub dec: (f32, f32, f32),
18    pub name: ColorName,
19    pub variant: ColorVariant,
20}
21
22impl Color {
23    /// Creates a `Color` from a hex string like `"ff00ff"` or `"#ffcc00"` along with the `ColorName`
24    /// and `ColorVariant`
25    ///
26    /// # Errors
27    ///
28    /// Returns `Err(TintedBuilderError::HexInputFormat)` if `hex_color` is not a valid
29    /// 6-digit hexadecimal color (optionally prefixed with `#`).
30    /// Creates a `Color` from a hex string like `"ff00ff"` or `"#ffcc00"`.
31    ///
32    /// The color is associated with an optional `ColorName` and `ColorVariant` for downstream usage.
33    ///
34    /// # Errors
35    ///
36    /// Returns `Err(TintedBuilderError::HexInputFormat)` if `hex_color` is not a valid
37    /// 3- or 6-digit hexadecimal color (optionally prefixed with `#`).
38    pub fn new(
39        hex_color: &str,
40        name: Option<ColorName>,
41        variant: Option<ColorVariant>,
42    ) -> Result<Self, TintedBuilderError> {
43        let hex_full = process_hex_input(hex_color).ok_or(TintedBuilderError::HexInputFormat)?;
44        let hex: (String, String, String) = (
45            hex_full[0..2].to_lowercase(),
46            hex_full[2..4].to_lowercase(),
47            hex_full[4..6].to_lowercase(),
48        );
49        let rgb = hex_to_rgb(&hex)?;
50        // Store normalized decimal channels in [0.0, 1.0]
51        let inv_255: f32 = 1.0 / 255.0;
52        let dec: (f32, f32, f32) = (
53            f32::from(rgb.0) * inv_255,
54            f32::from(rgb.1) * inv_255,
55            f32::from(rgb.2) * inv_255,
56        );
57
58        Ok(Self {
59            hex,
60            rgb,
61            dec,
62            name: name.unwrap_or(ColorName::Other),
63            variant: variant.unwrap_or(ColorVariant::Normal),
64        })
65    }
66
67    #[must_use]
68    /// Returns the 6-digit hex string (lowercase) without the leading `#`.
69    pub fn to_hex(&self) -> String {
70        format!("{}{}{}", &self.hex.0, &self.hex.1, &self.hex.2)
71    }
72
73    #[allow(
74        clippy::cast_possible_truncation,
75        clippy::cast_sign_loss,
76        clippy::missing_errors_doc
77    )]
78    /// Derives a `dim` or `bright` variant from a `normal` color according to the Tinted8 rules.
79    ///
80    /// # Errors
81    ///
82    /// Returns an error when the color cannot be converted.
83    pub fn try_to_variant(&self, color_variant: &ColorVariant) -> Result<Self, TintedBuilderError> {
84        let rgb = Rgb::new(self.rgb.0, self.rgb.1, self.rgb.2);
85        let hsl: Hsl = Hsl::from_color(rgb.into_format::<f32>());
86        let updated_hsl = adjust_normal_hsl_for_variant(hsl, color_variant);
87        let updated_rgb: Rgb = updated_hsl.into_color();
88        let updated_rgb_r: u8 = (updated_rgb.red.clamp(0.0, 1.0) * 255.0).round() as u8;
89        let updated_rgb_g: u8 = (updated_rgb.green.clamp(0.0, 1.0) * 255.0).round() as u8;
90        let updated_rgb_b: u8 = (updated_rgb.blue.clamp(0.0, 1.0) * 255.0).round() as u8;
91        let updated_hex = format!("{updated_rgb_r:02X}{updated_rgb_g:02X}{updated_rgb_b:02X}");
92
93        Self::new(
94            &updated_hex,
95            Some(self.name.clone()),
96            Some(color_variant.clone()),
97        )
98    }
99
100    #[allow(
101        clippy::missing_errors_doc,
102        clippy::cast_possible_truncation,
103        clippy::cast_sign_loss
104    )]
105    /// Derives supplemental colors (e.g., `orange` or `brown`) from a base color as specified.
106    ///
107    /// # Errors
108    ///
109    /// Returns an error when the requested conversion is unsupported.
110    pub fn try_to_color(&self, target_color_name: &ColorName) -> Result<Self, TintedBuilderError> {
111        let from_target_color_name = &self.name.clone();
112        let to_target_color_name = target_color_name.clone();
113        let to_color_variant = &self.variant.clone();
114
115        match (&from_target_color_name, &to_target_color_name) {
116            (ColorName::Yellow, ColorName::Orange) => {
117                let from_rgb = Rgb::new(self.rgb.0, self.rgb.1, self.rgb.2);
118                let from_hsl: Hsl = Hsl::from_color(from_rgb.into_format::<f32>());
119                let from_hsl_h = from_hsl.get_hue().into_degrees();
120                let from_hsl_s = from_hsl.saturation;
121                let from_hsl_l = from_hsl.lightness;
122                // Wrap-aware hue rotation toward orange (−12°), keep S/L unchanged
123                let h_prime = (from_hsl_h - 10.0 + 360.0) % 360.0;
124                let to_hsl: Hsl = Hsl::new(h_prime, from_hsl_s, from_hsl_l);
125                let to_rgb: Rgb = to_hsl.into_color();
126                let [to_rgb_r, to_rgb_g, to_rgb_b]: [u8; 3] =
127                    [to_rgb.red, to_rgb.green, to_rgb.blue]
128                        .map(|c| (c.clamp(0.0, 1.0) * 255.0).round() as u8);
129                let to_hex = format!("{to_rgb_r:02X}{to_rgb_g:02X}{to_rgb_b:02X}");
130
131                Self::new(
132                    &to_hex,
133                    Some(to_target_color_name.clone()),
134                    Some(to_color_variant.clone()),
135                )
136            }
137            (ColorName::Yellow, ColorName::Brown) => {
138                let from_rgb = Rgb::new(self.rgb.0, self.rgb.1, self.rgb.2);
139                let from_hsl: Hsl = Hsl::from_color(from_rgb.into_format::<f32>());
140                let from_hsl_h = from_hsl.get_hue().into_degrees();
141                let from_hsl_s = from_hsl.saturation;
142                let from_hsl_l = from_hsl.lightness;
143                let h_difference = 15.0;
144                let l_difference = 0.3;
145                let s_perc_difference = 0.65;
146                // Clamp S/L after adjustment as per spec
147                let s_prime = (from_hsl_s * s_perc_difference).clamp(0.0, 1.0);
148                let l_prime = (from_hsl_l - l_difference).clamp(0.0, 1.0);
149                let to_hsl: Hsl = Hsl::new(from_hsl_h - h_difference, s_prime, l_prime);
150                let to_rgb: Rgb = to_hsl.into_color();
151                let [to_rgb_r, to_rgb_g, to_rgb_b]: [u8; 3] =
152                    [to_rgb.red, to_rgb.green, to_rgb.blue]
153                        .map(|c| (c.clamp(0.0, 1.0) * 255.0).round() as u8);
154                let to_hex = format!("{to_rgb_r:02X}{to_rgb_g:02X}{to_rgb_b:02X}");
155
156                Self::new(
157                    &to_hex,
158                    Some(to_target_color_name.clone()),
159                    Some(to_color_variant.clone()),
160                )
161            }
162            _ => Err(TintedBuilderError::UnsupportedColorDerivation {
163                from_color: self.name.to_string(),
164                target: target_color_name.to_string(),
165                supported_derivations: "yellow→orange, yellow→brown".to_string(),
166            }),
167        }
168    }
169}
170
171impl fmt::Display for Color {
172    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
173        write!(f, "#{}", &self.to_hex())
174    }
175}
176
177/// Variants for a color token.
178#[derive(Clone, Debug, Deserialize, Serialize)]
179#[non_exhaustive]
180pub enum ColorVariant {
181    Dim,
182    Normal,
183    Bright,
184}
185
186impl fmt::Display for ColorVariant {
187    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
188        match self {
189            Self::Dim => write!(f, "dim"),
190            Self::Normal => write!(f, "normal"),
191            Self::Bright => write!(f, "bright"),
192        }
193    }
194}
195
196impl FromStr for ColorVariant {
197    type Err = TintedBuilderError;
198
199    /// Parses a string to create a `ColorVariant`.
200    ///
201    /// # Errors
202    ///
203    /// Returns a `TintedBuilderError` if the input string does not match
204    /// any valid color variant.
205    fn from_str(variant_str: &str) -> Result<Self, Self::Err> {
206        match variant_str {
207            "dim" => Ok(Self::Dim),
208            "normal" => Ok(Self::Normal),
209            "bright" => Ok(Self::Bright),
210            _ => Err(TintedBuilderError::InvalidColorVariant(
211                variant_str.to_string(),
212            )),
213        }
214    }
215}
216
217impl ColorVariant {
218    #[must_use]
219    pub const fn get_list<'a>() -> &'a [Self] {
220        &[Self::Dim, Self::Normal, Self::Bright]
221    }
222}
223
224/// Canonical color names used by the palette and theming properties.
225#[derive(Clone, Debug, Deserialize, Serialize)]
226#[non_exhaustive]
227pub enum ColorName {
228    Black,
229    Red,
230    Green,
231    Yellow,
232    Blue,
233    Magenta,
234    Cyan,
235    White,
236    Orange,
237    Gray,
238    Brown,
239    Other, // For unknown colors
240}
241
242impl fmt::Display for ColorName {
243    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
244        match self {
245            Self::Black => write!(f, "black"),
246            Self::Red => write!(f, "red"),
247            Self::Green => write!(f, "green"),
248            Self::Yellow => write!(f, "yellow"),
249            Self::Blue => write!(f, "blue"),
250            Self::Magenta => write!(f, "magenta"),
251            Self::Cyan => write!(f, "cyan"),
252            Self::White => write!(f, "white"),
253            Self::Orange => write!(f, "orange"),
254            Self::Gray => write!(f, "gray"),
255            Self::Brown => write!(f, "brown"),
256            Self::Other => write!(f, "other"),
257        }
258    }
259}
260
261impl ColorName {
262    #[must_use]
263    pub const fn get_list<'a>() -> &'a [Self] {
264        &[
265            Self::Black,
266            Self::Red,
267            Self::Green,
268            Self::Yellow,
269            Self::Blue,
270            Self::Magenta,
271            Self::Cyan,
272            Self::White,
273            Self::Orange,
274            Self::Gray,
275            Self::Brown,
276        ]
277    }
278}
279
280pub struct ColorType(pub ColorName, pub ColorVariant);
281
282impl FromStr for ColorName {
283    type Err = TintedBuilderError;
284
285    /// Parses a string to create a `ColorName`.
286    ///
287    /// # Errors
288    ///
289    /// Returns a `TintedBuilderError` if the input string does not match
290    /// any valid color name.
291    fn from_str(name_str: &str) -> Result<Self, Self::Err> {
292        match name_str {
293            "black" => Ok(Self::Black),
294            "red" => Ok(Self::Red),
295            "green" => Ok(Self::Green),
296            "yellow" => Ok(Self::Yellow),
297            "blue" => Ok(Self::Blue),
298            "magenta" => Ok(Self::Magenta),
299            "cyan" => Ok(Self::Cyan),
300            "white" => Ok(Self::White),
301            "orange" => Ok(Self::Orange),
302            "gray" => Ok(Self::Gray),
303            "brown" => Ok(Self::Brown),
304            "other" => Ok(Self::Other),
305            _ => Err(TintedBuilderError::InvalidColorName(name_str.to_string())),
306        }
307    }
308}
309
310impl FromStr for ColorType {
311    type Err = TintedBuilderError;
312
313    /// Parses a string like `white_normal` into a `ColorType`.
314    ///
315    /// # Errors
316    ///
317    /// Returns a `TintedBuilderError` if the input string does not match
318    /// any valid color type.
319    fn from_str(color_str: &str) -> Result<Self, Self::Err> {
320        let trimmed = color_str.trim();
321        let lower = trimmed.to_lowercase();
322
323        let (name, variant) = lower
324            .split_once('_')
325            .or_else(|| lower.split_once('-'))
326            .ok_or_else(|| TintedBuilderError::InvalidColorType(trimmed.to_string()))?;
327
328        Ok(Self(
329            ColorName::from_str(name)?,
330            ColorVariant::from_str(variant)?,
331        ))
332    }
333}
334
335/// Converts a `(rr, gg, bb)` hex tuple to `rgb` bytes.
336fn hex_to_rgb(hex: &(String, String, String)) -> Result<(u8, u8, u8), TintedBuilderError> {
337    let r = u8::from_str_radix(hex.0.as_str(), 16)?;
338    let g = u8::from_str_radix(hex.1.as_str(), 16)?;
339    let b = u8::from_str_radix(hex.2.as_str(), 16)?;
340
341    Ok((r, g, b))
342}
343
344/// Normalizes a hex string by removing an optional `#` and expanding 3-digit to 6-digit.
345fn process_hex_input(input: &str) -> Option<String> {
346    // Check and process the hash prefix
347    let hex_str = input.strip_prefix('#').unwrap_or(input);
348
349    match hex_str.len() {
350        // Convert 3-length hex to 6-length by duplicating each character
351        3 => {
352            if hex_str.chars().all(|c| c.is_ascii_hexdigit()) {
353                Some(
354                    hex_str
355                        .chars()
356                        .flat_map(|c| std::iter::repeat(c).take(2))
357                        .collect(),
358                )
359            } else {
360                None // Contains invalid characters
361            }
362        }
363        // Validate the 6-length hex value
364        6 => {
365            if hex_str.chars().all(|c| c.is_ascii_hexdigit()) {
366                Some(hex_str.to_string())
367            } else {
368                None // Contains invalid characters
369            }
370        }
371        // Invalid length
372        _ => None,
373    }
374}
375
376const DL: f32 = 0.12;
377
378/// Adjusts HSL channels to derive `dim`/`bright` from `normal` according to Tinted8 rules.
379fn adjust_normal_hsl_for_variant(hsl: Hsl, color_variant: &ColorVariant) -> Hsl {
380    let mut updated_s = hsl.saturation;
381    let mut updated_l = hsl.lightness;
382
383    match color_variant {
384        ColorVariant::Dim => {
385            let k: f32 = if hsl.lightness < 0.4 {
386                1.04
387            } else if hsl.lightness < 0.7 {
388                1.07
389            } else {
390                1.1
391            };
392            let delta_l = DL.min(hsl.lightness);
393
394            updated_l = (hsl.lightness - delta_l).clamp(0.0, 1.0);
395            updated_s = (hsl.saturation * k).clamp(0.0, 1.0);
396        }
397        ColorVariant::Bright => {
398            let k: f32 = if hsl.lightness < 0.5 {
399                1.08
400            } else if hsl.lightness < 0.8 {
401                1.00
402            } else {
403                0.9
404            };
405            let delta_l = DL.min(1.0 - hsl.lightness);
406
407            updated_l = (hsl.lightness + delta_l).clamp(0.0, 1.0);
408            updated_s = (hsl.saturation * k).clamp(0.0, 1.0);
409        }
410        _ => {}
411    }
412
413    Hsl::new(hsl.hue, updated_s, updated_l)
414}
415
416#[derive(Serialize)]
417struct RgbSer {
418    r: u8,
419    g: u8,
420    b: u8,
421}
422#[derive(Serialize)]
423struct Rgb16Ser {
424    r: u16,
425    g: u16,
426    b: u16,
427}
428#[derive(Serialize)]
429struct DecSer {
430    r: String,
431    g: String,
432    b: String,
433}
434
435impl Serialize for Color {
436    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
437    where
438        S: Serializer,
439    {
440        use serde::ser::SerializeMap;
441
442        let mut map = serializer.serialize_map(Some(8))?;
443        map.serialize_entry("hex", &self.to_hex())?;
444        map.serialize_entry("hex-r", &self.hex.0)?;
445        map.serialize_entry("hex-g", &self.hex.1)?;
446        map.serialize_entry("hex-b", &self.hex.2)?;
447        let hex_bgr = format!("{}{}{}", self.hex.2, self.hex.1, self.hex.0);
448        map.serialize_entry("hex-bgr", &hex_bgr)?;
449
450        let rgb = RgbSer {
451            r: self.rgb.0,
452            g: self.rgb.1,
453            b: self.rgb.2,
454        };
455        map.serialize_entry("rgb", &rgb)?;
456
457        let rgb16 = Rgb16Ser {
458            r: u16::from(self.rgb.0) * 257,
459            g: u16::from(self.rgb.1) * 257,
460            b: u16::from(self.rgb.2) * 257,
461        };
462        map.serialize_entry("rgb16", &rgb16)?;
463
464        let dec = DecSer {
465            r: format!("{:.8}", f64::from(self.dec.0)),
466            g: format!("{:.8}", f64::from(self.dec.1)),
467            b: format!("{:.8}", f64::from(self.dec.2)),
468        };
469        map.serialize_entry("dec", &dec)?;
470
471        map.end()
472    }
473}
474
475#[cfg(test)]
476mod tests {
477    use super::*;
478
479    #[test]
480    fn serializes_to_color_object() {
481        let color = Color::new("#AABBCC", Some(ColorName::Blue), Some(ColorVariant::Normal))
482            .expect("unable to create new color");
483        let yaml = serde_yaml::to_string(&color).expect("unable to serialize color");
484        assert!(yaml.contains("hex: aabbcc"));
485        assert!(yaml.contains("hex-r: aa"));
486        assert!(yaml.contains("hex-g: bb"));
487        assert!(yaml.contains("hex-b: cc"));
488        assert!(yaml.contains("hex-bgr: ccbbaa"));
489        assert!(yaml.contains(
490            "rgb:
491  r: 170
492  g: 187
493  b: 204"
494        ));
495        assert!(yaml.contains(
496            "rgb16:
497  r: 43690
498  g: 48059
499  b: 52428"
500        ));
501        assert!(yaml.contains(
502            "dec:
503  r: '0.66666669'
504  g: '0.73333335'
505  b: '0.80000007'"
506        ));
507    }
508
509    #[test]
510    fn color_object_field_types() {
511        let color = Color::new("#112233", Some(ColorName::Blue), Some(ColorVariant::Normal))
512            .expect("unable to create color");
513        let val = serde_yaml::to_value(&color).expect("unable to deserialize color");
514        let map = val.as_mapping().expect("unable to create mapping");
515        // hex is string
516        assert!(map
517            .get(serde_yaml::Value::String("hex".into()))
518            .expect("unable to get 'hex' property")
519            .as_str()
520            .is_some());
521        // rgb is mapping with numeric values
522        let rgb = map
523            .get(serde_yaml::Value::String("rgb".into()))
524            .expect("unable to get 'rgb' property")
525            .as_mapping()
526            .expect("unable to create mapping");
527        assert!(rgb
528            .get(serde_yaml::Value::String("r".into()))
529            .expect("unable to get 'rgb.r' property")
530            .as_i64()
531            .is_some());
532        // rgb16 numeric
533        let rgb16 = map
534            .get(serde_yaml::Value::String("rgb16".into()))
535            .expect("unable to get 'rgb16' property")
536            .as_mapping()
537            .expect("unable to create mapping");
538        assert!(rgb16
539            .get(serde_yaml::Value::String("r".into()))
540            .expect("unable to get 'rgb16.r' property")
541            .as_i64()
542            .is_some());
543        // dec strings
544        let dec = map
545            .get(serde_yaml::Value::String("dec".into()))
546            .expect("unable to get 'dec' property")
547            .as_mapping()
548            .expect("unable to create mapping");
549        assert!(dec
550            .get(serde_yaml::Value::String("r".into()))
551            .expect("unable to get 'dec.r' property")
552            .as_str()
553            .is_some());
554    }
555}