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