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