Skip to content

CSS Gradient Generator — Rust source

Build linear, radial, and conic CSS gradients with multiple color stops and positions. Live preview and copy-ready CSS.

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

//! Pure CSS-gradient builder — Rust polyglot showcase port.
//!
//! Language:    Rust (standard library only)
//! Origin:      CosmoDev polyglot showcase — port of the css-gradient-generator tool
//! Ported from: src/lib/cssGradient.ts (the canonical TypeScript implementation)
//!
//! Purpose:     Build linear / radial / conic CSS gradient strings from a small
//!              config struct. Deterministic and side-effect free; invalid input
//!              degrades gracefully (unknown colors → solid black, too few stops
//!              → black/white default) rather than panicking.
//!
//! Display source — part of CosmoDev's polyglot tool pages.

use std::cmp::Ordering;

/// CSS gradient kinds we know how to render.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GradientType {
    Linear,
    Radial,
    Conic,
}

/// One color anchor on the gradient ramp. Position is a percentage in 0..100.
#[derive(Debug, Clone)]
pub struct GradientStop {
    pub color: String,
    pub position: f64,
}

/// Full input to [`build_gradient`]. `radial_shape` is only meaningful for
/// [`GradientType::Radial`]; `None` falls back to `"circle"` (mirroring the
/// TypeScript `?? 'circle'` — an explicit `Some("")` passes through unchanged).
#[derive(Debug, Clone)]
pub struct GradientConfig {
    pub kind: GradientType,
    pub angle: f64,
    pub stops: Vec<GradientStop>,
    pub radial_shape: Option<String>,
}

/// Outcome of [`parse_color`]: an `ok` flag plus an optional human message.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ColorResult {
    pub ok: bool,
    pub error: Option<String>,
}

/// Named CSS colors this tool accepts. The full CSS spec defines ~148, but we
/// intentionally accept only the common, unambiguous set so output stays
/// predictable (mirrors the TypeScript allow-list).
const NAMED_COLORS: &[&str] = &[
    "transparent", "black", "white", "red", "green", "blue", "yellow", "orange",
    "purple", "pink", "gray", "grey", "brown", "cyan", "magenta", "none", "currentcolor",
];

/// True if a byte is a lowercase ASCII hex digit (`0-9` or `a-f`). Only the
/// lowercase form is accepted because [`parse_color`] lowercases its input
/// before testing, exactly like the TS regex `[0-9a-f]`.
#[inline]
fn is_hex_byte(b: u8) -> bool {
    b.is_ascii_digit() || (b'a'..=b'f').contains(&b)
}

/// Validates a hex color by shape: `#` followed by exactly 3, 6, or 8 lowercase
/// hex digits. This collapses the two TS regexes (`#[0-9a-f]{3}([0-9a-f]{3})?`
/// and `#[0-9a-f]{8}`) into one structural check — no external regex crate.
fn is_hex_color(c: &str) -> bool {
    let bytes = c.as_bytes();
    let valid_len = matches!(bytes.len(), 4 | 7 | 9); // '#' + {3,6,8} digits
    valid_len && bytes[0] == b'#' && bytes[1..].iter().all(|&b| is_hex_byte(b))
}

/// Validates a functional color form `name(...)`: the string must start with one
/// of `openers` (e.g. `"rgba("`, `"rgb("`), end with `)`, and have a nonempty
/// body containing no `)`. Mirrors the TS `^rgba?\([^)]+\)$` / `^hsla?\([^)]+\)$`.
/// Longer openers must come first so `"rgba("` is tried before `"rgb("`.
fn is_functional_color(c: &str, openers: &[&str]) -> bool {
    for &opener in openers {
        if let Some(rest) = c.strip_prefix(opener) {
            if let Some(body) = rest.strip_suffix(')') {
                return !body.is_empty() && !body.contains(')');
            }
            return false;
        }
    }
    false
}

/// Validate a CSS color string. Accepts named colors, #RGB / #RRGGBB /
/// #RRGGBBAA hex, and rgb()/rgba()/hsl()/hsla() functional forms. The input is
/// trimmed and lowercased before testing.
pub fn parse_color(color: &str) -> ColorResult {
    let c = color.trim().to_lowercase();
    if c.is_empty() {
        return ColorResult { ok: false, error: Some("empty color".to_string()) };
    }
    if NAMED_COLORS.iter().any(|n| *n == c) {
        return ColorResult { ok: true, error: None };
    }
    if is_hex_color(&c) {
        return ColorResult { ok: true, error: None };
    }
    if is_functional_color(&c, &["rgba(", "rgb("]) || is_functional_color(&c, &["hsla(", "hsl("]) {
        return ColorResult { ok: true, error: None };
    }
    ColorResult { ok: false, error: Some(format!("invalid color: {}", color)) }
}

/// Coerce a possibly-invalid color to a safe value: valid → trimmed original
/// (casing preserved), invalid → solid black. Guarantees the gradient always
/// has a usable color value.
fn normalize_color(color: &str) -> String {
    if parse_color(color).ok {
        color.trim().to_string()
    } else {
        "#000000".to_string()
    }
}

/// Render a float the way JavaScript's template literal does — shortest
/// round-tripping decimal, with no trailing `.0` on whole numbers (so `90.0`
/// becomes `"90"`, matching `String(90)`). Rust's default `Display` for `f64`
/// already produces the shortest form; we only strip the trailing `.0`.
fn format_number(x: f64) -> String {
    let s = x.to_string();
    if let Some(stripped) = s.strip_suffix(".0") {
        stripped.to_string()
    } else {
        s
    }
}

/// Render a complete CSS gradient string.
///
/// Stops are sorted ascending by position (stable — Rust's `sort_by` is
/// stable, matching modern JavaScript's `Array.sort`). Fewer than two stops
/// collapse to a black → white default ramp so the output is always renderable.
/// Positions round with `f64::round`, which agrees with `Math.round` for
/// non-negative values (the 0..100 position domain).
pub fn build_gradient(config: &GradientConfig) -> String {
    // Copy and stable-sort stops ascending by position. partial_cmp returns
    // None only for NaN; we treat NaN as equal (degenerate, out of domain).
    let mut stops: Vec<GradientStop> = config
        .stops
        .iter()
        .map(|s| GradientStop { color: s.color.clone(), position: s.position })
        .collect();
    stops.sort_by(|a, b| a.position.partial_cmp(&b.position).unwrap_or(Ordering::Equal));

    if stops.len() < 2 {
        stops = vec![
            GradientStop { color: "#000000".into(), position: 0.0 },
            GradientStop { color: "#ffffff".into(), position: 100.0 },
        ];
    }

    let parts: Vec<String> = stops
        .iter()
        .map(|s| format!("{} {}%", normalize_color(&s.color), s.position.round() as i64))
        .collect();
    let stops_str = parts.join(", ");

    match config.kind {
        GradientType::Linear => {
            format!("linear-gradient({}deg, {})", format_number(config.angle), stops_str)
        }
        GradientType::Radial => {
            // `?? 'circle'`: None → "circle"; Some(s) → s verbatim (even if empty).
            let shape = config.radial_shape.as_deref().unwrap_or("circle");
            format!("radial-gradient({}, {})", shape, stops_str)
        }
        GradientType::Conic => {
            format!("conic-gradient(from {}deg, {})", format_number(config.angle), stops_str)
        }
    }
}

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 →