Skip to main content

ferroui_base/media/
hsl_color.rs

1//! Defines a color using the hue/saturation/lightness (HSL) model.
2//!
3//! Color conversion portions of this file are adapted from the Windows Community
4//! Toolkit project (https://github.com/CommunityToolkit/WindowsCommunityToolkit),
5//! MIT licensed.
6
7use std::fmt;
8use std::str::FromStr;
9
10use crate::media::color::{split_components, strip_css_function, try_parse_double_or_percent};
11use crate::media::{Color, HsvColor};
12use crate::utilities::math_utilities::{self, MathUtilities};
13use crate::utilities::span_helpers::{try_parse_double, FixedF2, InvariantF64, NumberStyles};
14use crate::utilities::{CultureInfo, FormatError};
15
16/// Defines a color using the hue/saturation/lightness (HSL) model.
17/// This uses a cylindrical-coordinate representation of a color.
18#[derive(Clone, Copy, Debug, PartialEq, Default)]
19pub struct HslColor {
20    /// The Alpha (transparency) component in the range from 0..1 (percentage).
21    /// 0 is fully transparent, 1 is fully opaque.
22    pub a: f64,
23    /// The Hue component in the range from 0..360 (degrees). This is the color's
24    /// location, in degrees, on a color wheel/circle from 0 to 360. Note that 360
25    /// is equivalent to 0 and will be adjusted automatically.
26    pub h: f64,
27    /// The Saturation component in the range from 0..1 (percentage). HSL
28    /// saturation is not the same as HSV saturation.
29    pub s: f64,
30    /// The Lightness component in the range from 0..1 (percentage). 0 is fully
31    /// black and 1 is fully white.
32    pub l: f64,
33}
34
35#[inline]
36fn round_to_byte(value: f64) -> u8 {
37    value.round_ties_even() as u8
38}
39
40#[inline]
41fn round_to_int(value: f64) -> i32 {
42    value.round_ties_even() as i32
43}
44
45/// Brings a hue into `[0, 360)` by repeated addition/subtraction of 360.
46///
47/// A hue that cannot be brought into range that way (infinite, or so large that
48/// subtracting 360 no longer changes it) is returned as is instead of looping forever.
49//
50// Deviation (DEVIATIONS.md, Colors): upstream's `while (hue >= 360.0) hue -= 360.0;`
51// and `while (hue < 0.0) hue += 360.0;` never end for such a hue.
52#[inline]
53fn wrap_hue(mut hue: f64) -> f64 {
54    while hue >= 360.0 {
55        let next = hue - 360.0;
56        if next == hue {
57            break;
58        }
59        hue = next;
60    }
61
62    while hue < 0.0 {
63        let next = hue + 360.0;
64        if next == hue {
65            break;
66        }
67        hue = next;
68    }
69
70    hue
71}
72
73#[inline]
74fn clamp01(value: f64) -> f64 {
75    let value = if value < 0.0 { 0.0 } else { value };
76    if value > 1.0 {
77        1.0
78    } else {
79        value
80    }
81}
82
83impl HslColor {
84    /// Initializes a new instance of the [`HslColor`] struct. Components are
85    /// clamped to their ranges and a hue of 360 becomes 0.
86    #[inline]
87    pub fn new(alpha: f64, hue: f64, saturation: f64, lightness: f64) -> Self {
88        Self::new_with_clamp(alpha, hue, saturation, lightness, true)
89    }
90
91    /// Initializes a new instance of the [`HslColor`] struct.
92    ///
93    /// This constructor exists only for internal use where performance is critical.
94    /// Whether or not the channel values are in the correct range must be known:
95    /// when `clamp_values` is `false` the components are stored as given.
96    #[inline]
97    pub fn new_with_clamp(
98        alpha: f64,
99        hue: f64,
100        saturation: f64,
101        lightness: f64,
102        clamp_values: bool,
103    ) -> Self {
104        if clamp_values {
105            let h = MathUtilities::clamp(hue, 0.0, 360.0);
106
107            Self {
108                a: MathUtilities::clamp(alpha, 0.0, 1.0),
109                // The maximum value of Hue is technically 360 minus epsilon (359.999...).
110                // A value of 360 itself wraps to zero.
111                h: if h == 360.0 { 0.0 } else { h },
112                s: MathUtilities::clamp(saturation, 0.0, 1.0),
113                l: MathUtilities::clamp(lightness, 0.0, 1.0),
114            }
115        } else {
116            Self {
117                a: alpha,
118                h: hue,
119                s: saturation,
120                l: lightness,
121            }
122        }
123    }
124
125    /// Initializes a new instance of the [`HslColor`] struct from an RGB color.
126    #[inline]
127    pub fn from_color(color: Color) -> Self {
128        color.to_hsl()
129    }
130
131    #[inline]
132    pub fn equals(&self, other: HslColor) -> bool {
133        other.a == self.a && other.h == self.h && other.s == self.s && other.l == self.l
134    }
135
136    /// Returns the RGB color model equivalent of this HSL color.
137    #[inline]
138    pub fn to_rgb(&self) -> Color {
139        HslColor::hsl_to_rgb(self.h, self.s, self.l, self.a)
140    }
141
142    /// Returns the HSV color model equivalent of this HSL color.
143    #[inline]
144    pub fn to_hsv(&self) -> HsvColor {
145        HslColor::hsl_to_hsv(self.h, self.s, self.l, self.a)
146    }
147
148    /// Returns the string representation of the color using a format specifier.
149    ///
150    /// Supported format specifiers:
151    /// - `L` / `l`: CSS `hsla(h, s%, l%, a)` / `hsl(h, s%, l%)`
152    /// - `L%` / `l%`: `hsla(h%, s%, l%, a%)` / `hsl(h%, s%, l%)`
153    /// - `X`, `x`, `H`, `R`, `r`, `R%`, `r%`: converted to RGB and formatted as such
154    /// - `V`, `v`, `V%`, `v%`: converted to HSV and formatted as such
155    ///
156    /// A null or empty format is the same as `to_string()`. Any other format is an
157    /// error. `format_provider` is ignored: color formatting is culture-invariant.
158    pub fn to_string_format(
159        &self,
160        format: Option<&str>,
161        format_provider: Option<&CultureInfo>,
162    ) -> Result<String, FormatError> {
163        let format = match format {
164            Some(format) if !format.is_empty() => format,
165            _ => return Ok(self.to_string()),
166        };
167
168        match format {
169            "X" | "x" | "H" | "R" | "r" | "R%" | "r%" => self.to_rgb().to_string_format(Some(format), format_provider),
170            "L" => Ok(self.format_hsl_css(true)),
171            "l" => Ok(self.format_hsl_css(false)),
172            "L%" => Ok(self.format_hsl_percent_css(true)),
173            "l%" => Ok(self.format_hsl_percent_css(false)),
174            "V" | "v" | "V%" | "v%" => self.to_hsv().to_string_format(Some(format), format_provider),
175            _ => Err(FormatError::from_string(format!(
176                "Format string '{format}' is not supported."
177            ))),
178        }
179    }
180
181    fn format_hsl_css(&self, include_alpha: bool) -> String {
182        let h_deg = round_to_int(self.h);
183        let s_pct = round_to_int(self.s * 100.0);
184        let l_pct = round_to_int(self.l * 100.0);
185
186        if include_alpha {
187            return format!("hsla({h_deg}, {s_pct}%, {l_pct}%, {})", FixedF2(self.a));
188        }
189
190        format!("hsl({h_deg}, {s_pct}%, {l_pct}%)")
191    }
192
193    fn format_hsl_percent_css(&self, include_alpha: bool) -> String {
194        let h_pct = round_to_int(self.h / 360.0 * 100.0);
195        let s_pct = round_to_int(self.s * 100.0);
196        let l_pct = round_to_int(self.l * 100.0);
197
198        if include_alpha {
199            let a_pct = round_to_int(self.a * 100.0);
200            return format!("hsla({h_pct}%, {s_pct}%, {l_pct}%, {a_pct}%)");
201        }
202
203        format!("hsl({h_pct}%, {s_pct}%, {l_pct}%)")
204    }
205
206    /// Parses an HSL color string (`hsl(h, s, l)` or `hsla(h, s, l, a)`).
207    pub fn parse(s: &str) -> Result<HslColor, FormatError> {
208        match HslColor::try_parse(s) {
209            Some(hsl_color) => Ok(hsl_color),
210            None => Err(FormatError::from_string(format!(
211                "Invalid HSL color string: '{s}'."
212            ))),
213        }
214    }
215
216    /// Parses an HSL color string (`hsl(h, s, l)` or `hsla(h, s, l, a)`),
217    /// returning `None` when it is not valid. Saturation, lightness and alpha may
218    /// be given as fractions or percentages.
219    pub fn try_parse(s: &str) -> Option<HslColor> {
220        let working = strip_css_function(s, "hsla(", "hsl(")?;
221        let (components, count) = split_components(working);
222
223        if count == 3 {
224            // HSL
225            let hue = try_parse_double(components[0], NumberStyles::NUMBER)?;
226            let saturation = try_parse_double_or_percent(components[1])?;
227            let lightness = try_parse_double_or_percent(components[2])?;
228
229            Some(HslColor::new(1.0, hue, saturation, lightness))
230        } else if count == 4 {
231            // HSLA
232            let hue = try_parse_double(components[0], NumberStyles::NUMBER)?;
233            let saturation = try_parse_double_or_percent(components[1])?;
234            let lightness = try_parse_double_or_percent(components[2])?;
235            let alpha = try_parse_double_or_percent(components[3])?;
236
237            Some(HslColor::new(alpha, hue, saturation, lightness))
238        } else {
239            None
240        }
241    }
242
243    /// Creates an [`HslColor`] from alpha, hue, saturation and lightness (clamped).
244    #[inline]
245    pub fn from_ahsl(a: f64, h: f64, s: f64, l: f64) -> HslColor {
246        HslColor::new(a, h, s, l)
247    }
248
249    /// Creates an opaque [`HslColor`] from hue, saturation and lightness (clamped).
250    #[inline]
251    pub fn from_hsl(h: f64, s: f64, l: f64) -> HslColor {
252        HslColor::new(1.0, h, s, l)
253    }
254
255    /// Converts the given HSLA color component values to their RGB color equivalent.
256    ///
257    /// The hue is wrapped into `[0, 360)`; the other components are clamped to `0..1`.
258    pub fn hsl_to_rgb(hue: f64, saturation: f64, lightness: f64, alpha: f64) -> Color {
259        // Note: Conversion code is originally based on ColorHelper in the Windows
260        // Community Toolkit (licensed MIT). It has been modified.
261
262        // We want the hue to be between 0 and 359,
263        // so we first ensure that that's the case.
264        let hue = wrap_hue(hue);
265
266        // We similarly clamp saturation, lightness and alpha between 0 and 1.
267        let saturation = clamp01(saturation);
268        let lightness = clamp01(lightness);
269        let alpha = clamp01(alpha);
270
271        let chroma = (1.0 - ((2.0 * lightness) - 1.0).abs()) * saturation;
272        let h1 = hue / 60.0;
273        let x = chroma * (1.0 - ((h1 % 2.0) - 1.0).abs());
274        let m = lightness - (0.5 * chroma);
275        let (r1, g1, b1);
276
277        if h1 < 1.0 {
278            r1 = chroma;
279            g1 = x;
280            b1 = 0.0;
281        } else if h1 < 2.0 {
282            r1 = x;
283            g1 = chroma;
284            b1 = 0.0;
285        } else if h1 < 3.0 {
286            r1 = 0.0;
287            g1 = chroma;
288            b1 = x;
289        } else if h1 < 4.0 {
290            r1 = 0.0;
291            g1 = x;
292            b1 = chroma;
293        } else if h1 < 5.0 {
294            r1 = x;
295            g1 = 0.0;
296            b1 = chroma;
297        } else {
298            r1 = chroma;
299            g1 = 0.0;
300            b1 = x;
301        }
302
303        Color::new(
304            round_to_byte(255.0 * alpha),
305            round_to_byte(255.0 * (r1 + m)),
306            round_to_byte(255.0 * (g1 + m)),
307            round_to_byte(255.0 * (b1 + m)),
308        )
309    }
310
311    /// Converts the given HSLA color component values to their HSV color equivalent.
312    pub fn hsl_to_hsv(hue: f64, saturation: f64, lightness: f64, alpha: f64) -> HsvColor {
313        // We want the hue to be between 0 and 359,
314        // so we first ensure that that's the case.
315        let hue = wrap_hue(hue);
316
317        // We similarly clamp saturation, lightness and alpha between 0 and 1.
318        let saturation = clamp01(saturation);
319        let lightness = clamp01(lightness);
320        let alpha = clamp01(alpha);
321
322        // The conversion algorithm is from the below link
323        // https://en.wikipedia.org/wiki/HSL_and_HSV#Interconversion
324
325        let v = lightness + (saturation * math_utilities::min(lightness, 1.0 - lightness));
326
327        let s = if v <= 0.0 {
328            0.0
329        } else {
330            2.0 * (1.0 - (lightness / v))
331        };
332
333        HsvColor::new(alpha, hue, s, v)
334    }
335}
336
337/// Converts the HSL color to RGB (an explicit conversion in the reference API).
338impl From<HslColor> for Color {
339    #[inline]
340    fn from(hsl_color: HslColor) -> Color {
341        hsl_color.to_rgb()
342    }
343}
344
345impl FromStr for HslColor {
346    type Err = FormatError;
347    #[inline]
348    fn from_str(s: &str) -> Result<Self, FormatError> {
349        HslColor::parse(s)
350    }
351}
352
353impl fmt::Display for HslColor {
354    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
355        write!(
356            f,
357            "hsla({}, {}, {}, {})",
358            InvariantF64(self.h),
359            InvariantF64(self.s),
360            InvariantF64(self.l),
361            InvariantF64(self.a)
362        )
363    }
364}
365
366// Not from upstream: clamping, display, parse errors and conversions of edge
367// values that the upstream suite (`color_tests.rs`) does not cover.
368#[cfg(test)]
369mod tests {
370    use super::*;
371
372    #[test]
373    fn construction_clamps_and_wraps() {
374        assert_eq!(
375            HslColor::new(2.0, 360.0, -1.0, 5.0),
376            HslColor::new_with_clamp(1.0, 0.0, 0.0, 1.0, false)
377        );
378        assert!(HslColor::new(1.0, f64::NAN, 0.0, 0.0).h.is_nan());
379        assert_eq!(
380            HslColor::from_hsl(10.0, 0.5, 0.5),
381            HslColor::from_ahsl(1.0, 10.0, 0.5, 0.5)
382        );
383        assert_eq!(HslColor::default(), HslColor::new(0.0, 0.0, 0.0, 0.0));
384    }
385
386    #[test]
387    fn display_parse_and_conversions() {
388        assert_eq!(
389            HslColor::new(0.8, 200.0, 0.6, 0.4).to_string(),
390            "hsla(200, 0.6, 0.4, 0.8)"
391        );
392        assert_eq!(
393            HslColor::parse("nope").unwrap_err().message(),
394            "Invalid HSL color string: 'nope'."
395        );
396        assert_eq!(
397            "hsl(120, 100%, 50%)".parse::<HslColor>().unwrap(),
398            HslColor::new(1.0, 120.0, 1.0, 0.5)
399        );
400        assert_eq!(HslColor::try_parse("hsl(120, 100%)"), None);
401        assert_eq!(HslColor::try_parse("hsl(1, 2, 3, 4, 5)"), None);
402        assert_eq!(
403            Color::from(HslColor::new(1.0, 120.0, 1.0, 0.5)),
404            Color::new(255, 0, 255, 0)
405        );
406        assert_eq!(
407            HslColor::from_color(Color::new(255, 0, 0, 255)),
408            HslColor::new(1.0, 240.0, 1.0, 0.5)
409        );
410        // Out-of-range hues wrap; non-finite hues terminate.
411        assert_eq!(
412            HslColor::hsl_to_rgb(480.0, 1.0, 0.5, 1.0),
413            Color::new(255, 0, 255, 0)
414        );
415        assert_eq!(
416            HslColor::hsl_to_rgb(-240.0, 1.0, 0.5, 1.0),
417            Color::new(255, 0, 255, 0)
418        );
419        let _ = HslColor::hsl_to_rgb(f64::INFINITY, 1.0, 0.5, 1.0);
420        let _ = HslColor::hsl_to_rgb(f64::NEG_INFINITY, 1.0, 0.5, 1.0);
421        let _ = HslColor::hsl_to_rgb(1e300, 1.0, 0.5, 1.0);
422    }
423}