1#[non_exhaustive]
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
8#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
9pub enum Color {
10 Reset,
12 Black,
14 Red,
16 Green,
18 Yellow,
20 Blue,
22 Magenta,
24 Cyan,
26 White,
28 DarkGray,
30 LightRed,
32 LightGreen,
34 LightYellow,
36 LightBlue,
38 LightMagenta,
40 LightCyan,
42 LightWhite,
44 Rgb(u8, u8, u8),
46 Indexed(u8),
48}
49
50#[inline]
51fn to_linear(c: f32) -> f32 {
52 if c <= 0.04045 {
53 c / 12.92
54 } else {
55 ((c + 0.055) / 1.055).powf(2.4)
56 }
57}
58
59impl Color {
60 fn to_rgb(self) -> (u8, u8, u8) {
65 match self {
66 Color::Rgb(r, g, b) => (r, g, b),
67 Color::Black => (0, 0, 0),
68 Color::Red => (205, 49, 49),
69 Color::Green => (13, 188, 121),
70 Color::Yellow => (229, 229, 16),
71 Color::Blue => (36, 114, 200),
72 Color::Magenta => (188, 63, 188),
73 Color::Cyan => (17, 168, 205),
74 Color::White => (229, 229, 229),
75 Color::DarkGray => (128, 128, 128),
76 Color::LightRed => (255, 0, 0),
77 Color::LightGreen => (0, 255, 0),
78 Color::LightYellow => (255, 255, 0),
79 Color::LightBlue => (0, 0, 255),
80 Color::LightMagenta => (255, 0, 255),
81 Color::LightCyan => (0, 255, 255),
82 Color::LightWhite => (255, 255, 255),
83 Color::Reset => (0, 0, 0),
84 Color::Indexed(idx) => xterm256_to_rgb(idx),
85 }
86 }
87
88 pub fn luminance(self) -> f32 {
106 let (r, g, b) = self.to_rgb();
107 let rf = to_linear(r as f32 / 255.0);
108 let gf = to_linear(g as f32 / 255.0);
109 let bf = to_linear(b as f32 / 255.0);
110 0.2126 * rf + 0.7152 * gf + 0.0722 * bf
111 }
112
113 pub fn contrast_fg(bg: Color) -> Color {
129 if bg.luminance() > 0.179 {
130 Color::Rgb(0, 0, 0)
131 } else {
132 Color::Rgb(255, 255, 255)
133 }
134 }
135
136 pub fn blend(self, other: Color, alpha: f32) -> Color {
152 let alpha = alpha.clamp(0.0, 1.0);
153 let (r1, g1, b1) = self.to_rgb();
154 let (r2, g2, b2) = other.to_rgb();
155 let r = (r1 as f32 * alpha + r2 as f32 * (1.0 - alpha)).round() as u8;
156 let g = (g1 as f32 * alpha + g2 as f32 * (1.0 - alpha)).round() as u8;
157 let b = (b1 as f32 * alpha + b2 as f32 * (1.0 - alpha)).round() as u8;
158 Color::Rgb(r, g, b)
159 }
160
161 pub fn lighten(self, amount: f32) -> Color {
166 Color::Rgb(255, 255, 255).blend(self, 1.0 - amount.clamp(0.0, 1.0))
167 }
168
169 pub fn darken(self, amount: f32) -> Color {
174 Color::Rgb(0, 0, 0).blend(self, 1.0 - amount.clamp(0.0, 1.0))
175 }
176
177 pub fn contrast_ratio(a: Color, b: Color) -> f32 {
191 let la = a.luminance() + 0.05;
192 let lb = b.luminance() + 0.05;
193 if la > lb {
194 la / lb
195 } else {
196 lb / la
197 }
198 }
199
200 pub fn meets_contrast_aa(fg: Color, bg: Color) -> bool {
203 Self::contrast_ratio(fg, bg) >= 4.5
204 }
205
206 pub fn downsampled(self, depth: ColorDepth) -> Color {
216 match depth {
217 ColorDepth::TrueColor => self,
218 ColorDepth::EightBit => match self {
219 Color::Rgb(r, g, b) => Color::Indexed(rgb_to_ansi256(r, g, b)),
220 other => other,
221 },
222 ColorDepth::Basic => match self {
223 Color::Rgb(r, g, b) => rgb_to_ansi16(r, g, b),
224 Color::Indexed(i) => {
225 let (r, g, b) = xterm256_to_rgb(i);
226 rgb_to_ansi16(r, g, b)
227 }
228 other => other,
229 },
230 ColorDepth::NoColor => Color::Reset,
231 }
232 }
233}
234
235#[non_exhaustive]
241#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
242#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
243pub enum ColorDepth {
244 TrueColor,
246 EightBit,
248 Basic,
250 NoColor,
255}
256
257#[cfg(test)]
258mod color_depth_tests {
259 use super::{Color, ColorDepth};
260
261 #[test]
262 fn no_color_downsamples_everything_to_reset() {
263 assert_eq!(Color::Red.downsampled(ColorDepth::NoColor), Color::Reset);
264 assert_eq!(
265 Color::Rgb(10, 20, 30).downsampled(ColorDepth::NoColor),
266 Color::Reset
267 );
268 assert_eq!(
269 Color::Indexed(44).downsampled(ColorDepth::NoColor),
270 Color::Reset
271 );
272 }
273}
274
275impl ColorDepth {
276 pub fn detect() -> Self {
284 if std::env::var("NO_COLOR")
286 .ok()
287 .is_some_and(|v| !v.is_empty())
288 {
289 return Self::NoColor;
290 }
291 if let Ok(ct) = std::env::var("COLORTERM") {
292 let ct = ct.to_lowercase();
293 if ct == "truecolor" || ct == "24bit" {
294 return Self::TrueColor;
295 }
296 }
297 if let Ok(term) = std::env::var("TERM") {
298 if term.contains("256color") {
299 return Self::EightBit;
300 }
301 }
302 Self::Basic
303 }
304}
305
306fn rgb_to_ansi256(r: u8, g: u8, b: u8) -> u8 {
307 if r == g && g == b {
308 if r < 8 {
309 return 16;
310 }
311 if r >= 248 {
312 return 231;
313 }
314 return 232 + (((r as u16 - 8) * 24 / 240) as u8);
315 }
316
317 let ri = if r < 48 {
318 0
319 } else {
320 ((r as u16 - 35) / 40) as u8
321 };
322 let gi = if g < 48 {
323 0
324 } else {
325 ((g as u16 - 35) / 40) as u8
326 };
327 let bi = if b < 48 {
328 0
329 } else {
330 ((b as u16 - 35) / 40) as u8
331 };
332 16 + 36 * ri.min(5) + 6 * gi.min(5) + bi.min(5)
333}
334
335fn rgb_to_ansi16(r: u8, g: u8, b: u8) -> Color {
336 let lum = 0.2126 * to_linear(r as f32 / 255.0)
337 + 0.7152 * to_linear(g as f32 / 255.0)
338 + 0.0722 * to_linear(b as f32 / 255.0);
339
340 let max = r.max(g).max(b);
341 let min = r.min(g).min(b);
342 let saturation = if max == 0 {
343 0.0
344 } else {
345 (max - min) as f32 / max as f32
346 };
347
348 if saturation < 0.2 {
349 return match lum {
351 l if l < 0.05 => Color::Black,
352 l if l < 0.25 => Color::DarkGray,
353 l if l < 0.7 => Color::White,
354 _ => Color::White, };
356 }
357
358 let bright = max >= 200 && min >= 64;
366
367 let rf = r as f32;
368 let gf = g as f32;
369 let bf = b as f32;
370
371 if rf >= gf && rf >= bf {
372 if gf > bf * 1.5 {
373 if bright {
374 Color::LightYellow
375 } else {
376 Color::Yellow
377 }
378 } else if bf > gf * 1.5 {
379 if bright {
380 Color::LightMagenta
381 } else {
382 Color::Magenta
383 }
384 } else if bright {
385 Color::LightRed
386 } else {
387 Color::Red
388 }
389 } else if gf >= rf && gf >= bf {
390 if bf > rf * 1.5 {
391 if bright {
392 Color::LightCyan
393 } else {
394 Color::Cyan
395 }
396 } else if bright {
397 Color::LightGreen
398 } else {
399 Color::Green
400 }
401 } else if rf > gf * 1.5 {
402 if bright {
403 Color::LightMagenta
404 } else {
405 Color::Magenta
406 }
407 } else if gf > rf * 1.5 {
408 if bright {
409 Color::LightCyan
410 } else {
411 Color::Cyan
412 }
413 } else if bright {
414 Color::LightBlue
415 } else {
416 Color::Blue
417 }
418}
419
420fn xterm256_to_rgb(idx: u8) -> (u8, u8, u8) {
421 match idx {
422 0 => (0, 0, 0),
423 1 => (128, 0, 0),
424 2 => (0, 128, 0),
425 3 => (128, 128, 0),
426 4 => (0, 0, 128),
427 5 => (128, 0, 128),
428 6 => (0, 128, 128),
429 7 => (192, 192, 192),
430 8 => (128, 128, 128),
431 9 => (255, 0, 0),
432 10 => (0, 255, 0),
433 11 => (255, 255, 0),
434 12 => (0, 0, 255),
435 13 => (255, 0, 255),
436 14 => (0, 255, 255),
437 15 => (255, 255, 255),
438 16..=231 => {
439 let n = idx - 16;
440 let b_idx = n % 6;
441 let g_idx = (n / 6) % 6;
442 let r_idx = n / 36;
443 let to_val = |i: u8| if i == 0 { 0u8 } else { 55 + 40 * i };
444 (to_val(r_idx), to_val(g_idx), to_val(b_idx))
445 }
446 232..=255 => {
447 let v = 8 + 10 * (idx - 232);
448 (v, v, v)
449 }
450 }
451}
452
453#[cfg(test)]
454mod tests {
455 #![allow(clippy::unwrap_used)]
456 use super::*;
457
458 #[test]
459 fn blend_halfway_rounds_to_128() {
460 assert_eq!(
461 Color::Rgb(255, 255, 255).blend(Color::Rgb(0, 0, 0), 0.5),
462 Color::Rgb(128, 128, 128)
463 );
464 }
465
466 #[test]
467 fn contrast_ratio_white_on_black_is_high() {
468 let ratio = Color::contrast_ratio(Color::White, Color::Black);
469 assert!(ratio > 15.0);
470 }
471
472 #[test]
473 fn contrast_ratio_same_color_is_one() {
474 let ratio = Color::contrast_ratio(Color::Rgb(100, 100, 100), Color::Rgb(100, 100, 100));
475 assert!((ratio - 1.0).abs() < 0.01);
476 }
477
478 #[test]
479 fn meets_contrast_aa_white_on_black() {
480 assert!(Color::meets_contrast_aa(Color::White, Color::Black));
481 }
482
483 #[test]
484 fn meets_contrast_aa_low_contrast_fails() {
485 assert!(!Color::meets_contrast_aa(
486 Color::Rgb(180, 180, 180),
487 Color::Rgb(200, 200, 200)
488 ));
489 }
490
491 #[test]
494 fn rgb_to_ansi256_no_overflow_full_range() {
495 for r in 0u8..=255 {
497 for g in 0u8..=255 {
498 for b in 0u8..=255 {
499 let _ = Color::Rgb(r, g, b).downsampled(ColorDepth::EightBit);
500 }
501 }
502 }
503 }
504
505 #[test]
506 fn rgb_248_maps_to_231() {
507 assert_eq!(
508 Color::Rgb(248, 248, 248).downsampled(ColorDepth::EightBit),
509 Color::Indexed(231)
510 );
511 }
512
513 #[test]
516 fn luminance_dracula_purple_wcag() {
517 let l = Color::Rgb(189, 147, 249).luminance();
518 assert!((l - 0.385).abs() < 0.01, "expected ~0.385, got {l}");
519 }
520
521 #[test]
522 fn contrast_aa_dracula_pair() {
523 let p = Color::Rgb(189, 147, 249);
524 let bg = Color::Rgb(40, 42, 54);
525 assert!(Color::meets_contrast_aa(p, bg));
526 let r = Color::contrast_ratio(p, bg);
527 assert!((r - 5.90).abs() < 0.1, "expected ~5.90, got {r}");
528 }
529
530 #[test]
531 fn contrast_white_on_black_is_21() {
532 let r = Color::contrast_ratio(Color::Rgb(255, 255, 255), Color::Rgb(0, 0, 0));
533 assert!((r - 21.0).abs() < 0.5, "expected ~21.0, got {r}");
534 }
535
536 #[test]
539 fn rgb_to_ansi16_bright_variants() {
540 assert_eq!(
542 Color::Rgb(255, 80, 80).downsampled(ColorDepth::Basic),
543 Color::LightRed
544 );
545 assert_eq!(
547 Color::Rgb(128, 20, 20).downsampled(ColorDepth::Basic),
548 Color::Red
549 );
550 assert_eq!(
552 Color::Rgb(200, 200, 200).downsampled(ColorDepth::Basic),
553 Color::White
554 );
555 assert_eq!(
557 Color::Rgb(80, 80, 80).downsampled(ColorDepth::Basic),
558 Color::DarkGray
559 );
560 }
561}