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
50impl Color {
51 fn to_rgb(self) -> (u8, u8, u8) {
56 match self {
57 Color::Rgb(r, g, b) => (r, g, b),
58 Color::Black => (0, 0, 0),
59 Color::Red => (205, 49, 49),
60 Color::Green => (13, 188, 121),
61 Color::Yellow => (229, 229, 16),
62 Color::Blue => (36, 114, 200),
63 Color::Magenta => (188, 63, 188),
64 Color::Cyan => (17, 168, 205),
65 Color::White => (229, 229, 229),
66 Color::DarkGray => (128, 128, 128),
67 Color::LightRed => (255, 0, 0),
68 Color::LightGreen => (0, 255, 0),
69 Color::LightYellow => (255, 255, 0),
70 Color::LightBlue => (0, 0, 255),
71 Color::LightMagenta => (255, 0, 255),
72 Color::LightCyan => (0, 255, 255),
73 Color::LightWhite => (255, 255, 255),
74 Color::Reset => (0, 0, 0),
75 Color::Indexed(idx) => xterm256_to_rgb(idx),
76 }
77 }
78
79 pub fn luminance(self) -> f32 {
97 let (r, g, b) = self.to_rgb();
98 let rf = r as f32 / 255.0;
99 let gf = g as f32 / 255.0;
100 let bf = b as f32 / 255.0;
101 0.2126 * rf + 0.7152 * gf + 0.0722 * bf
102 }
103
104 pub fn contrast_fg(bg: Color) -> Color {
120 if bg.luminance() > 0.5 {
121 Color::Rgb(0, 0, 0)
122 } else {
123 Color::Rgb(255, 255, 255)
124 }
125 }
126
127 pub fn blend(self, other: Color, alpha: f32) -> Color {
143 let alpha = alpha.clamp(0.0, 1.0);
144 let (r1, g1, b1) = self.to_rgb();
145 let (r2, g2, b2) = other.to_rgb();
146 let r = (r1 as f32 * alpha + r2 as f32 * (1.0 - alpha)).round() as u8;
147 let g = (g1 as f32 * alpha + g2 as f32 * (1.0 - alpha)).round() as u8;
148 let b = (b1 as f32 * alpha + b2 as f32 * (1.0 - alpha)).round() as u8;
149 Color::Rgb(r, g, b)
150 }
151
152 pub fn lighten(self, amount: f32) -> Color {
157 Color::Rgb(255, 255, 255).blend(self, 1.0 - amount.clamp(0.0, 1.0))
158 }
159
160 pub fn darken(self, amount: f32) -> Color {
165 Color::Rgb(0, 0, 0).blend(self, 1.0 - amount.clamp(0.0, 1.0))
166 }
167
168 pub fn contrast_ratio(a: Color, b: Color) -> f32 {
182 let la = a.luminance() + 0.05;
183 let lb = b.luminance() + 0.05;
184 if la > lb {
185 la / lb
186 } else {
187 lb / la
188 }
189 }
190
191 pub fn meets_contrast_aa(fg: Color, bg: Color) -> bool {
194 Self::contrast_ratio(fg, bg) >= 4.5
195 }
196
197 pub fn downsampled(self, depth: ColorDepth) -> Color {
207 match depth {
208 ColorDepth::TrueColor => self,
209 ColorDepth::EightBit => match self {
210 Color::Rgb(r, g, b) => Color::Indexed(rgb_to_ansi256(r, g, b)),
211 other => other,
212 },
213 ColorDepth::Basic => match self {
214 Color::Rgb(r, g, b) => rgb_to_ansi16(r, g, b),
215 Color::Indexed(i) => {
216 let (r, g, b) = xterm256_to_rgb(i);
217 rgb_to_ansi16(r, g, b)
218 }
219 other => other,
220 },
221 ColorDepth::NoColor => Color::Reset,
222 }
223 }
224}
225
226#[non_exhaustive]
232#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
233#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
234pub enum ColorDepth {
235 TrueColor,
237 EightBit,
239 Basic,
241 NoColor,
246}
247
248#[cfg(test)]
249mod color_depth_tests {
250 use super::{Color, ColorDepth};
251
252 #[test]
253 fn no_color_downsamples_everything_to_reset() {
254 assert_eq!(Color::Red.downsampled(ColorDepth::NoColor), Color::Reset);
255 assert_eq!(
256 Color::Rgb(10, 20, 30).downsampled(ColorDepth::NoColor),
257 Color::Reset
258 );
259 assert_eq!(
260 Color::Indexed(44).downsampled(ColorDepth::NoColor),
261 Color::Reset
262 );
263 }
264}
265
266impl ColorDepth {
267 pub fn detect() -> Self {
275 if std::env::var("NO_COLOR")
277 .ok()
278 .is_some_and(|v| !v.is_empty())
279 {
280 return Self::NoColor;
281 }
282 if let Ok(ct) = std::env::var("COLORTERM") {
283 let ct = ct.to_lowercase();
284 if ct == "truecolor" || ct == "24bit" {
285 return Self::TrueColor;
286 }
287 }
288 if let Ok(term) = std::env::var("TERM") {
289 if term.contains("256color") {
290 return Self::EightBit;
291 }
292 }
293 Self::Basic
294 }
295}
296
297fn rgb_to_ansi256(r: u8, g: u8, b: u8) -> u8 {
298 if r == g && g == b {
299 if r < 8 {
300 return 16;
301 }
302 if r > 248 {
303 return 231;
304 }
305 return 232 + (((r as u16 - 8) * 24 / 240) as u8);
306 }
307
308 let ri = if r < 48 {
309 0
310 } else {
311 ((r as u16 - 35) / 40) as u8
312 };
313 let gi = if g < 48 {
314 0
315 } else {
316 ((g as u16 - 35) / 40) as u8
317 };
318 let bi = if b < 48 {
319 0
320 } else {
321 ((b as u16 - 35) / 40) as u8
322 };
323 16 + 36 * ri.min(5) + 6 * gi.min(5) + bi.min(5)
324}
325
326fn rgb_to_ansi16(r: u8, g: u8, b: u8) -> Color {
327 let lum =
328 0.2126 * (r as f32 / 255.0) + 0.7152 * (g as f32 / 255.0) + 0.0722 * (b as f32 / 255.0);
329
330 let max = r.max(g).max(b);
331 let min = r.min(g).min(b);
332 let saturation = if max == 0 {
333 0.0
334 } else {
335 (max - min) as f32 / max as f32
336 };
337
338 if saturation < 0.2 {
339 return if lum < 0.15 {
340 Color::Black
341 } else {
342 Color::White
343 };
344 }
345
346 let rf = r as f32;
347 let gf = g as f32;
348 let bf = b as f32;
349
350 if rf >= gf && rf >= bf {
351 if gf > bf * 1.5 {
352 Color::Yellow
353 } else if bf > gf * 1.5 {
354 Color::Magenta
355 } else {
356 Color::Red
357 }
358 } else if gf >= rf && gf >= bf {
359 if bf > rf * 1.5 {
360 Color::Cyan
361 } else {
362 Color::Green
363 }
364 } else if rf > gf * 1.5 {
365 Color::Magenta
366 } else if gf > rf * 1.5 {
367 Color::Cyan
368 } else {
369 Color::Blue
370 }
371}
372
373fn xterm256_to_rgb(idx: u8) -> (u8, u8, u8) {
374 match idx {
375 0 => (0, 0, 0),
376 1 => (128, 0, 0),
377 2 => (0, 128, 0),
378 3 => (128, 128, 0),
379 4 => (0, 0, 128),
380 5 => (128, 0, 128),
381 6 => (0, 128, 128),
382 7 => (192, 192, 192),
383 8 => (128, 128, 128),
384 9 => (255, 0, 0),
385 10 => (0, 255, 0),
386 11 => (255, 255, 0),
387 12 => (0, 0, 255),
388 13 => (255, 0, 255),
389 14 => (0, 255, 255),
390 15 => (255, 255, 255),
391 16..=231 => {
392 let n = idx - 16;
393 let b_idx = n % 6;
394 let g_idx = (n / 6) % 6;
395 let r_idx = n / 36;
396 let to_val = |i: u8| if i == 0 { 0u8 } else { 55 + 40 * i };
397 (to_val(r_idx), to_val(g_idx), to_val(b_idx))
398 }
399 232..=255 => {
400 let v = 8 + 10 * (idx - 232);
401 (v, v, v)
402 }
403 }
404}
405
406#[cfg(test)]
407mod tests {
408 use super::*;
409
410 #[test]
411 fn blend_halfway_rounds_to_128() {
412 assert_eq!(
413 Color::Rgb(255, 255, 255).blend(Color::Rgb(0, 0, 0), 0.5),
414 Color::Rgb(128, 128, 128)
415 );
416 }
417
418 #[test]
419 fn contrast_ratio_white_on_black_is_high() {
420 let ratio = Color::contrast_ratio(Color::White, Color::Black);
421 assert!(ratio > 15.0);
422 }
423
424 #[test]
425 fn contrast_ratio_same_color_is_one() {
426 let ratio = Color::contrast_ratio(Color::Rgb(100, 100, 100), Color::Rgb(100, 100, 100));
427 assert!((ratio - 1.0).abs() < 0.01);
428 }
429
430 #[test]
431 fn meets_contrast_aa_white_on_black() {
432 assert!(Color::meets_contrast_aa(Color::White, Color::Black));
433 }
434
435 #[test]
436 fn meets_contrast_aa_low_contrast_fails() {
437 assert!(!Color::meets_contrast_aa(
438 Color::Rgb(180, 180, 180),
439 Color::Rgb(200, 200, 200)
440 ));
441 }
442}