Skip to main content

presentar_terminal/
theme.rs

1//! Theme system with CIELAB perceptual gradients.
2//!
3//! Provides color themes and smooth gradient interpolation for terminal UIs.
4//! Based on trueno-viz theme system for visual consistency.
5
6use presentar_core::Color;
7
8/// A color gradient with 2-3 stops for smooth interpolation.
9#[derive(Debug, Clone)]
10pub struct Gradient {
11    /// Gradient color stops (RGB hex strings like "#FF0000").
12    stops: Vec<Color>,
13}
14
15impl Gradient {
16    /// Create a two-color gradient.
17    #[must_use]
18    pub fn two(start: Color, end: Color) -> Self {
19        Self {
20            stops: vec![start, end],
21        }
22    }
23
24    /// Create a three-color gradient.
25    #[must_use]
26    pub fn three(start: Color, mid: Color, end: Color) -> Self {
27        Self {
28            stops: vec![start, mid, end],
29        }
30    }
31
32    /// Create gradient from hex strings.
33    #[must_use]
34    pub fn from_hex(stops: &[&str]) -> Self {
35        Self {
36            stops: stops.iter().map(|s| parse_hex(s)).collect(),
37        }
38    }
39
40    /// Sample the gradient at position t (0.0 - 1.0).
41    #[must_use]
42    pub fn sample(&self, t: f64) -> Color {
43        // Handle NaN/Inf by treating as 0.0
44        let t = if t.is_finite() {
45            t.clamp(0.0, 1.0)
46        } else {
47            0.0
48        };
49
50        if self.stops.is_empty() {
51            return Color::WHITE;
52        }
53
54        if self.stops.len() == 1 {
55            return self.stops[0];
56        }
57
58        // Find the segment
59        let segment_count = self.stops.len() - 1;
60        let segment_size = 1.0 / segment_count as f64;
61        let segment = ((t / segment_size) as usize).min(segment_count - 1);
62        let local_t = (t - segment as f64 * segment_size) / segment_size;
63
64        let start = self.stops[segment];
65        let end = self.stops[segment + 1];
66
67        interpolate_lab(start, end, local_t)
68    }
69
70    /// Get color for a percentage value (0-100).
71    #[must_use]
72    pub fn for_percent(&self, percent: f64) -> Color {
73        self.sample(percent / 100.0)
74    }
75}
76
77impl Default for Gradient {
78    fn default() -> Self {
79        // Green -> Yellow -> Red (classic usage gradient)
80        Self::from_hex(&["#00FF00", "#FFFF00", "#FF0000"])
81    }
82}
83
84/// Theme configuration for terminal UI.
85#[derive(Debug, Clone)]
86pub struct Theme {
87    /// Theme name.
88    pub name: String,
89    /// Background color.
90    pub background: Color,
91    /// Foreground (text) color.
92    pub foreground: Color,
93    /// Border color.
94    pub border: Color,
95    /// Dim/inactive color.
96    pub dim: Color,
97    /// CPU usage gradient.
98    pub cpu: Gradient,
99    /// Memory usage gradient.
100    pub memory: Gradient,
101    /// GPU usage gradient.
102    pub gpu: Gradient,
103    /// Temperature gradient.
104    pub temperature: Gradient,
105    /// Network gradient.
106    pub network: Gradient,
107}
108
109impl Default for Theme {
110    fn default() -> Self {
111        Self::tokyo_night()
112    }
113}
114
115impl Theme {
116    /// Create a new default theme.
117    #[must_use]
118    pub fn new() -> Self {
119        Self::default()
120    }
121
122    /// Tokyo Night theme (dark, modern).
123    #[must_use]
124    pub fn tokyo_night() -> Self {
125        Self {
126            name: "tokyo_night".to_string(),
127            background: parse_hex("#1a1b26"),
128            foreground: parse_hex("#c0caf5"),
129            border: parse_hex("#414868"),
130            dim: parse_hex("#565f89"),
131            cpu: Gradient::from_hex(&["#7aa2f7", "#e0af68", "#f7768e"]),
132            memory: Gradient::from_hex(&["#9ece6a", "#e0af68", "#f7768e"]),
133            gpu: Gradient::from_hex(&["#bb9af7", "#7dcfff", "#f7768e"]),
134            temperature: Gradient::from_hex(&["#7dcfff", "#e0af68", "#f7768e"]),
135            network: Gradient::from_hex(&["#7dcfff", "#9ece6a"]),
136        }
137    }
138
139    /// Dracula theme (dark, purple).
140    #[must_use]
141    pub fn dracula() -> Self {
142        Self {
143            name: "dracula".to_string(),
144            background: parse_hex("#282a36"),
145            foreground: parse_hex("#f8f8f2"),
146            border: parse_hex("#6272a4"),
147            dim: parse_hex("#44475a"),
148            cpu: Gradient::from_hex(&["#50fa7b", "#f1fa8c", "#ff5555"]),
149            memory: Gradient::from_hex(&["#8be9fd", "#f1fa8c", "#ff5555"]),
150            gpu: Gradient::from_hex(&["#bd93f9", "#ff79c6", "#ff5555"]),
151            temperature: Gradient::from_hex(&["#8be9fd", "#ffb86c", "#ff5555"]),
152            network: Gradient::from_hex(&["#8be9fd", "#50fa7b"]),
153        }
154    }
155
156    /// Nord theme (cool, arctic).
157    #[must_use]
158    pub fn nord() -> Self {
159        Self {
160            name: "nord".to_string(),
161            background: parse_hex("#2e3440"),
162            foreground: parse_hex("#eceff4"),
163            border: parse_hex("#4c566a"),
164            dim: parse_hex("#3b4252"),
165            cpu: Gradient::from_hex(&["#a3be8c", "#ebcb8b", "#bf616a"]),
166            memory: Gradient::from_hex(&["#88c0d0", "#ebcb8b", "#bf616a"]),
167            gpu: Gradient::from_hex(&["#b48ead", "#81a1c1", "#bf616a"]),
168            temperature: Gradient::from_hex(&["#88c0d0", "#ebcb8b", "#bf616a"]),
169            network: Gradient::from_hex(&["#88c0d0", "#a3be8c"]),
170        }
171    }
172
173    /// Monokai theme (classic).
174    #[must_use]
175    pub fn monokai() -> Self {
176        Self {
177            name: "monokai".to_string(),
178            background: parse_hex("#272822"),
179            foreground: parse_hex("#f8f8f2"),
180            border: parse_hex("#49483e"),
181            dim: parse_hex("#75715e"),
182            cpu: Gradient::from_hex(&["#a6e22e", "#e6db74", "#f92672"]),
183            memory: Gradient::from_hex(&["#66d9ef", "#e6db74", "#f92672"]),
184            gpu: Gradient::from_hex(&["#ae81ff", "#fd971f", "#f92672"]),
185            temperature: Gradient::from_hex(&["#66d9ef", "#fd971f", "#f92672"]),
186            network: Gradient::from_hex(&["#66d9ef", "#a6e22e"]),
187        }
188    }
189
190    /// Get color for CPU usage percentage.
191    #[must_use]
192    pub fn cpu_color(&self, percent: f64) -> Color {
193        self.cpu.for_percent(percent)
194    }
195
196    /// Get color for memory usage percentage.
197    #[must_use]
198    pub fn memory_color(&self, percent: f64) -> Color {
199        self.memory.for_percent(percent)
200    }
201
202    /// Get color for GPU usage percentage.
203    #[must_use]
204    pub fn gpu_color(&self, percent: f64) -> Color {
205        self.gpu.for_percent(percent)
206    }
207
208    /// Get color for temperature (0-100 mapped to cold-hot).
209    #[must_use]
210    pub fn temp_color(&self, temp_c: f64, max_temp: f64) -> Color {
211        let percent = (temp_c / max_temp * 100.0).clamp(0.0, 100.0);
212        self.temperature.for_percent(percent)
213    }
214}
215
216/// Parse hex color string to Color.
217fn parse_hex(hex: &str) -> Color {
218    let hex = hex.trim_start_matches('#');
219    if hex.len() != 6 {
220        return Color::WHITE;
221    }
222
223    let r = u8::from_str_radix(&hex[0..2], 16).unwrap_or(255);
224    let g = u8::from_str_radix(&hex[2..4], 16).unwrap_or(255);
225    let b = u8::from_str_radix(&hex[4..6], 16).unwrap_or(255);
226
227    // Color uses 0.0-1.0 range
228    Color::new(r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, 1.0)
229}
230
231/// Interpolate between two colors using CIELAB for perceptual uniformity.
232#[allow(clippy::many_single_char_names)]
233fn interpolate_lab(start: Color, end: Color, t: f64) -> Color {
234    // Convert to LAB
235    let lab1 = rgb_to_lab(start);
236    let lab2 = rgb_to_lab(end);
237
238    // Interpolate in LAB space
239    let l = lab1.0 + t * (lab2.0 - lab1.0);
240    let a = lab1.1 + t * (lab2.1 - lab1.1);
241    let b = lab1.2 + t * (lab2.2 - lab1.2);
242
243    // Convert back to RGB
244    lab_to_rgb(l, a, b)
245}
246
247/// Convert RGB to CIELAB.
248#[allow(clippy::many_single_char_names, clippy::unreadable_literal)]
249fn rgb_to_lab(c: Color) -> (f64, f64, f64) {
250    // Color is already in 0-1 range
251    let r = c.r as f64;
252    let g = c.g as f64;
253    let b = c.b as f64;
254
255    // sRGB to linear
256    let r = if r > 0.04045 {
257        ((r + 0.055) / 1.055).powf(2.4)
258    } else {
259        r / 12.92
260    };
261    let g = if g > 0.04045 {
262        ((g + 0.055) / 1.055).powf(2.4)
263    } else {
264        g / 12.92
265    };
266    let b = if b > 0.04045 {
267        ((b + 0.055) / 1.055).powf(2.4)
268    } else {
269        b / 12.92
270    };
271
272    // Linear RGB to XYZ
273    let x = r * 0.4124564 + g * 0.3575761 + b * 0.1804375;
274    let y = r * 0.2126729 + g * 0.7151522 + b * 0.0721750;
275    let z = r * 0.0193339 + g * 0.1191920 + b * 0.9503041;
276
277    // XYZ to LAB (D65 white point)
278    let x = x / 0.95047;
279    let z = z / 1.08883;
280
281    let fx = if x > 0.008856 {
282        x.cbrt()
283    } else {
284        (7.787 * x) + (16.0 / 116.0)
285    };
286    let fy = if y > 0.008856 {
287        y.cbrt()
288    } else {
289        (7.787 * y) + (16.0 / 116.0)
290    };
291    let fz = if z > 0.008856 {
292        z.cbrt()
293    } else {
294        (7.787 * z) + (16.0 / 116.0)
295    };
296
297    let l = (116.0 * fy) - 16.0;
298    let a = 500.0 * (fx - fy);
299    let b_val = 200.0 * (fy - fz);
300
301    (l, a, b_val)
302}
303
304/// Convert CIELAB to RGB.
305#[allow(clippy::many_single_char_names, clippy::unreadable_literal)]
306fn lab_to_rgb(l: f64, a: f64, b: f64) -> Color {
307    // LAB to XYZ
308    let fy = (l + 16.0) / 116.0;
309    let fx = a / 500.0 + fy;
310    let fz = fy - b / 200.0;
311
312    let x = if fx.powi(3) > 0.008856 {
313        fx.powi(3)
314    } else {
315        (fx - 16.0 / 116.0) / 7.787
316    };
317    let y = if l > 7.9996 { fy.powi(3) } else { l / 903.3 };
318    let z = if fz.powi(3) > 0.008856 {
319        fz.powi(3)
320    } else {
321        (fz - 16.0 / 116.0) / 7.787
322    };
323
324    // D65 white point
325    let x = x * 0.95047;
326    let z = z * 1.08883;
327
328    // XYZ to linear RGB
329    let r = x * 3.2404542 + y * -1.5371385 + z * -0.4985314;
330    let g = x * -0.9692660 + y * 1.8760108 + z * 0.0415560;
331    let b_val = x * 0.0556434 + y * -0.2040259 + z * 1.0572252;
332
333    // Linear to sRGB
334    let r = if r > 0.0031308 {
335        1.055 * r.powf(1.0 / 2.4) - 0.055
336    } else {
337        12.92 * r
338    };
339    let g = if g > 0.0031308 {
340        1.055 * g.powf(1.0 / 2.4) - 0.055
341    } else {
342        12.92 * g
343    };
344    let b_val = if b_val > 0.0031308 {
345        1.055 * b_val.powf(1.0 / 2.4) - 0.055
346    } else {
347        12.92 * b_val
348    };
349
350    // Clamp to 0-1 range
351    let r = r.clamp(0.0, 1.0) as f32;
352    let g = g.clamp(0.0, 1.0) as f32;
353    let b_val = b_val.clamp(0.0, 1.0) as f32;
354
355    Color::new(r, g, b_val, 1.0)
356}
357
358#[cfg(test)]
359mod tests {
360    use super::*;
361
362    #[test]
363    fn test_parse_hex() {
364        let c = parse_hex("#FF0000");
365        assert!((c.r - 1.0).abs() < 0.01); // 0-1 range
366        assert!((c.g - 0.0).abs() < 0.01);
367        assert!((c.b - 0.0).abs() < 0.01);
368    }
369
370    #[test]
371    fn test_parse_hex_green() {
372        let c = parse_hex("#00FF00");
373        assert!((c.r - 0.0).abs() < 0.01);
374        assert!((c.g - 1.0).abs() < 0.01); // 0-1 range
375        assert!((c.b - 0.0).abs() < 0.01);
376    }
377
378    #[test]
379    fn test_gradient_two() {
380        let g = Gradient::two(Color::RED, Color::BLUE);
381        let start = g.sample(0.0);
382        let end = g.sample(1.0);
383        assert!((start.r - 1.0).abs() < 0.01); // 0-1 range
384        assert!((end.b - 1.0).abs() < 0.01); // 0-1 range
385    }
386
387    #[test]
388    fn test_gradient_three() {
389        let g = Gradient::three(Color::RED, Color::GREEN, Color::BLUE);
390        let mid = g.sample(0.5);
391        // At 0.5, should be close to green
392        assert!(mid.g > mid.r);
393        assert!(mid.g > mid.b);
394    }
395
396    #[test]
397    fn test_gradient_from_hex() {
398        let g = Gradient::from_hex(&["#FF0000", "#00FF00"]);
399        let start = g.sample(0.0);
400        assert!((start.r - 1.0).abs() < 0.01); // 0-1 range
401    }
402
403    #[test]
404    fn test_gradient_for_percent() {
405        let g = Gradient::default();
406        let _ = g.for_percent(50.0);
407        let _ = g.for_percent(0.0);
408        let _ = g.for_percent(100.0);
409    }
410
411    #[test]
412    fn test_theme_tokyo_night() {
413        let t = Theme::tokyo_night();
414        assert_eq!(t.name, "tokyo_night");
415    }
416
417    #[test]
418    fn test_theme_dracula() {
419        let t = Theme::dracula();
420        assert_eq!(t.name, "dracula");
421    }
422
423    #[test]
424    fn test_theme_nord() {
425        let t = Theme::nord();
426        assert_eq!(t.name, "nord");
427    }
428
429    #[test]
430    fn test_theme_monokai() {
431        let t = Theme::monokai();
432        assert_eq!(t.name, "monokai");
433    }
434
435    #[test]
436    fn test_theme_cpu_color() {
437        let t = Theme::default();
438        let _ = t.cpu_color(0.0);
439        let _ = t.cpu_color(50.0);
440        let _ = t.cpu_color(100.0);
441    }
442
443    #[test]
444    fn test_theme_memory_color() {
445        let t = Theme::default();
446        let _ = t.memory_color(75.0);
447    }
448
449    #[test]
450    fn test_theme_gpu_color() {
451        let t = Theme::default();
452        let _ = t.gpu_color(25.0);
453    }
454
455    #[test]
456    fn test_theme_temp_color() {
457        let t = Theme::default();
458        let _ = t.temp_color(65.0, 100.0);
459    }
460
461    #[test]
462    fn test_gradient_empty() {
463        let g = Gradient { stops: vec![] };
464        let c = g.sample(0.5);
465        assert_eq!(c, Color::WHITE);
466    }
467
468    #[test]
469    fn test_gradient_single() {
470        let g = Gradient {
471            stops: vec![Color::RED],
472        };
473        let c = g.sample(0.5);
474        assert!((c.r - 1.0).abs() < 0.01); // 0-1 range
475    }
476
477    #[test]
478    fn test_gradient_clamp() {
479        let g = Gradient::default();
480        let _ = g.sample(-1.0); // Should clamp to 0
481        let _ = g.sample(2.0); // Should clamp to 1
482    }
483
484    #[test]
485    fn test_lab_roundtrip() {
486        // Use 0-1 range values
487        let original = Color::new(0.5, 0.25, 0.75, 1.0);
488        let lab = rgb_to_lab(original);
489        let back = lab_to_rgb(lab.0, lab.1, lab.2);
490        assert!((original.r - back.r).abs() < 0.02);
491        assert!((original.g - back.g).abs() < 0.02);
492        assert!((original.b - back.b).abs() < 0.02);
493    }
494
495    #[test]
496    fn test_interpolate_lab_endpoints() {
497        let start = Color::RED;
498        let end = Color::BLUE;
499
500        let at_start = interpolate_lab(start, end, 0.0);
501        let at_end = interpolate_lab(start, end, 1.0);
502
503        assert!((at_start.r - 1.0).abs() < 0.02); // 0-1 range
504        assert!((at_end.b - 1.0).abs() < 0.02); // 0-1 range
505    }
506
507    #[test]
508    fn test_theme_default() {
509        let t = Theme::default();
510        assert_eq!(t.name, "tokyo_night");
511    }
512
513    #[test]
514    fn test_parse_hex_invalid() {
515        let c = parse_hex("invalid");
516        assert_eq!(c, Color::WHITE);
517    }
518
519    #[test]
520    fn test_parse_hex_no_hash() {
521        let c = parse_hex("FF0000");
522        assert!((c.r - 1.0).abs() < 0.01); // 0-1 range
523    }
524
525    #[test]
526    fn test_parse_hex_blue() {
527        let c = parse_hex("#0000FF");
528        assert!((c.r - 0.0).abs() < 0.01);
529        assert!((c.g - 0.0).abs() < 0.01);
530        assert!((c.b - 1.0).abs() < 0.01);
531    }
532
533    #[test]
534    fn test_parse_hex_mixed() {
535        let c = parse_hex("#808080");
536        assert!((c.r - 0.5).abs() < 0.01);
537        assert!((c.g - 0.5).abs() < 0.01);
538        assert!((c.b - 0.5).abs() < 0.01);
539    }
540
541    #[test]
542    fn test_gradient_default() {
543        let g = Gradient::default();
544        // Green -> Yellow -> Red
545        let start = g.sample(0.0);
546        assert!(start.g > 0.9); // Green
547        let end = g.sample(1.0);
548        assert!(end.r > 0.9); // Red
549    }
550
551    #[test]
552    fn test_theme_new() {
553        let t = Theme::new();
554        assert_eq!(t.name, "tokyo_night");
555    }
556
557    #[test]
558    fn test_theme_colors_non_panic() {
559        let t = Theme::default();
560        // Test all color functions don't panic
561        for pct in [0.0, 25.0, 50.0, 75.0, 100.0] {
562            let _ = t.cpu_color(pct);
563            let _ = t.memory_color(pct);
564            let _ = t.gpu_color(pct);
565        }
566        for temp in [0.0, 25.0, 50.0, 75.0, 100.0] {
567            let _ = t.temp_color(temp, 100.0);
568        }
569    }
570
571    #[test]
572    fn test_temp_color_cold() {
573        let t = Theme::default();
574        let cold = t.temp_color(0.0, 100.0);
575        let hot = t.temp_color(100.0, 100.0);
576        // Cold and hot should be different
577        assert!((cold.r - hot.r).abs() > 0.1 || (cold.g - hot.g).abs() > 0.1);
578    }
579
580    #[test]
581    fn test_temp_color_clamped() {
582        let t = Theme::default();
583        // Over max should clamp to 100%
584        let over = t.temp_color(150.0, 100.0);
585        let at_max = t.temp_color(100.0, 100.0);
586        assert!((over.r - at_max.r).abs() < 0.01);
587    }
588
589    #[test]
590    fn test_gradient_four_stops() {
591        // Test gradient with more stops
592        let g = Gradient {
593            stops: vec![Color::RED, Color::GREEN, Color::BLUE, Color::WHITE],
594        };
595        let _ = g.sample(0.0);
596        let _ = g.sample(0.33);
597        let _ = g.sample(0.66);
598        let _ = g.sample(1.0);
599    }
600
601    #[test]
602    fn test_theme_background_foreground() {
603        let t = Theme::tokyo_night();
604        // Background should be dark
605        assert!(t.background.r < 0.2);
606        assert!(t.background.g < 0.2);
607        // Foreground should be light
608        assert!(t.foreground.r > 0.5);
609        assert!(t.foreground.g > 0.5);
610    }
611
612    #[test]
613    fn test_theme_border_dim() {
614        let t = Theme::dracula();
615        // Border and dim should be mid-range
616        assert!(t.border.r > 0.2 && t.border.r < 0.8);
617        assert!(t.dim.r > 0.1 && t.dim.r < 0.5);
618    }
619
620    #[test]
621    fn test_interpolate_lab_midpoint() {
622        let start = Color::new(0.0, 0.0, 0.0, 1.0); // Black
623        let end = Color::new(1.0, 1.0, 1.0, 1.0); // White
624        let mid = interpolate_lab(start, end, 0.5);
625        // Middle gray should be around 0.5
626        assert!(mid.r > 0.3 && mid.r < 0.7);
627    }
628
629    #[test]
630    fn test_rgb_lab_roundtrip_black() {
631        let black = Color::new(0.0, 0.0, 0.0, 1.0);
632        let lab = rgb_to_lab(black);
633        let back = lab_to_rgb(lab.0, lab.1, lab.2);
634        assert!((back.r - 0.0).abs() < 0.02);
635    }
636
637    #[test]
638    fn test_rgb_lab_roundtrip_white() {
639        let white = Color::new(1.0, 1.0, 1.0, 1.0);
640        let lab = rgb_to_lab(white);
641        let back = lab_to_rgb(lab.0, lab.1, lab.2);
642        assert!((back.r - 1.0).abs() < 0.02);
643    }
644
645    #[test]
646    fn test_gradient_segment_boundary() {
647        let g = Gradient::three(Color::RED, Color::GREEN, Color::BLUE);
648        // At exactly 0.5, should be at/near green
649        let at_half = g.sample(0.5);
650        assert!(at_half.g > at_half.r);
651        assert!(at_half.g > at_half.b);
652    }
653
654    #[test]
655    fn test_all_themes_valid() {
656        let themes = [
657            Theme::tokyo_night(),
658            Theme::dracula(),
659            Theme::nord(),
660            Theme::monokai(),
661        ];
662        for t in themes {
663            assert!(!t.name.is_empty());
664            // All gradients should work
665            let _ = t.cpu.sample(0.5);
666            let _ = t.memory.sample(0.5);
667            let _ = t.gpu.sample(0.5);
668            let _ = t.temperature.sample(0.5);
669            let _ = t.network.sample(0.5);
670        }
671    }
672
673    #[test]
674    fn test_parse_hex_short() {
675        // Too short should return white
676        let c = parse_hex("#FFF");
677        assert_eq!(c, Color::WHITE);
678    }
679
680    #[test]
681    fn test_parse_hex_long() {
682        // Too long should return white
683        let c = parse_hex("#FFFFFFFF");
684        assert_eq!(c, Color::WHITE);
685    }
686}