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