#![cfg_attr(feature = "fail-on-warnings", deny(warnings))]
#![warn(clippy::all, clippy::pedantic, clippy::nursery, clippy::cargo)]
#![allow(clippy::multiple_crate_versions)]
pub use color_hex::color_from_hex;
use thiserror::Error;
#[cfg(feature = "arb")]
pub mod arb;
#[derive(Debug, Error)]
pub enum ParseHexError {
#[error("Invalid character at index {0} '{1}'")]
InvalidCharacter(usize, char),
#[error("Invalid non-ASCII character at index {0}")]
InvalidNonAsciiCharacter(usize),
#[error("Hex string too long")]
StringTooLong,
#[error("Hex string invalid length")]
InvalidLength,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Color {
pub r: u8,
pub g: u8,
pub b: u8,
pub a: Option<u8>,
}
impl Color {
pub const BLACK: Self = Self {
r: 0,
g: 0,
b: 0,
a: None,
};
pub const WHITE: Self = Self {
r: 255,
g: 255,
b: 255,
a: None,
};
#[allow(clippy::many_single_char_names)]
pub fn try_from_hex(hex: &str) -> Result<Self, ParseHexError> {
let mut short_r = 0;
let mut short_g = 0;
let mut short_b = 0;
let mut short_a = 0;
let mut three_chars = false;
let mut four_chars = false;
let mut r = 0;
let mut g = 0;
let mut b = 0;
let mut maybe_a = None;
let mut a = None;
let hex = hex.strip_prefix('#').unwrap_or(hex);
for (i, value) in hex.trim().chars().enumerate().map(|(i, x)| {
(
i,
match x {
'0'..='9' => Ok(x as u8 - 48),
'A'..='F' => Ok(x as u8 - 55),
'a'..='f' => Ok(x as u8 - 87),
c if c.is_ascii() => Err(ParseHexError::InvalidCharacter(i, x)),
_ => Err(ParseHexError::InvalidNonAsciiCharacter(i)),
},
)
}) {
let value = value?;
match i {
0 => {
short_r = value;
r = value << 4;
}
1 => {
short_g = value;
r += value;
}
2 => {
three_chars = true;
short_b = value;
g = value << 4;
}
3 => {
three_chars = false;
four_chars = true;
short_a = value;
g += value;
}
4 => {
four_chars = false;
b = value << 4;
}
5 => {
b += value;
}
6 => {
maybe_a = Some(value << 4);
}
7 => {
a = maybe_a.map(|a| a + value);
}
_ => {
return Err(ParseHexError::StringTooLong);
}
}
}
moosicbox_assert::assert_or_err!(
maybe_a.is_none() || a.is_some(),
ParseHexError::InvalidLength,
);
if three_chars {
r = (short_r << 4) + short_r;
g = (short_g << 4) + short_g;
b = (short_b << 4) + short_b;
}
if four_chars {
r = (short_r << 4) + short_r;
g = (short_g << 4) + short_g;
b = (short_b << 4) + short_b;
a = Some((short_a << 4) + short_a);
}
Ok(Self { r, g, b, a })
}
#[must_use]
pub fn from_hex(hex: &str) -> Self {
Self::try_from_hex(hex).unwrap()
}
}
#[cfg(feature = "egui")]
impl From<Color> for egui::Color32 {
fn from(value: Color) -> Self {
value.a.map_or_else(
|| Self::from_rgb(value.r, value.g, value.b),
|a| Self::from_rgba_unmultiplied(value.r, value.g, value.b, a),
)
}
}
#[cfg(feature = "egui")]
impl From<&Color> for egui::Color32 {
fn from(value: &Color) -> Self {
value.a.map_or_else(
|| Self::from_rgb(value.r, value.g, value.b),
|a| Self::from_rgba_unmultiplied(value.r, value.g, value.b, a),
)
}
}
impl std::fmt::Display for Color {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if let Some(a) = self.a {
f.write_fmt(format_args!(
"#{:02X}{:02X}{:02X}{:02X}",
self.r, self.g, self.b, a
))
} else {
f.write_fmt(format_args!("#{:02X}{:02X}{:02X}", self.r, self.g, self.b))
}
}
}
impl From<&str> for Color {
fn from(s: &str) -> Self {
Self::from_hex(s)
}
}
impl From<String> for Color {
fn from(s: String) -> Self {
Self::from_hex(&s)
}
}
impl From<&String> for Color {
fn from(s: &String) -> Self {
Self::from_hex(s)
}
}
#[cfg(test)]
mod test {
use pretty_assertions::assert_eq;
use crate::Color;
#[test_log::test]
fn can_parse_rgb_hex_string_to_color() {
assert_eq!(
Color::from_hex("#010203"),
Color {
r: 1,
g: 2,
b: 3,
a: None
}
);
}
#[test_log::test]
fn can_parse_rgba_hex_string_to_color() {
assert_eq!(
Color::from_hex("#01020304"),
Color {
r: 1,
g: 2,
b: 3,
a: Some(4)
}
);
}
#[test_log::test]
fn can_display_small_rgb_as_hex_string() {
assert_eq!(
Color {
r: 1,
g: 2,
b: 3,
a: None
}
.to_string(),
"#010203".to_string(),
);
}
#[test_log::test]
fn can_display_large_rgb_as_hex_string() {
assert_eq!(
Color {
r: 255,
g: 2,
b: 254,
a: None
}
.to_string(),
"#FF02FE".to_string(),
);
}
#[test_log::test]
fn can_display_small_rgba_as_hex_string() {
assert_eq!(
Color {
r: 1,
g: 2,
b: 3,
a: Some(4)
}
.to_string(),
"#01020304".to_string(),
);
}
#[test_log::test]
fn can_display_large_rgba_as_hex_string() {
assert_eq!(
Color {
r: 255,
g: 2,
b: 254,
a: Some(4)
}
.to_string(),
"#FF02FE04".to_string(),
);
}
#[test_log::test]
fn can_parse_short_rgb_hex_string() {
assert_eq!(
Color::from_hex("#ABC"),
Color {
r: 0xAA,
g: 0xBB,
b: 0xCC,
a: None
}
);
}
#[test_log::test]
fn can_parse_short_rgba_hex_string() {
assert_eq!(
Color::from_hex("#ABCD"),
Color {
r: 0xAA,
g: 0xBB,
b: 0xCC,
a: Some(0xDD)
}
);
}
#[test_log::test]
fn can_parse_short_rgb_with_zero_values() {
assert_eq!(
Color::from_hex("#000"),
Color {
r: 0,
g: 0,
b: 0,
a: None
}
);
}
#[test_log::test]
fn can_parse_short_rgb_with_max_values() {
assert_eq!(
Color::from_hex("#FFF"),
Color {
r: 255,
g: 255,
b: 255,
a: None
}
);
}
#[test_log::test]
fn can_parse_short_rgba_with_zero_alpha() {
assert_eq!(
Color::from_hex("#ABC0"),
Color {
r: 0xAA,
g: 0xBB,
b: 0xCC,
a: Some(0)
}
);
}
#[test_log::test]
fn invalid_character_returns_error() {
let result = Color::try_from_hex("#GGHHII");
assert!(result.is_err());
match result.unwrap_err() {
crate::ParseHexError::InvalidCharacter(idx, ch) => {
assert_eq!(idx, 0);
assert_eq!(ch, 'G');
}
_ => panic!("Expected InvalidCharacter error"),
}
}
#[test_log::test]
fn non_ascii_character_returns_error() {
let result = Color::try_from_hex("#日本語");
assert!(result.is_err());
match result.unwrap_err() {
crate::ParseHexError::InvalidNonAsciiCharacter(idx) => {
assert_eq!(idx, 0);
}
_ => panic!("Expected InvalidNonAsciiCharacter error"),
}
}
#[test_log::test]
fn string_too_long_returns_error() {
let result = Color::try_from_hex("#123456789");
assert!(result.is_err());
match result.unwrap_err() {
crate::ParseHexError::StringTooLong => {}
_ => panic!("Expected StringTooLong error"),
}
}
#[test_log::test]
fn invalid_length_returns_error() {
let result = Color::try_from_hex("#1234567");
assert!(result.is_err());
match result.unwrap_err() {
crate::ParseHexError::InvalidLength => {}
_ => panic!("Expected InvalidLength error"),
}
}
#[test_log::test]
fn can_parse_hex_without_hash_prefix() {
assert_eq!(
Color::from_hex("FF5733"),
Color {
r: 255,
g: 87,
b: 51,
a: None
}
);
}
#[test_log::test]
fn can_parse_hex_with_trailing_whitespace() {
assert_eq!(
Color::from_hex("#FF5733 "),
Color {
r: 255,
g: 87,
b: 51,
a: None
}
);
}
#[test_log::test]
fn can_parse_hex_with_trailing_whitespace_no_prefix() {
assert_eq!(
Color::from_hex("FF5733 "),
Color {
r: 255,
g: 87,
b: 51,
a: None
}
);
}
#[test_log::test]
fn can_parse_lowercase_hex() {
assert_eq!(
Color::from_hex("#ff5733"),
Color {
r: 255,
g: 87,
b: 51,
a: None
}
);
}
#[test_log::test]
fn can_parse_mixed_case_hex() {
assert_eq!(
Color::from_hex("#Ff5733"),
Color {
r: 255,
g: 87,
b: 51,
a: None
}
);
}
#[test_log::test]
fn can_parse_uppercase_hex() {
assert_eq!(
Color::from_hex("#FF5733"),
Color {
r: 255,
g: 87,
b: 51,
a: None
}
);
}
#[test_log::test]
fn can_parse_all_zeros_rgba() {
assert_eq!(
Color::from_hex("#00000000"),
Color {
r: 0,
g: 0,
b: 0,
a: Some(0)
}
);
}
#[test_log::test]
fn can_parse_all_max_rgba() {
assert_eq!(
Color::from_hex("#FFFFFFFF"),
Color {
r: 255,
g: 255,
b: 255,
a: Some(255)
}
);
}
#[test_log::test]
fn invalid_character_in_middle_returns_error() {
let result = Color::try_from_hex("#FF5G33");
assert!(result.is_err());
match result.unwrap_err() {
crate::ParseHexError::InvalidCharacter(idx, ch) => {
assert_eq!(idx, 3);
assert_eq!(ch, 'G');
}
_ => panic!("Expected InvalidCharacter error"),
}
}
#[test_log::test]
fn special_ascii_character_returns_error() {
let result = Color::try_from_hex("#FF5@33");
assert!(result.is_err());
match result.unwrap_err() {
crate::ParseHexError::InvalidCharacter(idx, ch) => {
assert_eq!(idx, 3);
assert_eq!(ch, '@');
}
_ => panic!("Expected InvalidCharacter error"),
}
}
#[test_log::test]
fn empty_string_parses_as_black() {
assert_eq!(
Color::from_hex(""),
Color {
r: 0,
g: 0,
b: 0,
a: None
}
);
}
#[test_log::test]
fn only_hash_parses_as_black() {
assert_eq!(
Color::from_hex("#"),
Color {
r: 0,
g: 0,
b: 0,
a: None
}
);
}
#[test_log::test]
fn single_character_parses_as_color() {
let result = Color::try_from_hex("#A");
assert!(result.is_ok());
}
#[test_log::test]
fn two_characters_parses_as_color() {
let result = Color::try_from_hex("#AB");
assert!(result.is_ok());
}
#[test_log::test]
fn five_characters_parses_as_color() {
let result = Color::try_from_hex("#ABCDE");
assert!(result.is_ok());
}
#[test_log::test]
fn leading_whitespace_before_hash_is_not_supported() {
let result = Color::try_from_hex(" #FF5733");
assert!(result.is_err());
match result.unwrap_err() {
crate::ParseHexError::InvalidCharacter(idx, ch) => {
assert_eq!(idx, 0);
assert_eq!(ch, '#');
}
_ => panic!("Expected InvalidCharacter error"),
}
}
#[test_log::test]
fn leading_whitespace_without_hash_is_supported() {
assert_eq!(
Color::from_hex(" FF5733"),
Color {
r: 255,
g: 87,
b: 51,
a: None
}
);
}
#[test_log::test]
fn round_trip_rgb_color_is_consistent() {
let original = Color {
r: 171,
g: 205,
b: 239,
a: None,
};
let hex_string = original.to_string();
let parsed = Color::from_hex(&hex_string);
assert_eq!(original, parsed);
}
#[test_log::test]
fn round_trip_rgba_color_is_consistent() {
let original = Color {
r: 171,
g: 205,
b: 239,
a: Some(128),
};
let hex_string = original.to_string();
let parsed = Color::from_hex(&hex_string);
assert_eq!(original, parsed);
}
#[test_log::test]
fn short_rgb_expands_correctly_then_round_trips() {
let from_short = Color::from_hex("#ABC");
let hex_string = from_short.to_string();
assert_eq!(hex_string, "#AABBCC");
let round_tripped = Color::from_hex(&hex_string);
assert_eq!(from_short, round_tripped);
}
#[test_log::test]
fn short_rgba_expands_correctly_then_round_trips() {
let from_short = Color::from_hex("#ABCD");
let hex_string = from_short.to_string();
assert_eq!(hex_string, "#AABBCCDD");
let round_tripped = Color::from_hex(&hex_string);
assert_eq!(from_short, round_tripped);
}
}
#[cfg(all(test, feature = "arb"))]
mod prop_tests {
use proptest::prelude::*;
use crate::Color;
proptest! {
#[test]
fn roundtrip_to_string_then_from_hex_preserves_color(color: Color) {
let hex_string = color.to_string();
let parsed = Color::from_hex(&hex_string);
prop_assert_eq!(color, parsed);
}
}
}