ecolor/
hex_color_runtime.rs

1//! Convert colors to and from the hex-color string format at runtime
2//!
3//! Supports the 3, 4, 6, and 8-digit formats, according to the specification in
4//! <https://drafts.csswg.org/css-color-4/#hex-color>
5
6use std::{fmt::Display, str::FromStr};
7
8use crate::Color32;
9
10#[repr(C)]
11#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
12#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
13/// A wrapper around Color32 that converts to and from a hex-color string
14///
15/// Implements [`Display`] and [`FromStr`] to convert to and from the hex string.
16pub enum HexColor {
17    /// 3 hexadecimal digits, one for each of the r, g, b channels
18    Hex3(Color32),
19
20    /// 4 hexadecimal digits, one for each of the r, g, b, a channels
21    Hex4(Color32),
22
23    /// 6 hexadecimal digits, two for each of the r, g, b channels
24    Hex6(Color32),
25
26    /// 8 hexadecimal digits, one for each of the r, g, b, a channels
27    Hex8(Color32),
28}
29
30#[derive(Clone, Debug, Eq, PartialEq)]
31pub enum ParseHexColorError {
32    MissingHash,
33    InvalidLength,
34    InvalidInt(std::num::ParseIntError),
35}
36
37impl FromStr for HexColor {
38    type Err = ParseHexColorError;
39
40    fn from_str(s: &str) -> Result<Self, Self::Err> {
41        s.strip_prefix('#')
42            .ok_or(ParseHexColorError::MissingHash)
43            .and_then(Self::from_str_without_hash)
44    }
45}
46
47impl Display for HexColor {
48    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49        match self {
50            Self::Hex3(color) => {
51                let [r, g, b, _] = color.to_srgba_unmultiplied().map(|u| u >> 4);
52                f.write_fmt(format_args!("#{r:x}{g:x}{b:x}"))
53            }
54            Self::Hex4(color) => {
55                let [r, g, b, a] = color.to_srgba_unmultiplied().map(|u| u >> 4);
56                f.write_fmt(format_args!("#{r:x}{g:x}{b:x}{a:x}"))
57            }
58            Self::Hex6(color) => {
59                let [r, g, b, _] = color.to_srgba_unmultiplied();
60                let u = u32::from_be_bytes([0, r, g, b]);
61                f.write_fmt(format_args!("#{u:06x}"))
62            }
63            Self::Hex8(color) => {
64                let [r, g, b, a] = color.to_srgba_unmultiplied();
65                let u = u32::from_be_bytes([r, g, b, a]);
66                f.write_fmt(format_args!("#{u:08x}"))
67            }
68        }
69    }
70}
71
72impl HexColor {
73    /// Retrieves the inner [`Color32`]
74    #[inline]
75    pub fn color(&self) -> Color32 {
76        match self {
77            Self::Hex3(color) | Self::Hex4(color) | Self::Hex6(color) | Self::Hex8(color) => *color,
78        }
79    }
80
81    /// Parses a string as a hex color without the leading `#` character
82    ///
83    /// # Errors
84    /// Returns an error if the length of the string does not correspond to one of the standard
85    /// formats (3, 4, 6, or 8), or if it contains non-hex characters.
86    #[inline]
87    pub fn from_str_without_hash(s: &str) -> Result<Self, ParseHexColorError> {
88        match s.len() {
89            3 => {
90                let [r, gb] = u16::from_str_radix(s, 16)
91                    .map_err(ParseHexColorError::InvalidInt)?
92                    .to_be_bytes();
93                let [r, g, b] = [r, gb >> 4, gb & 0x0f].map(|u| u << 4 | u);
94                Ok(Self::Hex3(Color32::from_rgb(r, g, b)))
95            }
96            4 => {
97                let [r_g, b_a] = u16::from_str_radix(s, 16)
98                    .map_err(ParseHexColorError::InvalidInt)?
99                    .to_be_bytes();
100                let [r, g, b, a] = [r_g >> 4, r_g & 0x0f, b_a >> 4, b_a & 0x0f].map(|u| u << 4 | u);
101                Ok(Self::Hex4(Color32::from_rgba_unmultiplied(r, g, b, a)))
102            }
103            6 => {
104                let [_, r, g, b] = u32::from_str_radix(s, 16)
105                    .map_err(ParseHexColorError::InvalidInt)?
106                    .to_be_bytes();
107                Ok(Self::Hex6(Color32::from_rgb(r, g, b)))
108            }
109            8 => {
110                let [r, g, b, a] = u32::from_str_radix(s, 16)
111                    .map_err(ParseHexColorError::InvalidInt)?
112                    .to_be_bytes();
113                Ok(Self::Hex8(Color32::from_rgba_unmultiplied(r, g, b, a)))
114            }
115            _ => Err(ParseHexColorError::InvalidLength)?,
116        }
117    }
118}
119
120impl Color32 {
121    /// Parses a color from a hex string.
122    ///
123    /// Supports the 3, 4, 6, and 8-digit formats, according to the specification in
124    /// <https://drafts.csswg.org/css-color-4/#hex-color>
125    ///
126    /// To parse hex colors from string literals with compile-time checking, use the macro
127    /// [`crate::hex_color!`] instead.
128    ///
129    /// # Example
130    /// ```rust
131    /// use ecolor::Color32;
132    /// assert_eq!(Ok(Color32::RED), Color32::from_hex("#ff0000"));
133    /// assert_eq!(Ok(Color32::GREEN), Color32::from_hex("#00ff00ff"));
134    /// assert_eq!(Ok(Color32::BLUE), Color32::from_hex("#00f"));
135    /// assert_eq!(Ok(Color32::TRANSPARENT), Color32::from_hex("#0000"));
136    /// ```
137    ///
138    /// # Errors
139    /// Returns an error if the string doesn't start with the hash `#` character, if the remaining
140    /// length does not correspond to one of the standard formats (3, 4, 6, or 8), if it contains
141    /// non-hex characters.
142    pub fn from_hex(hex: &str) -> Result<Self, ParseHexColorError> {
143        HexColor::from_str(hex).map(|h| h.color())
144    }
145
146    /// Formats the color as a hex string.
147    ///
148    /// # Example
149    /// ```rust
150    /// use ecolor::Color32;
151    /// assert_eq!(Color32::RED.to_hex(), "#ff0000ff");
152    /// assert_eq!(Color32::GREEN.to_hex(), "#00ff00ff");
153    /// assert_eq!(Color32::BLUE.to_hex(), "#0000ffff");
154    /// assert_eq!(Color32::TRANSPARENT.to_hex(), "#00000000");
155    /// ```
156    ///
157    /// Uses the 8-digit format described in <https://drafts.csswg.org/css-color-4/#hex-color>,
158    /// as that is the only format that is lossless.
159    /// For other formats, see [`HexColor`].
160    #[inline]
161    pub fn to_hex(&self) -> String {
162        HexColor::Hex8(*self).to_string()
163    }
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169
170    #[test]
171    fn hex_string_formats() {
172        use Color32 as C;
173        use HexColor as H;
174        let cases = [
175            (H::Hex3(C::RED), "#f00"),
176            (H::Hex4(C::RED), "#f00f"),
177            (H::Hex6(C::RED), "#ff0000"),
178            (H::Hex8(C::RED), "#ff0000ff"),
179            (H::Hex3(C::GREEN), "#0f0"),
180            (H::Hex4(C::GREEN), "#0f0f"),
181            (H::Hex6(C::GREEN), "#00ff00"),
182            (H::Hex8(C::GREEN), "#00ff00ff"),
183            (H::Hex3(C::BLUE), "#00f"),
184            (H::Hex4(C::BLUE), "#00ff"),
185            (H::Hex6(C::BLUE), "#0000ff"),
186            (H::Hex8(C::BLUE), "#0000ffff"),
187            (H::Hex3(C::WHITE), "#fff"),
188            (H::Hex4(C::WHITE), "#ffff"),
189            (H::Hex6(C::WHITE), "#ffffff"),
190            (H::Hex8(C::WHITE), "#ffffffff"),
191            (H::Hex3(C::BLACK), "#000"),
192            (H::Hex4(C::BLACK), "#000f"),
193            (H::Hex6(C::BLACK), "#000000"),
194            (H::Hex8(C::BLACK), "#000000ff"),
195            (H::Hex4(C::TRANSPARENT), "#0000"),
196            (H::Hex8(C::TRANSPARENT), "#00000000"),
197        ];
198        for (color, string) in cases {
199            assert_eq!(color.to_string(), string, "{color:?} <=> {string}");
200            assert_eq!(
201                H::from_str(string).unwrap(),
202                color,
203                "{color:?} <=> {string}"
204            );
205        }
206    }
207
208    #[test]
209    fn hex_string_round_trip() {
210        use Color32 as C;
211        let cases = [
212            C::from_rgba_unmultiplied(10, 20, 30, 0),
213            C::from_rgba_unmultiplied(10, 20, 30, 40),
214            C::from_rgba_unmultiplied(10, 20, 30, 255),
215            C::from_rgba_unmultiplied(0, 20, 30, 0),
216            C::from_rgba_unmultiplied(10, 0, 30, 40),
217            C::from_rgba_unmultiplied(10, 20, 0, 255),
218        ];
219        for color in cases {
220            assert_eq!(C::from_hex(color.to_hex().as_str()), Ok(color));
221        }
222    }
223}