Skip to main content

omp_tui/color/
mod.rs

1//! CSS color parsing: every color form from CSS Color Module Level 4,
2//! plus `hsv()`/`hsva()` as a non-CSS convenience.
3//!
4//! Parsing is context-free and lossless: [`CssColor`] preserves alpha,
5//! `currentcolor`, and system-color keywords instead of guessing what an
6//! opaque cell should show. Lowering to a terminal [`Color`] is a
7//! separate, explicit step โ€” [`CssColor::resolve`] with only a theme in
8//! hand, [`CssColor::composite`] when the backdrop and current
9//! foreground are known, or [`Color::parse`] as the documented
10//! context-free shorthand.
11//!
12//! Each submodule owns one conversion family; this module owns the
13//! grammar: function dispatch, component tokenizing ([`Components`]),
14//! and the shared value readers (`<number>`, `<percentage>`, `<hue>`,
15//! `<alpha-value>`).
16//!
17//! Supported forms, all case-insensitive:
18//! - named colors, `transparent`, `currentcolor`, and the CSS system colors
19//!   (current keywords plus the deprecated aliases)
20//! - `#rgb`/`#rgba`/`#rrggbb`/`#rrggbbaa`
21//! - `rgb()`/`rgba()`, `hsl()`/`hsla()`, `hwb()`, `hsv()`/`hsva()`
22//! - `lab()`/`lch()`, `oklab()`/`oklch()`
23//! - `color()` with the predefined RGB and XYZ color spaces
24//!
25//! Conversion follows the CSS Color 4 algorithms, including `OKLCh`
26//! chroma-reduction gamut mapping (ยง13.2) for results outside sRGB.
27//!
28//! Deliberate deviations from the spec:
29//! - Legacy comma syntax and modern features (`none`, mixed number/percentage
30//!   components) combine freely: a lenient superset.
31//! - `calc()` and relative color syntax are not supported; theme tokens play
32//!   that role in markup.
33
34mod 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/// A parsed CSS color value, before lowering to a terminal [`Color`].
52///
53/// Terminal cells are opaque and know nothing of CSS inheritance, so
54/// translucency and contextual keywords survive parsing here and only
55/// collapse when a caller lowers them with the context it actually has.
56#[derive(Clone, Copy, Debug, PartialEq)]
57pub enum CssColor {
58	/// An absolute color: sRGB channel bytes plus an alpha in `[0, 1]`.
59	Rgba(u8, u8, u8, f32),
60	/// The `currentcolor` keyword: the element's own foreground.
61	Current,
62	/// A system color keyword, resolved against the [`Theme`].
63	System(SystemColor),
64}
65
66impl CssColor {
67	/// Parses any supported CSS color; `None` when `value` is not one.
68	pub fn parse(value: &str) -> Option<Self> {
69		parse(value)
70	}
71
72	/// An opaque absolute color.
73	pub const fn rgb(red: u8, green: u8, blue: u8) -> Self {
74		Self::Rgba(red, green, blue, 1.0)
75	}
76
77	/// Lowers to a cell color with only a theme in hand: system colors
78	/// read the theme, while `currentcolor` and fully transparent values
79	/// become [`Color::Default`] โ€” the terminal's own pass-through
80	/// color is the nearest thing to "inherit" and "let the background
81	/// show". Translucent values keep their color: without a backdrop
82	/// there is nothing to blend with (see [`Self::composite`]).
83	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	/// Lowers to a cell color with full context: `currentcolor` becomes
93	/// `current`, system colors read the theme, and translucency is
94	/// alpha-blended over `backdrop`. Blending needs concrete channels,
95	/// so over a default or indexed backdrop full transparency yields
96	/// the backdrop itself and partial translucency keeps the color.
97	///
98	/// # Example
99	/// ```
100	/// use omp_tui::{Color, CssColor, Theme};
101	/// let red = CssColor::parse("rgb(255 0 0 / 50%)").unwrap();
102	/// let lowered = red.composite(&Theme::default(), Color::Default, Color::Rgb(0, 0, 0));
103	/// assert_eq!(lowered, Color::Rgb(128, 0, 0));
104	/// ```
105	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
127/// Parses any supported CSS color; `None` when `value` is not one.
128pub 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	// Every remaining form is ASCII; the guard also makes the byte
134	// slicing below safe for arbitrary UTF-8 input.
135	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
145/// Dispatches one `name(body)` form to its family parser.
146fn 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
168/// Component tokens of one color-function body: up to four space- or
169/// comma-separated values plus an optional `/`-separated alpha.
170pub 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	/// Tokenizes a function body. `None` on an empty list, empty or
179	/// multi-token components, more than four components, or a
180	/// malformed alpha tail.
181	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	/// Exactly three channels plus optional alpha; a fourth
223	/// comma-separated component is legacy alpha (`rgba(r, g, b, a)`).
224	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	/// Exactly three space-separated channels; comma syntax rejected
235	/// (`hwb()` and the lab-family functions never had a legacy form).
236	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	/// Every component plus alpha, for `color()`'s ident-led argument
242	/// list; `commas` reports whether legacy separators were used.
243	pub(super) fn all(&self) -> (&[&'a str], Option<&'a str>, bool) {
244		(&self.parts[..self.count], self.alpha, self.commas)
245	}
246}
247
248/// Parses a CSS `<number>`: float with optional sign and exponent.
249/// Rejects NaN, infinities, and unit suffixes.
250pub fn number(token: &str) -> Option<f32> {
251	// Reject alphabetic forms Rust accepts but CSS does not ("inf",
252	// "NaN") along with any stray unit; the exponent marker is the one
253	// legal letter.
254	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
264/// Parses a `<number>` (taken raw) or `<percentage>` (`100%` maps to
265/// `scale`); the `none` keyword reads as zero.
266pub 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
276/// Parses a CSS `<hue>`: a bare number in degrees or an angle with a
277/// `deg`/`grad`/`rad`/`turn` unit; returns degrees normalized to
278/// `[0, 360)`. The `none` keyword reads as zero.
279pub fn hue(token: &str) -> Option<f32> {
280	if token.eq_ignore_ascii_case("none") {
281		return Some(0.0);
282	}
283	// "grad" must be tried before its suffix "rad".
284	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
298/// Parses an optional `<alpha-value>` clamped to `[0, 1]`: a number, a
299/// percentage, or `none` (fully transparent). A missing component is
300/// opaque.
301pub 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
308/// Case-insensitively strips a trailing unit from an ASCII token.
309fn 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		// No concrete channels to blend with: keep the color.
385		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		// Legacy commas combined with a slash alpha, or five components,
468		// are rejected at interpretation time.
469		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}