Skip to main content

native_theme/
color.rs

1// Rgba color type with custom hex serde
2
3use serde::de;
4use serde::{Deserialize, Deserializer, Serialize, Serializer};
5use std::fmt;
6use std::str::FromStr;
7
8/// An sRGB color with alpha, stored as four u8 components.
9///
10/// All values are in the sRGB color space. When parsing hex strings,
11/// alpha defaults to 255 (fully opaque) if omitted.
12///
13/// # Hex Format
14///
15/// Supports parsing from and displaying as hex strings:
16/// - `#RGB` / `RGB` -- 3-digit shorthand (each digit doubled: `#abc` -> `#aabbcc`)
17/// - `#RGBA` / `RGBA` -- 4-digit shorthand with alpha
18/// - `#RRGGBB` / `RRGGBB` -- standard 6-digit hex
19/// - `#RRGGBBAA` / `RRGGBBAA` -- 8-digit hex with alpha
20///
21/// Display outputs lowercase hex: `#rrggbb` when alpha is 255,
22/// `#rrggbbaa` otherwise.
23///
24/// # Examples
25///
26/// ```
27/// use native_theme::Rgba;
28///
29/// // Create an opaque color
30/// let blue = Rgba::rgb(0, 120, 215);
31/// assert_eq!(blue.a, 255);
32///
33/// // Parse from a hex string
34/// let parsed: Rgba = "#3daee9".parse().unwrap();
35/// assert_eq!(parsed.r, 61);
36///
37/// // Convert to f32 array for toolkit interop
38/// let arr = Rgba::rgb(255, 0, 0).to_f32_array();
39/// assert_eq!(arr, [1.0, 0.0, 0.0, 1.0]);
40/// ```
41#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
42pub struct Rgba {
43    /// Red component (0-255).
44    pub r: u8,
45    /// Green component (0-255).
46    pub g: u8,
47    /// Blue component (0-255).
48    pub b: u8,
49    /// Alpha component (0-255, where 255 is fully opaque).
50    pub a: u8,
51}
52
53impl Rgba {
54    /// Create an opaque color (alpha = 255).
55    pub const fn rgb(r: u8, g: u8, b: u8) -> Self {
56        Self { r, g, b, a: 255 }
57    }
58
59    /// Create a color with explicit alpha.
60    #[allow(clippy::self_named_constructors)]
61    pub const fn rgba(r: u8, g: u8, b: u8, a: u8) -> Self {
62        Self { r, g, b, a }
63    }
64
65    /// Create a color from floating-point components in the 0.0..=1.0 range.
66    ///
67    /// Values are clamped to 0.0..=1.0 before conversion.
68    pub fn from_f32(r: f32, g: f32, b: f32, a: f32) -> Self {
69        Self {
70            r: (r.clamp(0.0, 1.0) * 255.0).round() as u8,
71            g: (g.clamp(0.0, 1.0) * 255.0).round() as u8,
72            b: (b.clamp(0.0, 1.0) * 255.0).round() as u8,
73            a: (a.clamp(0.0, 1.0) * 255.0).round() as u8,
74        }
75    }
76
77    /// Convert to `[r, g, b, a]` in the 0.0..=1.0 range (for toolkit interop).
78    pub fn to_f32_array(&self) -> [f32; 4] {
79        [
80            self.r as f32 / 255.0,
81            self.g as f32 / 255.0,
82            self.b as f32 / 255.0,
83            self.a as f32 / 255.0,
84        ]
85    }
86
87    /// Convert to `(r, g, b, a)` tuple in the 0.0..=1.0 range.
88    pub fn to_f32_tuple(&self) -> (f32, f32, f32, f32) {
89        (
90            self.r as f32 / 255.0,
91            self.g as f32 / 255.0,
92            self.b as f32 / 255.0,
93            self.a as f32 / 255.0,
94        )
95    }
96}
97
98impl fmt::Display for Rgba {
99    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
100        if self.a == 255 {
101            write!(f, "#{:02x}{:02x}{:02x}", self.r, self.g, self.b)
102        } else {
103            write!(
104                f,
105                "#{:02x}{:02x}{:02x}{:02x}",
106                self.r, self.g, self.b, self.a
107            )
108        }
109    }
110}
111
112impl FromStr for Rgba {
113    type Err = String;
114
115    fn from_str(s: &str) -> Result<Self, Self::Err> {
116        let hex = s.strip_prefix('#').unwrap_or(s);
117
118        if hex.is_empty() {
119            return Err("empty hex color string".to_string());
120        }
121
122        match hex.len() {
123            // #RGB shorthand: each digit doubled (e.g., 'a' -> 0xaa = a * 17)
124            3 => {
125                let r = u8::from_str_radix(&hex[0..1], 16)
126                    .map_err(|e| format!("invalid red component: {e}"))?;
127                let g = u8::from_str_radix(&hex[1..2], 16)
128                    .map_err(|e| format!("invalid green component: {e}"))?;
129                let b = u8::from_str_radix(&hex[2..3], 16)
130                    .map_err(|e| format!("invalid blue component: {e}"))?;
131                Ok(Rgba::rgb(r * 17, g * 17, b * 17))
132            }
133            // #RGBA shorthand
134            4 => {
135                let r = u8::from_str_radix(&hex[0..1], 16)
136                    .map_err(|e| format!("invalid red component: {e}"))?;
137                let g = u8::from_str_radix(&hex[1..2], 16)
138                    .map_err(|e| format!("invalid green component: {e}"))?;
139                let b = u8::from_str_radix(&hex[2..3], 16)
140                    .map_err(|e| format!("invalid blue component: {e}"))?;
141                let a = u8::from_str_radix(&hex[3..4], 16)
142                    .map_err(|e| format!("invalid alpha component: {e}"))?;
143                Ok(Rgba::rgba(r * 17, g * 17, b * 17, a * 17))
144            }
145            // #RRGGBB
146            6 => {
147                let r = u8::from_str_radix(&hex[0..2], 16)
148                    .map_err(|e| format!("invalid red component: {e}"))?;
149                let g = u8::from_str_radix(&hex[2..4], 16)
150                    .map_err(|e| format!("invalid green component: {e}"))?;
151                let b = u8::from_str_radix(&hex[4..6], 16)
152                    .map_err(|e| format!("invalid blue component: {e}"))?;
153                Ok(Rgba::rgb(r, g, b))
154            }
155            // #RRGGBBAA
156            8 => {
157                let r = u8::from_str_radix(&hex[0..2], 16)
158                    .map_err(|e| format!("invalid red component: {e}"))?;
159                let g = u8::from_str_radix(&hex[2..4], 16)
160                    .map_err(|e| format!("invalid green component: {e}"))?;
161                let b = u8::from_str_radix(&hex[4..6], 16)
162                    .map_err(|e| format!("invalid blue component: {e}"))?;
163                let a = u8::from_str_radix(&hex[6..8], 16)
164                    .map_err(|e| format!("invalid alpha component: {e}"))?;
165                Ok(Rgba::rgba(r, g, b, a))
166            }
167            other => Err(format!(
168                "invalid hex color length {other}: expected 3, 4, 6, or 8 hex digits"
169            )),
170        }
171    }
172}
173
174impl Serialize for Rgba {
175    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
176        serializer.serialize_str(&self.to_string())
177    }
178}
179
180impl<'de> Deserialize<'de> for Rgba {
181    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
182        let s = String::deserialize(deserializer)?;
183        Rgba::from_str(&s).map_err(de::Error::custom)
184    }
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190
191    // === Constructor tests ===
192
193    #[test]
194    fn rgb_constructor_sets_alpha_255() {
195        let c = Rgba::rgb(61, 174, 233);
196        assert_eq!(
197            c,
198            Rgba {
199                r: 61,
200                g: 174,
201                b: 233,
202                a: 255
203            }
204        );
205    }
206
207    #[test]
208    fn rgba_constructor_sets_all_fields() {
209        let c = Rgba::rgba(61, 174, 233, 128);
210        assert_eq!(
211            c,
212            Rgba {
213                r: 61,
214                g: 174,
215                b: 233,
216                a: 128
217            }
218        );
219    }
220
221    // === FromStr parsing tests ===
222
223    #[test]
224    fn parse_6_digit_hex_with_hash() {
225        let c: Rgba = "#3daee9".parse().unwrap();
226        assert_eq!(c, Rgba::rgb(61, 174, 233));
227    }
228
229    #[test]
230    fn parse_8_digit_hex_with_hash() {
231        let c: Rgba = "#3daee980".parse().unwrap();
232        assert_eq!(c, Rgba::rgba(61, 174, 233, 128));
233    }
234
235    #[test]
236    fn parse_6_digit_hex_without_hash() {
237        let c: Rgba = "3daee9".parse().unwrap();
238        assert_eq!(c, Rgba::rgb(61, 174, 233));
239    }
240
241    #[test]
242    fn parse_3_digit_shorthand() {
243        let c: Rgba = "#abc".parse().unwrap();
244        assert_eq!(c, Rgba::rgb(0xaa, 0xbb, 0xcc));
245    }
246
247    #[test]
248    fn parse_4_digit_shorthand() {
249        let c: Rgba = "#abcd".parse().unwrap();
250        assert_eq!(c, Rgba::rgba(0xaa, 0xbb, 0xcc, 0xdd));
251    }
252
253    #[test]
254    fn parse_uppercase_hex() {
255        let c: Rgba = "#AABBCC".parse().unwrap();
256        assert_eq!(c, Rgba::rgb(0xaa, 0xbb, 0xcc));
257    }
258
259    #[test]
260    fn parse_empty_string_is_error() {
261        assert!("".parse::<Rgba>().is_err());
262    }
263
264    #[test]
265    fn parse_invalid_hex_chars_is_error() {
266        assert!("#gggggg".parse::<Rgba>().is_err());
267    }
268
269    #[test]
270    fn parse_invalid_length_5_chars_is_error() {
271        assert!("#12345".parse::<Rgba>().is_err());
272    }
273
274    // === Display tests ===
275
276    #[test]
277    fn display_omits_alpha_when_255() {
278        assert_eq!(Rgba::rgb(61, 174, 233).to_string(), "#3daee9");
279    }
280
281    #[test]
282    fn display_includes_alpha_when_not_255() {
283        assert_eq!(Rgba::rgba(61, 174, 233, 128).to_string(), "#3daee980");
284    }
285
286    // === Serde round-trip tests ===
287
288    #[test]
289    fn serde_json_round_trip() {
290        let c = Rgba::rgb(61, 174, 233);
291        let json = serde_json::to_string(&c).unwrap();
292        assert_eq!(json, "\"#3daee9\"");
293        let deserialized: Rgba = serde_json::from_str(&json).unwrap();
294        assert_eq!(deserialized, c);
295    }
296
297    #[test]
298    fn serde_toml_round_trip() {
299        #[derive(Debug, PartialEq, Serialize, Deserialize)]
300        struct Wrapper {
301            color: Rgba,
302        }
303        let w = Wrapper {
304            color: Rgba::rgba(61, 174, 233, 128),
305        };
306        let toml_str = toml::to_string(&w).unwrap();
307        let deserialized: Wrapper = toml::from_str(&toml_str).unwrap();
308        assert_eq!(deserialized, w);
309    }
310
311    // === to_f32_array tests ===
312
313    #[test]
314    fn to_f32_array_black() {
315        let arr = Rgba::rgb(0, 0, 0).to_f32_array();
316        assert_eq!(arr, [0.0, 0.0, 0.0, 1.0]);
317    }
318
319    #[test]
320    fn to_f32_array_white_transparent() {
321        let arr = Rgba::rgba(255, 255, 255, 0).to_f32_array();
322        assert_eq!(arr, [1.0, 1.0, 1.0, 0.0]);
323    }
324
325    // === Trait tests ===
326
327    #[test]
328    fn rgba_is_copy() {
329        let a = Rgba::rgb(1, 2, 3);
330        let b = a; // Copy
331        assert_eq!(a, b); // a still accessible after copy
332    }
333
334    #[test]
335    fn rgba_default_is_transparent_black() {
336        let d = Rgba::default();
337        assert_eq!(
338            d,
339            Rgba {
340                r: 0,
341                g: 0,
342                b: 0,
343                a: 0
344            }
345        );
346    }
347
348    #[test]
349    fn rgba_is_hash() {
350        use std::collections::HashSet;
351        let mut set = HashSet::new();
352        set.insert(Rgba::rgb(1, 2, 3));
353        assert!(set.contains(&Rgba::rgb(1, 2, 3)));
354    }
355
356    // === from_f32 tests ===
357
358    #[test]
359    fn from_f32_basic() {
360        let c = Rgba::from_f32(1.0, 0.5, 0.0, 1.0);
361        assert_eq!(c.r, 255);
362        assert_eq!(c.g, 128); // 0.5 * 255 = 127.5, round to 128
363        assert_eq!(c.b, 0);
364        assert_eq!(c.a, 255);
365    }
366
367    #[test]
368    fn from_f32_clamps_out_of_range() {
369        let c = Rgba::from_f32(-0.5, 1.5, 0.0, 0.0);
370        assert_eq!(c.r, 0);
371        assert_eq!(c.g, 255);
372    }
373
374    // === to_f32_tuple test ===
375
376    #[test]
377    fn to_f32_tuple_matches_array() {
378        let c = Rgba::rgb(128, 64, 32);
379        let arr = c.to_f32_array();
380        let tup = c.to_f32_tuple();
381        assert_eq!(tup, (arr[0], arr[1], arr[2], arr[3]));
382    }
383}