1use serde::{Deserialize, Serialize};
4use std::env;
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]
8pub struct Color {
9 pub r: u8,
11 pub g: u8,
13 pub b: u8,
15}
16
17impl Color {
18 pub const fn new(r: u8, g: u8, b: u8) -> Self {
20 Self { r, g, b }
21 }
22
23 pub fn from_hex(hex: &str) -> Option<Self> {
25 if hex.len() != 7 || !hex.starts_with('#') {
26 return None;
27 }
28
29 let r = u8::from_str_radix(&hex[1..3], 16).ok()?;
30 let g = u8::from_str_radix(&hex[3..5], 16).ok()?;
31 let b = u8::from_str_radix(&hex[5..7], 16).ok()?;
32
33 Some(Self { r, g, b })
34 }
35
36 pub fn to_hex(&self) -> String {
38 format!("#{:02x}{:02x}{:02x}", self.r, self.g, self.b)
39 }
40
41 pub fn luminance(&self) -> f32 {
43 let r = self.r as f32 / 255.0;
44 let g = self.g as f32 / 255.0;
45 let b = self.b as f32 / 255.0;
46
47 let r = if r <= 0.03928 {
48 r / 12.92
49 } else {
50 ((r + 0.055) / 1.055).powf(2.4)
51 };
52 let g = if g <= 0.03928 {
53 g / 12.92
54 } else {
55 ((g + 0.055) / 1.055).powf(2.4)
56 };
57 let b = if b <= 0.03928 {
58 b / 12.92
59 } else {
60 ((b + 0.055) / 1.055).powf(2.4)
61 };
62
63 0.2126 * r + 0.7152 * g + 0.0722 * b
64 }
65
66 pub fn contrast_ratio(&self, other: &Color) -> f32 {
68 let l1 = self.luminance();
69 let l2 = other.luminance();
70
71 let lighter = l1.max(l2);
72 let darker = l1.min(l2);
73
74 (lighter + 0.05) / (darker + 0.05)
75 }
76
77 pub fn meets_wcag_aa(&self, other: &Color) -> bool {
79 self.contrast_ratio(other) >= 4.5
80 }
81
82 pub fn meets_wcag_aaa(&self, other: &Color) -> bool {
84 self.contrast_ratio(other) >= 7.0
85 }
86}
87
88#[derive(Debug, Clone, Copy, Default)]
90pub struct TextStyle {
91 pub fg: Option<Color>,
93 pub bg: Option<Color>,
95 pub bold: bool,
97 pub italic: bool,
99 pub underline: bool,
101}
102
103impl TextStyle {
104 pub const fn new() -> Self {
106 Self {
107 fg: None,
108 bg: None,
109 bold: false,
110 italic: false,
111 underline: false,
112 }
113 }
114
115 pub const fn fg(mut self, color: Color) -> Self {
117 self.fg = Some(color);
118 self
119 }
120
121 pub const fn bg(mut self, color: Color) -> Self {
123 self.bg = Some(color);
124 self
125 }
126
127 pub const fn bold(mut self) -> Self {
129 self.bold = true;
130 self
131 }
132
133 pub const fn italic(mut self) -> Self {
135 self.italic = true;
136 self
137 }
138
139 pub const fn underline(mut self) -> Self {
141 self.underline = true;
142 self
143 }
144}
145
146#[derive(Debug, Clone)]
148pub struct ProgressIndicator {
149 pub progress: u8,
151 pub total: u32,
153 pub current: u32,
155}
156
157impl ProgressIndicator {
158 pub fn new(total: u32) -> Self {
160 Self {
161 progress: 0,
162 total,
163 current: 0,
164 }
165 }
166
167 pub fn update(&mut self, current: u32) {
169 self.current = current.min(self.total);
170 self.progress = ((self.current as f32 / self.total as f32) * 100.0) as u8;
171 }
172
173 pub fn bar(&self, width: usize) -> String {
175 let filled = (width as f32 * self.progress as f32 / 100.0) as usize;
176 let empty = width.saturating_sub(filled);
177 format!("[{}{}]", "=".repeat(filled), " ".repeat(empty))
178 }
179}
180
181#[derive(Debug, Clone, Serialize, Deserialize)]
183pub struct Theme {
184 pub name: String,
186 pub primary: Color,
188 pub secondary: Color,
190 pub accent: Color,
192 pub background: Color,
194 pub foreground: Color,
196 pub error: Color,
198 pub warning: Color,
200 pub success: Color,
202}
203
204impl Default for Theme {
205 fn default() -> Self {
206 Self {
208 name: "dark".to_string(),
209 primary: Color::new(0, 122, 255), secondary: Color::new(90, 200, 250), accent: Color::new(255, 45, 85), background: Color::new(17, 24, 39), foreground: Color::new(243, 244, 246), error: Color::new(239, 68, 68), warning: Color::new(245, 158, 11), success: Color::new(34, 197, 94), }
218 }
219}
220
221impl Theme {
222 pub fn light() -> Self {
224 Self {
225 name: "light".to_string(),
226 primary: Color::new(0, 102, 204), secondary: Color::new(102, 178, 255), accent: Color::new(204, 0, 0), background: Color::new(255, 255, 255), foreground: Color::new(0, 0, 0), error: Color::new(220, 38, 38), warning: Color::new(217, 119, 6), success: Color::new(22, 163, 74), }
235 }
236
237 pub fn monokai() -> Self {
239 Self {
240 name: "monokai".to_string(),
241 primary: Color::new(102, 217, 239), secondary: Color::new(249, 38, 114), accent: Color::new(166, 226, 46), background: Color::new(39, 40, 34), foreground: Color::new(248, 248, 242), error: Color::new(249, 38, 114), warning: Color::new(253, 151, 31), success: Color::new(166, 226, 46), }
250 }
251
252 pub fn dracula() -> Self {
254 Self {
255 name: "dracula".to_string(),
256 primary: Color::new(139, 233, 253), secondary: Color::new(189, 147, 249), accent: Color::new(255, 121, 198), background: Color::new(40, 42, 54), foreground: Color::new(248, 248, 242), error: Color::new(255, 85, 85), warning: Color::new(241, 250, 140), success: Color::new(80, 250, 123), }
265 }
266
267 pub fn nord() -> Self {
269 Self {
270 name: "nord".to_string(),
271 primary: Color::new(136, 192, 208), secondary: Color::new(163, 190, 140), accent: Color::new(191, 97, 106), background: Color::new(46, 52, 64), foreground: Color::new(236, 239, 244), error: Color::new(191, 97, 106), warning: Color::new(235, 203, 139), success: Color::new(163, 190, 140), }
280 }
281
282 pub fn high_contrast() -> Self {
284 Self {
285 name: "high-contrast".to_string(),
286 primary: Color::new(255, 255, 255), secondary: Color::new(255, 255, 0), accent: Color::new(255, 0, 0), background: Color::new(0, 0, 0), foreground: Color::new(255, 255, 255), error: Color::new(255, 0, 0), warning: Color::new(255, 255, 0), success: Color::new(0, 255, 0), }
295 }
296
297 pub fn detect_color_support() -> ColorSupport {
299 if let Ok(colorterm) = env::var("COLORTERM") {
301 if colorterm.contains("truecolor") || colorterm.contains("24bit") {
302 return ColorSupport::TrueColor;
303 }
304 }
305
306 if let Ok(term) = env::var("TERM") {
308 if term.contains("256color") {
309 return ColorSupport::Color256;
310 }
311 if term.contains("color") {
312 return ColorSupport::Color16;
313 }
314 }
315
316 ColorSupport::Color256
318 }
319
320 pub fn by_name(name: &str) -> Option<Self> {
322 match name.to_lowercase().as_str() {
323 "dark" => Some(Self::default()),
324 "light" => Some(Self::light()),
325 "monokai" => Some(Self::monokai()),
326 "dracula" => Some(Self::dracula()),
327 "nord" => Some(Self::nord()),
328 "high-contrast" => Some(Self::high_contrast()),
329 _ => None,
330 }
331 }
332
333 pub fn available_themes() -> Vec<&'static str> {
335 vec![
336 "dark",
337 "light",
338 "monokai",
339 "dracula",
340 "nord",
341 "high-contrast",
342 ]
343 }
344
345 pub fn meets_wcag_aa(&self) -> bool {
347 self.foreground.meets_wcag_aa(&self.background)
349 && self.primary.meets_wcag_aa(&self.background)
350 && self.error.meets_wcag_aa(&self.background)
351 }
352
353 pub fn meets_wcag_aaa(&self) -> bool {
355 self.foreground.meets_wcag_aaa(&self.background)
357 && self.primary.meets_wcag_aaa(&self.background)
358 && self.error.meets_wcag_aaa(&self.background)
359 }
360
361 pub fn foreground_contrast(&self) -> f32 {
363 self.foreground.contrast_ratio(&self.background)
364 }
365
366 pub fn primary_contrast(&self) -> f32 {
368 self.primary.contrast_ratio(&self.background)
369 }
370
371 pub fn error_contrast(&self) -> f32 {
373 self.error.contrast_ratio(&self.background)
374 }
375}
376
377#[derive(Debug, Clone, Copy, PartialEq, Eq)]
379pub enum ColorSupport {
380 Color16,
382 Color256,
384 TrueColor,
386}
387
388#[cfg(test)]
389mod tests {
390 use super::*;
391
392 #[test]
393 fn test_color_creation() {
394 let color = Color::new(255, 128, 64);
395 assert_eq!(color.r, 255);
396 assert_eq!(color.g, 128);
397 assert_eq!(color.b, 64);
398 }
399
400 #[test]
401 fn test_color_hex() {
402 let color = Color::new(255, 128, 64);
403 assert_eq!(color.to_hex(), "#ff8040");
404
405 let parsed = Color::from_hex("#ff8040").unwrap();
406 assert_eq!(parsed, color);
407 }
408
409 #[test]
410 fn test_text_style() {
411 let color = Color::new(255, 0, 0);
412 let style = TextStyle::new().fg(color).bold().underline();
413 assert_eq!(style.fg, Some(color));
414 assert!(style.bold);
415 assert!(style.underline);
416 assert!(!style.italic);
417 }
418
419 #[test]
420 fn test_progress_indicator() {
421 let mut progress = ProgressIndicator::new(100);
422 assert_eq!(progress.progress, 0);
423
424 progress.update(50);
425 assert_eq!(progress.progress, 50);
426 assert_eq!(progress.current, 50);
427
428 progress.update(150);
429 assert_eq!(progress.current, 100);
430 assert_eq!(progress.progress, 100);
431 }
432
433 #[test]
434 fn test_progress_bar() {
435 let mut progress = ProgressIndicator::new(100);
436 progress.update(50);
437 let bar = progress.bar(10);
438 assert_eq!(bar, "[===== ]");
439 }
440
441 #[test]
442 fn test_theme_default() {
443 let theme = Theme::default();
444 assert_eq!(theme.name, "dark");
445 }
446
447 #[test]
448 fn test_theme_light() {
449 let theme = Theme::light();
450 assert_eq!(theme.name, "light");
451 }
452
453 #[test]
454 fn test_theme_monokai() {
455 let theme = Theme::monokai();
456 assert_eq!(theme.name, "monokai");
457 }
458
459 #[test]
460 fn test_theme_dracula() {
461 let theme = Theme::dracula();
462 assert_eq!(theme.name, "dracula");
463 }
464
465 #[test]
466 fn test_theme_nord() {
467 let theme = Theme::nord();
468 assert_eq!(theme.name, "nord");
469 }
470
471 #[test]
472 fn test_color_support_detection() {
473 let support = ColorSupport::Color256;
474 assert_eq!(support, ColorSupport::Color256);
475 }
476
477 #[test]
478 fn test_theme_by_name() {
479 assert!(Theme::by_name("dark").is_some());
480 assert!(Theme::by_name("light").is_some());
481 assert!(Theme::by_name("monokai").is_some());
482 assert!(Theme::by_name("dracula").is_some());
483 assert!(Theme::by_name("nord").is_some());
484 assert!(Theme::by_name("invalid").is_none());
485 }
486
487 #[test]
488 fn test_theme_by_name_case_insensitive() {
489 assert!(Theme::by_name("DARK").is_some());
490 assert!(Theme::by_name("Light").is_some());
491 assert!(Theme::by_name("MONOKAI").is_some());
492 }
493
494 #[test]
495 fn test_available_themes() {
496 let themes = Theme::available_themes();
497 assert_eq!(themes.len(), 6);
498 assert!(themes.contains(&"dark"));
499 assert!(themes.contains(&"light"));
500 assert!(themes.contains(&"monokai"));
501 assert!(themes.contains(&"dracula"));
502 assert!(themes.contains(&"nord"));
503 assert!(themes.contains(&"high-contrast"));
504 }
505
506 #[test]
507 fn test_color_contrast_ratio() {
508 let white = Color::new(255, 255, 255);
509 let black = Color::new(0, 0, 0);
510 let contrast = white.contrast_ratio(&black);
511 assert!(contrast > 20.0);
513 }
514
515 #[test]
516 fn test_wcag_aa_compliance() {
517 let white = Color::new(255, 255, 255);
518 let black = Color::new(0, 0, 0);
519 assert!(white.meets_wcag_aa(&black));
520 assert!(white.meets_wcag_aaa(&black));
521 }
522
523 #[test]
524 fn test_high_contrast_theme_wcag_compliance() {
525 let theme = Theme::high_contrast();
526 assert!(theme.meets_wcag_aa());
528 }
529
530 #[test]
531 fn test_theme_contrast_ratios() {
532 let theme = Theme::high_contrast();
533 let fg_contrast = theme.foreground_contrast();
534 let primary_contrast = theme.primary_contrast();
535 let error_contrast = theme.error_contrast();
536
537 assert!(fg_contrast >= 7.0);
539 assert!(primary_contrast >= 7.0);
540 assert!(error_contrast >= 4.5);
542 }
543}