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