Skip to content

Color Picker & Converter — Rust source

Pick a color and convert between HEX, RGB, HSL, HSV, and CMYK with a live preview. Edit any format and copy the rest - runs entirely in your browser.

This is the Rust implementation — the same logic the interactive tool runs, in a shareable, citable form.

//! color-picker — color-space conversions, WCAG contrast, and named-color lookup.
//!
//! Language: Rust.
//!
//! CosmoDev polyglot showcase port of the color-picker tool, ported from
//! `src/lib/colorConvert.ts`. Functionally equivalent: identical outputs for
//! identical inputs, including clamping, NaN/Infinity handling, and `None` on
//! invalid hex.
//!
//! RGB is the canonical hub: every other space converts through it, and
//! `normalize_color` re-derives the hex from its own clamped RGB so the five
//! display formats can never disagree.
//!
//! Display source — part of CosmoDev's polyglot tool pages.

use std::sync::OnceLock;

/// A color resolved into every supported space, all mutually consistent.
#[derive(Debug, Clone, PartialEq)]
pub struct ColorBundle {
    pub hex: String,                       // #rrggbb (lowercase) — the canonical handle
    pub rgb: [f64; 3],                     // 0–255 each
    pub hsl: [f64; 3],                     // h: 0–360, s/l: 0–100
    pub hsv: [f64; 3],                     // h: 0–360, s/v: 0–100
    pub cmyk: [f64; 4],                    // 0–100 each
}

/// A named CSS color with its hex.
#[derive(Debug, Clone, PartialEq)]
pub struct NamedColor {
    pub name: &'static str,
    pub hex: &'static str,
}

// --- Internal clamp helpers --------------------------------------------------
// Every public function is total: invalid hex → None; out-of-range numbers are
// clamped into their valid interval. f64::min/max propagate NaN, so NaN is
// handled explicitly before they ever see it.

/// Clamp `n` to `[min, max]`. NaN collapses to `min` (degrades like -infinity);
/// ±infinity snap to the nearer bound.
fn clamp(n: f64, min: f64, max: f64) -> f64 {
    if n.is_nan() {
        return min;
    }
    if n == f64::INFINITY {
        return max;
    }
    if n == f64::NEG_INFINITY {
        return min;
    }
    n.max(min).min(max)
}

fn clamp01(n: f64) -> f64 {
    clamp(n, 0.0, 1.0)
}

/// Normalize any hue (negative, >360, or NaN) into `[0, 360)`, matching
/// JS `((Number(h) || 0) % 360 + 360) % 360`.
fn mod360(h: f64) -> f64 {
    let h = if h.is_nan() { 0.0 } else { h };
    (((h % 360.0) + 360.0) % 360.0)
}

// --- HEX ↔ RGB ---------------------------------------------------------------

/// Reports whether every byte of `s` is an ASCII hex digit. Used in lieu of a
/// regex to keep this snippet dependency-free.
fn is_hex_digits(s: &[u8]) -> bool {
    if s.is_empty() {
        return false;
    }
    s.iter().all(|&c| c.is_ascii_hexdigit())
}

/// Parse two validated ASCII hex digits into 0–255.
fn parse_hex_byte(s: &[u8]) -> u8 {
    let hi = (s[0] as char).to_digit(16).unwrap();
    let lo = (s[1] as char).to_digit(16).unwrap();
    ((hi << 4) | lo) as u8
}

/// Parse `#rgb` / `#rrggbb` (case-insensitive, `#` optional) → `[r,g,b]`, or
/// `None` for anything that is not a 3- or 6-digit hex color.
pub fn hex_to_rgb(hex: &str) -> Option<[f64; 3]> {
    let trimmed = hex.trim();
    // TS strips exactly one leading '#'.
    let stripped = trimmed.strip_prefix('#').unwrap_or(trimmed);

    // Expand a shorthand (#rgb → #rrggbb) only when it is exactly three hex digits.
    let expanded: String;
    let h: &str = if stripped.len() == 3 && is_hex_digits(stripped.as_bytes()) {
        expanded = stripped.chars().flat_map(|c| [c, c]).collect();
        &expanded
    } else {
        stripped
    };

    if h.len() != 6 || !is_hex_digits(h.as_bytes()) {
        return None;
    }
    let b = h.as_bytes();
    Some([
        parse_hex_byte(&b[0..2]) as f64,
        parse_hex_byte(&b[2..4]) as f64,
        parse_hex_byte(&b[4..6]) as f64,
    ])
}

/// `[r,g,b]` (clamped to 0–255) → `#rrggbb` (lowercase, zero-padded).
pub fn rgb_to_hex(r: f64, g: f64, b: f64) -> String {
    let byte = |n: f64| format!("{:02x}", clamp(n, 0.0, 255.0).round() as u8);
    format!("#{}{}{}", byte(r), byte(g), byte(b))
}

// --- RGB ↔ HSL ---------------------------------------------------------------

/// `[r,g,b]` (0–255) → `[h,s,l]` with h: 0–360, s/l: 0–100.
pub fn rgb_to_hsl(r: f64, g: f64, b: f64) -> [f64; 3] {
    let rn = clamp01(r / 255.0);
    let gn = clamp01(g / 255.0);
    let bn = clamp01(b / 255.0);
    let max = rn.max(gn).max(bn);
    let min = rn.min(gn).min(bn);
    let d = max - min;
    let l = (max + min) / 2.0;
    let mut h = 0.0;
    let mut s = 0.0;
    if d != 0.0 {
        // Saturation formula branches on which half of the lightness axis we sit on.
        s = if l > 0.5 {
            d / (2.0 - max - min)
        } else {
            d / (max + min)
        };
        if max == rn {
            let extra = if gn < bn { 6.0 } else { 0.0 };
            h = (gn - bn) / d + extra;
        } else if max == gn {
            h = (bn - rn) / d + 2.0;
        } else {
            h = (rn - gn) / d + 4.0;
        }
        h *= 60.0;
    }
    [h.round(), (s * 100.0).round(), (l * 100.0).round()]
}

/// `[h,s,l]` (h: 0–360, s/l: 0–100) → `[r,g,b]` (0–255).
pub fn hsl_to_rgb(h: f64, s: f64, l: f64) -> [f64; 3] {
    let hn = mod360(h);
    let sn = clamp01(s / 100.0);
    let ln = clamp01(l / 100.0);
    let c = (1.0 - (2.0 * ln - 1.0).abs()) * sn;
    let x = c * (1.0 - ((hn / 60.0) % 2.0 - 1.0).abs());
    let m = ln - c / 2.0;
    let (rr, gg, bb) = if hn < 60.0 {
        (c, x, 0.0)
    } else if hn < 120.0 {
        (x, c, 0.0)
    } else if hn < 180.0 {
        (0.0, c, x)
    } else if hn < 240.0 {
        (0.0, x, c)
    } else if hn < 300.0 {
        (x, 0.0, c)
    } else {
        (c, 0.0, x)
    };
    [(rr + m) * 255.0, (gg + m) * 255.0, (bb + m) * 255.0]
}

/// `#hex` → `[h,s,l]`, or `None` when the hex is invalid.
pub fn hex_to_hsl(hex: &str) -> Option<[f64; 3]> {
    let [r, g, b] = hex_to_rgb(hex)?;
    Some(rgb_to_hsl(r, g, b))
}

/// `[h,s,l]` → `#rrggbb`.
pub fn hsl_to_hex(h: f64, s: f64, l: f64) -> String {
    let [r, g, b] = hsl_to_rgb(h, s, l);
    rgb_to_hex(r, g, b)
}

// --- RGB ↔ HSV ---------------------------------------------------------------

/// `[r,g,b]` (0–255) → `[h,s,v]` with h: 0–360, s/v: 0–100.
pub fn rgb_to_hsv(r: f64, g: f64, b: f64) -> [f64; 3] {
    let rn = clamp01(r / 255.0);
    let gn = clamp01(g / 255.0);
    let bn = clamp01(b / 255.0);
    let max = rn.max(gn).max(bn);
    let min = rn.min(gn).min(bn);
    let d = max - min;
    let mut h = 0.0;
    if d != 0.0 {
        if max == rn {
            let extra = if gn < bn { 6.0 } else { 0.0 };
            h = (gn - bn) / d + extra;
        } else if max == gn {
            h = (bn - rn) / d + 2.0;
        } else {
            h = (rn - gn) / d + 4.0;
        }
        h *= 60.0;
    }
    let s = if max == 0.0 { 0.0 } else { d / max };
    [h.round(), (s * 100.0).round(), (max * 100.0).round()]
}

/// `[h,s,v]` (h: 0–360, s/v: 0–100) → `[r,g,b]` (0–255).
pub fn hsv_to_rgb(h: f64, s: f64, v: f64) -> [f64; 3] {
    let hn = mod360(h);
    let sn = clamp01(s / 100.0);
    let vn = clamp01(v / 100.0);
    let c = vn * sn;
    let x = c * (1.0 - ((hn / 60.0) % 2.0 - 1.0).abs());
    let m = vn - c;
    let (rr, gg, bb) = if hn < 60.0 {
        (c, x, 0.0)
    } else if hn < 120.0 {
        (x, c, 0.0)
    } else if hn < 180.0 {
        (0.0, c, x)
    } else if hn < 240.0 {
        (0.0, x, c)
    } else if hn < 300.0 {
        (x, 0.0, c)
    } else {
        (c, 0.0, x)
    };
    [(rr + m) * 255.0, (gg + m) * 255.0, (bb + m) * 255.0]
}

/// `[h,s,v]` → `#rrggbb`.
pub fn hsv_to_hex(h: f64, s: f64, v: f64) -> String {
    let [r, g, b] = hsv_to_rgb(h, s, v);
    rgb_to_hex(r, g, b)
}

// --- RGB ↔ CMYK --------------------------------------------------------------

/// `[r,g,b]` (0–255) → `[c,m,y,k]` (0–100 each).
pub fn rgb_to_cmyk(r: f64, g: f64, b: f64) -> [f64; 4] {
    let rn = clamp01(r / 255.0);
    let gn = clamp01(g / 255.0);
    let bn = clamp01(b / 255.0);
    let k = 1.0 - rn.max(gn).max(bn);
    if k == 1.0 {
        return [0.0, 0.0, 0.0, 100.0]; // pure black — avoid divide-by-zero
    }
    let c = (1.0 - rn - k) / (1.0 - k);
    let m = (1.0 - gn - k) / (1.0 - k);
    let y = (1.0 - bn - k) / (1.0 - k);
    [
        (c * 100.0).round(),
        (m * 100.0).round(),
        (y * 100.0).round(),
        (k * 100.0).round(),
    ]
}

/// `[c,m,y,k]` (0–100 each) → `[r,g,b]` (0–255).
pub fn cmyk_to_rgb(c: f64, m: f64, y: f64, k: f64) -> [f64; 3] {
    let cn = clamp01(c / 100.0);
    let mn = clamp01(m / 100.0);
    let yn = clamp01(y / 100.0);
    let kn = clamp01(k / 100.0);
    [
        255.0 * (1.0 - cn) * (1.0 - kn),
        255.0 * (1.0 - mn) * (1.0 - kn),
        255.0 * (1.0 - yn) * (1.0 - kn),
    ]
}

/// `[c,m,y,k]` → `#rrggbb`.
pub fn cmyk_to_hex(c: f64, m: f64, y: f64, k: f64) -> String {
    let [r, g, b] = cmyk_to_rgb(c, m, y, k);
    rgb_to_hex(r, g, b)
}

// --- Round-robin normalizer --------------------------------------------------

/// Resolve any hex into one consistent `ColorBundle`: the hex is re-derived
/// from its own clamped RGB, then HSL/HSV/CMYK are all computed from that same
/// RGB. This is the single funnel the UI routes every edit through, so the five
/// display formats can never disagree. Returns `None` for invalid hex.
pub fn normalize_color(hex: &str) -> Option<ColorBundle> {
    let rgb = hex_to_rgb(hex)?;
    let [r, g, b] = rgb;
    Some(ColorBundle {
        hex: rgb_to_hex(r, g, b),
        rgb,
        hsl: rgb_to_hsl(r, g, b),
        hsv: rgb_to_hsv(r, g, b),
        cmyk: rgb_to_cmyk(r, g, b),
    })
}

// --- WCAG luminance, contrast & text suggestion ------------------------------

/// Linearize a single sRGB channel (0–255) per the WCAG 2.x relative-luminance
/// definition.
fn srgb_channel(c: f64) -> f64 {
    let s = clamp01(c / 255.0);
    if s <= 0.03928 {
        s / 12.92
    } else {
        ((s + 0.055) / 1.055).powf(2.4)
    }
}

/// WCAG 2.x relative luminance of a hex (0 = black, 1 = white), or `None`.
pub fn relative_luminance(hex: &str) -> Option<f64> {
    let [r, g, b] = hex_to_rgb(hex)?;
    Some(0.2126 * srgb_channel(r) + 0.7152 * srgb_channel(g) + 0.0722 * srgb_channel(b))
}

/// WCAG contrast ratio between two hexes (1–21), or `None` if either is invalid.
pub fn contrast_ratio(a: &str, b: &str) -> Option<f64> {
    let la = relative_luminance(a)?;
    let lb = relative_luminance(b)?;
    let (hi, lo) = if la >= lb { (la, lb) } else { (lb, la) };
    Some((hi + 0.05) / (lo + 0.05))
}

/// Pick black or white text for maximum legibility on `hex`, or `None` if invalid.
pub fn suggest_text_hex(hex: &str) -> Option<&'static str> {
    let l = relative_luminance(hex)?;
    Some(if l > 0.179 { "#000000" } else { "#ffffff" })
}

// --- Closest named CSS color -------------------------------------------------

// A curated set of well-known CSS named colors. Kept intentionally to entries
// whose hex is verifiable from memory — exhaustive tables typed by hand risk
// shipping wrong data, which unit tests cannot catch.
const NAMED_COLORS_RAW: &[(&str, &str)] = &[
    ("black", "#000000"), ("dim gray", "#696969"),
    ("gray", "#808080"), ("dark gray", "#a9a9a9"),
    ("silver", "#c0c0c0"), ("light gray", "#d3d3d3"),
    ("gainsboro", "#dcdcdc"), ("white smoke", "#f5f5f5"),
    ("white", "#ffffff"), ("snow", "#fffafa"),
    ("ivory", "#fffff0"), ("seashell", "#fff5ee"),
    ("red", "#ff0000"), ("crimson", "#dc143c"),
    ("dark red", "#8b0000"),
    ("firebrick", "#b22222"), ("indian red", "#cd5c5c"),
    ("salmon", "#fa8072"), ("tomato", "#ff6347"),
    ("coral", "#ff7f50"), ("orange", "#ffa500"),
    ("dark orange", "#ff8c00"), ("gold", "#ffd700"),
    ("chocolate", "#d2691e"), ("brown", "#a52a2a"),
    ("sienna", "#a0522d"), ("tan", "#d2b48c"),
    ("yellow", "#ffff00"), ("khaki", "#f0e68c"),
    ("lime", "#00ff00"), ("lime green", "#32cd32"),
    ("forest green", "#228b22"), ("sea green", "#2e8b57"),
    ("green", "#008000"), ("dark green", "#006400"),
    ("spring green", "#00ff7f"), ("olive", "#808000"),
    ("teal", "#008080"), ("dark cyan", "#008b8b"),
    ("turquoise", "#40e0d0"), ("cyan", "#00ffff"),
    ("sky blue", "#87ceeb"),
    ("deep sky blue", "#00bfff"), ("steel blue", "#4682b4"),
    ("dodger blue", "#1e90ff"), ("royal blue", "#4169e1"),
    ("blue", "#0000ff"), ("navy", "#000080"),
    ("midnight blue", "#191970"), ("indigo", "#4b0082"),
    ("purple", "#800080"), ("dark violet", "#9400d3"),
    ("blue violet", "#8a2be2"), ("medium purple", "#9370db"),
    ("orchid", "#da70d6"), ("violet", "#ee82ee"),
    ("plum", "#dda0dd"), ("magenta", "#ff00ff"),
    ("deep pink", "#ff1493"), ("hot pink", "#ff69b4"),
    ("pink", "#ffc0cb"), ("lavender", "#e6e6fa"),
];

/// Pre-resolved named color: name, hex, and parsed RGB.
type ResolvedNamed = (&'static str, &'static str, [f64; 3]);

/// Build the resolved named-color table once (lazy on first use) so nearest-match
/// lookups never re-parse. Uses only the std library (`OnceLock`).
fn named_colors() -> &'static [ResolvedNamed] {
    static TABLE: OnceLock<Vec<ResolvedNamed>> = OnceLock::new();
    TABLE.get_or_init(|| {
        NAMED_COLORS_RAW
            .iter()
            .map(|&(name, hex)| {
                let rgb = hex_to_rgb(hex).expect("curated named-color hex must parse");
                (name, hex, rgb)
            })
            .collect()
    })
}

/// Closest entry in the named-color table by squared RGB Euclidean distance.
/// Returns `None` for invalid input. The table is a curated subset, so "closest"
/// is approximate, not a guarantee of identity.
pub fn nearest_named_color(hex: &str) -> Option<NamedColor> {
    let [r, g, b] = hex_to_rgb(hex)?;
    let table = named_colors();
    let mut best_idx = 0;
    let mut best_d = f64::INFINITY;
    for (i, entry) in table.iter().enumerate() {
        let (nr, ng, nb) = (entry.2[0], entry.2[1], entry.2[2]);
        let d = (nr - r).powi(2) + (ng - g).powi(2) + (nb - b).powi(2);
        if d < best_d {
            best_d = d;
            best_idx = i;
        }
    }
    let entry = &table[best_idx];
    Some(NamedColor {
        name: entry.0,
        hex: entry.1,
    })
}

Also available in 13 other languages

Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →