fix(color)!: hsl and hsluv are now clamped before conversion (#1436)

The `from_hsl` and `from_hsluv` functions now clamp the HSL and HSLuv
values before converting them to RGB. This ensures that the input values
are within the expected range before conversion.

Also note that the ranges of Saturation and Lightness values have been
aligned to be consisten with the palette crate. Saturation and Lightness
for `from_hsl` are now in the range [0.0..1.0] while `from_hsluv` are
in the range [0.0..100.0].

Refs:
- <https://github.com/Ogeon/palette/discussions/253>
- <https://docs.rs/palette/latest/palette/struct.Hsl.html>
- <https://docs.rs/palette/latest/palette/struct.Hsluv.html>

Fixes: <https://github.com/ratatui/ratatui/issues/1433>
This commit is contained in:
Josh McKinney 2024-10-20 00:46:04 -07:00 committed by GitHub
parent 611086eba4
commit 4f5503dbf6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -371,30 +371,46 @@ impl Color {
/// Converts a HSL representation to a `Color::Rgb` instance.
///
/// The `from_hsl` function converts the Hue, Saturation and Lightness values to a
/// corresponding `Color` RGB equivalent.
/// The `from_hsl` function converts the Hue, Saturation and Lightness values to a corresponding
/// `Color` RGB equivalent.
///
/// Hue values should be in the range [0, 360].
/// Saturation and L values should be in the range [0, 100].
/// Values that are not in the range are clamped to be within the range.
/// Hue values should be in the range [-180..180]. Values outside this range are normalized by
/// wrapping.
///
/// Saturation and L values should be in the range [0.0..1.0]. Values outside this range are
/// clamped.
///
/// Clamping to valid ranges happens before conversion to RGB.
///
/// # Examples
///
/// ```
/// use ratatui::{palette::Hsl, style::Color};
///
/// // Black
/// // Minimum Lightness is black
/// let color: Color = Color::from_hsl(Hsl::new(0.0, 0.0, 0.0));
/// assert_eq!(color, Color::Rgb(0, 0, 0));
///
/// // White - note: 100% saturation is not converted to white
/// let color: Color = Color::from_hsl(Hsl::new(0.0, 0.0, 100.0));
/// // Maximum Lightness is white
/// let color: Color = Color::from_hsl(Hsl::new(0.0, 0.0, 1.0));
/// assert_eq!(color, Color::Rgb(255, 255, 255));
///
/// // Minimum Saturation is fully desaturated red = gray
/// let color: Color = Color::from_hsl(Hsl::new(0.0, 0.0, 0.5));
/// assert_eq!(color, Color::Rgb(128, 128, 128));
///
/// // Bright red
/// let color: Color = Color::from_hsl(Hsl::new(0.0, 1.0, 0.5));
/// assert_eq!(color, Color::Rgb(255, 0, 0));
///
/// // Bright blue
/// let color: Color = Color::from_hsl(Hsl::new(-120.0, 1.0, 0.5));
/// assert_eq!(color, Color::Rgb(0, 0, 255));
/// ```
#[cfg(feature = "palette")]
pub fn from_hsl(hsl: palette::Hsl) -> Self {
use palette::{FromColor, Srgb};
use palette::{Clamp, FromColor, Srgb};
let hsl = hsl.clamp();
let Srgb {
red,
green,
@ -410,25 +426,43 @@ impl Color {
/// The `from_hsluv` function converts the Hue, Saturation and Lightness values to a
/// corresponding `Color` RGB equivalent.
///
/// Hue values should be in the range [0, 360].
/// Saturation and L values should be in the range [0, 100].
/// Values that are not in the range are clamped to be within the range.
/// Hue values should be in the range [-180.0..180.0]. Values outside this range are normalized
/// by wrapping.
///
/// Saturation and L values should be in the range [0.0..100.0]. Values outside this range are
/// clamped.
///
/// Clamping to valid ranges happens before conversion to RGB.
///
/// # Examples
///
/// ```
/// use ratatui::{palette::Hsluv, style::Color};
///
/// let color = Color::from_hsluv(Hsluv::new(360.0, 50.0, 75.0));
/// assert_eq!(color, Color::Rgb(223, 171, 181));
///
/// let color: Color = Color::from_hsluv(Hsluv::new(0.0, 0.0, 0.0));
/// // Minimum Lightness is black
/// let color: Color = Color::from_hsluv(Hsluv::new(0.0, 100.0, 0.0));
/// assert_eq!(color, Color::Rgb(0, 0, 0));
///
/// // Maximum Lightness is white
/// let color: Color = Color::from_hsluv(Hsluv::new(0.0, 0.0, 100.0));
/// assert_eq!(color, Color::Rgb(255, 255, 255));
///
/// // Minimum Saturation is fully desaturated red = gray
/// let color = Color::from_hsluv(Hsluv::new(0.0, 0.0, 50.0));
/// assert_eq!(color, Color::Rgb(119, 119, 119));
///
/// // Bright Red
/// let color = Color::from_hsluv(Hsluv::new(12.18, 100.0, 53.2));
/// assert_eq!(color, Color::Rgb(255, 0, 0));
///
/// // Bright Blue
/// let color = Color::from_hsluv(Hsluv::new(-94.13, 100.0, 32.3));
/// assert_eq!(color, Color::Rgb(0, 0, 255));
/// ```
#[cfg(feature = "palette")]
pub fn from_hsluv(hsluv: palette::Hsluv) -> Self {
use palette::{FromColor, Srgb};
use palette::{Clamp, FromColor, Srgb};
let hsluv = hsluv.clamp();
let Srgb {
red,
green,
@ -444,69 +478,61 @@ impl Color {
mod tests {
use std::error::Error;
#[cfg(feature = "palette")]
use palette::{Hsl, Hsluv};
use rstest::rstest;
#[cfg(feature = "serde")]
use serde::de::{Deserialize, IntoDeserializer};
use super::*;
#[cfg(feature = "palette")]
#[test]
fn test_hsl_to_rgb() {
use palette::Hsl;
// Test with valid HSL values
let color = Color::from_hsl(Hsl::new(120.0, 50.0, 75.0));
assert_eq!(color, Color::Rgb(159, 223, 159));
// Test with H value at upper bound
let color = Color::from_hsl(Hsl::new(360.0, 50.0, 75.0));
assert_eq!(color, Color::Rgb(223, 159, 159));
// Test with H value exceeding the upper bound
let color = Color::from_hsl(Hsl::new(400.0, 50.0, 75.0));
assert_eq!(color, Color::Rgb(223, 159, 159));
// Test with S and L values exceeding the upper bound
let color = Color::from_hsl(Hsl::new(240.0, 120.0, 150.0));
assert_eq!(color, Color::Rgb(255, 255, 255));
// Test with H, S, and L values below the lower bound
let color = Color::from_hsl(Hsl::new(-20.0, -50.0, -20.0));
assert_eq!(color, Color::Rgb(0, 0, 0));
// Test with S and L values below the lower bound
let color = Color::from_hsl(Hsl::new(60.0, -20.0, -10.0));
assert_eq!(color, Color::Rgb(0, 0, 0));
#[rstest]
#[case::black(Hsl::new(0.0, 0.0, 0.0), Color::Rgb(0, 0, 0))]
#[case::white(Hsl::new(0.0, 0.0, 1.0), Color::Rgb(255, 255, 255))]
#[case::valid(Hsl::new(120.0, 0.5, 0.75), Color::Rgb(159, 223, 159))]
#[case::min_hue(Hsl::new(-180.0, 0.5, 0.75), Color::Rgb(159, 223, 223))]
#[case::max_hue(Hsl::new(180.0, 0.5, 0.75), Color::Rgb(159, 223, 223))]
#[case::min_saturation(Hsl::new(0.0, 0.0, 0.5), Color::Rgb(128, 128, 128))]
#[case::max_saturation(Hsl::new(0.0, 1.0, 0.5), Color::Rgb(255, 0, 0))]
#[case::min_lightness(Hsl::new(0.0, 0.5, 0.0), Color::Rgb(0, 0, 0))]
#[case::max_lightness(Hsl::new(0.0, 0.5, 1.0), Color::Rgb(255, 255, 255))]
#[case::under_hue_wraps(Hsl::new(-240.0, 0.5, 0.75), Color::Rgb(159, 223, 159))]
#[case::over_hue_wraps(Hsl::new(480.0, 0.5, 0.75), Color::Rgb(159, 223, 159))]
#[case::under_saturation_clamps(Hsl::new(0.0, -0.5, 0.75), Color::Rgb(191, 191, 191))]
#[case::over_saturation_clamps(Hsl::new(0.0, 1.2, 0.75), Color::Rgb(255, 128, 128))]
#[case::under_lightness_clamps(Hsl::new(0.0, 0.5, -0.20), Color::Rgb(0, 0, 0))]
#[case::over_lightness_clamps(Hsl::new(0.0, 0.5, 1.5), Color::Rgb(255, 255, 255))]
#[case::under_saturation_lightness_clamps(Hsl::new(0.0, -0.5, -0.20), Color::Rgb(0, 0, 0))]
#[case::over_saturation_lightness_clamps(Hsl::new(0.0, 1.2, 1.5), Color::Rgb(255, 255, 255))]
fn test_hsl_to_rgb(#[case] hsl: palette::Hsl, #[case] expected: Color) {
assert_eq!(Color::from_hsl(hsl), expected);
}
#[cfg(feature = "palette")]
#[test]
fn test_hsluv_to_rgb() {
use palette::Hsluv;
// Test with valid HSLuv values
let color = Color::from_hsluv(Hsluv::new(120.0, 50.0, 75.0));
assert_eq!(color, Color::Rgb(147, 198, 129));
// Test with H value at upper bound
let color = Color::from_hsluv(Hsluv::new(360.0, 50.0, 75.0));
assert_eq!(color, Color::Rgb(223, 171, 181));
// Test with H value exceeding the upper bound
let color = Color::from_hsluv(Hsluv::new(400.0, 50.0, 75.0));
assert_eq!(color, Color::Rgb(226, 174, 140));
// Test with S and L values exceeding the upper bound
let color = Color::from_hsluv(Hsluv::new(240.0, 120.0, 150.0));
assert_eq!(color, Color::Rgb(255, 255, 255));
// Test with H, S, and L values below the lower bound
let color = Color::from_hsluv(Hsluv::new(0.0, 0.0, 0.0));
assert_eq!(color, Color::Rgb(0, 0, 0));
// Test with S and L values below the lower bound
let color = Color::from_hsluv(Hsluv::new(60.0, 0.0, 0.0));
assert_eq!(color, Color::Rgb(0, 0, 0));
#[rstest]
#[case::black(Hsluv::new(0.0, 0.0, 0.0), Color::Rgb(0, 0, 0))]
#[case::white(Hsluv::new(0.0, 0.0, 100.0), Color::Rgb(255, 255, 255))]
#[case::valid(Hsluv::new(120.0, 50.0, 75.0), Color::Rgb(147, 198, 129))]
#[case::min_hue(Hsluv::new(-180.0, 50.0, 75.0), Color::Rgb(135,196, 188))]
#[case::max_hue(Hsluv::new(180.0, 50.0, 75.0), Color::Rgb(135, 196, 188))]
#[case::min_saturation(Hsluv::new(0.0, 0.0, 75.0), Color::Rgb(185, 185, 185))]
#[case::max_saturation(Hsluv::new(0.0, 100.0, 75.0), Color::Rgb(255, 156, 177))]
#[case::min_lightness(Hsluv::new(0.0, 50.0, 0.0), Color::Rgb(0, 0, 0))]
#[case::max_lightness(Hsluv::new(0.0, 50.0, 100.0), Color::Rgb(255, 255, 255))]
#[case::under_hue_wraps(Hsluv::new(-240.0, 50.0, 75.0), Color::Rgb(147, 198, 129))]
#[case::over_hue_wraps(Hsluv::new(480.0, 50.0, 75.0), Color::Rgb(147, 198, 129))]
#[case::under_saturation_clamps(Hsluv::new(0.0, -50.0, 75.0), Color::Rgb(185, 185, 185))]
#[case::over_saturation_clamps(Hsluv::new(0.0, 150.0, 75.0), Color::Rgb(255, 156, 177))]
#[case::under_lightness_clamps(Hsluv::new(0.0, 50.0, -20.0), Color::Rgb(0, 0, 0))]
#[case::over_lightness_clamps(Hsluv::new(0.0, 50.0, 150.0), Color::Rgb(255, 255, 255))]
#[case::under_saturation_lightness_clamps(Hsluv::new(0.0, -50.0, -20.0), Color::Rgb(0, 0, 0))]
#[case::over_saturation_lightness_clamps(
Hsluv::new(0.0, 150.0, 150.0),
Color::Rgb(255, 255, 255)
)]
fn test_hsluv_to_rgb(#[case] hsluv: palette::Hsluv, #[case] expected: Color) {
assert_eq!(Color::from_hsluv(hsluv), expected);
}
#[test]