Skip to main content

bevy_react/canvas/
color.rs

1//! CSS color-string parsing, shared by the canvas rasterizer (`parse_rgba8`) and
2//! `core`'s `ui_map::parse_color`. It lives in this module because `ui_map`
3//! depends on `crate::canvas` (never the reverse), so this is the lowest
4//! point both color paths can share.
5//!
6//! [`parse_css_color`] returns a straight-alpha [`Srgba`], or `None` when the
7//! string matches no known form — each caller applies its own default and
8//! (in core's case) a `warn!`, rather than this returning a silent fallback.
9
10use bevy::color::palettes::{basic, css};
11use bevy::color::{Color, Srgba};
12
13/// Parse a CSS color string into a straight-alpha [`Srgba`]. Supports:
14///
15/// - hex: `#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa` (the leading `#` is optional);
16/// - the CSS named colors (case-insensitive) plus `transparent`;
17/// - functional notation `rgb()/rgba()`, `hsl()/hsla()`, `hwb()`, `oklab()`,
18///   `oklch()` — accepting both the legacy comma form (`rgb(255, 0, 0)`) and the
19///   modern space form with an optional `/ alpha` (`rgb(255 0 0 / 50%)`).
20///
21/// Returns `None` if the string matches none of these.
22pub fn parse_css_color(input: &str) -> Option<Srgba> {
23    let s = input.trim();
24    if s.is_empty() {
25        return None;
26    }
27
28    // Functional notation: `name( ... )`.
29    if let Some(open) = s.find('(') {
30        if let Some(inner) = s.strip_suffix(')') {
31            let name = s[..open].trim().to_ascii_lowercase();
32            return parse_color_fn(&name, &inner[open + 1..]);
33        }
34        return None;
35    }
36
37    let lower = s.to_ascii_lowercase();
38    if lower == "transparent" {
39        return Some(Srgba::NONE);
40    }
41    if let Some(c) = named_color(&lower) {
42        return Some(c);
43    }
44
45    // Hex last, so bare words like "red" are tried as names first. `Srgba::hex`
46    // strips a leading `#` itself and accepts 3/4/6/8 digits.
47    Srgba::hex(s).ok()
48}
49
50/// Dispatch a parsed function name + the raw argument string (without the
51/// surrounding parens) to the matching color-space constructor.
52fn parse_color_fn(name: &str, inner: &str) -> Option<Srgba> {
53    let (comps, slash_alpha) = split_args(inner);
54    // Alpha comes from a `/ alpha` segment (modern) or a trailing 4th component
55    // (legacy comma form). Absent → fully opaque.
56    let alpha_tok = slash_alpha.or_else(|| comps.get(3).copied());
57    let a = match alpha_tok {
58        Some(t) => parse_alpha(t)?,
59        None => 1.0,
60    };
61    let c0 = comps.first().copied()?;
62    let c1 = comps.get(1).copied()?;
63    let c2 = comps.get(2).copied()?;
64
65    let color: Color = match name {
66        "rgb" | "rgba" => {
67            return Some(Srgba::new(
68                parse_rgb_channel(c0)?,
69                parse_rgb_channel(c1)?,
70                parse_rgb_channel(c2)?,
71                a,
72            ));
73        }
74        "hsl" | "hsla" => {
75            bevy::color::Hsla::new(parse_hue(c0)?, parse_fraction(c1)?, parse_fraction(c2)?, a)
76                .into()
77        }
78        "hwb" => {
79            bevy::color::Hwba::new(parse_hue(c0)?, parse_fraction(c1)?, parse_fraction(c2)?, a)
80                .into()
81        }
82        "oklab" => {
83            bevy::color::Oklaba::new(parse_fraction(c0)?, parse_num(c1)?, parse_num(c2)?, a).into()
84        }
85        "oklch" => {
86            bevy::color::Oklcha::new(parse_fraction(c0)?, parse_num(c1)?, parse_hue(c2)?, a).into()
87        }
88        _ => return None,
89    };
90    Some(color.to_srgba())
91}
92
93/// Split a function's argument list into component tokens and an optional alpha
94/// token (the part after a `/`). Components are separated by commas and/or
95/// whitespace, so both `255, 0, 0` and `255 0 0` tokenize the same way.
96fn split_args(inner: &str) -> (Vec<&str>, Option<&str>) {
97    let (comp_part, slash_alpha) = match inner.split_once('/') {
98        Some((c, a)) => (c, Some(a.trim())),
99        None => (inner, None),
100    };
101    let comps = comp_part
102        .split(|c: char| c == ',' || c.is_whitespace())
103        .map(str::trim)
104        .filter(|t| !t.is_empty())
105        .collect();
106    (comps, slash_alpha)
107}
108
109/// A bare float token (e.g. an `oklab` a/b axis, or a unitless `oklch` lightness).
110fn parse_num(tok: &str) -> Option<f32> {
111    tok.parse().ok()
112}
113
114/// A 0..=1 fraction: a `%` token divided by 100, else the bare number as-is
115/// (so both `50%` and `0.5` work for saturation/lightness/whiteness).
116fn parse_fraction(tok: &str) -> Option<f32> {
117    match tok.strip_suffix('%') {
118        Some(p) => p.trim().parse::<f32>().ok().map(|v| v / 100.0),
119        None => tok.parse().ok(),
120    }
121}
122
123/// An sRGB channel in 0..=1: a `%` token is /100, a bare number is /255.
124fn parse_rgb_channel(tok: &str) -> Option<f32> {
125    match tok.strip_suffix('%') {
126        Some(p) => p.trim().parse::<f32>().ok().map(|v| v / 100.0),
127        None => tok.parse::<f32>().ok().map(|v| v / 255.0),
128    }
129}
130
131/// An alpha in 0..=1: a `%` token is /100, a bare number is already a fraction.
132fn parse_alpha(tok: &str) -> Option<f32> {
133    match tok.strip_suffix('%') {
134        Some(p) => p.trim().parse::<f32>().ok().map(|v| v / 100.0),
135        None => tok.parse().ok(),
136    }
137}
138
139/// A hue in degrees: strips a trailing `deg` if present; bare numbers are degrees.
140fn parse_hue(tok: &str) -> Option<f32> {
141    tok.strip_suffix("deg").unwrap_or(tok).trim().parse().ok()
142}
143
144/// Look up a CSS named color (already lowercased). Covers the 16 basic colors and
145/// the extended CSS palette, plus the `gray`/`cyan`/`fuchsia` synonyms Bevy's
146/// palette spells differently.
147fn named_color(name: &str) -> Option<Srgba> {
148    let c = match name {
149        // --- basic (16 HTML colors) ---
150        "aqua" => basic::AQUA,
151        "black" => basic::BLACK,
152        "blue" => basic::BLUE,
153        "fuchsia" => basic::FUCHSIA,
154        "gray" => basic::GRAY,
155        "green" => basic::GREEN,
156        "lime" => basic::LIME,
157        "maroon" => basic::MAROON,
158        "navy" => basic::NAVY,
159        "olive" => basic::OLIVE,
160        "purple" => basic::PURPLE,
161        "red" => basic::RED,
162        "silver" => basic::SILVER,
163        "teal" => basic::TEAL,
164        "white" => basic::WHITE,
165        "yellow" => basic::YELLOW,
166        // --- extended CSS palette ---
167        "aliceblue" => css::ALICE_BLUE,
168        "antiquewhite" => css::ANTIQUE_WHITE,
169        "aquamarine" => css::AQUAMARINE,
170        "azure" => css::AZURE,
171        "beige" => css::BEIGE,
172        "bisque" => css::BISQUE,
173        "blanchedalmond" => css::BLANCHED_ALMOND,
174        "blueviolet" => css::BLUE_VIOLET,
175        "brown" => css::BROWN,
176        "burlywood" => css::BURLYWOOD,
177        "cadetblue" => css::CADET_BLUE,
178        "chartreuse" => css::CHARTREUSE,
179        "chocolate" => css::CHOCOLATE,
180        "coral" => css::CORAL,
181        "cornflowerblue" => css::CORNFLOWER_BLUE,
182        "cornsilk" => css::CORNSILK,
183        "crimson" => css::CRIMSON,
184        "darkblue" => css::DARK_BLUE,
185        "darkcyan" => css::DARK_CYAN,
186        "darkgoldenrod" => css::DARK_GOLDENROD,
187        "darkgray" => css::DARK_GRAY,
188        "darkgreen" => css::DARK_GREEN,
189        "darkgrey" => css::DARK_GREY,
190        "darkkhaki" => css::DARK_KHAKI,
191        "darkmagenta" => css::DARK_MAGENTA,
192        "darkolivegreen" => css::DARK_OLIVEGREEN,
193        "darkorange" => css::DARK_ORANGE,
194        "darkorchid" => css::DARK_ORCHID,
195        "darkred" => css::DARK_RED,
196        "darksalmon" => css::DARK_SALMON,
197        "darkseagreen" => css::DARK_SEA_GREEN,
198        "darkslateblue" => css::DARK_SLATE_BLUE,
199        "darkslategray" => css::DARK_SLATE_GRAY,
200        "darkslategrey" => css::DARK_SLATE_GREY,
201        "darkturquoise" => css::DARK_TURQUOISE,
202        "darkviolet" => css::DARK_VIOLET,
203        "deeppink" => css::DEEP_PINK,
204        "deepskyblue" => css::DEEP_SKY_BLUE,
205        "dimgray" => css::DIM_GRAY,
206        "dimgrey" => css::DIM_GREY,
207        "dodgerblue" => css::DODGER_BLUE,
208        "firebrick" => css::FIRE_BRICK,
209        "floralwhite" => css::FLORAL_WHITE,
210        "forestgreen" => css::FOREST_GREEN,
211        "gainsboro" => css::GAINSBORO,
212        "ghostwhite" => css::GHOST_WHITE,
213        "gold" => css::GOLD,
214        "goldenrod" => css::GOLDENROD,
215        "greenyellow" => css::GREEN_YELLOW,
216        "grey" => css::GREY,
217        "honeydew" => css::HONEYDEW,
218        "hotpink" => css::HOT_PINK,
219        "indianred" => css::INDIAN_RED,
220        "indigo" => css::INDIGO,
221        "ivory" => css::IVORY,
222        "khaki" => css::KHAKI,
223        "lavender" => css::LAVENDER,
224        "lavenderblush" => css::LAVENDER_BLUSH,
225        "lawngreen" => css::LAWN_GREEN,
226        "lemonchiffon" => css::LEMON_CHIFFON,
227        "lightblue" => css::LIGHT_BLUE,
228        "lightcoral" => css::LIGHT_CORAL,
229        "lightcyan" => css::LIGHT_CYAN,
230        "lightgoldenrodyellow" => css::LIGHT_GOLDENROD_YELLOW,
231        "lightgray" => css::LIGHT_GRAY,
232        "lightgreen" => css::LIGHT_GREEN,
233        "lightgrey" => css::LIGHT_GREY,
234        "lightpink" => css::LIGHT_PINK,
235        "lightsalmon" => css::LIGHT_SALMON,
236        "lightseagreen" => css::LIGHT_SEA_GREEN,
237        "lightskyblue" => css::LIGHT_SKY_BLUE,
238        "lightslategray" => css::LIGHT_SLATE_GRAY,
239        "lightslategrey" => css::LIGHT_SLATE_GREY,
240        "lightsteelblue" => css::LIGHT_STEEL_BLUE,
241        "lightyellow" => css::LIGHT_YELLOW,
242        "limegreen" => css::LIMEGREEN,
243        "linen" => css::LINEN,
244        "magenta" => css::MAGENTA,
245        "mediumaquamarine" => css::MEDIUM_AQUAMARINE,
246        "mediumblue" => css::MEDIUM_BLUE,
247        "mediumorchid" => css::MEDIUM_ORCHID,
248        "mediumpurple" => css::MEDIUM_PURPLE,
249        "mediumseagreen" => css::MEDIUM_SEA_GREEN,
250        "mediumslateblue" => css::MEDIUM_SLATE_BLUE,
251        "mediumspringgreen" => css::MEDIUM_SPRING_GREEN,
252        "mediumturquoise" => css::MEDIUM_TURQUOISE,
253        "mediumvioletred" => css::MEDIUM_VIOLET_RED,
254        "midnightblue" => css::MIDNIGHT_BLUE,
255        "mintcream" => css::MINT_CREAM,
256        "mistyrose" => css::MISTY_ROSE,
257        "moccasin" => css::MOCCASIN,
258        "navajowhite" => css::NAVAJO_WHITE,
259        "oldlace" => css::OLD_LACE,
260        "olivedrab" => css::OLIVE_DRAB,
261        "orange" => css::ORANGE,
262        "orangered" => css::ORANGE_RED,
263        "orchid" => css::ORCHID,
264        "palegoldenrod" => css::PALE_GOLDENROD,
265        "palegreen" => css::PALE_GREEN,
266        "paleturquoise" => css::PALE_TURQUOISE,
267        "palevioletred" => css::PALE_VIOLETRED,
268        "papayawhip" => css::PAPAYA_WHIP,
269        "peachpuff" => css::PEACHPUFF,
270        "peru" => css::PERU,
271        "pink" => css::PINK,
272        "plum" => css::PLUM,
273        "powderblue" => css::POWDER_BLUE,
274        "rebeccapurple" => css::REBECCA_PURPLE,
275        "rosybrown" => css::ROSY_BROWN,
276        "royalblue" => css::ROYAL_BLUE,
277        "saddlebrown" => css::SADDLE_BROWN,
278        "salmon" => css::SALMON,
279        "sandybrown" => css::SANDY_BROWN,
280        "seagreen" => css::SEA_GREEN,
281        "seashell" => css::SEASHELL,
282        "sienna" => css::SIENNA,
283        "skyblue" => css::SKY_BLUE,
284        "slateblue" => css::SLATE_BLUE,
285        "slategray" => css::SLATE_GRAY,
286        "slategrey" => css::SLATE_GREY,
287        "snow" => css::SNOW,
288        "springgreen" => css::SPRING_GREEN,
289        "steelblue" => css::STEEL_BLUE,
290        "tan" => css::TAN,
291        "thistle" => css::THISTLE,
292        "tomato" => css::TOMATO,
293        "turquoise" => css::TURQUOISE,
294        "violet" => css::VIOLET,
295        "wheat" => css::WHEAT,
296        "whitesmoke" => css::WHITE_SMOKE,
297        "yellowgreen" => css::YELLOW_GREEN,
298        // synonyms Bevy's palette spells differently
299        "cyan" => basic::AQUA,
300        _ => return None,
301    };
302    Some(c)
303}
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308
309    fn rgba8(s: &str) -> [u8; 4] {
310        let c = parse_css_color(s).expect("parsed");
311        [
312            (c.red.clamp(0.0, 1.0) * 255.0).round() as u8,
313            (c.green.clamp(0.0, 1.0) * 255.0).round() as u8,
314            (c.blue.clamp(0.0, 1.0) * 255.0).round() as u8,
315            (c.alpha.clamp(0.0, 1.0) * 255.0).round() as u8,
316        ]
317    }
318
319    #[test]
320    fn hex_forms() {
321        assert_eq!(rgba8("#ff0000"), [255, 0, 0, 255]);
322        assert_eq!(rgba8("#f00"), [255, 0, 0, 255]);
323        assert_eq!(rgba8("#00ff0080"), [0, 255, 0, 128]);
324        assert_eq!(rgba8("ff0000"), [255, 0, 0, 255]); // bare hex (no #)
325    }
326
327    #[test]
328    fn named_and_transparent() {
329        assert_eq!(rgba8("red"), [255, 0, 0, 255]);
330        assert_eq!(rgba8("RED"), [255, 0, 0, 255]); // case-insensitive
331        assert_eq!(rgba8("rebeccapurple"), [102, 51, 153, 255]);
332        assert_eq!(rgba8("cyan"), rgba8("aqua"));
333        assert_eq!(parse_css_color("transparent"), Some(Srgba::NONE));
334    }
335
336    #[test]
337    fn rgb_functions() {
338        assert_eq!(rgba8("rgb(255, 0, 0)"), [255, 0, 0, 255]);
339        assert_eq!(rgba8("rgb(255 0 0)"), [255, 0, 0, 255]);
340        assert_eq!(rgba8("rgba(255, 0, 0, 0.5)"), [255, 0, 0, 128]);
341        assert_eq!(rgba8("rgb(255 0 0 / 50%)"), [255, 0, 0, 128]);
342        assert_eq!(rgba8("rgb(100%, 0%, 0%)"), [255, 0, 0, 255]);
343    }
344
345    #[test]
346    fn hsl_functions() {
347        assert_eq!(rgba8("hsl(0, 100%, 50%)"), [255, 0, 0, 255]);
348        assert_eq!(rgba8("hsl(120 100% 50%)"), [0, 255, 0, 255]);
349        assert_eq!(rgba8("hsla(0, 100%, 50%, 0.5)"), [255, 0, 0, 128]);
350    }
351
352    #[test]
353    fn invalid_is_none() {
354        assert_eq!(parse_css_color("notacolor"), None);
355        assert_eq!(parse_css_color("rgb(1 2)"), None);
356        assert_eq!(parse_css_color(""), None);
357    }
358}