git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

commite6ff16797180bd8adc1456f57e714f306374c779
parentfde23f061d
authorLucas Galante <[email protected]>
date2026-07-07 12:05
feat(color): add public hex color parsers (single source of truth)

Add parse_hex_bytes ([u8;4] primitive), parse_hex_rgba/parse_hex_rgb (raw sRGB
f32), and parse_hex_rgba_linear (sRGB->linear). Replaces ~13 duplicated private
copies across the workspace. Gamma is the caller's choice since it depends on
the render target's surface format. Includes a unit test.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

 src/color.rs | 63 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 63 insertions(+)

diff --git a/src/color.rs b/src/color.rs
index 7315ed4..84db8c5 100644
--- a/src/color.rs
+++ b/src/color.rs
@@ -898,6 +898,47 @@ pub fn srgb_to_linear(c: f32) -> f32 {
     }
 }
 
+/// Parse a hex color string into raw RGBA bytes.
+///
+/// Accepts `#RRGGBB` or `#RRGGBBAA`, tolerating surrounding quotes/whitespace and
+/// an optional leading `#`. 6-digit input yields alpha `255`. Returns `None` for
+/// any shorter/invalid input. This is the primitive the `[f32;_]` parsers build on.
+pub fn parse_hex_bytes(s: &str) -> Option<[u8; 4]> {
+    let hex = s
+        .trim_matches(|c| c == '"' || c == '\'' || c == ' ')
+        .trim_start_matches('#');
+    let b = |i: usize| u8::from_str_radix(hex.get(i * 2..i * 2 + 2)?, 16).ok();
+    if hex.len() >= 8 {
+        Some([b(0)?, b(1)?, b(2)?, b(3)?])
+    } else if hex.len() >= 6 {
+        Some([b(0)?, b(1)?, b(2)?, 255])
+    } else {
+        None
+    }
+}
+
+/// Parse a hex color string into raw sRGB RGBA in `[0,1]` (no gamma conversion).
+/// See [`parse_hex_bytes`] for the accepted formats. Apply [`srgb_to_linear`] to
+/// the RGB channels yourself if your render target expects linear color.
+pub fn parse_hex_rgba(s: &str) -> Option<[f32; 4]> {
+    parse_hex_bytes(s).map(|[r, g, b, a]| {
+        [r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, a as f32 / 255.0]
+    })
+}
+
+/// Like [`parse_hex_rgba`] but drops alpha, returning raw sRGB RGB in `[0,1]`.
+pub fn parse_hex_rgb(s: &str) -> Option<[f32; 3]> {
+    parse_hex_rgba(s).map(|[r, g, b, _]| [r, g, b])
+}
+
+/// Like [`parse_hex_rgba`] but converts RGB from sRGB to linear (alpha kept as-is).
+/// Use when your render target samples colors in linear space.
+pub fn parse_hex_rgba_linear(s: &str) -> Option<[f32; 4]> {
+    parse_hex_rgba(s).map(|[r, g, b, a]| {
+        [srgb_to_linear(r), srgb_to_linear(g), srgb_to_linear(b), a]
+    })
+}
+
 pub fn linear_to_srgb(c: f32) -> f32 {
     if c <= 0.0031308 {
         c * 12.92
@@ -1730,6 +1771,28 @@ pub fn control_label_color_detached_for_state(hovered: bool, focused: bool) -> [
 mod color_tests {
     use super::*;
 
+    #[test]
+    fn test_parse_hex_rgba() {
+        // 6-digit → alpha 1.0, raw sRGB
+        assert_eq!(parse_hex_rgba("#ff0000"), Some([1.0, 0.0, 0.0, 1.0]));
+        // 8-digit → explicit alpha
+        assert_eq!(parse_hex_rgba("#00ff0080"), Some([0.0, 1.0, 0.0, 128.0 / 255.0]));
+        // leading '#' optional; quotes/whitespace tolerated
+        assert_eq!(parse_hex_rgba("\" ffffff \""), Some([1.0, 1.0, 1.0, 1.0]));
+        assert_eq!(parse_hex_rgba("000000"), Some([0.0, 0.0, 0.0, 1.0]));
+        // invalid
+        assert_eq!(parse_hex_rgba("#fff"), None);
+        assert_eq!(parse_hex_rgba("nothex"), None);
+        assert_eq!(parse_hex_rgba(""), None);
+        // rgb drops alpha; linear applies gamma to rgb only
+        assert_eq!(parse_hex_rgb("#ff0000"), Some([1.0, 0.0, 0.0]));
+        assert_eq!(parse_hex_rgba_linear("#000000ff"), Some([0.0, 0.0, 0.0, 1.0]));
+        // byte primitive
+        assert_eq!(parse_hex_bytes("#010203"), Some([1, 2, 3, 255]));
+        assert_eq!(parse_hex_bytes("#01020304"), Some([1, 2, 3, 4]));
+        assert_eq!(parse_hex_bytes("#fff"), None);
+    }
+
     #[test]
     fn test_print_active_config() {
         let path = crate::config::get_config_path();