1mod convert;
35mod hex;
36mod hsl;
37mod hsv;
38mod hwb;
39mod lab;
40mod lch;
41mod named;
42mod oklab;
43mod oklch;
44mod rgb;
45mod space;
46
47pub use named::SystemColor;
48
49use crate::{context::Theme, frame::Color};
50
51#[derive(Clone, Copy, Debug, PartialEq)]
57pub enum CssColor {
58 Rgba(u8, u8, u8, f32),
60 Current,
62 System(SystemColor),
64}
65
66impl CssColor {
67 pub fn parse(value: &str) -> Option<Self> {
69 parse(value)
70 }
71
72 pub const fn rgb(red: u8, green: u8, blue: u8) -> Self {
74 Self::Rgba(red, green, blue, 1.0)
75 }
76
77 pub fn resolve(self, theme: &Theme) -> Color {
84 match self {
85 Self::Rgba(_, _, _, alpha) if alpha <= 0.0 => Color::Default,
86 Self::Rgba(red, green, blue, _) => Color::Rgb(red, green, blue),
87 Self::Current => Color::Default,
88 Self::System(system) => system.resolve(theme),
89 }
90 }
91
92 pub fn composite(self, theme: &Theme, current: Color, backdrop: Color) -> Color {
106 let (color, alpha) = match self {
107 Self::Rgba(red, green, blue, alpha) => (Color::Rgb(red, green, blue), alpha),
108 Self::Current => (current, 1.0),
109 Self::System(system) => (system.resolve(theme), 1.0),
110 };
111 match (color, backdrop) {
112 _ if alpha >= 1.0 => color,
113 (Color::Rgb(red, green, blue), Color::Rgb(below_r, below_g, below_b)) => {
114 let mix = |top: u8, below: u8| {
115 f32::from(top)
116 .mul_add(alpha, f32::from(below) * (1.0 - alpha))
117 .round() as u8
118 };
119 Color::Rgb(mix(red, below_r), mix(green, below_g), mix(blue, below_b))
120 },
121 _ if alpha <= 0.0 => backdrop,
122 _ => color,
123 }
124 }
125}
126
127pub fn parse(value: &str) -> Option<CssColor> {
129 let value = value.trim();
130 if let Some(hex) = value.strip_prefix('#') {
131 return hex::parse(hex);
132 }
133 if !value.is_ascii() {
136 return None;
137 }
138 if let Some(open) = value.find('(') {
139 let body = value[open + 1..].strip_suffix(')')?;
140 return function(&value[..open], body);
141 }
142 named::parse(value)
143}
144
145fn function(name: &str, body: &str) -> Option<CssColor> {
147 type Family = fn(&str) -> Option<CssColor>;
148 const FAMILIES: &[(&str, Family)] = &[
149 ("color", space::parse),
150 ("hsl", hsl::parse),
151 ("hsla", hsl::parse),
152 ("hsv", hsv::parse),
153 ("hsva", hsv::parse),
154 ("hwb", hwb::parse),
155 ("lab", lab::parse),
156 ("lch", lch::parse),
157 ("oklab", oklab::parse),
158 ("oklch", oklch::parse),
159 ("rgb", rgb::parse),
160 ("rgba", rgb::parse),
161 ];
162 let (_, family) = FAMILIES
163 .iter()
164 .find(|(candidate, _)| name.eq_ignore_ascii_case(candidate))?;
165 family(body)
166}
167
168pub struct Components<'a> {
171 parts: [&'a str; 4],
172 count: usize,
173 alpha: Option<&'a str>,
174 commas: bool,
175}
176
177impl<'a> Components<'a> {
178 pub(super) fn split(body: &'a str) -> Option<Self> {
182 let (left, alpha) = match body.split_once('/') {
183 Some((left, alpha)) => {
184 let alpha = alpha.trim();
185 if alpha.is_empty()
186 || alpha.contains(['/', ','])
187 || alpha.contains(|c: char| c.is_ascii_whitespace())
188 {
189 return None;
190 }
191 (left, Some(alpha))
192 },
193 None => (body, None),
194 };
195 let commas = left.contains(',');
196 let mut parts = [""; 4];
197 let mut count = 0;
198 let mut push = |token: &'a str| {
199 if count == 4 {
200 return None;
201 }
202 parts[count] = token;
203 count += 1;
204 Some(())
205 };
206 if commas {
207 for part in left.split(',') {
208 let part = part.trim();
209 if part.is_empty() || part.contains(|c: char| c.is_ascii_whitespace()) {
210 return None;
211 }
212 push(part)?;
213 }
214 } else {
215 for part in left.split_ascii_whitespace() {
216 push(part)?;
217 }
218 }
219 (count > 0).then_some(Self { parts, count, alpha, commas })
220 }
221
222 pub(super) const fn three(&self) -> Option<([&'a str; 3], Option<&'a str>)> {
225 let channels = [self.parts[0], self.parts[1], self.parts[2]];
226 match (self.count, self.commas, self.alpha) {
227 (3, false, alpha) => Some((channels, alpha)),
228 (3, true, None) => Some((channels, None)),
229 (4, true, None) => Some((channels, Some(self.parts[3]))),
230 _ => None,
231 }
232 }
233
234 pub(super) fn modern3(&self) -> Option<([&'a str; 3], Option<&'a str>)> {
237 (self.count == 3 && !self.commas)
238 .then(|| ([self.parts[0], self.parts[1], self.parts[2]], self.alpha))
239 }
240
241 pub(super) fn all(&self) -> (&[&'a str], Option<&'a str>, bool) {
244 (&self.parts[..self.count], self.alpha, self.commas)
245 }
246}
247
248pub fn number(token: &str) -> Option<f32> {
251 if token
255 .bytes()
256 .any(|b| b.is_ascii_alphabetic() && !matches!(b, b'e' | b'E'))
257 {
258 return None;
259 }
260 let value: f32 = token.parse().ok()?;
261 value.is_finite().then_some(value)
262}
263
264pub fn number_or_percent(token: &str, scale: f32) -> Option<f32> {
267 if token.eq_ignore_ascii_case("none") {
268 return Some(0.0);
269 }
270 match token.strip_suffix('%') {
271 Some(percent) => Some(number(percent)? / 100.0 * scale),
272 None => number(token),
273 }
274}
275
276pub fn hue(token: &str) -> Option<f32> {
280 if token.eq_ignore_ascii_case("none") {
281 return Some(0.0);
282 }
283 let (raw, factor) = if let Some(raw) = strip_unit(token, "grad") {
285 (raw, 0.9)
286 } else if let Some(raw) = strip_unit(token, "rad") {
287 (raw, 180.0 / std::f32::consts::PI)
288 } else if let Some(raw) = strip_unit(token, "deg") {
289 (raw, 1.0)
290 } else if let Some(raw) = strip_unit(token, "turn") {
291 (raw, 360.0)
292 } else {
293 (token, 1.0)
294 };
295 Some((number(raw)? * factor).rem_euclid(360.0))
296}
297
298pub fn alpha(token: Option<&str>) -> Option<f32> {
302 match token {
303 None => Some(1.0),
304 Some(token) => Some(number_or_percent(token, 1.0)?.clamp(0.0, 1.0)),
305 }
306}
307
308fn strip_unit<'a>(token: &'a str, unit: &str) -> Option<&'a str> {
310 let split = token.len().checked_sub(unit.len())?;
311 token[split..]
312 .eq_ignore_ascii_case(unit)
313 .then(|| &token[..split])
314}
315
316#[cfg(test)]
317mod tests {
318 use super::*;
319
320 #[test]
321 fn red_in_every_exact_notation() {
322 let red = Some(CssColor::rgb(255, 0, 0));
323 for form in [
324 "red",
325 "RED",
326 " red ",
327 "#f00",
328 "#F00f",
329 "#ff0000",
330 "#ff0000ff",
331 "rgb(255, 0, 0)",
332 "RGB(255 0 0)",
333 "rgb(100% 0% 0%)",
334 "hsl(0 100% 50%)",
335 "hsl(360deg, 100%, 50%)",
336 "hsv(0 100% 100%)",
337 "hwb(0 0% 0%)",
338 "color(srgb 1 0 0)",
339 "color(srgb-linear 1 0 0)",
340 ] {
341 assert_eq!(parse(form), red, "{form}");
342 }
343 }
344
345 #[test]
346 fn alpha_survives_parsing_in_every_family() {
347 assert_eq!(parse("rgba(255, 0, 0, 0.5)"), Some(CssColor::Rgba(255, 0, 0, 0.5)));
348 assert_eq!(parse("rgb(255 0 0 / 25%)"), Some(CssColor::Rgba(255, 0, 0, 0.25)));
349 assert_eq!(parse("#ff000080"), Some(CssColor::Rgba(255, 0, 0, 128.0 / 255.0)));
350 assert_eq!(parse("hsl(0 100% 50% / 25%)"), Some(CssColor::Rgba(255, 0, 0, 0.25)));
351 assert_eq!(parse("hsva(0, 100%, 100%, 0)"), Some(CssColor::Rgba(255, 0, 0, 0.0)));
352 assert_eq!(parse("lab(100 0 0 / 0.5)"), Some(CssColor::Rgba(255, 255, 255, 0.5)));
353 assert_eq!(parse("color(srgb 1 0 0 / 75%)"), Some(CssColor::Rgba(255, 0, 0, 0.75)));
354 }
355
356 #[test]
357 fn keywords_keep_their_css_semantics() {
358 assert_eq!(parse("transparent"), Some(CssColor::Rgba(0, 0, 0, 0.0)));
359 assert_eq!(parse("currentcolor"), Some(CssColor::Current));
360 assert_eq!(parse("CurrentColor"), Some(CssColor::Current));
361 assert_eq!(parse("Canvas"), Some(CssColor::System(SystemColor::Canvas)));
362 assert_eq!(parse("buttontext"), Some(CssColor::System(SystemColor::ButtonText)));
363 }
364
365 #[test]
366 fn resolve_lowers_with_theme_context() {
367 let theme = Theme::default();
368 assert_eq!(CssColor::rgb(1, 2, 3).resolve(&theme), Color::Rgb(1, 2, 3));
369 assert_eq!(CssColor::Rgba(9, 9, 9, 0.0).resolve(&theme), Color::Default);
370 assert_eq!(CssColor::Rgba(9, 9, 9, 0.5).resolve(&theme), Color::Rgb(9, 9, 9));
371 assert_eq!(CssColor::Current.resolve(&theme), Color::Default);
372 assert_eq!(CssColor::System(SystemColor::CanvasText).resolve(&theme), theme.fg);
373 assert_eq!(CssColor::System(SystemColor::Canvas).resolve(&theme), Color::Default);
374 }
375
376 #[test]
377 fn composite_blends_translucency_over_a_concrete_backdrop() {
378 let theme = Theme::default();
379 let half_red = CssColor::Rgba(255, 0, 0, 0.5);
380 assert_eq!(
381 half_red.composite(&theme, Color::Default, Color::Rgb(0, 0, 255)),
382 Color::Rgb(128, 0, 128)
383 );
384 assert_eq!(half_red.composite(&theme, Color::Default, Color::Default), Color::Rgb(255, 0, 0));
386 assert_eq!(
387 CssColor::Current.composite(&theme, Color::Rgb(7, 7, 7), Color::Rgb(0, 0, 0)),
388 Color::Rgb(7, 7, 7)
389 );
390 assert_eq!(
391 CssColor::Rgba(1, 1, 1, 0.0).composite(&theme, Color::Default, Color::Indexed(3)),
392 Color::Indexed(3)
393 );
394 }
395
396 #[test]
397 fn function_dispatch_rejects_malformed_shells() {
398 for form in ["rgb(", "rgb)", "rgb 255 0 0", "rgb (255 0 0)", "nosuch(1 2 3)", "rgb(๐ฅ)"] {
399 assert_eq!(parse(form), None, "{form}");
400 }
401 }
402
403 #[test]
404 fn number_rejects_css_invalid_floats() {
405 assert_eq!(number("1e2"), Some(100.0));
406 assert_eq!(number("-.5"), Some(-0.5));
407 for token in ["nan", "inf", "infinity", "1px", "0x10", ""] {
408 assert_eq!(number(token), None, "{token}");
409 }
410 }
411
412 #[test]
413 fn percent_maps_to_scale_and_number_stays_raw() {
414 assert_eq!(number_or_percent("50%", 255.0), Some(127.5));
415 assert_eq!(number_or_percent("50", 255.0), Some(50.0));
416 assert_eq!(number_or_percent("100%", 0.4), Some(0.4));
417 assert_eq!(number_or_percent("none", 255.0), Some(0.0));
418 assert_eq!(number_or_percent("%", 255.0), None);
419 }
420
421 #[test]
422 fn hue_units_convert_to_degrees() {
423 assert_eq!(hue("90"), Some(90.0));
424 assert_eq!(hue("90deg"), Some(90.0));
425 assert_eq!(hue("100GRAD"), Some(90.0));
426 assert_eq!(hue("0.25turn"), Some(90.0));
427 let radians = hue("1.5707964rad").unwrap();
428 assert!((radians - 90.0).abs() < 1e-3, "{radians}");
429 assert_eq!(hue("none"), Some(0.0));
430 assert_eq!(hue("90px"), None);
431 }
432
433 #[test]
434 fn hue_normalizes_into_one_turn() {
435 assert_eq!(hue("-90"), Some(270.0));
436 assert_eq!(hue("450"), Some(90.0));
437 assert_eq!(hue("-0.5turn"), Some(180.0));
438 }
439
440 #[test]
441 fn alpha_reads_number_percent_and_none() {
442 assert_eq!(alpha(None), Some(1.0));
443 assert_eq!(alpha(Some("0.5")), Some(0.5));
444 assert_eq!(alpha(Some("50%")), Some(0.5));
445 assert_eq!(alpha(Some("none")), Some(0.0));
446 assert_eq!(alpha(Some("300%")), Some(1.0));
447 assert_eq!(alpha(Some("-1")), Some(0.0));
448 assert_eq!(alpha(Some("half")), None);
449 }
450
451 #[test]
452 fn components_split_legacy_and_modern() {
453 let legacy = Components::split("1, 2, 3, 0.5").unwrap();
454 assert_eq!(legacy.three(), Some((["1", "2", "3"], Some("0.5"))));
455 let modern = Components::split("1 2 3 / 25%").unwrap();
456 assert_eq!(modern.three(), Some((["1", "2", "3"], Some("25%"))));
457 assert_eq!(modern.modern3(), Some((["1", "2", "3"], Some("25%"))));
458 let bare = Components::split("1 2 3").unwrap();
459 assert_eq!(bare.three(), Some((["1", "2", "3"], None)));
460 }
461
462 #[test]
463 fn components_reject_mixed_and_overflowing_forms() {
464 for body in ["", " ", "1,,3", "1 2, 3", "1,2,3,4,5", "1 2 3 4 5", "1,2,3 / 0.5 0.5"] {
465 assert!(Components::split(body).is_none(), "{body}");
466 }
467 assert_eq!(Components::split("1,2,3 / .5").unwrap().three(), None);
470 assert_eq!(Components::split("1 2 3 4").unwrap().three(), None);
471 assert_eq!(Components::split("1 2 3 4").unwrap().modern3(), None);
472 }
473}