diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..45bb7cc --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,23 @@ +name: CI + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + rust: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + - name: cargo fmt --check + run: cargo fmt --check + - name: cargo clippy --workspace --all-targets -- -D warnings + run: cargo clippy --workspace --all-targets -- -D warnings + - name: cargo test --workspace + run: cargo test --workspace diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..5eedae1 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,36 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "use-contrast" +version = "0.1.0" +dependencies = [ + "use-luminance", + "use-rgb", +] + +[[package]] +name = "use-hex-color" +version = "0.1.0" +dependencies = [ + "use-rgb", +] + +[[package]] +name = "use-hsl" +version = "0.1.0" +dependencies = [ + "use-rgb", +] + +[[package]] +name = "use-luminance" +version = "0.1.0" +dependencies = [ + "use-rgb", +] + +[[package]] +name = "use-rgb" +version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..47b7594 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,15 @@ +[workspace] +members = [ + "crates/use-rgb", + "crates/use-hex-color", + "crates/use-hsl", + "crates/use-luminance", + "crates/use-contrast", +] +resolver = "2" + +[workspace.package] +version = "0.1.0" +edition = "2021" +license = "MIT" +repository = "https://github.com/RustUse/use-color" diff --git a/README.md b/README.md index 39c4d07..057b1a0 100644 --- a/README.md +++ b/README.md @@ -1 +1,48 @@ -# use-color \ No newline at end of file +# use-color + +Composable color primitives for Rust. + +`use-color` provides small utilities for RGB values, hex colors, HSL conversion, relative luminance, and contrast checks. + +## RustUse relationship + +- `use-color` is a sibling set to `use-math`, `use-text`, and `use-wave`. +- Crates stay one layer deep. +- Each crate should be independently useful. + +## Workspace crates + +- [`use-rgb`](./crates/use-rgb): RGB color primitives. +- [`use-hex-color`](./crates/use-hex-color): Hex color parsing and formatting. +- [`use-hsl`](./crates/use-hsl): HSL color primitives and RGB conversion. +- [`use-luminance`](./crates/use-luminance): Relative luminance helpers. +- [`use-contrast`](./crates/use-contrast): WCAG-style contrast ratio helpers. + +## Examples + +```rust +use use_contrast::{contrast_ratio, passes_aa}; +use use_hex_color::HexColor; +use use_hsl::Hsl; +use use_luminance::relative_luminance; +use use_rgb::Rgb; + +let orange = Rgb::new(255, 69, 0); +let white = Rgb::white(); + +let hex = HexColor::from_rgb(orange); +let parsed = HexColor::parse("#FF4500").unwrap(); + +let hsl = Hsl::from_rgb(orange); +let luminance = relative_luminance(orange); + +let ratio = contrast_ratio(orange, white); +let accessible = passes_aa(orange, white); + +assert_eq!(hex.as_str(), "#FF4500"); +assert_eq!(parsed.to_rgb(), orange); +assert!(hsl.h() >= 0.0); +assert!(luminance >= 0.0); +assert!(ratio >= 1.0); +assert_eq!(accessible, passes_aa(orange, white)); +``` diff --git a/crates/use-contrast/Cargo.toml b/crates/use-contrast/Cargo.toml new file mode 100644 index 0000000..b410ddc --- /dev/null +++ b/crates/use-contrast/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "use-contrast" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +readme = "README.md" +description = "WCAG-style contrast ratio helpers for Rust." + +[dependencies] +use-luminance = { path = "../use-luminance" } +use-rgb = { path = "../use-rgb" } diff --git a/crates/use-contrast/README.md b/crates/use-contrast/README.md new file mode 100644 index 0000000..960934c --- /dev/null +++ b/crates/use-contrast/README.md @@ -0,0 +1,14 @@ +# use-contrast + +WCAG-style contrast ratio helpers for Rust. + +## Example + +```rust +use use_contrast::{contrast_ratio, passes_aa}; +use use_rgb::Rgb; + +let ratio = contrast_ratio(Rgb::black(), Rgb::white()); +assert!(ratio > 20.0); +assert!(passes_aa(Rgb::black(), Rgb::white())); +``` diff --git a/crates/use-contrast/src/lib.rs b/crates/use-contrast/src/lib.rs new file mode 100644 index 0000000..1cb5262 --- /dev/null +++ b/crates/use-contrast/src/lib.rs @@ -0,0 +1,151 @@ +//! Contrast ratio helpers for sRGB colors. +//! +//! Contrast ratio is computed using the WCAG formula: +//! +//! `(L1 + 0.05) / (L2 + 0.05)` +//! +//! where `L1` is the lighter relative luminance and `L2` is the darker relative +//! luminance. +//! +//! # Examples +//! +//! ```rust +//! use use_contrast::{contrast_ratio, passes_aa}; +//! use use_rgb::Rgb; +//! +//! let ratio = contrast_ratio(Rgb::black(), Rgb::white()); +//! assert!(ratio > 20.0); +//! assert!(passes_aa(Rgb::black(), Rgb::white())); +//! ``` + +use use_luminance::relative_luminance; +use use_rgb::Rgb; + +const AA_THRESHOLD: f64 = 4.5; +const AA_LARGE_TEXT_THRESHOLD: f64 = 3.0; +const AAA_THRESHOLD: f64 = 7.0; +const AAA_LARGE_TEXT_THRESHOLD: f64 = 4.5; + +/// Computes the WCAG-style contrast ratio between two colors. +/// +/// The lighter luminance is always used as the numerator. +/// +/// # Examples +/// +/// ```rust +/// use use_contrast::contrast_ratio; +/// use use_rgb::Rgb; +/// +/// assert_eq!(contrast_ratio(Rgb::black(), Rgb::white()), 21.0); +/// ``` +#[must_use] +pub fn contrast_ratio(foreground: Rgb, background: Rgb) -> f64 { + let foreground_luminance = relative_luminance(foreground); + let background_luminance = relative_luminance(background); + let lighter = foreground_luminance.max(background_luminance); + let darker = foreground_luminance.min(background_luminance); + + (lighter + 0.05) / (darker + 0.05) +} + +/// Returns `true` when the contrast ratio meets the WCAG AA threshold for +/// normal text (`4.5`). +/// +/// # Examples +/// +/// ```rust +/// use use_contrast::passes_aa; +/// use use_rgb::Rgb; +/// +/// assert!(passes_aa(Rgb::black(), Rgb::white())); +/// ``` +#[must_use] +pub fn passes_aa(foreground: Rgb, background: Rgb) -> bool { + contrast_ratio(foreground, background) >= AA_THRESHOLD +} + +/// Returns `true` when the contrast ratio meets the WCAG AA threshold for +/// large text (`3.0`). +/// +/// # Examples +/// +/// ```rust +/// use use_contrast::passes_aa_large_text; +/// use use_rgb::Rgb; +/// +/// assert!(passes_aa_large_text(Rgb::new(119, 119, 119), Rgb::white())); +/// ``` +#[must_use] +pub fn passes_aa_large_text(foreground: Rgb, background: Rgb) -> bool { + contrast_ratio(foreground, background) >= AA_LARGE_TEXT_THRESHOLD +} + +/// Returns `true` when the contrast ratio meets the WCAG AAA threshold for +/// normal text (`7.0`). +/// +/// # Examples +/// +/// ```rust +/// use use_contrast::passes_aaa; +/// use use_rgb::Rgb; +/// +/// assert!(passes_aaa(Rgb::blue(), Rgb::white())); +/// ``` +#[must_use] +pub fn passes_aaa(foreground: Rgb, background: Rgb) -> bool { + contrast_ratio(foreground, background) >= AAA_THRESHOLD +} + +/// Returns `true` when the contrast ratio meets the WCAG AAA threshold for +/// large text (`4.5`). +/// +/// # Examples +/// +/// ```rust +/// use use_contrast::passes_aaa_large_text; +/// use use_rgb::Rgb; +/// +/// assert!(passes_aaa_large_text(Rgb::blue(), Rgb::white())); +/// ``` +#[must_use] +pub fn passes_aaa_large_text(foreground: Rgb, background: Rgb) -> bool { + contrast_ratio(foreground, background) >= AAA_LARGE_TEXT_THRESHOLD +} + +#[cfg(test)] +mod tests { + use super::{ + contrast_ratio, passes_aa, passes_aa_large_text, passes_aaa, passes_aaa_large_text, + }; + use use_rgb::Rgb; + + const TOLERANCE: f64 = 1e-12; + + fn assert_close(actual: f64, expected: f64) { + assert!( + (actual - expected).abs() <= TOLERANCE, + "expected {expected}, got {actual}" + ); + } + + #[test] + fn black_and_white_have_expected_contrast() { + assert_close(contrast_ratio(Rgb::black(), Rgb::white()), 21.0); + assert_close(contrast_ratio(Rgb::white(), Rgb::black()), 21.0); + } + + #[test] + fn threshold_helpers_match_wcag_levels() { + let blue_on_white = (Rgb::blue(), Rgb::white()); + assert!(passes_aa(blue_on_white.0, blue_on_white.1)); + assert!(passes_aa_large_text(blue_on_white.0, blue_on_white.1)); + assert!(passes_aaa(blue_on_white.0, blue_on_white.1)); + assert!(passes_aaa_large_text(blue_on_white.0, blue_on_white.1)); + + let gray_on_white = (Rgb::new(119, 119, 119), Rgb::white()); + assert!(!passes_aa(gray_on_white.0, gray_on_white.1)); + assert!(passes_aa_large_text(gray_on_white.0, gray_on_white.1)); + assert!(!passes_aaa(gray_on_white.0, gray_on_white.1)); + assert!(!passes_aaa_large_text(gray_on_white.0, gray_on_white.1)); + } +} diff --git a/crates/use-hex-color/Cargo.toml b/crates/use-hex-color/Cargo.toml new file mode 100644 index 0000000..e2af983 --- /dev/null +++ b/crates/use-hex-color/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "use-hex-color" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +readme = "README.md" +description = "Hex color parsing and formatting for Rust." + +[dependencies] +use-rgb = { path = "../use-rgb" } diff --git a/crates/use-hex-color/README.md b/crates/use-hex-color/README.md new file mode 100644 index 0000000..95196d1 --- /dev/null +++ b/crates/use-hex-color/README.md @@ -0,0 +1,14 @@ +# use-hex-color + +Hex color parsing and formatting for Rust. + +## Example + +```rust +use use_hex_color::HexColor; +use use_rgb::Rgb; + +let parsed = HexColor::parse("#fff").unwrap(); +assert_eq!(parsed.as_str(), "#FFFFFF"); +assert_eq!(parsed.to_rgb(), Rgb::white()); +``` diff --git a/crates/use-hex-color/src/lib.rs b/crates/use-hex-color/src/lib.rs new file mode 100644 index 0000000..0708059 --- /dev/null +++ b/crates/use-hex-color/src/lib.rs @@ -0,0 +1,194 @@ +//! Hex color parsing and formatting. +//! +//! Accepted input forms are `#RRGGBB`, `RRGGBB`, `#RGB`, and `RGB`. +//! All parsed values are normalized to uppercase six-digit strings with a +//! leading `#`. +//! +//! # Examples +//! +//! ```rust +//! use use_hex_color::HexColor; +//! use use_rgb::Rgb; +//! +//! let color = HexColor::parse("#fff").unwrap(); +//! assert_eq!(color.as_str(), "#FFFFFF"); +//! assert_eq!(color.to_rgb(), Rgb::white()); +//! ``` + +use std::error::Error; +use std::fmt; + +use use_rgb::Rgb; + +/// A normalized hex color string such as `#FF4500`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HexColor(String); + +/// An error returned when parsing a hex color fails. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum HexColorError { + /// The input was empty. + Empty, + /// The trimmed input had an unsupported length. + InvalidLength(usize), + /// The input contained a non-hexadecimal character. + InvalidCharacter { character: char, index: usize }, +} + +impl HexColor { + /// Parses a hex color from `#RRGGBB`, `RRGGBB`, `#RGB`, or `RGB` input. + /// + /// The returned color is normalized to uppercase six-digit form with a + /// leading `#`. + /// + /// # Examples + /// + /// ```rust + /// use use_hex_color::HexColor; + /// + /// let color = HexColor::parse("fff").unwrap(); + /// assert_eq!(color.as_str(), "#FFFFFF"); + /// ``` + pub fn parse(input: &str) -> Result { + if input.is_empty() { + return Err(HexColorError::Empty); + } + + let trimmed = input.strip_prefix('#').unwrap_or(input); + if trimmed.is_empty() { + return Err(HexColorError::InvalidLength(0)); + } + + for (index, character) in trimmed.char_indices() { + if !character.is_ascii_hexdigit() { + return Err(HexColorError::InvalidCharacter { character, index }); + } + } + + let normalized = match trimmed.len() { + 3 => { + let mut expanded = String::with_capacity(7); + expanded.push('#'); + for character in trimmed.chars() { + expanded.push(character.to_ascii_uppercase()); + expanded.push(character.to_ascii_uppercase()); + } + expanded + } + 6 => format!("#{}", trimmed.to_ascii_uppercase()), + length => return Err(HexColorError::InvalidLength(length)), + }; + + Ok(Self(normalized)) + } + + /// Creates a normalized hex color from an [`Rgb`] value. + /// + /// # Examples + /// + /// ```rust + /// use use_hex_color::HexColor; + /// use use_rgb::Rgb; + /// + /// let color = HexColor::from_rgb(Rgb::new(255, 69, 0)); + /// assert_eq!(color.as_str(), "#FF4500"); + /// ``` + #[must_use] + pub fn from_rgb(rgb: Rgb) -> Self { + Self(format!("#{:02X}{:02X}{:02X}", rgb.r(), rgb.g(), rgb.b())) + } + + /// Converts the hex color to an [`Rgb`] value. + /// + /// # Examples + /// + /// ```rust + /// use use_hex_color::HexColor; + /// use use_rgb::Rgb; + /// + /// let color = HexColor::parse("#00FF00").unwrap(); + /// assert_eq!(color.to_rgb(), Rgb::green()); + /// ``` + #[must_use] + pub fn to_rgb(&self) -> Rgb { + let value = &self.0[1..]; + let r = u8::from_str_radix(&value[0..2], 16).expect("normalized hex red channel"); + let g = u8::from_str_radix(&value[2..4], 16).expect("normalized hex green channel"); + let b = u8::from_str_radix(&value[4..6], 16).expect("normalized hex blue channel"); + Rgb::new(r, g, b) + } + + /// Returns the normalized hex color string. + /// + /// # Examples + /// + /// ```rust + /// use use_hex_color::HexColor; + /// + /// let color = HexColor::parse("#abc").unwrap(); + /// assert_eq!(color.as_str(), "#AABBCC"); + /// ``` + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for HexColor { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(f) + } +} + +impl fmt::Display for HexColorError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Empty => write!(f, "hex color input cannot be empty"), + Self::InvalidLength(length) => { + write!(f, "hex color input must have 3 or 6 digits, found {length}") + } + Self::InvalidCharacter { character, index } => { + write!(f, "invalid hex character '{character}' at index {index}") + } + } + } +} + +impl Error for HexColorError {} + +#[cfg(test)] +mod tests { + use super::{HexColor, HexColorError}; + use use_rgb::Rgb; + + #[test] + fn parses_supported_formats() { + assert_eq!(HexColor::parse("#FF4500").unwrap().as_str(), "#FF4500"); + assert_eq!(HexColor::parse("FF4500").unwrap().as_str(), "#FF4500"); + assert_eq!(HexColor::parse("#fff").unwrap().as_str(), "#FFFFFF"); + assert_eq!(HexColor::parse("fff").unwrap().as_str(), "#FFFFFF"); + } + + #[test] + fn invalid_inputs_return_explicit_errors() { + assert_eq!(HexColor::parse(""), Err(HexColorError::Empty)); + assert_eq!(HexColor::parse("#12"), Err(HexColorError::InvalidLength(2))); + assert_eq!( + HexColor::parse("#GGGGGG"), + Err(HexColorError::InvalidCharacter { + character: 'G', + index: 0, + }) + ); + } + + #[test] + fn rgb_conversion_round_trips() { + let rgb = Rgb::new(255, 69, 0); + let hex = HexColor::from_rgb(rgb); + + assert_eq!(hex.as_str(), "#FF4500"); + assert_eq!(hex.to_string(), "#FF4500"); + assert_eq!(hex.to_rgb(), rgb); + } +} diff --git a/crates/use-hsl/Cargo.toml b/crates/use-hsl/Cargo.toml new file mode 100644 index 0000000..5bf9eb8 --- /dev/null +++ b/crates/use-hsl/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "use-hsl" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +readme = "README.md" +description = "HSL color primitives and RGB conversion for Rust." + +[dependencies] +use-rgb = { path = "../use-rgb" } diff --git a/crates/use-hsl/README.md b/crates/use-hsl/README.md new file mode 100644 index 0000000..2a1a8dc --- /dev/null +++ b/crates/use-hsl/README.md @@ -0,0 +1,13 @@ +# use-hsl + +HSL color primitives and RGB conversion for Rust. + +## Example + +```rust +use use_hsl::Hsl; +use use_rgb::Rgb; + +let hsl = Hsl::from_rgb(Rgb::red()); +assert_eq!(hsl.to_rgb(), Rgb::red()); +``` diff --git a/crates/use-hsl/src/lib.rs b/crates/use-hsl/src/lib.rs new file mode 100644 index 0000000..cbe148c --- /dev/null +++ b/crates/use-hsl/src/lib.rs @@ -0,0 +1,253 @@ +//! HSL color primitives and RGB conversion. +//! +//! Hue is expressed in degrees in the range `[0, 360)`. Saturation and +//! lightness are expressed as fractions in the inclusive range `[0.0, 1.0]`. +//! +//! # Examples +//! +//! ```rust +//! use use_hsl::Hsl; +//! use use_rgb::Rgb; +//! +//! let hsl = Hsl::from_rgb(Rgb::blue()); +//! assert_eq!(hsl.to_rgb(), Rgb::blue()); +//! ``` + +use use_rgb::Rgb; + +/// An HSL color. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Hsl { + h: f64, + s: f64, + l: f64, +} + +impl Hsl { + /// Creates an HSL color when all components are finite and in range. + /// + /// Hue is normalized into `[0, 360)`. Saturation and lightness must both be + /// within `0.0..=1.0`. + /// + /// # Examples + /// + /// ```rust + /// use use_hsl::Hsl; + /// + /// let color = Hsl::new(360.0, 1.0, 0.5).unwrap(); + /// assert_eq!(color.h(), 0.0); + /// ``` + #[must_use] + pub fn new(h: f64, s: f64, l: f64) -> Option { + if !h.is_finite() || !s.is_finite() || !l.is_finite() { + return None; + } + + if !(0.0..=1.0).contains(&s) || !(0.0..=1.0).contains(&l) { + return None; + } + + Some(Self { + h: h.rem_euclid(360.0), + s, + l, + }) + } + + /// Converts an [`Rgb`] color into HSL. + /// + /// # Examples + /// + /// ```rust + /// use use_hsl::Hsl; + /// use use_rgb::Rgb; + /// + /// let color = Hsl::from_rgb(Rgb::red()); + /// assert_eq!(color.h(), 0.0); + /// assert_eq!(color.s(), 1.0); + /// assert_eq!(color.l(), 0.5); + /// ``` + #[must_use] + pub fn from_rgb(rgb: Rgb) -> Self { + let r = f64::from(rgb.r()) / 255.0; + let g = f64::from(rgb.g()) / 255.0; + let b = f64::from(rgb.b()) / 255.0; + + let max = r.max(g).max(b); + let min = r.min(g).min(b); + let l = (max + min) / 2.0; + let delta = max - min; + + if delta.abs() < f64::EPSILON { + return Self { h: 0.0, s: 0.0, l }; + } + + let s = delta / (1.0 - (2.0 * l - 1.0).abs()); + let h = if (max - r).abs() < f64::EPSILON { + 60.0 * ((g - b) / delta).rem_euclid(6.0) + } else if (max - g).abs() < f64::EPSILON { + 60.0 * (((b - r) / delta) + 2.0) + } else { + 60.0 * (((r - g) / delta) + 4.0) + }; + + Self { h, s, l } + } + + /// Converts the HSL color into [`Rgb`]. + /// + /// # Examples + /// + /// ```rust + /// use use_hsl::Hsl; + /// use use_rgb::Rgb; + /// + /// let color = Hsl::new(120.0, 1.0, 0.5).unwrap(); + /// assert_eq!(color.to_rgb(), Rgb::green()); + /// ``` + #[must_use] + pub fn to_rgb(&self) -> Rgb { + if self.s.abs() < f64::EPSILON { + let channel = to_channel(self.l); + return Rgb::new(channel, channel, channel); + } + + let c = (1.0 - (2.0 * self.l - 1.0).abs()) * self.s; + let h_prime = self.h / 60.0; + let x = c * (1.0 - ((h_prime.rem_euclid(2.0)) - 1.0).abs()); + + let (r1, g1, b1) = if h_prime < 1.0 { + (c, x, 0.0) + } else if h_prime < 2.0 { + (x, c, 0.0) + } else if h_prime < 3.0 { + (0.0, c, x) + } else if h_prime < 4.0 { + (0.0, x, c) + } else if h_prime < 5.0 { + (x, 0.0, c) + } else { + (c, 0.0, x) + }; + + let m = self.l - c / 2.0; + Rgb::new(to_channel(r1 + m), to_channel(g1 + m), to_channel(b1 + m)) + } + + /// Returns the hue in degrees. + /// + /// # Examples + /// + /// ```rust + /// use use_hsl::Hsl; + /// + /// assert_eq!(Hsl::new(420.0, 1.0, 0.5).unwrap().h(), 60.0); + /// ``` + #[must_use] + pub fn h(&self) -> f64 { + self.h + } + + /// Returns the saturation in the range `0.0..=1.0`. + /// + /// # Examples + /// + /// ```rust + /// use use_hsl::Hsl; + /// + /// assert_eq!(Hsl::new(120.0, 0.25, 0.5).unwrap().s(), 0.25); + /// ``` + #[must_use] + pub fn s(&self) -> f64 { + self.s + } + + /// Returns the lightness in the range `0.0..=1.0`. + /// + /// # Examples + /// + /// ```rust + /// use use_hsl::Hsl; + /// + /// assert_eq!(Hsl::new(120.0, 0.25, 0.5).unwrap().l(), 0.5); + /// ``` + #[must_use] + pub fn l(&self) -> f64 { + self.l + } +} + +fn to_channel(value: f64) -> u8 { + (value * 255.0).round().clamp(0.0, 255.0) as u8 +} + +#[cfg(test)] +mod tests { + use super::Hsl; + use use_rgb::Rgb; + + const TOLERANCE: f64 = 1e-10; + + fn assert_close(actual: f64, expected: f64) { + assert!( + (actual - expected).abs() <= TOLERANCE, + "expected {expected}, got {actual}" + ); + } + + #[test] + fn new_validates_and_normalizes_values() { + let wrapped = Hsl::new(-30.0, 0.5, 0.25).unwrap(); + assert_close(wrapped.h(), 330.0); + assert_close(wrapped.s(), 0.5); + assert_close(wrapped.l(), 0.25); + + assert!(Hsl::new(f64::NAN, 0.5, 0.5).is_none()); + assert!(Hsl::new(f64::INFINITY, 0.5, 0.5).is_none()); + assert!(Hsl::new(0.0, -0.1, 0.5).is_none()); + assert!(Hsl::new(0.0, 0.5, 1.1).is_none()); + } + + #[test] + fn rgb_to_hsl_matches_common_colors() { + let black = Hsl::from_rgb(Rgb::black()); + assert_close(black.h(), 0.0); + assert_close(black.s(), 0.0); + assert_close(black.l(), 0.0); + + let white = Hsl::from_rgb(Rgb::white()); + assert_close(white.h(), 0.0); + assert_close(white.s(), 0.0); + assert_close(white.l(), 1.0); + + let red = Hsl::from_rgb(Rgb::red()); + assert_close(red.h(), 0.0); + assert_close(red.s(), 1.0); + assert_close(red.l(), 0.5); + + let green = Hsl::from_rgb(Rgb::green()); + assert_close(green.h(), 120.0); + assert_close(green.s(), 1.0); + assert_close(green.l(), 0.5); + + let blue = Hsl::from_rgb(Rgb::blue()); + assert_close(blue.h(), 240.0); + assert_close(blue.s(), 1.0); + assert_close(blue.l(), 0.5); + } + + #[test] + fn hsl_to_rgb_round_trips_common_colors() { + let colors = [ + Rgb::black(), + Rgb::white(), + Rgb::red(), + Rgb::green(), + Rgb::blue(), + ]; + + for color in colors { + assert_eq!(Hsl::from_rgb(color).to_rgb(), color); + } + } +} diff --git a/crates/use-luminance/Cargo.toml b/crates/use-luminance/Cargo.toml new file mode 100644 index 0000000..c707ddc --- /dev/null +++ b/crates/use-luminance/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "use-luminance" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +readme = "README.md" +description = "Relative luminance helpers for Rust." + +[dependencies] +use-rgb = { path = "../use-rgb" } diff --git a/crates/use-luminance/README.md b/crates/use-luminance/README.md new file mode 100644 index 0000000..52e2249 --- /dev/null +++ b/crates/use-luminance/README.md @@ -0,0 +1,12 @@ +# use-luminance + +Relative luminance helpers for Rust. + +## Example + +```rust +use use_luminance::relative_luminance; +use use_rgb::Rgb; + +assert_eq!(relative_luminance(Rgb::black()), 0.0); +``` diff --git a/crates/use-luminance/src/lib.rs b/crates/use-luminance/src/lib.rs new file mode 100644 index 0000000..c43e1aa --- /dev/null +++ b/crates/use-luminance/src/lib.rs @@ -0,0 +1,106 @@ +//! Relative luminance helpers for sRGB colors. +//! +//! Relative luminance is computed using the standard sRGB formula: +//! +//! `0.2126 * R + 0.7152 * G + 0.0722 * B` +//! +//! where `R`, `G`, and `B` are gamma-expanded linear channel values. +//! +//! # Examples +//! +//! ```rust +//! use use_luminance::relative_luminance; +//! use use_rgb::Rgb; +//! +//! assert_eq!(relative_luminance(Rgb::black()), 0.0); +//! ``` + +use use_rgb::Rgb; + +const LIGHT_THRESHOLD: f64 = 0.5; + +/// Computes the relative luminance of an sRGB color. +/// +/// # Examples +/// +/// ```rust +/// use use_luminance::relative_luminance; +/// use use_rgb::Rgb; +/// +/// assert_eq!(relative_luminance(Rgb::white()), 1.0); +/// ``` +#[must_use] +pub fn relative_luminance(rgb: Rgb) -> f64 { + let r = linearize(rgb.r()); + let g = linearize(rgb.g()); + let b = linearize(rgb.b()); + + 0.2126 * r + 0.7152 * g + 0.0722 * b +} + +/// Returns `true` when relative luminance is at least `0.5`. +/// +/// # Examples +/// +/// ```rust +/// use use_luminance::is_light; +/// use use_rgb::Rgb; +/// +/// assert!(is_light(Rgb::white())); +/// ``` +#[must_use] +pub fn is_light(rgb: Rgb) -> bool { + relative_luminance(rgb) >= LIGHT_THRESHOLD +} + +/// Returns `true` when relative luminance is below `0.5`. +/// +/// # Examples +/// +/// ```rust +/// use use_luminance::is_dark; +/// use use_rgb::Rgb; +/// +/// assert!(is_dark(Rgb::black())); +/// ``` +#[must_use] +pub fn is_dark(rgb: Rgb) -> bool { + !is_light(rgb) +} + +fn linearize(channel: u8) -> f64 { + let srgb = f64::from(channel) / 255.0; + if srgb <= 0.04045 { + srgb / 12.92 + } else { + ((srgb + 0.055) / 1.055).powf(2.4) + } +} + +#[cfg(test)] +mod tests { + use super::{is_dark, is_light, relative_luminance}; + use use_rgb::Rgb; + + const TOLERANCE: f64 = 1e-12; + + fn assert_close(actual: f64, expected: f64) { + assert!( + (actual - expected).abs() <= TOLERANCE, + "expected {expected}, got {actual}" + ); + } + + #[test] + fn relative_luminance_matches_black_and_white() { + assert_close(relative_luminance(Rgb::black()), 0.0); + assert_close(relative_luminance(Rgb::white()), 1.0); + } + + #[test] + fn light_and_dark_helpers_use_documented_threshold() { + assert!(is_light(Rgb::white())); + assert!(is_dark(Rgb::black())); + assert!(is_dark(Rgb::new(127, 127, 127))); + } +} diff --git a/crates/use-rgb/Cargo.toml b/crates/use-rgb/Cargo.toml new file mode 100644 index 0000000..4d0f6a8 --- /dev/null +++ b/crates/use-rgb/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "use-rgb" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +readme = "README.md" +description = "RGB color primitives for Rust." + +[dependencies] diff --git a/crates/use-rgb/README.md b/crates/use-rgb/README.md new file mode 100644 index 0000000..b5af015 --- /dev/null +++ b/crates/use-rgb/README.md @@ -0,0 +1,12 @@ +# use-rgb + +RGB color primitives for Rust. + +## Example + +```rust +use use_rgb::Rgb; + +let orange = Rgb::new(255, 69, 0); +assert_eq!(orange.as_tuple(), (255, 69, 0)); +``` diff --git a/crates/use-rgb/src/lib.rs b/crates/use-rgb/src/lib.rs new file mode 100644 index 0000000..05c7338 --- /dev/null +++ b/crates/use-rgb/src/lib.rs @@ -0,0 +1,194 @@ +//! RGB color primitives. +//! +//! This crate provides a small, dependency-free [`Rgb`] type for working with +//! red, green, and blue channel values. +//! +//! # Examples +//! +//! ```rust +//! use use_rgb::Rgb; +//! +//! let color = Rgb::new(255, 69, 0); +//! assert_eq!(color.as_tuple(), (255, 69, 0)); +//! ``` + +/// An RGB color with 8-bit red, green, and blue channels. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Rgb { + /// The red channel. + pub r: u8, + /// The green channel. + pub g: u8, + /// The blue channel. + pub b: u8, +} + +impl Rgb { + /// Creates an RGB color from red, green, and blue channel values. + /// + /// # Examples + /// + /// ```rust + /// use use_rgb::Rgb; + /// + /// let color = Rgb::new(12, 34, 56); + /// assert_eq!(color.as_tuple(), (12, 34, 56)); + /// ``` + #[must_use] + pub const fn new(r: u8, g: u8, b: u8) -> Self { + Self { r, g, b } + } + + /// Returns black (`0, 0, 0`). + /// + /// # Examples + /// + /// ```rust + /// use use_rgb::Rgb; + /// + /// assert_eq!(Rgb::black(), Rgb::new(0, 0, 0)); + /// ``` + #[must_use] + pub const fn black() -> Self { + Self::new(0, 0, 0) + } + + /// Returns white (`255, 255, 255`). + /// + /// # Examples + /// + /// ```rust + /// use use_rgb::Rgb; + /// + /// assert_eq!(Rgb::white(), Rgb::new(255, 255, 255)); + /// ``` + #[must_use] + pub const fn white() -> Self { + Self::new(255, 255, 255) + } + + /// Returns red (`255, 0, 0`). + /// + /// # Examples + /// + /// ```rust + /// use use_rgb::Rgb; + /// + /// assert_eq!(Rgb::red(), Rgb::new(255, 0, 0)); + /// ``` + #[must_use] + pub const fn red() -> Self { + Self::new(255, 0, 0) + } + + /// Returns green (`0, 255, 0`). + /// + /// # Examples + /// + /// ```rust + /// use use_rgb::Rgb; + /// + /// assert_eq!(Rgb::green(), Rgb::new(0, 255, 0)); + /// ``` + #[must_use] + pub const fn green() -> Self { + Self::new(0, 255, 0) + } + + /// Returns blue (`0, 0, 255`). + /// + /// # Examples + /// + /// ```rust + /// use use_rgb::Rgb; + /// + /// assert_eq!(Rgb::blue(), Rgb::new(0, 0, 255)); + /// ``` + #[must_use] + pub const fn blue() -> Self { + Self::new(0, 0, 255) + } + + /// Returns the red channel. + /// + /// # Examples + /// + /// ```rust + /// use use_rgb::Rgb; + /// + /// assert_eq!(Rgb::new(1, 2, 3).r(), 1); + /// ``` + #[must_use] + pub const fn r(&self) -> u8 { + self.r + } + + /// Returns the green channel. + /// + /// # Examples + /// + /// ```rust + /// use use_rgb::Rgb; + /// + /// assert_eq!(Rgb::new(1, 2, 3).g(), 2); + /// ``` + #[must_use] + pub const fn g(&self) -> u8 { + self.g + } + + /// Returns the blue channel. + /// + /// # Examples + /// + /// ```rust + /// use use_rgb::Rgb; + /// + /// assert_eq!(Rgb::new(1, 2, 3).b(), 3); + /// ``` + #[must_use] + pub const fn b(&self) -> u8 { + self.b + } + + /// Returns the color as an `(r, g, b)` tuple. + /// + /// # Examples + /// + /// ```rust + /// use use_rgb::Rgb; + /// + /// assert_eq!(Rgb::new(1, 2, 3).as_tuple(), (1, 2, 3)); + /// ``` + #[must_use] + pub const fn as_tuple(&self) -> (u8, u8, u8) { + (self.r, self.g, self.b) + } +} + +#[cfg(test)] +mod tests { + use super::Rgb; + + #[test] + fn constructors_and_accessors_work() { + let color = Rgb::new(12, 34, 56); + + assert_eq!(color.r(), 12); + assert_eq!(color.g(), 34); + assert_eq!(color.b(), 56); + assert_eq!(color.as_tuple(), (12, 34, 56)); + assert_eq!(color.r, 12); + assert_eq!(color.g, 34); + assert_eq!(color.b, 56); + } + + #[test] + fn named_colors_match_expected_values() { + assert_eq!(Rgb::black(), Rgb::new(0, 0, 0)); + assert_eq!(Rgb::white(), Rgb::new(255, 255, 255)); + assert_eq!(Rgb::red(), Rgb::new(255, 0, 0)); + assert_eq!(Rgb::green(), Rgb::new(0, 255, 0)); + assert_eq!(Rgb::blue(), Rgb::new(0, 0, 255)); + } +}