#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Color {
Reset,
Black,
Red,
Green,
Yellow,
Blue,
Magenta,
Cyan,
White,
DarkGray,
LightRed,
LightGreen,
LightYellow,
LightBlue,
LightMagenta,
LightCyan,
LightWhite,
Rgb(u8, u8, u8),
Indexed(u8),
}
#[inline]
fn to_linear(c: f32) -> f32 {
if c <= 0.04045 {
c / 12.92
} else {
((c + 0.055) / 1.055).powf(2.4)
}
}
impl Color {
pub(crate) fn to_rgb(self) -> (u8, u8, u8) {
match self {
Color::Rgb(r, g, b) => (r, g, b),
Color::Black => (0, 0, 0),
Color::Red => (205, 49, 49),
Color::Green => (13, 188, 121),
Color::Yellow => (229, 229, 16),
Color::Blue => (36, 114, 200),
Color::Magenta => (188, 63, 188),
Color::Cyan => (17, 168, 205),
Color::White => (229, 229, 229),
Color::DarkGray => (128, 128, 128),
Color::LightRed => (255, 0, 0),
Color::LightGreen => (0, 255, 0),
Color::LightYellow => (255, 255, 0),
Color::LightBlue => (0, 0, 255),
Color::LightMagenta => (255, 0, 255),
Color::LightCyan => (0, 255, 255),
Color::LightWhite => (255, 255, 255),
Color::Reset => (0, 0, 0),
Color::Indexed(idx) => xterm256_to_rgb(idx),
}
}
pub fn luminance(self) -> f32 {
let (r, g, b) = self.to_rgb();
let rf = to_linear(r as f32 / 255.0);
let gf = to_linear(g as f32 / 255.0);
let bf = to_linear(b as f32 / 255.0);
0.2126 * rf + 0.7152 * gf + 0.0722 * bf
}
pub fn contrast_fg(bg: Color) -> Color {
if bg.luminance() > 0.179 {
Color::Rgb(0, 0, 0)
} else {
Color::Rgb(255, 255, 255)
}
}
pub fn blend(self, other: Color, alpha: f32) -> Color {
let alpha = alpha.clamp(0.0, 1.0);
let (r1, g1, b1) = self.to_rgb();
let (r2, g2, b2) = other.to_rgb();
let r = (r1 as f32 * alpha + r2 as f32 * (1.0 - alpha)).round() as u8;
let g = (g1 as f32 * alpha + g2 as f32 * (1.0 - alpha)).round() as u8;
let b = (b1 as f32 * alpha + b2 as f32 * (1.0 - alpha)).round() as u8;
Color::Rgb(r, g, b)
}
pub fn lighten(self, amount: f32) -> Color {
Color::Rgb(255, 255, 255).blend(self, 1.0 - amount.clamp(0.0, 1.0))
}
pub fn darken(self, amount: f32) -> Color {
Color::Rgb(0, 0, 0).blend(self, 1.0 - amount.clamp(0.0, 1.0))
}
pub fn contrast_ratio(a: Color, b: Color) -> f32 {
let la = a.luminance() + 0.05;
let lb = b.luminance() + 0.05;
if la > lb { la / lb } else { lb / la }
}
pub fn meets_contrast_aa(fg: Color, bg: Color) -> bool {
Self::contrast_ratio(fg, bg) >= 4.5
}
pub fn downsampled(self, depth: ColorDepth) -> Color {
match depth {
ColorDepth::TrueColor => self,
ColorDepth::EightBit => match self {
Color::Rgb(r, g, b) => Color::Indexed(rgb_to_ansi256(r, g, b)),
other => other,
},
ColorDepth::Basic => match self {
Color::Rgb(r, g, b) => rgb_to_ansi16(r, g, b),
Color::Indexed(i) => {
let (r, g, b) = xterm256_to_rgb(i);
rgb_to_ansi16(r, g, b)
}
other => other,
},
ColorDepth::NoColor => Color::Reset,
}
}
#[doc(alias = "parse")]
pub fn from_hex(s: &str) -> Option<Color> {
let hex = s.strip_prefix('#')?;
match hex.len() {
3 => {
let mut it = hex.chars().map(|c| c.to_digit(16));
let r = it.next()??;
let g = it.next()??;
let b = it.next()??;
Some(Color::Rgb((r * 17) as u8, (g * 17) as u8, (b * 17) as u8))
}
6 => {
let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
Some(Color::Rgb(r, g, b))
}
_ => None,
}
}
pub fn to_hex(self) -> String {
let (r, g, b) = self.to_rgb();
format!("#{r:02x}{g:02x}{b:02x}")
}
pub fn from_hsl(h: f32, s: f32, l: f32) -> Color {
let (r, g, b) = hsl_to_rgb(h, s.clamp(0.0, 1.0), l.clamp(0.0, 1.0));
Color::Rgb(r, g, b)
}
pub fn from_hsv(h: f32, s: f32, v: f32) -> Color {
let (r, g, b) = hsv_to_rgb(h, s.clamp(0.0, 1.0), v.clamp(0.0, 1.0));
Color::Rgb(r, g, b)
}
pub fn rotate_hue(self, degrees: f32) -> Color {
let (r, g, b) = self.to_rgb();
let (h, s, l) = rgb_to_hsl(r, g, b);
let (nr, ng, nb) = hsl_to_rgb(h + degrees, s, l);
Color::Rgb(nr, ng, nb)
}
}
impl From<(u8, u8, u8)> for Color {
fn from((r, g, b): (u8, u8, u8)) -> Color {
Color::Rgb(r, g, b)
}
}
impl From<[u8; 3]> for Color {
fn from([r, g, b]: [u8; 3]) -> Color {
Color::Rgb(r, g, b)
}
}
impl From<u32> for Color {
fn from(value: u32) -> Color {
let r = ((value >> 16) & 0xff) as u8;
let g = ((value >> 8) & 0xff) as u8;
let b = (value & 0xff) as u8;
Color::Rgb(r, g, b)
}
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ColorParseError {
InvalidLength,
InvalidHexDigit,
Unknown,
}
impl std::fmt::Display for ColorParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let msg = match self {
ColorParseError::InvalidLength => "invalid color: hex form must have 3 or 6 digits",
ColorParseError::InvalidHexDigit => "invalid color: non-hex digit in hex form",
ColorParseError::Unknown => {
"invalid color: expected #rgb/#rrggbb, rrggbb, or a named color"
}
};
f.write_str(msg)
}
}
impl core::error::Error for ColorParseError {}
impl std::str::FromStr for Color {
type Err = ColorParseError;
fn from_str(s: &str) -> Result<Color, ColorParseError> {
let trimmed = s.trim();
if let Some(c) = named_color(trimmed) {
return Ok(c);
}
let had_hash = trimmed.starts_with('#');
let hex = trimmed.strip_prefix('#').unwrap_or(trimmed);
match hex.len() {
3 => {
let mut it = hex.chars().map(|c| c.to_digit(16));
let r = it
.next()
.flatten()
.ok_or(ColorParseError::InvalidHexDigit)?;
let g = it
.next()
.flatten()
.ok_or(ColorParseError::InvalidHexDigit)?;
let b = it
.next()
.flatten()
.ok_or(ColorParseError::InvalidHexDigit)?;
Ok(Color::Rgb((r * 17) as u8, (g * 17) as u8, (b * 17) as u8))
}
6 => {
let r = u8::from_str_radix(&hex[0..2], 16)
.map_err(|_| ColorParseError::InvalidHexDigit)?;
let g = u8::from_str_radix(&hex[2..4], 16)
.map_err(|_| ColorParseError::InvalidHexDigit)?;
let b = u8::from_str_radix(&hex[4..6], 16)
.map_err(|_| ColorParseError::InvalidHexDigit)?;
Ok(Color::Rgb(r, g, b))
}
_ if had_hash => Err(ColorParseError::InvalidLength),
_ => Err(ColorParseError::Unknown),
}
}
}
fn named_color(s: &str) -> Option<Color> {
let lower = s.to_ascii_lowercase();
Some(match lower.as_str() {
"reset" | "default" => Color::Reset,
"black" => Color::Black,
"red" => Color::Red,
"green" => Color::Green,
"yellow" => Color::Yellow,
"blue" => Color::Blue,
"magenta" => Color::Magenta,
"cyan" => Color::Cyan,
"white" => Color::White,
"darkgray" | "darkgrey" | "gray" | "grey" => Color::DarkGray,
"lightred" => Color::LightRed,
"lightgreen" => Color::LightGreen,
"lightyellow" => Color::LightYellow,
"lightblue" => Color::LightBlue,
"lightmagenta" => Color::LightMagenta,
"lightcyan" => Color::LightCyan,
"lightwhite" => Color::LightWhite,
_ => return None,
})
}
fn hsl_to_rgb(h: f32, s: f32, l: f32) -> (u8, u8, u8) {
let h = wrap_hue(h);
let c = (1.0 - (2.0 * l - 1.0).abs()) * s;
let x = c * (1.0 - (((h / 60.0) % 2.0) - 1.0).abs());
let m = l - c / 2.0;
let (r1, g1, b1) = hue_sextant(h, c, x);
(
round_channel(r1 + m),
round_channel(g1 + m),
round_channel(b1 + m),
)
}
fn hsv_to_rgb(h: f32, s: f32, v: f32) -> (u8, u8, u8) {
let h = wrap_hue(h);
let c = v * s;
let x = c * (1.0 - (((h / 60.0) % 2.0) - 1.0).abs());
let m = v - c;
let (r1, g1, b1) = hue_sextant(h, c, x);
(
round_channel(r1 + m),
round_channel(g1 + m),
round_channel(b1 + m),
)
}
fn rgb_to_hsl(r: u8, g: u8, b: u8) -> (f32, f32, f32) {
let rf = r as f32 / 255.0;
let gf = g as f32 / 255.0;
let bf = b as f32 / 255.0;
let max = rf.max(gf).max(bf);
let min = rf.min(gf).min(bf);
let delta = max - min;
let l = (max + min) / 2.0;
if delta <= f32::EPSILON {
return (0.0, 0.0, l);
}
let s = if l > 0.5 {
delta / (2.0 - max - min)
} else {
delta / (max + min)
};
let h = if max == rf {
let h = (gf - bf) / delta;
h % 6.0
} else if max == gf {
(bf - rf) / delta + 2.0
} else {
(rf - gf) / delta + 4.0
} * 60.0;
(wrap_hue(h), s, l)
}
#[inline]
fn hue_sextant(h: f32, c: f32, x: f32) -> (f32, f32, f32) {
match h {
h if h < 60.0 => (c, x, 0.0),
h if h < 120.0 => (x, c, 0.0),
h if h < 180.0 => (0.0, c, x),
h if h < 240.0 => (0.0, x, c),
h if h < 300.0 => (x, 0.0, c),
_ => (c, 0.0, x),
}
}
#[inline]
fn wrap_hue(h: f32) -> f32 {
let h = h % 360.0;
if h < 0.0 { h + 360.0 } else { h }
}
#[inline]
fn round_channel(v: f32) -> u8 {
(v * 255.0).round().clamp(0.0, 255.0) as u8
}
#[cfg(feature = "serde")]
impl Color {
fn named_token(self) -> Option<&'static str> {
Some(match self {
Color::Reset => "reset",
Color::Black => "black",
Color::Red => "red",
Color::Green => "green",
Color::Yellow => "yellow",
Color::Blue => "blue",
Color::Magenta => "magenta",
Color::Cyan => "cyan",
Color::White => "white",
Color::DarkGray => "darkgray",
Color::LightRed => "lightred",
Color::LightGreen => "lightgreen",
Color::LightYellow => "lightyellow",
Color::LightBlue => "lightblue",
Color::LightMagenta => "lightmagenta",
Color::LightCyan => "lightcyan",
Color::LightWhite => "lightwhite",
Color::Rgb(..) | Color::Indexed(_) => return None,
})
}
fn from_token(s: &str) -> Option<Color> {
if let Some(c) = Color::from_hex(s) {
return Some(c);
}
let lower = s.trim().to_ascii_lowercase();
if let Some(rest) = lower.strip_prefix("indexed:") {
return rest.trim().parse::<u8>().ok().map(Color::Indexed);
}
Some(match lower.as_str() {
"reset" | "default" => Color::Reset,
"black" => Color::Black,
"red" => Color::Red,
"green" => Color::Green,
"yellow" => Color::Yellow,
"blue" => Color::Blue,
"magenta" => Color::Magenta,
"cyan" => Color::Cyan,
"white" => Color::White,
"darkgray" | "darkgrey" | "gray" | "grey" => Color::DarkGray,
"lightred" => Color::LightRed,
"lightgreen" => Color::LightGreen,
"lightyellow" => Color::LightYellow,
"lightblue" => Color::LightBlue,
"lightmagenta" => Color::LightMagenta,
"lightcyan" => Color::LightCyan,
"lightwhite" => Color::LightWhite,
_ => return None,
})
}
fn to_token(self) -> String {
if let Some(name) = self.named_token() {
return name.to_string();
}
match self {
Color::Indexed(n) => format!("indexed:{n}"),
other => other.to_hex(),
}
}
}
#[cfg(feature = "serde")]
impl serde::Serialize for Color {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&self.to_token())
}
}
#[cfg(feature = "serde")]
impl<'de> serde::Deserialize<'de> for Color {
fn deserialize<D>(deserializer: D) -> Result<Color, D::Error>
where
D: serde::Deserializer<'de>,
{
struct ColorVisitor;
impl serde::de::Visitor<'_> for ColorVisitor {
type Value = Color;
fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("a color token like \"#ff6b6b\", \"cyan\", or \"indexed:245\"")
}
fn visit_str<E>(self, value: &str) -> Result<Color, E>
where
E: serde::de::Error,
{
Color::from_token(value).ok_or_else(|| {
E::custom(format!(
"invalid color token {value:?}: expected #rgb/#rrggbb, a named color, or indexed:N"
))
})
}
}
deserializer.deserialize_str(ColorVisitor)
}
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum ColorDepth {
TrueColor,
EightBit,
Basic,
NoColor,
}
#[cfg(test)]
mod color_depth_tests {
use super::{Color, ColorDepth};
#[test]
fn no_color_downsamples_everything_to_reset() {
assert_eq!(Color::Red.downsampled(ColorDepth::NoColor), Color::Reset);
assert_eq!(
Color::Rgb(10, 20, 30).downsampled(ColorDepth::NoColor),
Color::Reset
);
assert_eq!(
Color::Indexed(44).downsampled(ColorDepth::NoColor),
Color::Reset
);
}
}
impl ColorDepth {
pub fn detect() -> Self {
if std::env::var("NO_COLOR")
.ok()
.is_some_and(|v| !v.is_empty())
{
return Self::NoColor;
}
if let Ok(ct) = std::env::var("COLORTERM") {
let ct = ct.to_lowercase();
if ct == "truecolor" || ct == "24bit" {
return Self::TrueColor;
}
}
if let Ok(term) = std::env::var("TERM")
&& term.contains("256color")
{
return Self::EightBit;
}
Self::Basic
}
}
fn rgb_to_ansi256(r: u8, g: u8, b: u8) -> u8 {
if r == g && g == b {
if r < 8 {
return 16;
}
if r >= 248 {
return 231;
}
return 232 + (((r as u16 - 8) * 24 / 240) as u8);
}
let ri = if r < 48 {
0
} else {
((r as u16 - 35) / 40) as u8
};
let gi = if g < 48 {
0
} else {
((g as u16 - 35) / 40) as u8
};
let bi = if b < 48 {
0
} else {
((b as u16 - 35) / 40) as u8
};
16 + 36 * ri.min(5) + 6 * gi.min(5) + bi.min(5)
}
fn rgb_to_ansi16(r: u8, g: u8, b: u8) -> Color {
let lum = 0.2126 * to_linear(r as f32 / 255.0)
+ 0.7152 * to_linear(g as f32 / 255.0)
+ 0.0722 * to_linear(b as f32 / 255.0);
let max = r.max(g).max(b);
let min = r.min(g).min(b);
let saturation = if max == 0 {
0.0
} else {
(max - min) as f32 / max as f32
};
if saturation < 0.2 {
return match lum {
l if l < 0.05 => Color::Black,
l if l < 0.25 => Color::DarkGray,
l if l < 0.7 => Color::White,
_ => Color::White, };
}
let bright = max >= 200 && min >= 64;
let rf = r as f32;
let gf = g as f32;
let bf = b as f32;
if rf >= gf && rf >= bf {
if gf > bf * 1.5 {
if bright {
Color::LightYellow
} else {
Color::Yellow
}
} else if bf > gf * 1.5 {
if bright {
Color::LightMagenta
} else {
Color::Magenta
}
} else if bright {
Color::LightRed
} else {
Color::Red
}
} else if gf >= rf && gf >= bf {
if bf > rf * 1.5 {
if bright {
Color::LightCyan
} else {
Color::Cyan
}
} else if bright {
Color::LightGreen
} else {
Color::Green
}
} else if rf > gf * 1.5 {
if bright {
Color::LightMagenta
} else {
Color::Magenta
}
} else if gf > rf * 1.5 {
if bright {
Color::LightCyan
} else {
Color::Cyan
}
} else if bright {
Color::LightBlue
} else {
Color::Blue
}
}
fn xterm256_to_rgb(idx: u8) -> (u8, u8, u8) {
match idx {
0 => (0, 0, 0),
1 => (128, 0, 0),
2 => (0, 128, 0),
3 => (128, 128, 0),
4 => (0, 0, 128),
5 => (128, 0, 128),
6 => (0, 128, 128),
7 => (192, 192, 192),
8 => (128, 128, 128),
9 => (255, 0, 0),
10 => (0, 255, 0),
11 => (255, 255, 0),
12 => (0, 0, 255),
13 => (255, 0, 255),
14 => (0, 255, 255),
15 => (255, 255, 255),
16..=231 => {
let n = idx - 16;
let b_idx = n % 6;
let g_idx = (n / 6) % 6;
let r_idx = n / 36;
let to_val = |i: u8| if i == 0 { 0u8 } else { 55 + 40 * i };
(to_val(r_idx), to_val(g_idx), to_val(b_idx))
}
232..=255 => {
let v = 8 + 10 * (idx - 232);
(v, v, v)
}
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
#[test]
fn blend_halfway_rounds_to_128() {
assert_eq!(
Color::Rgb(255, 255, 255).blend(Color::Rgb(0, 0, 0), 0.5),
Color::Rgb(128, 128, 128)
);
}
#[test]
fn contrast_ratio_white_on_black_is_high() {
let ratio = Color::contrast_ratio(Color::White, Color::Black);
assert!(ratio > 15.0);
}
#[test]
fn contrast_ratio_same_color_is_one() {
let ratio = Color::contrast_ratio(Color::Rgb(100, 100, 100), Color::Rgb(100, 100, 100));
assert!((ratio - 1.0).abs() < 0.01);
}
#[test]
fn meets_contrast_aa_white_on_black() {
assert!(Color::meets_contrast_aa(Color::White, Color::Black));
}
#[test]
fn meets_contrast_aa_low_contrast_fails() {
assert!(!Color::meets_contrast_aa(
Color::Rgb(180, 180, 180),
Color::Rgb(200, 200, 200)
));
}
#[test]
fn rgb_to_ansi256_no_overflow_full_range() {
for r in 0u8..=255 {
for g in 0u8..=255 {
for b in 0u8..=255 {
let _ = Color::Rgb(r, g, b).downsampled(ColorDepth::EightBit);
}
}
}
}
#[test]
fn rgb_248_maps_to_231() {
assert_eq!(
Color::Rgb(248, 248, 248).downsampled(ColorDepth::EightBit),
Color::Indexed(231)
);
}
#[test]
fn luminance_dracula_purple_wcag() {
let l = Color::Rgb(189, 147, 249).luminance();
assert!((l - 0.385).abs() < 0.01, "expected ~0.385, got {l}");
}
#[test]
fn contrast_aa_dracula_pair() {
let p = Color::Rgb(189, 147, 249);
let bg = Color::Rgb(40, 42, 54);
assert!(Color::meets_contrast_aa(p, bg));
let r = Color::contrast_ratio(p, bg);
assert!((r - 5.90).abs() < 0.1, "expected ~5.90, got {r}");
}
#[test]
fn contrast_white_on_black_is_21() {
let r = Color::contrast_ratio(Color::Rgb(255, 255, 255), Color::Rgb(0, 0, 0));
assert!((r - 21.0).abs() < 0.5, "expected ~21.0, got {r}");
}
#[test]
fn rgb_to_ansi16_bright_variants() {
assert_eq!(
Color::Rgb(255, 80, 80).downsampled(ColorDepth::Basic),
Color::LightRed
);
assert_eq!(
Color::Rgb(128, 20, 20).downsampled(ColorDepth::Basic),
Color::Red
);
assert_eq!(
Color::Rgb(200, 200, 200).downsampled(ColorDepth::Basic),
Color::White
);
assert_eq!(
Color::Rgb(80, 80, 80).downsampled(ColorDepth::Basic),
Color::DarkGray
);
}
use std::str::FromStr;
#[test]
fn from_tuple_and_array() {
assert_eq!(Color::from((255, 107, 107)), Color::Rgb(255, 107, 107));
assert_eq!(Color::from([1u8, 2, 3]), Color::Rgb(1, 2, 3));
let c: Color = (10, 20, 30).into();
assert_eq!(c, Color::Rgb(10, 20, 30));
}
#[test]
fn from_u32_packs_rrggbb() {
assert_eq!(Color::from(0xff6b6b_u32), Color::Rgb(255, 107, 107));
assert_eq!(Color::from(0x000000_u32), Color::Rgb(0, 0, 0));
assert_eq!(Color::from(0xffffff_u32), Color::Rgb(255, 255, 255));
assert_eq!(Color::from(0xff00ff00_u32), Color::Rgb(0, 255, 0));
}
#[test]
fn from_str_hex_round_trips() {
assert_eq!(
Color::from_str("#ff6b6b").unwrap(),
Color::Rgb(255, 107, 107)
);
assert_eq!(
Color::from_str("ff6b6b").unwrap(),
Color::Rgb(255, 107, 107)
);
assert_eq!(Color::from_str("#abc").unwrap(), Color::Rgb(170, 187, 204));
assert_eq!(Color::from_str("abc").unwrap(), Color::Rgb(170, 187, 204));
assert_eq!(
Color::from_str(" #ff6b6b ").unwrap(),
Color::Rgb(255, 107, 107)
);
let c = Color::Rgb(18, 52, 86);
assert_eq!(Color::from_str(&c.to_hex()).unwrap(), c);
}
#[test]
fn from_str_named_colors() {
assert_eq!(Color::from_str("cyan").unwrap(), Color::Cyan);
assert_eq!(Color::from_str("LightBlue").unwrap(), Color::LightBlue);
assert_eq!(Color::from_str("DARKGRAY").unwrap(), Color::DarkGray);
assert_eq!(Color::from_str("grey").unwrap(), Color::DarkGray);
assert_eq!(Color::from_str("reset").unwrap(), Color::Reset);
assert_eq!(Color::from_str("default").unwrap(), Color::Reset);
}
#[test]
fn from_str_error_cases() {
assert_eq!(
Color::from_str("#ff6b").unwrap_err(),
ColorParseError::InvalidLength
);
assert_eq!(
Color::from_str("#zz0011").unwrap_err(),
ColorParseError::InvalidHexDigit
);
assert_eq!(
Color::from_str("#xyz").unwrap_err(),
ColorParseError::InvalidHexDigit
);
assert_eq!(
Color::from_str("nope").unwrap_err(),
ColorParseError::Unknown
);
assert_eq!(Color::from_str("").unwrap_err(), ColorParseError::Unknown);
}
#[test]
fn color_parse_error_display_and_error_trait() {
let e = ColorParseError::InvalidLength;
assert!(!e.to_string().is_empty());
let _: &dyn std::error::Error = &e;
}
#[test]
fn from_hsl_primaries() {
assert_eq!(Color::from_hsl(0.0, 1.0, 0.5), Color::Rgb(255, 0, 0));
assert_eq!(Color::from_hsl(120.0, 1.0, 0.5), Color::Rgb(0, 255, 0));
assert_eq!(Color::from_hsl(240.0, 1.0, 0.5), Color::Rgb(0, 0, 255));
assert_eq!(Color::from_hsl(0.0, 1.0, 0.0), Color::Rgb(0, 0, 0));
assert_eq!(Color::from_hsl(0.0, 1.0, 1.0), Color::Rgb(255, 255, 255));
assert_eq!(Color::from_hsl(123.0, 0.0, 0.5), Color::Rgb(128, 128, 128));
}
#[test]
fn from_hsl_wraps_and_clamps() {
assert_eq!(Color::from_hsl(360.0, 1.0, 0.5), Color::Rgb(255, 0, 0));
assert_eq!(Color::from_hsl(-120.0, 1.0, 0.5), Color::Rgb(0, 0, 255));
assert_eq!(Color::from_hsl(0.0, 5.0, 2.0), Color::Rgb(255, 255, 255));
}
#[test]
fn from_hsv_primaries() {
assert_eq!(Color::from_hsv(0.0, 1.0, 1.0), Color::Rgb(255, 0, 0));
assert_eq!(Color::from_hsv(120.0, 1.0, 1.0), Color::Rgb(0, 255, 0));
assert_eq!(Color::from_hsv(240.0, 1.0, 1.0), Color::Rgb(0, 0, 255));
assert_eq!(Color::from_hsv(0.0, 0.0, 1.0), Color::Rgb(255, 255, 255));
assert_eq!(Color::from_hsv(0.0, 0.0, 0.0), Color::Rgb(0, 0, 0));
}
#[test]
fn rotate_hue_primary_round_trip() {
assert_eq!(
Color::Rgb(255, 0, 0).rotate_hue(120.0),
Color::Rgb(0, 255, 0)
);
assert_eq!(
Color::Rgb(0, 255, 0).rotate_hue(120.0),
Color::Rgb(0, 0, 255)
);
assert_eq!(
Color::Rgb(255, 0, 0).rotate_hue(180.0),
Color::Rgb(0, 255, 255)
);
assert_eq!(
Color::Rgb(255, 0, 0).rotate_hue(360.0),
Color::Rgb(255, 0, 0)
);
}
#[test]
fn rotate_hue_resolves_named_to_rgb() {
let rotated = Color::Red.rotate_hue(0.0);
assert_eq!(rotated, Color::Rgb(205, 49, 49));
let gray = Color::Rgb(120, 120, 120).rotate_hue(90.0);
assert_eq!(gray, Color::Rgb(120, 120, 120));
}
}