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 →