Skip to main content

esoc_gfx/
color.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Color representation with RGBA channels and hex parsing.
3
4use crate::error::{GfxError, Result};
5
6/// An RGBA color with f64 channels in `[0.0, 1.0]`.
7///
8/// **Deprecated:** This type uses sRGB f64 values. Prefer [`esoc_color::Color`]
9/// which uses linear f32 RGBA (GPU-native). Use `esoc_color::Color::from(legacy_color)`
10/// to convert.
11#[deprecated(
12    note = "Use esoc_color::Color instead — this type uses sRGB f64, esoc_color uses linear f32"
13)]
14#[derive(Clone, Copy, Debug, PartialEq)]
15pub struct Color {
16    /// Red channel.
17    pub r: f64,
18    /// Green channel.
19    pub g: f64,
20    /// Blue channel.
21    pub b: f64,
22    /// Alpha channel (1.0 = fully opaque).
23    pub a: f64,
24}
25
26#[allow(deprecated)]
27impl Color {
28    /// Fully transparent.
29    pub const TRANSPARENT: Self = Self {
30        r: 0.0,
31        g: 0.0,
32        b: 0.0,
33        a: 0.0,
34    };
35    /// Black.
36    pub const BLACK: Self = Self {
37        r: 0.0,
38        g: 0.0,
39        b: 0.0,
40        a: 1.0,
41    };
42    /// White.
43    pub const WHITE: Self = Self {
44        r: 1.0,
45        g: 1.0,
46        b: 1.0,
47        a: 1.0,
48    };
49    /// Gray (50%).
50    pub const GRAY: Self = Self {
51        r: 0.5,
52        g: 0.5,
53        b: 0.5,
54        a: 1.0,
55    };
56    /// Light gray.
57    pub const LIGHT_GRAY: Self = Self {
58        r: 0.83,
59        g: 0.83,
60        b: 0.83,
61        a: 1.0,
62    };
63    /// Red.
64    pub const RED: Self = Self {
65        r: 1.0,
66        g: 0.0,
67        b: 0.0,
68        a: 1.0,
69    };
70    /// Green.
71    pub const GREEN: Self = Self {
72        r: 0.0,
73        g: 0.5,
74        b: 0.0,
75        a: 1.0,
76    };
77    /// Blue.
78    pub const BLUE: Self = Self {
79        r: 0.0,
80        g: 0.0,
81        b: 1.0,
82        a: 1.0,
83    };
84
85    /// Create a new color from RGBA channels in `[0.0, 1.0]`.
86    pub fn new(r: f64, g: f64, b: f64, a: f64) -> Self {
87        Self { r, g, b, a }
88    }
89
90    /// Create an opaque color from RGB channels in `[0.0, 1.0]`.
91    pub fn rgb(r: f64, g: f64, b: f64) -> Self {
92        Self { r, g, b, a: 1.0 }
93    }
94
95    /// Create a color from 8-bit RGB values.
96    pub fn from_rgb8(r: u8, g: u8, b: u8) -> Self {
97        Self {
98            r: f64::from(r) / 255.0,
99            g: f64::from(g) / 255.0,
100            b: f64::from(b) / 255.0,
101            a: 1.0,
102        }
103    }
104
105    /// Parse a hex color string (`#RRGGBB` or `#RRGGBBAA`).
106    pub fn from_hex(hex: &str) -> Result<Self> {
107        let hex = hex.strip_prefix('#').unwrap_or(hex);
108        let parse_byte = |s: &str| {
109            u8::from_str_radix(s, 16)
110                .map_err(|_| GfxError::InvalidColor(format!("invalid hex byte: {s}")))
111        };
112
113        match hex.len() {
114            6 => {
115                let r = parse_byte(&hex[0..2])?;
116                let g = parse_byte(&hex[2..4])?;
117                let b = parse_byte(&hex[4..6])?;
118                Ok(Self::from_rgb8(r, g, b))
119            }
120            8 => {
121                let r = parse_byte(&hex[0..2])?;
122                let g = parse_byte(&hex[2..4])?;
123                let b = parse_byte(&hex[4..6])?;
124                let a = parse_byte(&hex[6..8])?;
125                Ok(Self::new(
126                    f64::from(r) / 255.0,
127                    f64::from(g) / 255.0,
128                    f64::from(b) / 255.0,
129                    f64::from(a) / 255.0,
130                ))
131            }
132            _ => Err(GfxError::InvalidColor(format!(
133                "expected 6 or 8 hex digits, got {}",
134                hex.len()
135            ))),
136        }
137    }
138
139    /// Linearly interpolate between two colors.
140    pub fn lerp(self, other: Self, t: f64) -> Self {
141        let t = t.clamp(0.0, 1.0);
142        Self {
143            r: self.r + (other.r - self.r) * t,
144            g: self.g + (other.g - self.g) * t,
145            b: self.b + (other.b - self.b) * t,
146            a: self.a + (other.a - self.a) * t,
147        }
148    }
149
150    /// Return this color with a new alpha value.
151    pub fn with_alpha(mut self, a: f64) -> Self {
152        self.a = a;
153        self
154    }
155
156    /// Format as an SVG color string (`rgb(R,G,B)` or `rgba(R,G,B,A)`).
157    pub fn to_svg_string(self) -> String {
158        let r = (self.r * 255.0).round() as u8;
159        let g = (self.g * 255.0).round() as u8;
160        let b = (self.b * 255.0).round() as u8;
161        if (self.a - 1.0).abs() < 1e-6 {
162            format!("rgb({r},{g},{b})")
163        } else {
164            format!("rgba({r},{g},{b},{:.3})", self.a)
165        }
166    }
167
168    /// Format as a hex string (`#RRGGBB`).
169    pub fn to_hex(self) -> String {
170        let r = (self.r * 255.0).round() as u8;
171        let g = (self.g * 255.0).round() as u8;
172        let b = (self.b * 255.0).round() as u8;
173        format!("#{r:02x}{g:02x}{b:02x}")
174    }
175}
176
177#[allow(deprecated)]
178impl From<Color> for esoc_color::Color {
179    /// Convert from legacy sRGB f64 Color to linear f32 Color.
180    ///
181    /// Applies sRGB→linear conversion on each channel.
182    fn from(c: Color) -> Self {
183        fn srgb_to_linear(s: f64) -> f32 {
184            let v = if s <= 0.04045 {
185                s / 12.92
186            } else {
187                ((s + 0.055) / 1.055).powf(2.4)
188            };
189            v as f32
190        }
191        Self::new(
192            srgb_to_linear(c.r),
193            srgb_to_linear(c.g),
194            srgb_to_linear(c.b),
195            c.a as f32,
196        )
197    }
198}
199
200#[allow(deprecated)]
201impl From<esoc_color::Color> for Color {
202    /// Convert from new linear f32 Color back to legacy sRGB f64 Color.
203    ///
204    /// Applies linear→sRGB conversion on each channel.
205    fn from(c: esoc_color::Color) -> Self {
206        fn linear_to_srgb(l: f32) -> f64 {
207            let v = if l <= 0.003_130_8 {
208                l * 12.92
209            } else {
210                1.055 * l.powf(1.0 / 2.4) - 0.055
211            };
212            f64::from(v)
213        }
214        Self {
215            r: linear_to_srgb(c.r),
216            g: linear_to_srgb(c.g),
217            b: linear_to_srgb(c.b),
218            a: f64::from(c.a),
219        }
220    }
221}
222
223#[allow(deprecated)]
224impl Default for Color {
225    fn default() -> Self {
226        Self::BLACK
227    }
228}
229
230#[cfg(test)]
231#[allow(deprecated)]
232mod tests {
233    use super::*;
234
235    #[test]
236    fn test_hex_parsing() {
237        let c = Color::from_hex("#1f77b4").unwrap();
238        assert_eq!((c.r * 255.0).round() as u8, 0x1f);
239        assert_eq!((c.g * 255.0).round() as u8, 0x77);
240        assert_eq!((c.b * 255.0).round() as u8, 0xb4);
241        assert!((c.a - 1.0).abs() < 1e-6);
242    }
243
244    #[test]
245    fn test_hex_parsing_with_alpha() {
246        let c = Color::from_hex("#ff000080").unwrap();
247        assert!((c.r - 1.0).abs() < 0.01);
248        assert!((c.a - 128.0 / 255.0).abs() < 0.01);
249    }
250
251    #[test]
252    fn test_hex_invalid() {
253        assert!(Color::from_hex("#gg0000").is_err());
254        assert!(Color::from_hex("#123").is_err());
255    }
256
257    #[test]
258    fn test_lerp() {
259        let a = Color::BLACK;
260        let b = Color::WHITE;
261        let mid = a.lerp(b, 0.5);
262        assert!((mid.r - 0.5).abs() < 1e-6);
263        assert!((mid.g - 0.5).abs() < 1e-6);
264        assert!((mid.b - 0.5).abs() < 1e-6);
265    }
266
267    #[test]
268    fn test_to_svg_string() {
269        assert_eq!(Color::RED.to_svg_string(), "rgb(255,0,0)");
270        let semi = Color::RED.with_alpha(0.5);
271        assert_eq!(semi.to_svg_string(), "rgba(255,0,0,0.500)");
272    }
273
274    #[test]
275    fn test_to_hex() {
276        assert_eq!(Color::RED.to_hex(), "#ff0000");
277    }
278}