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::color::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#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
43pub struct Rgba {
44    /// Red component (0-255).
45    pub r: u8,
46    /// Green component (0-255).
47    pub g: u8,
48    /// Blue component (0-255).
49    pub b: u8,
50    /// Alpha component (0-255, where 255 is fully opaque).
51    pub a: u8,
52}
53
54// Phase 93-01 (G1): no `impl Default for Rgba`. ยง16 of the v0.5.7 API
55// critique flagged that a theme library where `Rgba::default()` silently
56// returns "transparent black" is a footgun. Callers who need a named zero
57// colour use `Rgba::TRANSPARENT`, `Rgba::BLACK`, or `Rgba::WHITE` (all
58// `const`). Task 1 of this plan broke the sole transitive bound chain that
59// forced the Default impl (see `resolve::validate_helpers::require`).
60
61impl Rgba {
62    /// Create an opaque color (alpha = 255).
63    #[must_use]
64    pub const fn rgb(r: u8, g: u8, b: u8) -> Self {
65        Self { r, g, b, a: 255 }
66    }
67
68    /// Create a color with explicit red, green, blue, and alpha components.
69    #[must_use]
70    pub const fn new(r: u8, g: u8, b: u8, a: u8) -> Self {
71        Self { r, g, b, a }
72    }
73
74    /// Transparent black `(0, 0, 0, 0)` -- the zero colour.
75    ///
76    /// ```
77    /// use native_theme::color::Rgba;
78    ///
79    /// assert_eq!(Rgba::TRANSPARENT, Rgba::new(0, 0, 0, 0));
80    /// assert_eq!(Rgba::BLACK, Rgba::new(0, 0, 0, 255));
81    /// assert_eq!(Rgba::WHITE, Rgba::new(255, 255, 255, 255));
82    /// ```
83    pub const TRANSPARENT: Self = Self {
84        r: 0,
85        g: 0,
86        b: 0,
87        a: 0,
88    };
89
90    /// Opaque black `(0, 0, 0, 255)`.
91    pub const BLACK: Self = Self {
92        r: 0,
93        g: 0,
94        b: 0,
95        a: 255,
96    };
97
98    /// Opaque white `(255, 255, 255, 255)`.
99    pub const WHITE: Self = Self {
100        r: 255,
101        g: 255,
102        b: 255,
103        a: 255,
104    };
105
106    /// Create a color from floating-point components in the 0.0..=1.0 range.
107    ///
108    /// Values are clamped to 0.0..=1.0 before conversion.
109    ///
110    /// Note: round-trip through `from_f32` -> `to_f32_array` is lossy due to
111    /// u8 quantization (e.g., `from_f32(0.5, ...)` -> r=128 ->
112    /// `to_f32_array()` -> 0.50196...).
113    #[must_use]
114    pub fn from_f32(r: f32, g: f32, b: f32, a: f32) -> Self {
115        Self {
116            r: (r.clamp(0.0, 1.0) * 255.0).round() as u8,
117            g: (g.clamp(0.0, 1.0) * 255.0).round() as u8,
118            b: (b.clamp(0.0, 1.0) * 255.0).round() as u8,
119            a: (a.clamp(0.0, 1.0) * 255.0).round() as u8,
120        }
121    }
122
123    /// Convert to `[r, g, b, a]` in the 0.0..=1.0 range (for toolkit interop).
124    ///
125    /// Note: round-trip through `from_f32` -> `to_f32_array` is lossy due to
126    /// u8 quantization (256 discrete steps per channel).
127    #[must_use]
128    pub fn to_f32_array(&self) -> [f32; 4] {
129        [
130            self.r as f32 / 255.0,
131            self.g as f32 / 255.0,
132            self.b as f32 / 255.0,
133            self.a as f32 / 255.0,
134        ]
135    }
136}
137
138impl fmt::Display for Rgba {
139    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
140        if self.a == 255 {
141            write!(f, "#{:02x}{:02x}{:02x}", self.r, self.g, self.b)
142        } else {
143            write!(
144                f,
145                "#{:02x}{:02x}{:02x}{:02x}",
146                self.r, self.g, self.b, self.a
147            )
148        }
149    }
150}
151
152/// Error returned when parsing a hex color string fails.
153///
154/// Wraps a human-readable message describing the failure cause.
155/// Implements [`std::error::Error`] so it works with `?` in functions
156/// returning `Box<dyn Error>`.
157#[derive(Debug, Clone)]
158pub struct ParseColorError(String);
159
160impl fmt::Display for ParseColorError {
161    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
162        f.write_str(&self.0)
163    }
164}
165
166impl std::error::Error for ParseColorError {}
167
168/// Parse one ASCII hex digit byte to a nibble (0..=15).
169fn hex_nibble(b: u8, label: &str) -> Result<u8, ParseColorError> {
170    char::from(b)
171        .to_digit(16)
172        .and_then(|v| u8::try_from(v).ok())
173        .ok_or_else(|| ParseColorError(format!("invalid {label} hex digit {:?}", char::from(b))))
174}
175
176/// Combine two nibbles (high, low) into a byte. Inputs assumed 0..=15.
177fn hex_byte(hi: u8, lo: u8) -> u8 {
178    // wrapping_shl(4) on 0..=15 produces 0..=240 in u8; BitOr is not panic-prone.
179    hi.wrapping_shl(4) | (lo & 0x0f)
180}
181
182/// Expand a nibble to a byte by doubling it: `0xf -> 0xff`, `0xa -> 0xaa`.
183/// Equivalent to `n * 17` for `n` in 0..=15, but panic-free.
184fn double_nibble(n: u8) -> u8 {
185    n.wrapping_shl(4) | (n & 0x0f)
186}
187
188impl FromStr for Rgba {
189    type Err = ParseColorError;
190
191    fn from_str(s: &str) -> Result<Self, Self::Err> {
192        let hex = s.strip_prefix('#').unwrap_or(s);
193
194        if hex.is_empty() {
195            return Err(ParseColorError("empty hex color string".into()));
196        }
197
198        // Destructure hex bytes directly. This is both UTF-8-safe (rejects any
199        // non-ASCII input implicitly, since each non-ASCII char occupies 2+ bytes
200        // and wouldn't match the single-byte slots) and avoids panic-prone string
201        // slicing / indexing.
202        match hex.as_bytes() {
203            // #RGB shorthand: each digit doubled (e.g., 'a' -> 0xaa)
204            [r, g, b] => {
205                let r = hex_nibble(*r, "red")?;
206                let g = hex_nibble(*g, "green")?;
207                let b = hex_nibble(*b, "blue")?;
208                Ok(Rgba::rgb(
209                    double_nibble(r),
210                    double_nibble(g),
211                    double_nibble(b),
212                ))
213            }
214            // #RGBA shorthand
215            [r, g, b, a] => {
216                let r = hex_nibble(*r, "red")?;
217                let g = hex_nibble(*g, "green")?;
218                let b = hex_nibble(*b, "blue")?;
219                let a = hex_nibble(*a, "alpha")?;
220                Ok(Rgba::new(
221                    double_nibble(r),
222                    double_nibble(g),
223                    double_nibble(b),
224                    double_nibble(a),
225                ))
226            }
227            // #RRGGBB
228            [r1, r2, g1, g2, b1, b2] => {
229                let r = hex_byte(hex_nibble(*r1, "red")?, hex_nibble(*r2, "red")?);
230                let g = hex_byte(hex_nibble(*g1, "green")?, hex_nibble(*g2, "green")?);
231                let b = hex_byte(hex_nibble(*b1, "blue")?, hex_nibble(*b2, "blue")?);
232                Ok(Rgba::rgb(r, g, b))
233            }
234            // #RRGGBBAA
235            [r1, r2, g1, g2, b1, b2, a1, a2] => {
236                let r = hex_byte(hex_nibble(*r1, "red")?, hex_nibble(*r2, "red")?);
237                let g = hex_byte(hex_nibble(*g1, "green")?, hex_nibble(*g2, "green")?);
238                let b = hex_byte(hex_nibble(*b1, "blue")?, hex_nibble(*b2, "blue")?);
239                let a = hex_byte(hex_nibble(*a1, "alpha")?, hex_nibble(*a2, "alpha")?);
240                Ok(Rgba::new(r, g, b, a))
241            }
242            other => Err(ParseColorError(format!(
243                "invalid hex color length {}: expected 3, 4, 6, or 8 hex digits",
244                other.len()
245            ))),
246        }
247    }
248}
249
250impl Serialize for Rgba {
251    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
252        serializer.serialize_str(&self.to_string())
253    }
254}
255
256impl<'de> Deserialize<'de> for Rgba {
257    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
258        let s = String::deserialize(deserializer)?;
259        Rgba::from_str(&s).map_err(de::Error::custom)
260    }
261}
262
263/// Convert premultiplied RGBA pixel data to straight (non-premultiplied) alpha.
264///
265/// For each pixel where `a > 0 && a < 255`:
266///   `channel = min(255, channel * 255 / a)`
267///
268/// Fully opaque pixels (a == 255) and fully transparent pixels (a == 0)
269/// are left unchanged.
270///
271/// Used by `rasterize`, `sficons`, and `winicons` modules (feature/platform gated).
272#[allow(dead_code)]
273pub(crate) fn unpremultiply_alpha(buffer: &mut [u8]) {
274    // `as_chunks_mut::<4>()` splits the buffer into `[u8; 4]` pixels plus a
275    // remainder shorter than one pixel; the remainder is dropped, as
276    // `chunks_exact_mut(4)` did. Array destructuring binds each byte without
277    // panic-prone indexing.
278    for pixel in buffer.as_chunks_mut::<4>().0 {
279        let [r, g, b, a] = pixel;
280        let a_val = u16::from(*a);
281        // `a_val in 1..=254` guarantees the divisor is non-zero; `saturating_mul`
282        // cannot overflow u16 (max 255 * 255 = 65025 < 65535). The `.min(255)` cap
283        // makes the final `as u8` a lossless conversion (no truncation of high bits).
284        if (1..255).contains(&a_val) {
285            #[allow(clippy::integer_division, clippy::arithmetic_side_effects)]
286            {
287                *r = (u16::from(*r).saturating_mul(255) / a_val).min(255) as u8;
288                *g = (u16::from(*g).saturating_mul(255) / a_val).min(255) as u8;
289                *b = (u16::from(*b).saturating_mul(255) / a_val).min(255) as u8;
290            }
291        }
292    }
293}
294
295/// Draw a monochrome straight-alpha RGBA raster in `rgb`.
296///
297/// Every pixel takes `rgb` and keeps its alpha, so the glyph's coverage is
298/// unchanged. `None` leaves the buffer as it is.
299///
300/// Used by `sficons` and `winicons` (platform gated) for their monochrome
301/// glyphs; full-colour icons are never passed here.
302#[allow(dead_code)]
303pub(crate) fn tint_monochrome(buffer: &mut [u8], rgb: Option<[u8; 3]>) {
304    let Some([r, g, b]) = rgb else {
305        return;
306    };
307    for pixel in buffer.as_chunks_mut::<4>().0 {
308        let [_, _, _, a] = *pixel;
309        *pixel = [r, g, b, a];
310    }
311}
312
313#[cfg(test)]
314#[allow(clippy::unwrap_used, clippy::expect_used)]
315mod tests {
316    use super::*;
317
318    // === Constructor tests ===
319
320    #[test]
321    fn rgb_constructor_sets_alpha_255() {
322        let c = Rgba::rgb(61, 174, 233);
323        assert_eq!(
324            c,
325            Rgba {
326                r: 61,
327                g: 174,
328                b: 233,
329                a: 255
330            }
331        );
332    }
333
334    #[test]
335    fn rgba_constructor_sets_all_fields() {
336        let c = Rgba::new(61, 174, 233, 128);
337        assert_eq!(
338            c,
339            Rgba {
340                r: 61,
341                g: 174,
342                b: 233,
343                a: 128
344            }
345        );
346    }
347
348    // === FromStr parsing tests ===
349
350    #[test]
351    fn parse_6_digit_hex_with_hash() {
352        let c: Rgba = "#3daee9".parse().unwrap();
353        assert_eq!(c, Rgba::rgb(61, 174, 233));
354    }
355
356    #[test]
357    fn parse_8_digit_hex_with_hash() {
358        let c: Rgba = "#3daee980".parse().unwrap();
359        assert_eq!(c, Rgba::new(61, 174, 233, 128));
360    }
361
362    #[test]
363    fn parse_6_digit_hex_without_hash() {
364        let c: Rgba = "3daee9".parse().unwrap();
365        assert_eq!(c, Rgba::rgb(61, 174, 233));
366    }
367
368    #[test]
369    fn parse_3_digit_shorthand() {
370        let c: Rgba = "#abc".parse().unwrap();
371        assert_eq!(c, Rgba::rgb(0xaa, 0xbb, 0xcc));
372    }
373
374    #[test]
375    fn parse_4_digit_shorthand() {
376        let c: Rgba = "#abcd".parse().unwrap();
377        assert_eq!(c, Rgba::new(0xaa, 0xbb, 0xcc, 0xdd));
378    }
379
380    #[test]
381    fn parse_uppercase_hex() {
382        let c: Rgba = "#AABBCC".parse().unwrap();
383        assert_eq!(c, Rgba::rgb(0xaa, 0xbb, 0xcc));
384    }
385
386    #[test]
387    fn parse_empty_string_is_error() {
388        assert!("".parse::<Rgba>().is_err());
389    }
390
391    #[test]
392    fn parse_invalid_hex_chars_is_error() {
393        assert!("#gggggg".parse::<Rgba>().is_err());
394    }
395
396    #[test]
397    fn parse_invalid_length_5_chars_is_error() {
398        assert!("#12345".parse::<Rgba>().is_err());
399    }
400
401    // === Display tests ===
402
403    #[test]
404    fn display_omits_alpha_when_255() {
405        assert_eq!(Rgba::rgb(61, 174, 233).to_string(), "#3daee9");
406    }
407
408    #[test]
409    fn display_includes_alpha_when_not_255() {
410        assert_eq!(Rgba::new(61, 174, 233, 128).to_string(), "#3daee980");
411    }
412
413    // === Serde round-trip tests ===
414
415    #[test]
416    fn serde_json_round_trip() {
417        let c = Rgba::rgb(61, 174, 233);
418        let json = serde_json::to_string(&c).unwrap();
419        assert_eq!(json, "\"#3daee9\"");
420        let deserialized: Rgba = serde_json::from_str(&json).unwrap();
421        assert_eq!(deserialized, c);
422    }
423
424    #[test]
425    fn serde_toml_round_trip() {
426        #[derive(Debug, PartialEq, Serialize, Deserialize)]
427        struct Wrapper {
428            color: Rgba,
429        }
430        let w = Wrapper {
431            color: Rgba::new(61, 174, 233, 128),
432        };
433        let toml_str = toml::to_string(&w).unwrap();
434        let deserialized: Wrapper = toml::from_str(&toml_str).unwrap();
435        assert_eq!(deserialized, w);
436    }
437
438    // === to_f32_array tests ===
439
440    #[test]
441    fn to_f32_array_black() {
442        let arr = Rgba::rgb(0, 0, 0).to_f32_array();
443        assert_eq!(arr, [0.0, 0.0, 0.0, 1.0]);
444    }
445
446    #[test]
447    fn to_f32_array_white_transparent() {
448        let arr = Rgba::new(255, 255, 255, 0).to_f32_array();
449        assert_eq!(arr, [1.0, 1.0, 1.0, 0.0]);
450    }
451
452    // === Trait tests ===
453
454    #[test]
455    fn rgba_is_copy() {
456        let a = Rgba::rgb(1, 2, 3);
457        let b = a; // Copy
458        assert_eq!(a, b); // a still accessible after copy
459    }
460
461    #[test]
462    fn rgba_is_hash() {
463        use std::collections::HashSet;
464        let mut set = HashSet::new();
465        set.insert(Rgba::rgb(1, 2, 3));
466        assert!(set.contains(&Rgba::rgb(1, 2, 3)));
467    }
468
469    // === from_f32 tests ===
470
471    #[test]
472    fn from_f32_basic() {
473        let c = Rgba::from_f32(1.0, 0.5, 0.0, 1.0);
474        assert_eq!(c.r, 255);
475        assert_eq!(c.g, 128); // 0.5 * 255 = 127.5, round to 128
476        assert_eq!(c.b, 0);
477        assert_eq!(c.a, 255);
478    }
479
480    #[test]
481    fn from_f32_clamps_out_of_range() {
482        let c = Rgba::from_f32(-0.5, 1.5, 0.0, 0.0);
483        assert_eq!(c.r, 0);
484        assert_eq!(c.g, 255);
485    }
486
487    // === tint_monochrome tests ===
488
489    #[test]
490    fn tint_monochrome_colours_a_white_glyph_and_keeps_alpha() {
491        let mut buf = [255u8, 255, 255, 255, 255, 255, 255, 127];
492        tint_monochrome(&mut buf, Some([10, 20, 30]));
493        assert_eq!(buf, [10, 20, 30, 255, 10, 20, 30, 127]);
494    }
495
496    #[test]
497    fn tint_monochrome_keeps_a_transparent_pixel_transparent() {
498        let mut buf = [0u8, 0, 0, 0];
499        tint_monochrome(&mut buf, Some([10, 20, 30]));
500        assert_eq!(buf[3], 0);
501    }
502
503    #[test]
504    fn tint_monochrome_without_a_colour_leaves_the_bytes() {
505        let original = [255u8, 255, 255, 127, 0, 0, 0, 255, 1, 2, 3, 0];
506        let mut buf = original;
507        tint_monochrome(&mut buf, None);
508        assert_eq!(buf, original);
509    }
510}