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}