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}