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 →