Skip to main content

ferroui_base/media/
color.rs

1//! An ARGB color.
2//!
3//! Color conversion portions of this file are adapted from the WinUI project
4//! (https://github.com/microsoft/microsoft-ui-xaml) and the Windows Community
5//! Toolkit project (https://github.com/CommunityToolkit/WindowsCommunityToolkit),
6//! both MIT licensed.
7
8use std::fmt;
9use std::str::FromStr;
10
11use crate::media::{HslColor, HsvColor, KnownColor, KnownColors};
12use crate::utilities::span_helpers::{
13    try_parse_byte, try_parse_double, try_parse_uint, FixedF2, NumberStyles,
14};
15use crate::utilities::{CultureInfo, FormatError};
16
17const BYTE_TO_DOUBLE: f64 = 1.0 / 255.0;
18
19/// An ARGB color.
20#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
21pub struct Color {
22    /// The Alpha component of the color.
23    pub a: u8,
24    /// The Red component of the color.
25    pub r: u8,
26    /// The Green component of the color.
27    pub g: u8,
28    /// The Blue component of the color.
29    pub b: u8,
30}
31
32/// Rounds half to even and converts to a byte, saturating at 0 and 255 (NaN becomes 0).
33#[inline]
34fn round_to_byte(value: f64) -> u8 {
35    value.round_ties_even() as u8
36}
37
38/// Rounds half to even and converts to an integer (saturating; NaN becomes 0).
39#[inline]
40fn round_to_int(value: f64) -> i32 {
41    value.round_ties_even() as i32
42}
43
44/// Length of the string in UTF-16 code units (the unit the format length rules
45/// are expressed in).
46#[inline]
47pub(crate) fn utf16_len(s: &str) -> usize {
48    if s.is_ascii() {
49        s.len()
50    } else {
51        s.encode_utf16().count()
52    }
53}
54
55/// ASCII case-insensitive `starts_with`.
56#[inline]
57pub(crate) fn starts_with_ignore_ascii_case(s: &str, prefix: &str) -> bool {
58    s.len() >= prefix.len() && s.as_bytes()[..prefix.len()].eq_ignore_ascii_case(prefix.as_bytes())
59}
60
61/// Splits `s` on commas into at most four components. Returns the components and
62/// the real component count (which may exceed four).
63#[inline]
64pub(crate) fn split_components(s: &str) -> ([&str; 4], usize) {
65    let mut components = [""; 4];
66    let mut count = 0;
67    for part in s.split(',') {
68        if count < 4 {
69            components[count] = part;
70        }
71        count += 1;
72    }
73    (components, count)
74}
75
76/// Strips a CSS-like function wrapper: `"<name>a(...)"` (when the trimmed string
77/// has at least 11 UTF-16 units) or `"<name>(...)"` (at least 10). `with_alpha`
78/// and `without_alpha` are the prefixes including the opening parenthesis.
79pub(crate) fn strip_css_function<'a>(
80    s: &'a str,
81    with_alpha: &str,
82    without_alpha: &str,
83) -> Option<&'a str> {
84    let working = s.trim();
85
86    if working.is_empty() || !working.contains(',') {
87        return None;
88    }
89
90    let length = utf16_len(working);
91
92    if length >= 11 && starts_with_ignore_ascii_case(working, with_alpha) && working.ends_with(')')
93    {
94        return Some(&working[with_alpha.len()..working.len() - 1]);
95    }
96
97    if length >= 10
98        && starts_with_ignore_ascii_case(working, without_alpha)
99        && working.ends_with(')')
100    {
101        return Some(&working[without_alpha.len()..working.len() - 1]);
102    }
103
104    None
105}
106
107/// Parses a double with an optional percent sign. Everything after the first `%`
108/// is ignored and the value before it is divided by 100.
109pub(crate) fn try_parse_double_or_percent(s: &str) -> Option<f64> {
110    match s.find('%') {
111        Some(percent_index) => {
112            try_parse_double(&s[..percent_index], NumberStyles::NUMBER).map(|p| p / 100.0)
113        }
114        None => try_parse_double(s, NumberStyles::NUMBER),
115    }
116}
117
118impl Color {
119    /// Initializes a new instance of the [`Color`] struct.
120    #[inline]
121    pub const fn new(a: u8, r: u8, g: u8, b: u8) -> Self {
122        Self { a, r, g, b }
123    }
124
125    /// Creates a [`Color`] from alpha, red, green and blue components.
126    #[inline]
127    pub const fn from_argb(a: u8, r: u8, g: u8, b: u8) -> Color {
128        Color::new(a, r, g, b)
129    }
130
131    /// Creates an opaque [`Color`] from red, green and blue components.
132    #[inline]
133    pub const fn from_rgb(r: u8, g: u8, b: u8) -> Color {
134        Color::new(0xff, r, g, b)
135    }
136
137    /// Creates a [`Color`] from an integer in `0xAARRGGBB` layout.
138    #[inline]
139    pub const fn from_uint32(value: u32) -> Color {
140        Color::new(
141            ((value >> 24) & 0xff) as u8,
142            ((value >> 16) & 0xff) as u8,
143            ((value >> 8) & 0xff) as u8,
144            (value & 0xff) as u8,
145        )
146    }
147
148    /// Parses a color string: `#rgb`, `#argb`, `#rrggbb`, `#aarrggbb`,
149    /// `rgb()`/`rgba()`, `hsl()`/`hsla()`, `hsv()`/`hsva()` or a known color name.
150    pub fn parse(s: &str) -> Result<Color, FormatError> {
151        match Color::try_parse(s) {
152            Some(color) => Ok(color),
153            None => Err(FormatError::from_string(format!(
154                "Invalid color string: '{s}'."
155            ))),
156        }
157    }
158
159    /// Parses a color string, returning `None` when it is not a valid color.
160    pub fn try_parse(s: &str) -> Option<Color> {
161        if s.is_empty() {
162            return None;
163        }
164
165        let bytes = s.as_bytes();
166
167        if bytes[0] == b'#' {
168            if let Some(color) = Color::try_parse_hex_format(s) {
169                return Some(color);
170            }
171        }
172
173        // Note: The length checks are also an important optimization.
174        // The shortest possible CSS format is "rgb(0,0,0)", Length = 10.
175        if bytes.len() >= 3 && utf16_len(s) >= 10 {
176            let prefix = [
177                bytes[0].to_ascii_lowercase(),
178                bytes[1].to_ascii_lowercase(),
179                bytes[2].to_ascii_lowercase(),
180            ];
181
182            if &prefix == b"rgb" {
183                if let Some(color) = Color::try_parse_css_format(s) {
184                    return Some(color);
185                }
186            }
187
188            if &prefix == b"hsl" {
189                if let Some(hsl_color) = HslColor::try_parse(s) {
190                    return Some(hsl_color.to_rgb());
191                }
192            }
193
194            if &prefix == b"hsv" {
195                if let Some(hsv_color) = HsvColor::try_parse(s) {
196                    return Some(hsv_color.to_rgb());
197                }
198            }
199        }
200
201        let known_color = KnownColors::get_known_color(s);
202
203        if known_color != KnownColor::NONE {
204            return Some(known_color.to_color());
205        }
206
207        None
208    }
209
210    /// Parses the given string representing a CSS color value into a new [`Color`].
211    fn try_parse_hex_format(s: &str) -> Option<Color> {
212        fn try_parse_core(input: &[u8]) -> Option<Color> {
213            let mut alpha_component = 0u32;
214
215            if input.len() == 6 {
216                alpha_component = 0xff000000;
217            } else if input.len() != 8 {
218                return None;
219            }
220
221            let text = std::str::from_utf8(input).ok()?;
222            let parsed = try_parse_uint(text, NumberStyles::HEX_NUMBER)?;
223
224            Some(Color::from_uint32(parsed | alpha_component))
225        }
226
227        let input = &s.as_bytes()[1..];
228
229        // Anything outside ASCII can be neither a hex digit nor skippable white space.
230        if !input.is_ascii() {
231            return None;
232        }
233
234        // Handle shorthand cases like #FFF (RGB) or #FFFF (ARGB).
235        if input.len() == 3 || input.len() == 4 {
236            let extended_length = 2 * input.len();
237            let mut extended = [0u8; 8];
238
239            for (i, &c) in input.iter().enumerate() {
240                extended[2 * i] = c;
241                extended[2 * i + 1] = c;
242            }
243
244            return try_parse_core(&extended[..extended_length]);
245        }
246
247        try_parse_core(input)
248    }
249
250    /// Parses the given string representing a CSS color value (`rgb()`/`rgba()`).
251    fn try_parse_css_format(s: &str) -> Option<Color> {
252        // Parses a byte value with an optional percentage sign. The percent sign, if
253        // it exists, ends the number; anything after it is ignored.
254        fn internal_try_parse_byte(s: &str) -> Option<u8> {
255            match s.find('%') {
256                Some(percent_index) => try_parse_double(&s[..percent_index], NumberStyles::NUMBER)
257                    .map(|percentage| round_to_byte((percentage / 100.0) * 255.0)),
258                None => try_parse_byte(s, NumberStyles::NUMBER),
259            }
260        }
261
262        let working = strip_css_function(s, "rgba(", "rgb(")?;
263        let (components, count) = split_components(working);
264
265        if count == 3 {
266            // RGB
267            let red = internal_try_parse_byte(components[0])?;
268            let green = internal_try_parse_byte(components[1])?;
269            let blue = internal_try_parse_byte(components[2])?;
270
271            Some(Color::new(0xFF, red, green, blue))
272        } else if count == 4 {
273            // RGBA
274            let red = internal_try_parse_byte(components[0])?;
275            let green = internal_try_parse_byte(components[1])?;
276            let blue = internal_try_parse_byte(components[2])?;
277            let alpha = try_parse_double_or_percent(components[3])?;
278
279            Some(Color::new(round_to_byte(alpha * 255.0), red, green, blue))
280        } else {
281            None
282        }
283    }
284
285    /// Returns the string representation of the color using a format specifier.
286    ///
287    /// Supported format specifiers:
288    /// - `X`: XAML hex with alpha, `#AARRGGBB`
289    /// - `x`: hex without alpha, `#RRGGBB`
290    /// - `H`: HTML/CSS hex with alpha, `#RRGGBBAA`
291    /// - `R` / `r`: CSS `rgba(r, g, b, a)` / `rgb(r, g, b)`
292    /// - `R%` / `r%`: CSS `rgba(r%, g%, b%, a%)` / `rgb(r%, g%, b%)`
293    /// - `L`, `l`, `L%`, `l%`: converted to HSL and formatted as such
294    /// - `V`, `v`, `V%`, `v%`: converted to HSV and formatted as such
295    ///
296    /// A null or empty format is the same as `to_string()`. Any other format is an
297    /// error. `format_provider` is ignored: color formatting is culture-invariant.
298    pub fn to_string_format(
299        &self,
300        format: Option<&str>,
301        format_provider: Option<&CultureInfo>,
302    ) -> Result<String, FormatError> {
303        let format = match format {
304            Some(format) if !format.is_empty() => format,
305            _ => return Ok(self.to_string()),
306        };
307
308        let (a, r, g, b) = (self.a, self.r, self.g, self.b);
309
310        Ok(match format {
311            "X" => format!("#{a:02X}{r:02X}{g:02X}{b:02X}"),
312            "x" => format!("#{r:02X}{g:02X}{b:02X}"),
313            "H" => format!("#{r:02X}{g:02X}{b:02X}{a:02X}"),
314            "R" => self.format_rgb_css(true),
315            "r" => self.format_rgb_css(false),
316            "R%" => self.format_rgb_percent_css(true),
317            "r%" => self.format_rgb_percent_css(false),
318            "L" | "l" | "L%" | "l%" => return self.to_hsl().to_string_format(Some(format), format_provider),
319            "V" | "v" | "V%" | "v%" => return self.to_hsv().to_string_format(Some(format), format_provider),
320            _ => {
321                return Err(FormatError::from_string(format!(
322                    "Format string '{format}' is not supported."
323                )))
324            }
325        })
326    }
327
328    fn format_rgb_css(&self, include_alpha: bool) -> String {
329        if include_alpha {
330            return format!(
331                "rgba({}, {}, {}, {})",
332                self.r,
333                self.g,
334                self.b,
335                FixedF2(self.a as f64 * BYTE_TO_DOUBLE)
336            );
337        }
338
339        format!("rgb({}, {}, {})", self.r, self.g, self.b)
340    }
341
342    fn format_rgb_percent_css(&self, include_alpha: bool) -> String {
343        let r_pct = round_to_int(self.r as f64 * BYTE_TO_DOUBLE * 100.0);
344        let g_pct = round_to_int(self.g as f64 * BYTE_TO_DOUBLE * 100.0);
345        let b_pct = round_to_int(self.b as f64 * BYTE_TO_DOUBLE * 100.0);
346
347        if include_alpha {
348            let a_pct = round_to_int(self.a as f64 * BYTE_TO_DOUBLE * 100.0);
349            return format!("rgba({r_pct}%, {g_pct}%, {b_pct}%, {a_pct}%)");
350        }
351
352        format!("rgb({r_pct}%, {g_pct}%, {b_pct}%)")
353    }
354
355    /// Returns the integer representation of the color in `0xAARRGGBB` layout.
356    #[inline]
357    pub const fn to_uint32(&self) -> u32 {
358        ((self.a as u32) << 24) | ((self.r as u32) << 16) | ((self.g as u32) << 8) | (self.b as u32)
359    }
360
361    /// Returns the HSL color model equivalent of this RGB color.
362    #[inline]
363    pub fn to_hsl(&self) -> HslColor {
364        Color::rgb_to_hsl(self.r, self.g, self.b, self.a)
365    }
366
367    /// Returns the HSV color model equivalent of this RGB color.
368    #[inline]
369    pub fn to_hsv(&self) -> HsvColor {
370        Color::rgb_to_hsv(self.r, self.g, self.b, self.a)
371    }
372
373    /// Check if two colors are equal.
374    #[inline]
375    pub const fn equals(&self, other: Color) -> bool {
376        self.a == other.a && self.r == other.r && self.g == other.g && self.b == other.b
377    }
378
379    /// Converts the given RGBA color component values to their HSL color equivalent.
380    #[inline]
381    pub fn rgb_to_hsl(red: u8, green: u8, blue: u8, alpha: u8) -> HslColor {
382        // Normalize RGBA components into the 0..1 range
383        Color::normalized_rgb_to_hsl(
384            BYTE_TO_DOUBLE * red as f64,
385            BYTE_TO_DOUBLE * green as f64,
386            BYTE_TO_DOUBLE * blue as f64,
387            BYTE_TO_DOUBLE * alpha as f64,
388        )
389    }
390
391    /// Converts the given RGBA color component values to their HSL color equivalent.
392    /// Warning: no bounds checks or clamping is done on the input component values;
393    /// they are assumed to be within the 0..1 range. The result is not clamped either.
394    pub fn normalized_rgb_to_hsl(r: f64, g: f64, b: f64, a: f64) -> HslColor {
395        let max = if r >= g {
396            if r >= b {
397                r
398            } else {
399                b
400            }
401        } else if g >= b {
402            g
403        } else {
404            b
405        };
406        let min = if r <= g {
407            if r <= b {
408                r
409            } else {
410                b
411            }
412        } else if g <= b {
413            g
414        } else {
415            b
416        };
417        let chroma = max - min;
418        let h1;
419
420        if chroma == 0.0 {
421            h1 = 0.0;
422        } else if max == r {
423            // The % operator doesn't do proper modulo on negative
424            // numbers, so we'll add 6 before using it
425            h1 = (((g - b) / chroma) + 6.0) % 6.0;
426        } else if max == g {
427            h1 = 2.0 + ((b - r) / chroma);
428        } else {
429            h1 = 4.0 + ((r - g) / chroma);
430        }
431
432        let lightness = 0.5 * (max + min);
433        let saturation = if chroma == 0.0 {
434            0.0
435        } else {
436            chroma / (1.0 - ((2.0 * lightness) - 1.0).abs())
437        };
438
439        HslColor::new_with_clamp(a, 60.0 * h1, saturation, lightness, false)
440    }
441
442    /// Converts the given RGBA color component values to their HSV color equivalent.
443    #[inline]
444    pub fn rgb_to_hsv(red: u8, green: u8, blue: u8, alpha: u8) -> HsvColor {
445        // Normalize RGBA components into the 0..1 range
446        Color::normalized_rgb_to_hsv(
447            BYTE_TO_DOUBLE * red as f64,
448            BYTE_TO_DOUBLE * green as f64,
449            BYTE_TO_DOUBLE * blue as f64,
450            BYTE_TO_DOUBLE * alpha as f64,
451        )
452    }
453
454    /// Converts the given RGBA color component values to their HSV color equivalent.
455    /// Warning: no bounds checks or clamping is done on the input component values;
456    /// they are assumed to be within the 0..1 range. The result is not clamped either.
457    pub fn normalized_rgb_to_hsv(r: f64, g: f64, b: f64, a: f64) -> HsvColor {
458        let mut hue;
459        let saturation;
460
461        let max = if r >= g {
462            if r >= b {
463                r
464            } else {
465                b
466            }
467        } else if g >= b {
468            g
469        } else {
470            b
471        };
472        let min = if r <= g {
473            if r <= b {
474                r
475            } else {
476                b
477            }
478        } else if g <= b {
479            g
480        } else {
481            b
482        };
483
484        // The value, a number between 0 and 1, is the largest of R, G, and B.
485        // Conceptually speaking, it represents how much color is present.
486        let value = max;
487
488        // The "chroma" of the color is a value directly proportional to the extent to which
489        // the color diverges from greyscale.
490        let chroma = max - min;
491
492        // If the chroma is zero, then hue is technically undefined - a greyscale color
493        // has no hue. For the sake of convenience, hue is set to zero. Since the color
494        // is purely gray, saturation is also equal to zero.
495        if chroma == 0.0 {
496            hue = 0.0;
497            saturation = 0.0;
498        } else {
499            // Hue can be thought of as a cyclical thing, between 0 degrees and 360 degrees.
500            // A hue of 0 degrees is red; 120 degrees is green; 240 degrees is blue; and 360 is back to red.
501            if r == max {
502                // The red channel is the most pronounced channel: somewhere between (-60, 60).
503                hue = 60.0 * (g - b) / chroma;
504            } else if g == max {
505                // Centered in the green third of the color wheel.
506                hue = 120.0 + (60.0 * (b - r) / chroma);
507            } else {
508                // Centered in the blue third of the color wheel.
509                hue = 240.0 + (60.0 * (r - g) / chroma);
510            }
511
512            // Since we want to work within the range [0, 360), we'll add 360 to any value less than zero -
513            // this will bump red values from within -60 to -1 to 300 to 359. The hue is the same at both values.
514            if hue < 0.0 {
515                hue += 360.0;
516            }
517
518            // The saturation is the chroma normalized by the maximum channel (i.e., the value).
519            saturation = chroma / value;
520        }
521
522        HsvColor::new_with_clamp(a, hue, saturation, value, false)
523    }
524}
525
526impl FromStr for Color {
527    type Err = FormatError;
528    #[inline]
529    fn from_str(s: &str) -> Result<Self, FormatError> {
530        Color::parse(s)
531    }
532}
533
534/// Writes the known color name when there is one, `#aarrggbb` (lower-case) otherwise.
535impl fmt::Display for Color {
536    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
537        let rgb = self.to_uint32();
538        match KnownColors::get_known_color_name(rgb) {
539            Some(name) => f.write_str(name),
540            None => write!(f, "#{rgb:08x}"),
541        }
542    }
543}
544
545// Not from upstream: edge cases of the parser and of the integer and model
546// conversions that the upstream suite (`color_tests.rs`) does not cover.
547#[cfg(test)]
548mod tests {
549    use super::*;
550
551    #[test]
552    fn css_parsing_edge_cases() {
553        // Too short for the css path and not a known color.
554        assert_eq!(Color::try_parse("rgb(0,0,)"), None);
555        assert_eq!(Color::try_parse("rgb(0,0,0,0,0)"), None);
556        assert_eq!(Color::try_parse("rgb(256,0,0)"), None);
557        assert_eq!(Color::try_parse("rgb(1e1,0,0)"), None);
558        assert_eq!(Color::try_parse("rgb(-1,0,0)"), None);
559        assert_eq!(
560            Color::try_parse("  RGB( 1 , 2.0 , 3 )  "),
561            None,
562            "the prefix is checked before trimming"
563        );
564        assert_eq!(
565            Color::try_parse("RGB( 1 , 2.0 , 3 )  "),
566            Some(Color::new(255, 1, 2, 3))
567        );
568        assert_eq!(
569            Color::try_parse("rgba(1,2,3,200%)"),
570            Some(Color::new(255, 1, 2, 3))
571        );
572        assert_eq!(
573            Color::try_parse("rgb(150%,0%,-10%)"),
574            Some(Color::new(255, 255, 0, 0))
575        );
576        assert_eq!(Color::try_parse("#ggg"), None);
577        assert_eq!(Color::try_parse("#"), None);
578        assert_eq!(Color::try_parse("#\u{e9}\u{e9}\u{e9}"), None);
579        assert_eq!(Color::try_parse("notacolor"), None);
580        assert_eq!(
581            Color::try_parse("transparent"),
582            Some(Color::new(0, 255, 255, 255))
583        );
584    }
585
586    #[test]
587    fn uint32_round_trip_and_model_conversions() {
588        let c = Color::from_uint32(0x40ff8844);
589        assert_eq!(c, Color::new(0x40, 0xff, 0x88, 0x44));
590        assert_eq!(c.to_uint32(), 0x40ff8844);
591        assert_eq!(Color::from_rgb(1, 2, 3), Color::from_argb(255, 1, 2, 3));
592        for value in [
593            0xff000000u32,
594            0xffffffff,
595            0xff336699,
596            0x80ff0000,
597            0xff00ff00,
598            0xff0000ff,
599            0xff7f7f7f,
600        ] {
601            let color = Color::from_uint32(value);
602            assert_eq!(color.to_hsl().to_rgb(), color, "{value:08x} via hsl");
603            assert_eq!(color.to_hsv().to_rgb(), color, "{value:08x} via hsv");
604        }
605        assert_eq!(
606            "#ff8844".parse::<Color>().unwrap(),
607            Color::new(255, 255, 0x88, 0x44)
608        );
609    }
610}