Skip to main content

ferroui_base/media/
hsv_color.rs

1//! Defines a color using the hue/saturation/value (HSV) model.
2//!
3//! Color conversion portions of this file are adapted from the WinUI project
4//! (https://github.com/microsoft/microsoft-ui-xaml), MIT licensed.
5
6use std::fmt;
7use std::str::FromStr;
8
9use crate::media::color::{split_components, strip_css_function, try_parse_double_or_percent};
10use crate::media::{Color, HslColor};
11use crate::utilities::math_utilities::{self, MathUtilities};
12use crate::utilities::span_helpers::{try_parse_double, FixedF2, InvariantF64, NumberStyles};
13use crate::utilities::{CultureInfo, FormatError};
14
15/// Defines a color using the hue/saturation/value (HSV) model.
16/// This uses a cylindrical-coordinate representation of a color.
17#[derive(Clone, Copy, Debug, PartialEq, Default)]
18pub struct HsvColor {
19    /// The Alpha (transparency) component in the range from 0..1 (percentage).
20    /// 0 is fully transparent, 1 is fully opaque.
21    pub a: f64,
22    /// The Hue component in the range from 0..360 (degrees). This is the color's
23    /// location, in degrees, on a color wheel/circle from 0 to 360. Note that 360
24    /// is equivalent to 0 and will be adjusted automatically.
25    pub h: f64,
26    /// The Saturation component in the range from 0..1 (percentage). 0 is a shade
27    /// of gray (no color) and 1 is the full color.
28    pub s: f64,
29    /// The Value component in the range from 0..1 (percentage). 0 is fully black
30    /// and 1 is the brightest.
31    pub v: f64,
32}
33
34#[inline]
35fn round_to_byte(value: f64) -> u8 {
36    value.round_ties_even() as u8
37}
38
39#[inline]
40fn round_to_int(value: f64) -> i32 {
41    value.round_ties_even() as i32
42}
43
44/// Brings a hue into `[0, 360)` by repeated addition/subtraction of 360.
45///
46/// A hue that cannot be brought into range that way (infinite, or so large that
47/// subtracting 360 no longer changes it) is returned as is instead of looping forever.
48//
49// Deviation (DEVIATIONS.md, Colors): upstream's `while (hue >= 360.0) hue -= 360.0;`
50// and `while (hue < 0.0) hue += 360.0;` never end for such a hue.
51#[inline]
52fn wrap_hue(mut hue: f64) -> f64 {
53    while hue >= 360.0 {
54        let next = hue - 360.0;
55        if next == hue {
56            break;
57        }
58        hue = next;
59    }
60
61    while hue < 0.0 {
62        let next = hue + 360.0;
63        if next == hue {
64            break;
65        }
66        hue = next;
67    }
68
69    hue
70}
71
72#[inline]
73fn clamp01(value: f64) -> f64 {
74    let value = if value < 0.0 { 0.0 } else { value };
75    if value > 1.0 {
76        1.0
77    } else {
78        value
79    }
80}
81
82impl HsvColor {
83    /// Initializes a new instance of the [`HsvColor`] struct. Components are
84    /// clamped to their ranges and a hue of 360 becomes 0.
85    #[inline]
86    pub fn new(alpha: f64, hue: f64, saturation: f64, value: f64) -> Self {
87        Self::new_with_clamp(alpha, hue, saturation, value, true)
88    }
89
90    /// Initializes a new instance of the [`HsvColor`] struct.
91    ///
92    /// This constructor exists only for internal use where performance is critical.
93    /// Whether or not the channel values are in the correct range must be known:
94    /// when `clamp_values` is `false` the components are stored as given.
95    #[inline]
96    pub fn new_with_clamp(
97        alpha: f64,
98        hue: f64,
99        saturation: f64,
100        value: f64,
101        clamp_values: bool,
102    ) -> Self {
103        if clamp_values {
104            let h = MathUtilities::clamp(hue, 0.0, 360.0);
105
106            Self {
107                a: MathUtilities::clamp(alpha, 0.0, 1.0),
108                // The maximum value of Hue is technically 360 minus epsilon (359.999...).
109                // A value of 360 itself wraps to zero.
110                h: if h == 360.0 { 0.0 } else { h },
111                s: MathUtilities::clamp(saturation, 0.0, 1.0),
112                v: MathUtilities::clamp(value, 0.0, 1.0),
113            }
114        } else {
115            Self {
116                a: alpha,
117                h: hue,
118                s: saturation,
119                v: value,
120            }
121        }
122    }
123
124    /// Initializes a new instance of the [`HsvColor`] struct from an RGB color.
125    #[inline]
126    pub fn from_color(color: Color) -> Self {
127        color.to_hsv()
128    }
129
130    #[inline]
131    pub fn equals(&self, other: HsvColor) -> bool {
132        other.a == self.a && other.h == self.h && other.s == self.s && other.v == self.v
133    }
134
135    /// Returns the RGB color model equivalent of this HSV color.
136    #[inline]
137    pub fn to_rgb(&self) -> Color {
138        HsvColor::hsv_to_rgb(self.h, self.s, self.v, self.a)
139    }
140
141    /// Returns the HSL color model equivalent of this HSV color.
142    #[inline]
143    pub fn to_hsl(&self) -> HslColor {
144        HsvColor::hsv_to_hsl(self.h, self.s, self.v, self.a)
145    }
146
147    /// Returns the string representation of the color using a format specifier.
148    ///
149    /// Supported format specifiers:
150    /// - `V` / `v`: `hsva(h, s%, v%, a)` / `hsv(h, s%, v%)`
151    /// - `V%` / `v%`: `hsva(h%, s%, v%, a%)` / `hsv(h%, s%, v%)`
152    /// - `X`, `x`, `H`, `R`, `r`, `R%`, `r%`: converted to RGB and formatted as such
153    /// - `L`, `l`, `L%`, `l%`: converted to HSL and formatted as such
154    ///
155    /// A null or empty format is the same as `to_string()`. Any other format is an
156    /// error. `format_provider` is ignored: color formatting is culture-invariant.
157    pub fn to_string_format(
158        &self,
159        format: Option<&str>,
160        format_provider: Option<&CultureInfo>,
161    ) -> Result<String, FormatError> {
162        let format = match format {
163            Some(format) if !format.is_empty() => format,
164            _ => return Ok(self.to_string()),
165        };
166
167        match format {
168            "X" | "x" | "H" | "R" | "r" | "R%" | "r%" => self.to_rgb().to_string_format(Some(format), format_provider),
169            "L" | "l" | "L%" | "l%" => self.to_hsl().to_string_format(Some(format), format_provider),
170            "V" => Ok(self.format_hsv_css(true)),
171            "v" => Ok(self.format_hsv_css(false)),
172            "V%" => Ok(self.format_hsv_percent_css(true)),
173            "v%" => Ok(self.format_hsv_percent_css(false)),
174            _ => Err(FormatError::from_string(format!(
175                "Format string '{format}' is not supported."
176            ))),
177        }
178    }
179
180    fn format_hsv_css(&self, include_alpha: bool) -> String {
181        let h_deg = round_to_int(self.h);
182        let s_pct = round_to_int(self.s * 100.0);
183        let v_pct = round_to_int(self.v * 100.0);
184
185        if include_alpha {
186            return format!("hsva({h_deg}, {s_pct}%, {v_pct}%, {})", FixedF2(self.a));
187        }
188
189        format!("hsv({h_deg}, {s_pct}%, {v_pct}%)")
190    }
191
192    fn format_hsv_percent_css(&self, include_alpha: bool) -> String {
193        let h_pct = round_to_int(self.h / 360.0 * 100.0);
194        let s_pct = round_to_int(self.s * 100.0);
195        let v_pct = round_to_int(self.v * 100.0);
196
197        if include_alpha {
198            let a_pct = round_to_int(self.a * 100.0);
199            return format!("hsva({h_pct}%, {s_pct}%, {v_pct}%, {a_pct}%)");
200        }
201
202        format!("hsv({h_pct}%, {s_pct}%, {v_pct}%)")
203    }
204
205    /// Parses an HSV color string (`hsv(h, s, v)` or `hsva(h, s, v, a)`).
206    pub fn parse(s: &str) -> Result<HsvColor, FormatError> {
207        match HsvColor::try_parse(s) {
208            Some(hsv_color) => Ok(hsv_color),
209            None => Err(FormatError::from_string(format!(
210                "Invalid HSV color string: '{s}'."
211            ))),
212        }
213    }
214
215    /// Parses an HSV color string (`hsv(h, s, v)` or `hsva(h, s, v, a)`),
216    /// returning `None` when it is not valid. Saturation, value and alpha may be
217    /// given as fractions or percentages.
218    pub fn try_parse(s: &str) -> Option<HsvColor> {
219        let working = strip_css_function(s, "hsva(", "hsv(")?;
220        let (components, count) = split_components(working);
221
222        if count == 3 {
223            // HSV
224            let hue = try_parse_double(components[0], NumberStyles::NUMBER)?;
225            let saturation = try_parse_double_or_percent(components[1])?;
226            let value = try_parse_double_or_percent(components[2])?;
227
228            Some(HsvColor::new(1.0, hue, saturation, value))
229        } else if count == 4 {
230            // HSVA
231            let hue = try_parse_double(components[0], NumberStyles::NUMBER)?;
232            let saturation = try_parse_double_or_percent(components[1])?;
233            let value = try_parse_double_or_percent(components[2])?;
234            let alpha = try_parse_double_or_percent(components[3])?;
235
236            Some(HsvColor::new(alpha, hue, saturation, value))
237        } else {
238            None
239        }
240    }
241
242    /// Creates an [`HsvColor`] from alpha, hue, saturation and value (clamped).
243    #[inline]
244    pub fn from_ahsv(a: f64, h: f64, s: f64, v: f64) -> HsvColor {
245        HsvColor::new(a, h, s, v)
246    }
247
248    /// Creates an opaque [`HsvColor`] from hue, saturation and value (clamped).
249    #[inline]
250    pub fn from_hsv(h: f64, s: f64, v: f64) -> HsvColor {
251        HsvColor::new(1.0, h, s, v)
252    }
253
254    /// Converts the given HSVA color component values to their RGB color equivalent.
255    ///
256    /// The hue is wrapped into `[0, 360)`; the other components are clamped to `0..1`.
257    pub fn hsv_to_rgb(hue: f64, saturation: f64, value: f64, alpha: f64) -> Color {
258        // Note: Conversion code is originally based on the C++ in WinUI (licensed MIT)
259        // https://github.com/microsoft/microsoft-ui-xaml/blob/main/dev/Common/ColorConversion.cpp
260        // This was used because it is the best documented and likely most optimized for performance
261        // Alpha support was added
262
263        // We want the hue to be between 0 and 359,
264        // so we first ensure that that's the case.
265        let hue = wrap_hue(hue);
266
267        // We similarly clamp saturation, value and alpha between 0 and 1.
268        let saturation = clamp01(saturation);
269        let value = clamp01(value);
270        let alpha = clamp01(alpha);
271
272        // The first thing that we need to do is to determine the chroma (see above for its definition).
273        // Remember from above that:
274        //
275        // 1. The chroma is the difference between the maximum and the minimum of the RGB channels,
276        // 2. The value is the maximum of the RGB channels, and
277        // 3. The saturation comes from dividing the chroma by the maximum of the RGB channels (i.e., the value).
278        //
279        // From these facts, you can see that we can retrieve the chroma by simply multiplying the saturation and the value,
280        // and we can retrieve the minimum of the RGB channels by subtracting the chroma from the value.
281        let chroma = saturation * value;
282        let min = value - chroma;
283
284        // If the chroma is zero, then we have a greyscale color.  In that case, the maximum and the minimum RGB channels
285        // have the same value (and, indeed, all of the RGB channels are the same), so we can just immediately return
286        // the minimum value as the value of all the channels.
287        if chroma == 0.0 {
288            return Color::from_argb(
289                round_to_byte(alpha * 255.0),
290                round_to_byte(min * 255.0),
291                round_to_byte(min * 255.0),
292                round_to_byte(min * 255.0),
293            );
294        }
295
296        // If the chroma is not zero, then we need to continue.  The first step is to figure out
297        // what section of the color wheel we're located in.  In order to do that, we'll divide the hue by 60.
298        // The resulting value means we're in one of the following locations:
299        //
300        // 0 - Between red and yellow.
301        // 1 - Between yellow and green.
302        // 2 - Between green and cyan.
303        // 3 - Between cyan and blue.
304        // 4 - Between blue and purple.
305        // 5 - Between purple and red.
306        //
307        // In each of these sextants, one of the RGB channels is completely present, one is partially present, and one is not present.
308        // For example, as we transition between red and yellow, red is completely present, green is becoming increasingly present, and blue is not present.
309        // Then, as we transition from yellow and green, green is now completely present, red is becoming decreasingly present, and blue is still not present.
310        // As we transition from green to cyan, green is still completely present, blue is becoming increasingly present, and red is no longer present.  And so on.
311        //
312        // To convert from hue to RGB value, we first need to figure out which of the three channels is in which configuration
313        // in the sextant that we're located in.  Next, we figure out what value the completely-present color should have.
314        // We know that chroma = (max - min), and we know that this color is the max color, so to find its value we simply add
315        // min to chroma to retrieve max.  Finally, we consider how far we've transitioned from the pure form of that color
316        // to the next color (e.g., how far we are from pure red towards yellow), and give a value to the partially present channel
317        // equal to the minimum plus the chroma (i.e., the max minus the min), multiplied by the percentage towards the new color.
318        // This gets us a value between the maximum and the minimum representing the partially present channel.
319        // Finally, the not-present color must be equal to the minimum value, since it is the one least participating in the overall color.
320        let sextant = (hue / 60.0) as i32;
321        let intermediate_color_percentage = (hue / 60.0) - sextant as f64;
322        let max = chroma + min;
323
324        let mut r = 0.0;
325        let mut g = 0.0;
326        let mut b = 0.0;
327
328        match sextant {
329            0 => {
330                r = max;
331                g = min + (chroma * intermediate_color_percentage);
332                b = min;
333            }
334            1 => {
335                r = min + (chroma * (1.0 - intermediate_color_percentage));
336                g = max;
337                b = min;
338            }
339            2 => {
340                r = min;
341                g = max;
342                b = min + (chroma * intermediate_color_percentage);
343            }
344            3 => {
345                r = min;
346                g = min + (chroma * (1.0 - intermediate_color_percentage));
347                b = max;
348            }
349            4 => {
350                r = min + (chroma * intermediate_color_percentage);
351                g = min;
352                b = max;
353            }
354            5 => {
355                r = max;
356                g = min;
357                b = min + (chroma * (1.0 - intermediate_color_percentage));
358            }
359            _ => {}
360        }
361
362        Color::new(
363            round_to_byte(alpha * 255.0),
364            round_to_byte(r * 255.0),
365            round_to_byte(g * 255.0),
366            round_to_byte(b * 255.0),
367        )
368    }
369
370    /// Converts the given HSVA color component values to their HSL color equivalent.
371    pub fn hsv_to_hsl(hue: f64, saturation: f64, value: f64, alpha: f64) -> HslColor {
372        // We want the hue to be between 0 and 359,
373        // so we first ensure that that's the case.
374        let hue = wrap_hue(hue);
375
376        // We similarly clamp saturation, value and alpha between 0 and 1.
377        let saturation = clamp01(saturation);
378        let value = clamp01(value);
379        let alpha = clamp01(alpha);
380
381        // The conversion algorithm is from the below link
382        // https://en.wikipedia.org/wiki/HSL_and_HSV#Interconversion
383
384        let l = value * (1.0 - (saturation / 2.0));
385
386        let s = if l <= 0.0 || l >= 1.0 {
387            0.0
388        } else {
389            (value - l) / math_utilities::min(l, 1.0 - l)
390        };
391
392        HslColor::new(alpha, hue, s, l)
393    }
394}
395
396/// Converts the HSV color to RGB (an explicit conversion in the reference API).
397impl From<HsvColor> for Color {
398    #[inline]
399    fn from(hsv_color: HsvColor) -> Color {
400        hsv_color.to_rgb()
401    }
402}
403
404impl FromStr for HsvColor {
405    type Err = FormatError;
406    #[inline]
407    fn from_str(s: &str) -> Result<Self, FormatError> {
408        HsvColor::parse(s)
409    }
410}
411
412impl fmt::Display for HsvColor {
413    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
414        write!(
415            f,
416            "hsva({}, {}, {}, {})",
417            InvariantF64(self.h),
418            InvariantF64(self.s),
419            InvariantF64(self.v),
420            InvariantF64(self.a)
421        )
422    }
423}
424
425// Not from upstream: clamping, display, parse errors and conversions of edge
426// values that the upstream suite (`color_tests.rs`) does not cover.
427#[cfg(test)]
428mod tests {
429    use super::*;
430
431    #[test]
432    fn construction_clamps_and_wraps() {
433        assert_eq!(
434            HsvColor::new(2.0, 360.0, -1.0, 5.0),
435            HsvColor::new_with_clamp(1.0, 0.0, 0.0, 1.0, false)
436        );
437        assert!(HsvColor::new(1.0, f64::NAN, 0.0, 0.0).h.is_nan());
438        assert_eq!(
439            HsvColor::from_hsv(10.0, 0.5, 0.5),
440            HsvColor::from_ahsv(1.0, 10.0, 0.5, 0.5)
441        );
442    }
443
444    #[test]
445    fn display_parse_and_conversions() {
446        assert_eq!(
447            HsvColor::new(0.8, 200.0, 0.6, 0.4).to_string(),
448            "hsva(200, 0.6, 0.4, 0.8)"
449        );
450        assert_eq!(
451            HsvColor::parse("nope").unwrap_err().message(),
452            "Invalid HSV color string: 'nope'."
453        );
454        assert_eq!(
455            "hsv(120, 100%, 100%)".parse::<HsvColor>().unwrap(),
456            HsvColor::new(1.0, 120.0, 1.0, 1.0)
457        );
458        assert_eq!(
459            Color::from(HsvColor::new(1.0, 120.0, 1.0, 1.0)),
460            Color::new(255, 0, 255, 0)
461        );
462        assert_eq!(
463            HsvColor::from_color(Color::new(255, 0, 0, 255)),
464            HsvColor::new(1.0, 240.0, 1.0, 1.0)
465        );
466        // Every sextant.
467        let expected = [
468            (30.0, Color::new(255, 255, 128, 0)),
469            (90.0, Color::new(255, 128, 255, 0)),
470            (150.0, Color::new(255, 0, 255, 128)),
471            (210.0, Color::new(255, 0, 128, 255)),
472            (270.0, Color::new(255, 128, 0, 255)),
473            (330.0, Color::new(255, 255, 0, 128)),
474        ];
475        for (hue, color) in expected {
476            assert_eq!(HsvColor::hsv_to_rgb(hue, 1.0, 1.0, 1.0), color, "hue {hue}");
477        }
478        assert_eq!(
479            HsvColor::hsv_to_rgb(480.0, 1.0, 1.0, 1.0),
480            Color::new(255, 0, 255, 0)
481        );
482        let _ = HsvColor::hsv_to_rgb(f64::INFINITY, 1.0, 1.0, 1.0);
483        let _ = HsvColor::hsv_to_rgb(-1e300, 1.0, 1.0, 1.0);
484    }
485}