slint-ui-system 0.5.0

Neon Design System — Slint UI components for Rust desktop apps. 35+ components, dark/light theme, neon accents.
//! Hex color parsing + formatting for the bridge between Rust
//! state and Slint's `color` type.
//!
//! Slint's `color` is a 32-bit ARGB value. Crossing the Slint↔Rust
//! boundary as a `slint::Color` works directly, but apps that
//! roundtrip colors through user input (a hex text field, a config
//! file, a CSS-style theme override) need a string ↔ value
//! conversion.
//!
//! Supported input forms:
//!  * `#rgb`      — short form, alpha = 0xff
//!  * `#rgba`     — short form with alpha
//!  * `#rrggbb`   — full form, alpha = 0xff
//!  * `#rrggbbaa` — full form with alpha
//!
//! Case is not significant. Whitespace is trimmed. Missing `#` is
//! rejected (we don't try to be clever about ambiguous input).

use std::fmt;

/// Parse error kinds for [`parse_hex`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HexError {
    /// Input didn't start with `#`.
    MissingHash,
    /// Input length wasn't 3 / 4 / 6 / 8 hex digits.
    BadLength(usize),
    /// One of the digits wasn't a hex digit.
    NotHex(char),
}

impl fmt::Display for HexError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            HexError::MissingHash => write!(f, "missing '#' prefix"),
            HexError::BadLength(n) => write!(
                f,
                "expected 3, 4, 6, or 8 hex digits after '#'; got {n}"
            ),
            HexError::NotHex(c) => write!(f, "not a hex digit: {c:?}"),
        }
    }
}

impl std::error::Error for HexError {}

/// Parse a hex color string into `(r, g, b, a)` with each
/// component 0..=255.
///
/// ```
/// use slint_ui_system::color::parse_hex;
/// assert_eq!(parse_hex("#ff0080").unwrap(), (255, 0, 128, 255));
/// assert_eq!(parse_hex("#0a0").unwrap(), (0x00, 0xaa, 0x00, 0xff));
/// assert_eq!(parse_hex("#ff000080").unwrap(), (255, 0, 0, 128));
/// ```
pub fn parse_hex(s: &str) -> Result<(u8, u8, u8, u8), HexError> {
    let s = s.trim();
    let body = s.strip_prefix('#').ok_or(HexError::MissingHash)?;
    let n = body.len();
    let (r, g, b, a) = match n {
        3 => {
            let r = expand_nibble(body, 0)?;
            let g = expand_nibble(body, 1)?;
            let b = expand_nibble(body, 2)?;
            (r, g, b, 0xff)
        }
        4 => {
            let r = expand_nibble(body, 0)?;
            let g = expand_nibble(body, 1)?;
            let b = expand_nibble(body, 2)?;
            let a = expand_nibble(body, 3)?;
            (r, g, b, a)
        }
        6 => {
            let r = byte(body, 0)?;
            let g = byte(body, 1)?;
            let b = byte(body, 2)?;
            (r, g, b, 0xff)
        }
        8 => {
            let r = byte(body, 0)?;
            let g = byte(body, 1)?;
            let b = byte(body, 2)?;
            let a = byte(body, 3)?;
            (r, g, b, a)
        }
        _ => return Err(HexError::BadLength(n)),
    };
    Ok((r, g, b, a))
}

/// Format an `(r, g, b)` triplet as `#rrggbb` (lowercase, no alpha).
pub fn format_rgb(r: u8, g: u8, b: u8) -> String {
    format!("#{:02x}{:02x}{:02x}", r, g, b)
}

/// Format an `(r, g, b, a)` tuple as `#rrggbbaa` (lowercase).
pub fn format_rgba(r: u8, g: u8, b: u8, a: u8) -> String {
    format!("#{:02x}{:02x}{:02x}{:02x}", r, g, b, a)
}

/// Convert parsed `(r, g, b, a)` into a Slint color value.
///
/// Slint's `slint::Color` constructor expects ARGB with each
/// component 0..255. Useful when threading user input through to
/// a Slint property:
///
/// ```ignore
/// let (r, g, b, a) = parse_hex(input)?;
/// my_window.set_brand_color(to_slint_color(r, g, b, a));
/// ```
pub fn to_slint_color(r: u8, g: u8, b: u8, a: u8) -> slint::Color {
    slint::Color::from_argb_u8(a, r, g, b)
}

// ── Internal helpers ────────────────────────────────────────────────

fn expand_nibble(body: &str, idx: usize) -> Result<u8, HexError> {
    let c = body.chars().nth(idx).ok_or(HexError::BadLength(body.len()))?;
    let n = hex_digit(c)?;
    Ok((n << 4) | n)
}

fn byte(body: &str, idx: usize) -> Result<u8, HexError> {
    let start = idx * 2;
    let bytes = body.as_bytes();
    if start + 1 >= bytes.len() {
        return Err(HexError::BadLength(bytes.len()));
    }
    let high = hex_digit(bytes[start] as char)?;
    let low = hex_digit(bytes[start + 1] as char)?;
    Ok((high << 4) | low)
}

fn hex_digit(c: char) -> Result<u8, HexError> {
    c.to_digit(16)
        .map(|d| d as u8)
        .ok_or(HexError::NotHex(c))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parse_short_no_alpha() {
        assert_eq!(parse_hex("#0a0").unwrap(), (0x00, 0xaa, 0x00, 0xff));
        assert_eq!(parse_hex("#FFF").unwrap(), (0xff, 0xff, 0xff, 0xff));
    }

    #[test]
    fn parse_short_with_alpha() {
        assert_eq!(parse_hex("#0a0f").unwrap(), (0x00, 0xaa, 0x00, 0xff));
        assert_eq!(parse_hex("#0a08").unwrap(), (0x00, 0xaa, 0x00, 0x88));
    }

    #[test]
    fn parse_full_no_alpha() {
        assert_eq!(parse_hex("#ff0080").unwrap(), (255, 0, 128, 255));
        assert_eq!(parse_hex("#000000").unwrap(), (0, 0, 0, 255));
    }

    #[test]
    fn parse_full_with_alpha() {
        assert_eq!(parse_hex("#ff000080").unwrap(), (255, 0, 0, 0x80));
    }

    #[test]
    fn parse_trims_whitespace() {
        assert_eq!(parse_hex("  #ff0000  ").unwrap(), (255, 0, 0, 255));
    }

    #[test]
    fn parse_rejects_missing_hash() {
        assert_eq!(parse_hex("ff0000"), Err(HexError::MissingHash));
    }

    #[test]
    fn parse_rejects_bad_length() {
        assert!(matches!(parse_hex("#ff"), Err(HexError::BadLength(2))));
        assert!(matches!(parse_hex("#fffff"), Err(HexError::BadLength(5))));
        assert!(matches!(parse_hex("#fffffffff"), Err(HexError::BadLength(9))));
    }

    #[test]
    fn parse_rejects_non_hex() {
        assert!(matches!(parse_hex("#zzz"), Err(HexError::NotHex('z'))));
    }

    #[test]
    fn round_trip_format() {
        let s = "#ff0080";
        let (r, g, b, _a) = parse_hex(s).unwrap();
        assert_eq!(format_rgb(r, g, b), s);
    }

    #[test]
    fn round_trip_with_alpha() {
        let s = "#ff008080";
        let (r, g, b, a) = parse_hex(s).unwrap();
        assert_eq!(format_rgba(r, g, b, a), s);
    }
}