From e2f5d36cee607fd778e960256a2d552a225d037b Mon Sep 17 00:00:00 2001 From: misaka10987 <136697326+misaka10987@users.noreply.github.com> Date: Fri, 9 May 2025 22:05:19 +0800 Subject: [PATCH 1/4] implement `FromStr` and `Display` for `Color` --- core/src/color.rs | 117 ++++++++++++++++++++++++++++++++-------------- 1 file changed, 82 insertions(+), 35 deletions(-) diff --git a/core/src/color.rs b/core/src/color.rs index ff1e18b0..70b3dac7 100644 --- a/core/src/color.rs +++ b/core/src/color.rs @@ -1,4 +1,15 @@ +use std::{fmt::Display, num::ParseIntError, str::FromStr}; + +use thiserror::Error; + /// A color in the `sRGB` color space. +/// +/// # String Representation +/// +/// A color can be represented in either of the following valid formats: `#rrggbb`, `#rrggbbaa`, `#rgb`, and `#rgba`. +/// Both uppercase and lowercase letters are supported. +/// +/// If `a` (transparency) is not specified, `1.0` (completely opaque) would be used by default. #[derive(Debug, Clone, Copy, PartialEq, Default)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct Color { @@ -108,41 +119,7 @@ impl Color { /// /// [`color!`]: crate::color! pub fn parse(s: &str) -> Option { - let hex = s.strip_prefix('#').unwrap_or(s); - - let parse_channel = |from: usize, to: usize| { - let num = - usize::from_str_radix(&hex[from..=to], 16).ok()? as f32 / 255.0; - - // If we only got half a byte (one letter), expand it into a full byte (two letters) - Some(if from == to { num + num * 16.0 } else { num }) - }; - - Some(match hex.len() { - 3 => Color::from_rgb( - parse_channel(0, 0)?, - parse_channel(1, 1)?, - parse_channel(2, 2)?, - ), - 4 => Color::from_rgba( - parse_channel(0, 0)?, - parse_channel(1, 1)?, - parse_channel(2, 2)?, - parse_channel(3, 3)?, - ), - 6 => Color::from_rgb( - parse_channel(0, 1)?, - parse_channel(2, 3)?, - parse_channel(4, 5)?, - ), - 8 => Color::from_rgba( - parse_channel(0, 1)?, - parse_channel(2, 3)?, - parse_channel(4, 5)?, - parse_channel(6, 7)?, - ), - _ => None?, - }) + s.parse().ok() } /// Converts the [`Color`] into its RGBA8 equivalent. @@ -232,6 +209,76 @@ impl From<[f32; 4]> for Color { } } +/// An error which can be returned when parsing color from an RGB hexadecimal string. +/// +/// See [`Color`] for specifications for the string. +#[derive(Debug, Error)] +pub enum ParseColorError { + /// The string could not be parsed to valid integers. + #[error(transparent)] + ParseIntError(#[from] ParseIntError), + /// The string is of invalid length. + #[error( + "expected hex string of length 3, 4, 6 or 8 excluding optional prefix '#', found {0}" + )] + InvalidLength(usize), +} + +impl FromStr for Color { + type Err = ParseColorError; + + fn from_str(s: &str) -> Result { + let hex = s.strip_prefix('#').unwrap_or(s); + + let parse_channel = + |from: usize, to: usize| -> Result { + let num = + usize::from_str_radix(&hex[from..=to], 16)? as f32 / 255.0; + + // If we only got half a byte (one letter), expand it into a full byte (two letters) + Ok(if from == to { num + num * 16.0 } else { num }) + }; + + let val = match hex.len() { + 3 => Color::from_rgb( + parse_channel(0, 0)?, + parse_channel(1, 1)?, + parse_channel(2, 2)?, + ), + 4 => Color::from_rgba( + parse_channel(0, 0)?, + parse_channel(1, 1)?, + parse_channel(2, 2)?, + parse_channel(3, 3)?, + ), + 6 => Color::from_rgb( + parse_channel(0, 1)?, + parse_channel(2, 3)?, + parse_channel(4, 5)?, + ), + 8 => Color::from_rgba( + parse_channel(0, 1)?, + parse_channel(2, 3)?, + parse_channel(4, 5)?, + parse_channel(6, 7)?, + ), + _ => return Err(ParseColorError::InvalidLength(hex.len())), + }; + + Ok(val) + } +} + +impl Display for Color { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let [r, g, b, a] = self.into_rgba8(); + if self.a == 1.0 { + return write!(f, "#{r:02x}{g:02x}{b:02x}"); + } + write!(f, "#{r:02x}{g:02x}{b:02x}{a:02x}") + } +} + /// Creates a [`Color`] with shorter and cleaner syntax. /// /// # Examples From 39d1971b46587abaa7169a08bbd7045a4b8949a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20Ram=C3=B3n=20Jim=C3=A9nez?= Date: Tue, 25 Nov 2025 23:36:51 +0100 Subject: [PATCH 2/4] Remove `Color::parse` method and add some more tests --- core/src/color.rs | 41 +++++++++++++++++------------------------ 1 file changed, 17 insertions(+), 24 deletions(-) diff --git a/core/src/color.rs b/core/src/color.rs index 70b3dac7..fe7c2702 100644 --- a/core/src/color.rs +++ b/core/src/color.rs @@ -7,9 +7,12 @@ use thiserror::Error; /// # String Representation /// /// A color can be represented in either of the following valid formats: `#rrggbb`, `#rrggbbaa`, `#rgb`, and `#rgba`. -/// Both uppercase and lowercase letters are supported. +/// Where `rgba` represent hexadecimal digits. Both uppercase and lowercase letters are supported. /// /// If `a` (transparency) is not specified, `1.0` (completely opaque) would be used by default. +/// +/// If you have a static color string, using the [`color!`] macro should be preferred +/// since it leverages hexadecimal literal notation and arithmetic directly. #[derive(Debug, Clone, Copy, PartialEq, Default)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct Color { @@ -109,19 +112,6 @@ impl Color { ) } - /// Parses a [`Color`] from a hex string. - /// - /// Supported formats are `#rrggbb`, `#rrggbbaa`, `#rgb`, and `#rgba`. - /// The starting "#" is optional. Both uppercase and lowercase are supported. - /// - /// If you have a static color string, using the [`color!`] macro should be preferred - /// since it leverages hexadecimal literal notation and arithmetic directly. - /// - /// [`color!`]: crate::color! - pub fn parse(s: &str) -> Option { - s.parse().ok() - } - /// Converts the [`Color`] into its RGBA8 equivalent. #[must_use] pub fn into_rgba8(self) -> [u8; 4] { @@ -272,9 +262,11 @@ impl FromStr for Color { impl Display for Color { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let [r, g, b, a] = self.into_rgba8(); + if self.a == 1.0 { return write!(f, "#{r:02x}{g:02x}{b:02x}"); } + write!(f, "#{r:02x}{g:02x}{b:02x}{a:02x}") } } @@ -318,19 +310,20 @@ mod tests { #[test] fn parse() { let tests = [ - ("#ff0000", [255, 0, 0, 255]), - ("00ff0080", [0, 255, 0, 128]), - ("#F80", [255, 136, 0, 255]), - ("#00f1", [0, 0, 255, 17]), + ("#ff0000", [255, 0, 0, 255], "#ff0000"), + ("00ff0080", [0, 255, 0, 128], "#00ff0080"), + ("#F80", [255, 136, 0, 255], "#ff8800"), + ("#00f1", [0, 0, 255, 17], "#0000ff11"), + ("#00ff", [0, 0, 255, 255], "#0000ff"), ]; - for (arg, expected) in tests { - assert_eq!( - Color::parse(arg).expect("color must parse").into_rgba8(), - expected - ); + for (arg, expected_rgba8, expected_str) in tests { + let color = arg.parse::().expect("color must parse"); + + assert_eq!(color.into_rgba8(), expected_rgba8); + assert_eq!(color.to_string(), expected_str); } - assert!(Color::parse("invalid").is_none()); + assert!("invalid".parse::().is_err()); } } From b6926342fef30169a3f621b80f6d0e079d5db673 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20Ram=C3=B3n=20Jim=C3=A9nez?= Date: Tue, 25 Nov 2025 23:39:34 +0100 Subject: [PATCH 3/4] Improve import consistency in `core::color` --- core/src/color.rs | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/core/src/color.rs b/core/src/color.rs index fe7c2702..e41948e8 100644 --- a/core/src/color.rs +++ b/core/src/color.rs @@ -1,7 +1,3 @@ -use std::{fmt::Display, num::ParseIntError, str::FromStr}; - -use thiserror::Error; - /// A color in the `sRGB` color space. /// /// # String Representation @@ -202,11 +198,11 @@ impl From<[f32; 4]> for Color { /// An error which can be returned when parsing color from an RGB hexadecimal string. /// /// See [`Color`] for specifications for the string. -#[derive(Debug, Error)] -pub enum ParseColorError { +#[derive(Debug, thiserror::Error)] +pub enum ParseError { /// The string could not be parsed to valid integers. #[error(transparent)] - ParseIntError(#[from] ParseIntError), + ParseIntError(#[from] std::num::ParseIntError), /// The string is of invalid length. #[error( "expected hex string of length 3, 4, 6 or 8 excluding optional prefix '#', found {0}" @@ -214,14 +210,14 @@ pub enum ParseColorError { InvalidLength(usize), } -impl FromStr for Color { - type Err = ParseColorError; +impl std::str::FromStr for Color { + type Err = ParseError; fn from_str(s: &str) -> Result { let hex = s.strip_prefix('#').unwrap_or(s); let parse_channel = - |from: usize, to: usize| -> Result { + |from: usize, to: usize| -> Result { let num = usize::from_str_radix(&hex[from..=to], 16)? as f32 / 255.0; @@ -252,14 +248,14 @@ impl FromStr for Color { parse_channel(4, 5)?, parse_channel(6, 7)?, ), - _ => return Err(ParseColorError::InvalidLength(hex.len())), + _ => return Err(ParseError::InvalidLength(hex.len())), }; Ok(val) } } -impl Display for Color { +impl std::fmt::Display for Color { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let [r, g, b, a] = self.into_rgba8(); From 0c075cc9c3b4971b927d9de0e608f800b273fa36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20Ram=C3=B3n=20Jim=C3=A9nez?= Date: Tue, 25 Nov 2025 23:43:22 +0100 Subject: [PATCH 4/4] Fix broken `color!` documentation link in `core::color` --- core/src/color.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/core/src/color.rs b/core/src/color.rs index e41948e8..6a09c2fb 100644 --- a/core/src/color.rs +++ b/core/src/color.rs @@ -9,6 +9,8 @@ /// /// If you have a static color string, using the [`color!`] macro should be preferred /// since it leverages hexadecimal literal notation and arithmetic directly. +/// +/// [`color!`]: crate::color! #[derive(Debug, Clone, Copy, PartialEq, Default)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct Color {