ricecoder_tui/
style.rs

1//! Styling and theming for the TUI
2
3use serde::{Deserialize, Serialize};
4use std::env;
5
6/// Color definition
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]
8pub struct Color {
9    /// Red component
10    pub r: u8,
11    /// Green component
12    pub g: u8,
13    /// Blue component
14    pub b: u8,
15}
16
17impl Color {
18    /// Create a new color
19    pub const fn new(r: u8, g: u8, b: u8) -> Self {
20        Self { r, g, b }
21    }
22
23    /// Create a color from hex string
24    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    /// Convert to hex string
37    pub fn to_hex(&self) -> String {
38        format!("#{:02x}{:02x}{:02x}", self.r, self.g, self.b)
39    }
40
41    /// Calculate relative luminance (WCAG formula)
42    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    /// Calculate contrast ratio between two colors (WCAG formula)
67    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    /// Check if contrast ratio meets WCAG AA standard (4.5:1 for normal text)
78    pub fn meets_wcag_aa(&self, other: &Color) -> bool {
79        self.contrast_ratio(other) >= 4.5
80    }
81
82    /// Check if contrast ratio meets WCAG AAA standard (7:1 for normal text)
83    pub fn meets_wcag_aaa(&self, other: &Color) -> bool {
84        self.contrast_ratio(other) >= 7.0
85    }
86}
87
88/// Text style
89#[derive(Debug, Clone, Copy, Default)]
90pub struct TextStyle {
91    /// Foreground color
92    pub fg: Option<Color>,
93    /// Background color
94    pub bg: Option<Color>,
95    /// Bold text
96    pub bold: bool,
97    /// Italic text
98    pub italic: bool,
99    /// Underlined text
100    pub underline: bool,
101}
102
103impl TextStyle {
104    /// Create a new text style
105    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    /// Set foreground color
116    pub const fn fg(mut self, color: Color) -> Self {
117        self.fg = Some(color);
118        self
119    }
120
121    /// Set background color
122    pub const fn bg(mut self, color: Color) -> Self {
123        self.bg = Some(color);
124        self
125    }
126
127    /// Set bold
128    pub const fn bold(mut self) -> Self {
129        self.bold = true;
130        self
131    }
132
133    /// Set italic
134    pub const fn italic(mut self) -> Self {
135        self.italic = true;
136        self
137    }
138
139    /// Set underline
140    pub const fn underline(mut self) -> Self {
141        self.underline = true;
142        self
143    }
144}
145
146/// Progress indicator
147#[derive(Debug, Clone)]
148pub struct ProgressIndicator {
149    /// Current progress (0-100)
150    pub progress: u8,
151    /// Total steps
152    pub total: u32,
153    /// Current step
154    pub current: u32,
155}
156
157impl ProgressIndicator {
158    /// Create a new progress indicator
159    pub fn new(total: u32) -> Self {
160        Self {
161            progress: 0,
162            total,
163            current: 0,
164        }
165    }
166
167    /// Update progress
168    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    /// Get progress bar string
174    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/// Theme definition
182#[derive(Debug, Clone, Serialize, Deserialize)]
183pub struct Theme {
184    /// Theme name
185    pub name: String,
186    /// Primary color
187    pub primary: Color,
188    /// Secondary color
189    pub secondary: Color,
190    /// Accent color
191    pub accent: Color,
192    /// Background color
193    pub background: Color,
194    /// Foreground color
195    pub foreground: Color,
196    /// Error color
197    pub error: Color,
198    /// Warning color
199    pub warning: Color,
200    /// Success color
201    pub success: Color,
202}
203
204impl Default for Theme {
205    fn default() -> Self {
206        // Dark theme as default
207        Self {
208            name: "dark".to_string(),
209            primary: Color::new(0, 122, 255),      // Blue
210            secondary: Color::new(90, 200, 250),   // Light blue
211            accent: Color::new(255, 45, 85),       // Red
212            background: Color::new(17, 24, 39),    // Dark gray
213            foreground: Color::new(243, 244, 246), // Light gray
214            error: Color::new(239, 68, 68),        // Red
215            warning: Color::new(245, 158, 11),     // Amber
216            success: Color::new(34, 197, 94),      // Green
217        }
218    }
219}
220
221impl Theme {
222    /// Create a light theme
223    pub fn light() -> Self {
224        Self {
225            name: "light".to_string(),
226            primary: Color::new(0, 102, 204),      // Blue
227            secondary: Color::new(102, 178, 255),  // Light blue
228            accent: Color::new(204, 0, 0),         // Red
229            background: Color::new(255, 255, 255), // White
230            foreground: Color::new(0, 0, 0),       // Black
231            error: Color::new(220, 38, 38),        // Red
232            warning: Color::new(217, 119, 6),      // Amber
233            success: Color::new(22, 163, 74),      // Green
234        }
235    }
236
237    /// Create a Monokai theme
238    pub fn monokai() -> Self {
239        Self {
240            name: "monokai".to_string(),
241            primary: Color::new(102, 217, 239),    // Cyan
242            secondary: Color::new(249, 38, 114),   // Magenta
243            accent: Color::new(166, 226, 46),      // Green
244            background: Color::new(39, 40, 34),    // Dark gray
245            foreground: Color::new(248, 248, 242), // Off-white
246            error: Color::new(249, 38, 114),       // Magenta
247            warning: Color::new(253, 151, 31),     // Orange
248            success: Color::new(166, 226, 46),     // Green
249        }
250    }
251
252    /// Create a Dracula theme
253    pub fn dracula() -> Self {
254        Self {
255            name: "dracula".to_string(),
256            primary: Color::new(139, 233, 253),    // Cyan
257            secondary: Color::new(189, 147, 249),  // Purple
258            accent: Color::new(255, 121, 198),     // Pink
259            background: Color::new(40, 42, 54),    // Dark gray
260            foreground: Color::new(248, 248, 242), // Off-white
261            error: Color::new(255, 85, 85),        // Red
262            warning: Color::new(241, 250, 140),    // Yellow
263            success: Color::new(80, 250, 123),     // Green
264        }
265    }
266
267    /// Create a Nord theme
268    pub fn nord() -> Self {
269        Self {
270            name: "nord".to_string(),
271            primary: Color::new(136, 192, 208),    // Frost 1
272            secondary: Color::new(163, 190, 140),  // Aurora 1
273            accent: Color::new(191, 97, 106),      // Aurora 5
274            background: Color::new(46, 52, 64),    // Polar night 0
275            foreground: Color::new(236, 239, 244), // Snow storm 0
276            error: Color::new(191, 97, 106),       // Aurora 5
277            warning: Color::new(235, 203, 139),    // Aurora 3
278            success: Color::new(163, 190, 140),    // Aurora 1
279        }
280    }
281
282    /// Create a high contrast theme for accessibility
283    pub fn high_contrast() -> Self {
284        Self {
285            name: "high-contrast".to_string(),
286            primary: Color::new(255, 255, 255), // Pure white (high contrast on black)
287            secondary: Color::new(255, 255, 0), // Pure yellow
288            accent: Color::new(255, 0, 0),      // Pure red
289            background: Color::new(0, 0, 0),    // Pure black
290            foreground: Color::new(255, 255, 255), // Pure white
291            error: Color::new(255, 0, 0),       // Pure red
292            warning: Color::new(255, 255, 0),   // Pure yellow
293            success: Color::new(0, 255, 0),     // Pure green
294        }
295    }
296
297    /// Detect terminal color capabilities
298    pub fn detect_color_support() -> ColorSupport {
299        // Check COLORTERM environment variable for true color support
300        if let Ok(colorterm) = env::var("COLORTERM") {
301            if colorterm.contains("truecolor") || colorterm.contains("24bit") {
302                return ColorSupport::TrueColor;
303            }
304        }
305
306        // Check TERM environment variable
307        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        // Default to 256 color support
317        ColorSupport::Color256
318    }
319
320    /// Get a theme by name
321    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    /// Get all available theme names
334    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    /// Check if the theme meets WCAG AA contrast standards
346    pub fn meets_wcag_aa(&self) -> bool {
347        // Check foreground vs background
348        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    /// Check if the theme meets WCAG AAA contrast standards
354    pub fn meets_wcag_aaa(&self) -> bool {
355        // Check foreground vs background
356        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    /// Get contrast ratio between foreground and background
362    pub fn foreground_contrast(&self) -> f32 {
363        self.foreground.contrast_ratio(&self.background)
364    }
365
366    /// Get contrast ratio between primary color and background
367    pub fn primary_contrast(&self) -> f32 {
368        self.primary.contrast_ratio(&self.background)
369    }
370
371    /// Get contrast ratio between error color and background
372    pub fn error_contrast(&self) -> f32 {
373        self.error.contrast_ratio(&self.background)
374    }
375}
376
377/// Terminal color support levels
378#[derive(Debug, Clone, Copy, PartialEq, Eq)]
379pub enum ColorSupport {
380    /// 16 colors (basic ANSI)
381    Color16,
382    /// 256 colors
383    Color256,
384    /// True color (24-bit RGB)
385    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        // White on black should have maximum contrast (21:1)
512        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        // High contrast theme should meet at least AA standards
527        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        // Foreground and primary should meet WCAG AAA standards (7:1)
538        assert!(fg_contrast >= 7.0);
539        assert!(primary_contrast >= 7.0);
540        // Error should at least meet AA standards (4.5:1)
541        assert!(error_contrast >= 4.5);
542    }
543}