Skip to main content

drmkit_csd/
color.rs

1// SPDX-FileCopyrightText: (c) 2026 Joel Winarske
2// SPDX-License-Identifier: MIT
3
4//! Colors, and reading them from a theme file.
5
6/// An 8-bit-per-channel color with alpha.
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
8#[allow(
9    clippy::exhaustive_structs,
10    reason = "a value defined by its fields; there is nothing to add to it"
11)]
12pub struct Color {
13    /// Red.
14    pub r: u8,
15    /// Green.
16    pub g: u8,
17    /// Blue.
18    pub b: u8,
19    /// Alpha. Zero is fully transparent.
20    pub a: u8,
21}
22
23/// Why a color could not be read.
24#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
25#[non_exhaustive]
26pub enum ColorError {
27    /// The text was not `#RRGGBB` or `#RRGGBBAA`.
28    #[error("expected #RRGGBB or #RRGGBBAA, got {got:?}")]
29    Malformed {
30        /// What was given.
31        got: String,
32    },
33}
34
35impl Color {
36    /// A color from its channels.
37    #[must_use]
38    pub const fn new(r: u8, g: u8, b: u8, a: u8) -> Self {
39        Self { r, g, b, a }
40    }
41
42    /// Parse `#RRGGBB` or `#RRGGBBAA`.
43    ///
44    /// Six digits means fully opaque. That is the CSS convention and the one a
45    /// theme author will expect; defaulting to transparent instead would make
46    /// every color written the short way invisible.
47    ///
48    /// Case-insensitive, because a hand-written theme file mixes both and
49    /// refusing lowercase would be a rule with nothing behind it.
50    ///
51    /// # Errors
52    ///
53    /// [`ColorError::Malformed`] for anything else — a missing `#`, a wrong
54    /// length, or a non-hex digit.
55    pub fn from_hex(hex: &str) -> Result<Self, ColorError> {
56        let malformed = || ColorError::Malformed {
57            got: hex.to_owned(),
58        };
59        let digits = hex.strip_prefix('#').ok_or_else(malformed)?;
60        // Every character a hex digit, as the reference's `parse_hex_byte`
61        // requires: `from_str_radix` alone would take a sign, so `#+1+2+3`
62        // would parse.
63        if !matches!(digits.len(), 6 | 8) || !digits.bytes().all(|b| b.is_ascii_hexdigit()) {
64            return Err(malformed());
65        }
66        let byte = |index: usize| {
67            u8::from_str_radix(digits.get(index..index + 2).ok_or_else(malformed)?, 16)
68                .map_err(|_| malformed())
69        };
70        Ok(Self {
71            r: byte(0)?,
72            g: byte(2)?,
73            b: byte(4)?,
74            // Six digits is opaque; eight carries its own alpha.
75            a: if digits.len() == 8 { byte(6)? } else { 0xFF },
76        })
77    }
78
79    /// Pack as `0xAARRGGBB`.
80    ///
81    /// The layout a 32-bit `ARGB8888` framebuffer word takes on a
82    /// little-endian host, which is what the renderer writes into.
83    #[must_use]
84    pub const fn packed_argb(self) -> u32 {
85        ((self.a as u32) << 24) | ((self.r as u32) << 16) | ((self.g as u32) << 8) | self.b as u32
86    }
87}