Skip to main content

entrenar/monitor/tui/
color.rs

1//! Terminal Color Support (ENT-122)
2//!
3//! Provides ANSI color output with automatic terminal capability detection.
4//! Based on presentar's color system with semantic colors for training metrics.
5
6use super::state::{LossTrend, TrainingStatus};
7use std::fmt;
8
9/// Safely convert an f32 to u8 with bounds clamping.
10/// Clamps to [0.0, 255.0] then converts through u16 with `try_from` for safety.
11#[inline]
12fn clamped_f32_to_u8(value: f32) -> u8 {
13    let clamped = value.clamp(0.0, 255.0);
14    // After clamp, value is in [0.0, 255.0]. Cast to u16 is safe for this range.
15    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
16    let wide = clamped as u16;
17    // u16 in [0, 255] always fits in u8; unwrap_or provides defense-in-depth
18    u8::try_from(wide).unwrap_or(u8::MAX)
19}
20
21/// Safely convert a non-negative f32 to usize with bounds clamping.
22#[inline]
23fn clamped_f32_to_usize(value: f32) -> usize {
24    let clamped = value.max(0.0);
25    // Value is non-negative after max(0.0); cast is safe for practical display sizes
26    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
27    let result = clamped as usize;
28    result
29}
30
31/// Terminal color capability mode
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
33pub enum ColorMode {
34    /// True color (24-bit RGB)
35    TrueColor,
36    /// 256 color palette
37    Color256,
38    /// 16 color palette
39    Color16,
40    /// No color (monochrome)
41    #[default]
42    Mono,
43}
44
45impl ColorMode {
46    /// Detect terminal color capability from environment
47    pub fn detect() -> Self {
48        Self::detect_with_env(
49            std::env::var("COLORTERM").ok().as_deref(),
50            std::env::var("TERM").ok().as_deref(),
51            std::env::var("NO_COLOR").ok().as_deref(),
52        )
53    }
54
55    /// Detect with explicit environment values (for testing)
56    pub fn detect_with_env(
57        colorterm: Option<&str>,
58        term: Option<&str>,
59        no_color: Option<&str>,
60    ) -> Self {
61        // NO_COLOR takes precedence
62        if no_color.is_some() {
63            return Self::Mono;
64        }
65
66        // Check COLORTERM for truecolor support
67        if let Some(ct) = colorterm {
68            if ct.contains("truecolor") || ct.contains("24bit") {
69                return Self::TrueColor;
70            }
71        }
72
73        // Check TERM for capability hints
74        if let Some(term) = term {
75            if term.contains("256color") || term.contains("kitty") || term.contains("alacritty") {
76                return Self::Color256;
77            }
78            if term.contains("xterm") || term.contains("screen") || term.contains("tmux") {
79                return Self::Color16;
80            }
81            if term == "dumb" || term.is_empty() {
82                return Self::Mono;
83            }
84        }
85
86        // Default to 16 colors for unknown terminals
87        Self::Color16
88    }
89}
90
91/// RGB color
92#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93pub struct Rgb {
94    pub r: u8,
95    pub g: u8,
96    pub b: u8,
97}
98
99impl Rgb {
100    pub const fn new(r: u8, g: u8, b: u8) -> Self {
101        Self { r, g, b }
102    }
103}
104
105impl From<(u8, u8, u8)> for Rgb {
106    fn from((r, g, b): (u8, u8, u8)) -> Self {
107        Self { r, g, b }
108    }
109}
110
111impl Rgb {
112    /// Convert to ANSI 256-color index (approximate)
113    pub fn to_256(self) -> u8 {
114        // Use the 216 color cube (indices 16-231)
115        // Each channel has 6 levels: 0, 95, 135, 175, 215, 255
116        let r6 = u8::try_from(u16::from(self.r) * 5 / 255).unwrap_or(u8::MAX);
117        let g6 = u8::try_from(u16::from(self.g) * 5 / 255).unwrap_or(u8::MAX);
118        let b6 = u8::try_from(u16::from(self.b) * 5 / 255).unwrap_or(u8::MAX);
119        16 + 36 * r6 + 6 * g6 + b6
120    }
121
122    /// Convert to ANSI 16-color index (approximate)
123    pub fn to_16(self) -> u8 {
124        // Use max channel for brightness detection (saturated colors should be bright)
125        let max_channel = self.r.max(self.g).max(self.b);
126        let is_bright = max_channel > 180;
127
128        // Determine dominant color
129        let r_dom = self.r >= self.g && self.r >= self.b;
130        let g_dom = self.g >= self.r && self.g >= self.b;
131        let b_dom = self.b >= self.r && self.b >= self.g;
132
133        // Mix detection
134        let r_present = self.r > 85;
135        let g_present = self.g > 85;
136        let b_present = self.b > 85;
137
138        let base = match (r_present, g_present, b_present) {
139            (true, true, true) => 7,   // white
140            (true, true, false) => 3,  // yellow
141            (true, false, true) => 5,  // magenta
142            (false, true, true) => 6,  // cyan
143            (true, false, false) => 1, // red
144            (false, true, false) => 2, // green
145            (false, false, true) => 4, // blue
146            (false, false, false) => {
147                // Near black - check if any color is dominant
148                if r_dom && self.r > 40 {
149                    1
150                } else if g_dom && self.g > 40 {
151                    2
152                } else if b_dom && self.b > 40 {
153                    4
154                } else {
155                    0
156                }
157            }
158        };
159
160        if is_bright {
161            base + 8
162        } else {
163            base
164        }
165    }
166}
167
168/// Styled text with foreground color
169pub struct Styled<'a> {
170    text: &'a str,
171    fg: Option<Rgb>,
172    bold: bool,
173    mode: ColorMode,
174}
175
176impl<'a> Styled<'a> {
177    pub fn new(text: &'a str, mode: ColorMode) -> Self {
178        Self { text, fg: None, bold: false, mode }
179    }
180
181    pub fn fg(mut self, color: impl Into<Rgb>) -> Self {
182        self.fg = Some(color.into());
183        self
184    }
185
186    pub fn bold(mut self) -> Self {
187        self.bold = true;
188        self
189    }
190}
191
192impl fmt::Display for Styled<'_> {
193    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
194        if self.mode == ColorMode::Mono {
195            return write!(f, "{}", self.text);
196        }
197
198        let mut has_style = false;
199
200        // Bold
201        if self.bold {
202            write!(f, "\x1b[1m")?;
203            has_style = true;
204        }
205
206        // Foreground color
207        if let Some(rgb) = self.fg {
208            match self.mode {
209                ColorMode::TrueColor => {
210                    write!(f, "\x1b[38;2;{};{};{}m", rgb.r, rgb.g, rgb.b)?;
211                }
212                ColorMode::Color256 => {
213                    write!(f, "\x1b[38;5;{}m", rgb.to_256())?;
214                }
215                ColorMode::Color16 => {
216                    let code = rgb.to_16();
217                    if code >= 8 {
218                        write!(f, "\x1b[9{}m", code - 8)?;
219                    } else {
220                        write!(f, "\x1b[3{code}m")?;
221                    }
222                }
223                ColorMode::Mono => {}
224            }
225            has_style = true;
226        }
227
228        write!(f, "{}", self.text)?;
229
230        if has_style {
231            write!(f, "\x1b[0m")?;
232        }
233
234        Ok(())
235    }
236}
237
238/// Semantic color palette for training metrics
239#[derive(Debug, Clone)]
240pub struct TrainingPalette {
241    pub mode: ColorMode,
242}
243
244impl Default for TrainingPalette {
245    fn default() -> Self {
246        Self { mode: ColorMode::detect() }
247    }
248}
249
250impl TrainingPalette {
251    pub fn new(mode: ColorMode) -> Self {
252        Self { mode }
253    }
254
255    /// Style text with this palette's color mode
256    pub fn style<'a>(&self, text: &'a str) -> Styled<'a> {
257        Styled::new(text, self.mode)
258    }
259
260    // ─────────────────────────────────────────────────────────────────────────
261    // Semantic Colors
262    // ─────────────────────────────────────────────────────────────────────────
263
264    /// Success/good state (green)
265    pub const SUCCESS: Rgb = Rgb::new(80, 200, 120);
266
267    /// Warning state (yellow/orange)
268    pub const WARNING: Rgb = Rgb::new(255, 193, 7);
269
270    /// Error/danger state (red)
271    pub const ERROR: Rgb = Rgb::new(244, 67, 54);
272
273    /// Info/neutral (blue)
274    pub const INFO: Rgb = Rgb::new(33, 150, 243);
275
276    /// Muted/secondary text (gray)
277    pub const MUTED: Rgb = Rgb::new(158, 158, 158);
278
279    /// Primary accent (cyan)
280    pub const PRIMARY: Rgb = Rgb::new(0, 188, 212);
281
282    // ─────────────────────────────────────────────────────────────────────────
283    // Threshold Lookup
284    // ─────────────────────────────────────────────────────────────────────────
285
286    /// Return the color for the first threshold in `thresholds` that `value` does not exceed,
287    /// or `fallback` if `value` exceeds all thresholds.
288    ///
289    /// `thresholds` is a slice of `(upper_bound_exclusive, color)` pairs in ascending order.
290    fn threshold_color(value: f32, thresholds: &[(f32, Rgb)], fallback: Rgb) -> Rgb {
291        for &(bound, color) in thresholds {
292            if value <= bound {
293                return color;
294            }
295        }
296        fallback
297    }
298
299    // ─────────────────────────────────────────────────────────────────────────
300    // GPU Metrics Colors
301    // ─────────────────────────────────────────────────────────────────────────
302
303    /// Color for GPU utilization based on percentage
304    pub fn gpu_util_color(percent: f32) -> Rgb {
305        let p = percent.clamp(0.0, 100.0);
306        Self::threshold_color(
307            p,
308            &[(30.0, Self::MUTED), (70.0, Self::SUCCESS), (90.0, Self::INFO)],
309            Self::PRIMARY,
310        )
311    }
312
313    /// Color for VRAM usage based on percentage
314    pub fn vram_color(percent: f32) -> Rgb {
315        let p = percent.clamp(0.0, 100.0);
316        Self::threshold_color(
317            p,
318            &[(50.0, Self::SUCCESS), (75.0, Self::INFO), (90.0, Self::WARNING)],
319            Self::ERROR,
320        )
321    }
322
323    /// Color for temperature in Celsius
324    pub fn temp_color(celsius: f32) -> Rgb {
325        let t = celsius.clamp(0.0, 200.0);
326        Self::threshold_color(
327            t,
328            &[(50.0, Self::SUCCESS), (70.0, Self::INFO), (80.0, Self::WARNING)],
329            Self::ERROR,
330        )
331    }
332
333    /// Color for power usage based on percentage of limit
334    pub fn power_color(percent: f32) -> Rgb {
335        let p = percent.clamp(0.0, 100.0);
336        Self::threshold_color(
337            p,
338            &[(60.0, Self::SUCCESS), (80.0, Self::INFO), (95.0, Self::WARNING)],
339            Self::ERROR,
340        )
341    }
342
343    // ─────────────────────────────────────────────────────────────────────────
344    // Training Metrics Colors
345    // ─────────────────────────────────────────────────────────────────────────
346
347    /// Color for gradient norm (explosion warning)
348    pub fn grad_norm_color(norm: f32) -> Rgb {
349        Self::threshold_color(
350            norm,
351            &[(1.0, Self::SUCCESS), (5.0, Self::INFO), (10.0, Self::WARNING)],
352            Self::ERROR,
353        )
354    }
355
356    /// Color for loss value (lower is better)
357    /// Returns a gradient from red (high loss) to green (low loss)
358    pub fn loss_color(loss: f32, min_loss: f32, max_loss: f32) -> Rgb {
359        if max_loss <= min_loss {
360            return Self::INFO;
361        }
362
363        let normalized = ((loss - min_loss) / (max_loss - min_loss)).clamp(0.0, 1.0);
364
365        // Gradient from green (0.0) to yellow (0.5) to red (1.0)
366        let (r, g, b) = if normalized < 0.5 {
367            // Green to yellow
368            let t = normalized * 2.0;
369            (
370                clamped_f32_to_u8(80.0 + t * 175.0),
371                clamped_f32_to_u8(200.0 - t * 7.0),
372                clamped_f32_to_u8(120.0 - t * 113.0),
373            )
374        } else {
375            // Yellow to red
376            let t = (normalized - 0.5) * 2.0;
377            (
378                clamped_f32_to_u8(255.0 - t * 11.0),
379                clamped_f32_to_u8(193.0 - t * 126.0),
380                clamped_f32_to_u8(7.0 + t * 47.0),
381            )
382        };
383
384        Rgb::new(r, g, b)
385    }
386
387    /// Color for training status
388    pub fn status_color(status: &TrainingStatus) -> Rgb {
389        match status {
390            TrainingStatus::Running => Self::SUCCESS,
391            TrainingStatus::Completed => Self::PRIMARY,
392            TrainingStatus::Paused => Self::WARNING,
393            TrainingStatus::Failed(_) => Self::ERROR,
394            TrainingStatus::Initializing => Self::INFO,
395        }
396    }
397
398    /// Color for loss trend indicator
399    pub fn loss_trend_color(trend: &LossTrend) -> Rgb {
400        match trend {
401            LossTrend::Decreasing => Self::SUCCESS, // Good - loss is going down
402            LossTrend::Stable => Self::INFO,        // Neutral - plateauing
403            LossTrend::Increasing => Self::ERROR,   // Bad - loss is going up
404            LossTrend::Unknown => Self::MUTED,      // Not enough data
405        }
406    }
407
408    // ─────────────────────────────────────────────────────────────────────────
409    // Progress Bar Colors
410    // ─────────────────────────────────────────────────────────────────────────
411
412    /// Color for progress bar fill based on completion percentage
413    pub fn progress_color(percent: f32) -> Rgb {
414        let p = percent.clamp(0.0, 100.0);
415        if p <= 75.0 {
416            Self::INFO // In progress (blue)
417        } else if p < 100.0 {
418            Self::SUCCESS // Almost done (green)
419        } else {
420            Self::PRIMARY // Complete (cyan)
421        }
422    }
423}
424
425// ─────────────────────────────────────────────────────────────────────────────
426// Colored Progress Bar
427// ─────────────────────────────────────────────────────────────────────────────
428
429/// Render a colored progress bar
430pub fn colored_bar(value: f32, max: f32, width: usize, color: Rgb, mode: ColorMode) -> String {
431    let percent = if max > 0.0 { value / max } else { 0.0 };
432    let percent = percent.clamp(0.0, 1.0);
433    // Safe: width is a display column count, clamped to u16 range for lossless f32 conversion
434    let width_clamped = u16::try_from(width).unwrap_or(u16::MAX);
435    let filled_f32 = f32::from(width_clamped) * percent;
436    let filled = clamped_f32_to_usize(filled_f32.clamp(0.0, f32::from(width_clamped))).min(width);
437    let empty = width.saturating_sub(filled);
438
439    let filled_str: String = std::iter::repeat_n('█', filled).collect();
440    let empty_str: String = std::iter::repeat_n('░', empty).collect();
441
442    if mode == ColorMode::Mono {
443        format!("{filled_str}{empty_str}")
444    } else {
445        format!(
446            "{}{}",
447            Styled::new(&filled_str, mode).fg(color),
448            Styled::new(&empty_str, mode).fg(TrainingPalette::MUTED)
449        )
450    }
451}
452
453/// Render a colored value with semantic coloring
454pub fn colored_value<T: fmt::Display>(value: T, color: Rgb, mode: ColorMode) -> String {
455    let text = value.to_string();
456    Styled::new(&text, mode).fg(color).to_string()
457}
458
459#[cfg(test)]
460mod tests {
461    use super::*;
462
463    #[test]
464    fn test_color_mode_detection() {
465        // NO_COLOR takes precedence
466        assert_eq!(
467            ColorMode::detect_with_env(Some("truecolor"), Some("xterm-256color"), Some("1")),
468            ColorMode::Mono
469        );
470
471        // COLORTERM truecolor
472        assert_eq!(ColorMode::detect_with_env(Some("truecolor"), None, None), ColorMode::TrueColor);
473
474        // TERM 256color
475        assert_eq!(
476            ColorMode::detect_with_env(None, Some("xterm-256color"), None),
477            ColorMode::Color256
478        );
479
480        // TERM xterm
481        assert_eq!(ColorMode::detect_with_env(None, Some("xterm"), None), ColorMode::Color16);
482
483        // TERM dumb
484        assert_eq!(ColorMode::detect_with_env(None, Some("dumb"), None), ColorMode::Mono);
485    }
486
487    #[test]
488    fn test_rgb_to_256() {
489        // Black
490        assert_eq!(Rgb::new(0, 0, 0).to_256(), 16);
491        // White
492        assert_eq!(Rgb::new(255, 255, 255).to_256(), 231);
493        // Red
494        assert_eq!(Rgb::new(255, 0, 0).to_256(), 196);
495        // Green
496        assert_eq!(Rgb::new(0, 255, 0).to_256(), 46);
497        // Blue
498        assert_eq!(Rgb::new(0, 0, 255).to_256(), 21);
499    }
500
501    #[test]
502    fn test_rgb_to_16() {
503        // Bright red
504        assert_eq!(Rgb::new(255, 50, 50).to_16(), 9); // bright red
505                                                      // Bright green
506        assert_eq!(Rgb::new(50, 255, 50).to_16(), 10); // bright green
507                                                       // Dark blue
508        assert_eq!(Rgb::new(0, 0, 100).to_16(), 4); // blue
509    }
510
511    #[test]
512    fn test_rgb_to_16_all_boolean_combos() {
513        // (r>85, g>85, b>85) = (true, true, true) → white (7), bright → 15
514        assert_eq!(Rgb::new(200, 200, 200).to_16(), 15); // bright white
515
516        // (true, true, false) → yellow (3), bright → 11
517        assert_eq!(Rgb::new(200, 200, 50).to_16(), 11); // bright yellow
518
519        // (true, false, true) → magenta (5), bright → 13
520        assert_eq!(Rgb::new(200, 50, 200).to_16(), 13); // bright magenta
521
522        // (false, true, true) → cyan (6), bright → 14
523        assert_eq!(Rgb::new(50, 200, 200).to_16(), 14); // bright cyan
524
525        // (true, false, false) → red (1), not bright → 1
526        assert_eq!(Rgb::new(100, 50, 50).to_16(), 1); // dark red
527
528        // (false, true, false) → green (2), not bright → 2
529        assert_eq!(Rgb::new(50, 100, 50).to_16(), 2); // dark green
530
531        // (false, false, true) → blue (4), not bright → 4
532        assert_eq!(Rgb::new(50, 50, 100).to_16(), 4); // dark blue
533
534        // (false, false, false) with dominant channels
535        assert_eq!(Rgb::new(60, 20, 20).to_16(), 1); // near-black, r dominant
536        assert_eq!(Rgb::new(20, 60, 20).to_16(), 2); // near-black, g dominant
537        assert_eq!(Rgb::new(20, 20, 60).to_16(), 4); // near-black, b dominant
538        assert_eq!(Rgb::new(20, 20, 20).to_16(), 0); // true black
539    }
540
541    #[test]
542    fn test_styled_display_truecolor() {
543        let styled = Styled::new("test", ColorMode::TrueColor).fg(Rgb::new(255, 0, 0));
544        let output = styled.to_string();
545        assert!(output.contains("\x1b[38;2;255;0;0m"));
546        assert!(output.contains("test"));
547        assert!(output.ends_with("\x1b[0m"));
548    }
549
550    #[test]
551    fn test_styled_display_mono() {
552        let styled = Styled::new("test", ColorMode::Mono).fg(Rgb::new(255, 0, 0));
553        let output = styled.to_string();
554        assert_eq!(output, "test");
555    }
556
557    #[test]
558    fn test_gpu_util_color() {
559        assert_eq!(TrainingPalette::gpu_util_color(20.0), TrainingPalette::MUTED);
560        assert_eq!(TrainingPalette::gpu_util_color(50.0), TrainingPalette::SUCCESS);
561        assert_eq!(TrainingPalette::gpu_util_color(80.0), TrainingPalette::INFO);
562        assert_eq!(TrainingPalette::gpu_util_color(95.0), TrainingPalette::PRIMARY);
563    }
564
565    #[test]
566    fn test_temp_color() {
567        assert_eq!(TrainingPalette::temp_color(40.0), TrainingPalette::SUCCESS);
568        assert_eq!(TrainingPalette::temp_color(65.0), TrainingPalette::INFO);
569        assert_eq!(TrainingPalette::temp_color(75.0), TrainingPalette::WARNING);
570        assert_eq!(TrainingPalette::temp_color(85.0), TrainingPalette::ERROR);
571    }
572
573    #[test]
574    fn test_grad_norm_color() {
575        assert_eq!(TrainingPalette::grad_norm_color(0.5), TrainingPalette::SUCCESS);
576        assert_eq!(TrainingPalette::grad_norm_color(3.0), TrainingPalette::INFO);
577        assert_eq!(TrainingPalette::grad_norm_color(8.0), TrainingPalette::WARNING);
578        assert_eq!(TrainingPalette::grad_norm_color(20.0), TrainingPalette::ERROR);
579    }
580
581    #[test]
582    fn test_loss_color_gradient() {
583        let min = 0.0;
584        let max = 1.0;
585
586        // Low loss should be greenish
587        let low = TrainingPalette::loss_color(0.1, min, max);
588        assert!(low.g > low.r); // More green than red
589
590        // High loss should be reddish
591        let high = TrainingPalette::loss_color(0.9, min, max);
592        assert!(high.r > high.g); // More red than green
593    }
594
595    #[test]
596    fn test_status_color_all_variants() {
597        // Exercise every match arm in TrainingPalette::status_color
598        let running = TrainingPalette::status_color(&TrainingStatus::Running);
599        assert_eq!(running, TrainingPalette::SUCCESS);
600
601        let completed = TrainingPalette::status_color(&TrainingStatus::Completed);
602        assert_eq!(completed, TrainingPalette::PRIMARY);
603
604        let paused = TrainingPalette::status_color(&TrainingStatus::Paused);
605        assert_eq!(paused, TrainingPalette::WARNING);
606
607        let failed = TrainingPalette::status_color(&TrainingStatus::Failed("error".to_string()));
608        assert_eq!(failed, TrainingPalette::ERROR);
609
610        let initializing = TrainingPalette::status_color(&TrainingStatus::Initializing);
611        assert_eq!(initializing, TrainingPalette::INFO);
612
613        // Verify exhaustive match on all TrainingStatus variants
614        for status in &[
615            TrainingStatus::Running,
616            TrainingStatus::Completed,
617            TrainingStatus::Paused,
618            TrainingStatus::Failed("test".to_string()),
619            TrainingStatus::Initializing,
620        ] {
621            match status {
622                TrainingStatus::Running => {
623                    assert_eq!(TrainingPalette::status_color(status), TrainingPalette::SUCCESS);
624                }
625                TrainingStatus::Completed => {
626                    assert_eq!(TrainingPalette::status_color(status), TrainingPalette::PRIMARY);
627                }
628                TrainingStatus::Paused => {
629                    assert_eq!(TrainingPalette::status_color(status), TrainingPalette::WARNING);
630                }
631                TrainingStatus::Failed(_) => {
632                    assert_eq!(TrainingPalette::status_color(status), TrainingPalette::ERROR);
633                }
634                TrainingStatus::Initializing => {
635                    assert_eq!(TrainingPalette::status_color(status), TrainingPalette::INFO);
636                }
637            }
638        }
639    }
640
641    #[test]
642    fn test_colored_bar() {
643        let bar = colored_bar(50.0, 100.0, 10, TrainingPalette::SUCCESS, ColorMode::Mono);
644        assert!(bar.contains('█'));
645        assert!(bar.contains('░'));
646        assert_eq!(bar.chars().filter(|&c| c == '█').count(), 5);
647        assert_eq!(bar.chars().filter(|&c| c == '░').count(), 5);
648    }
649
650    // ── Additional coverage tests ──
651
652    #[test]
653    fn test_clamped_f32_to_u8_boundaries() {
654        assert_eq!(clamped_f32_to_u8(0.0), 0);
655        assert_eq!(clamped_f32_to_u8(255.0), 255);
656        assert_eq!(clamped_f32_to_u8(-10.0), 0);
657        assert_eq!(clamped_f32_to_u8(300.0), 255);
658        assert_eq!(clamped_f32_to_u8(127.5), 127);
659    }
660
661    #[test]
662    fn test_clamped_f32_to_usize_boundaries() {
663        assert_eq!(clamped_f32_to_usize(0.0), 0);
664        assert_eq!(clamped_f32_to_usize(-5.0), 0);
665        assert_eq!(clamped_f32_to_usize(10.0), 10);
666        assert_eq!(clamped_f32_to_usize(100.5), 100);
667    }
668
669    #[test]
670    fn test_color_mode_default() {
671        assert_eq!(ColorMode::default(), ColorMode::Mono);
672    }
673
674    #[test]
675    fn test_color_mode_detect_24bit() {
676        assert_eq!(ColorMode::detect_with_env(Some("24bit"), None, None), ColorMode::TrueColor);
677    }
678
679    #[test]
680    fn test_color_mode_detect_kitty() {
681        assert_eq!(ColorMode::detect_with_env(None, Some("kitty"), None), ColorMode::Color256);
682    }
683
684    #[test]
685    fn test_color_mode_detect_alacritty() {
686        assert_eq!(ColorMode::detect_with_env(None, Some("alacritty"), None), ColorMode::Color256);
687    }
688
689    #[test]
690    fn test_color_mode_detect_screen() {
691        assert_eq!(ColorMode::detect_with_env(None, Some("screen"), None), ColorMode::Color16);
692    }
693
694    #[test]
695    fn test_color_mode_detect_tmux() {
696        assert_eq!(ColorMode::detect_with_env(None, Some("tmux"), None), ColorMode::Color16);
697    }
698
699    #[test]
700    fn test_color_mode_detect_empty_term() {
701        assert_eq!(ColorMode::detect_with_env(None, Some(""), None), ColorMode::Mono);
702    }
703
704    #[test]
705    fn test_color_mode_detect_unknown_term() {
706        assert_eq!(
707            ColorMode::detect_with_env(None, Some("something-unknown"), None),
708            ColorMode::Color16
709        );
710    }
711
712    #[test]
713    fn test_color_mode_detect_no_env() {
714        assert_eq!(ColorMode::detect_with_env(None, None, None), ColorMode::Color16);
715    }
716
717    #[test]
718    fn test_rgb_new() {
719        let c = Rgb::new(10, 20, 30);
720        assert_eq!(c.r, 10);
721        assert_eq!(c.g, 20);
722        assert_eq!(c.b, 30);
723    }
724
725    #[test]
726    fn test_rgb_from_tuple() {
727        let c: Rgb = (100, 200, 50).into();
728        assert_eq!(c.r, 100);
729        assert_eq!(c.g, 200);
730        assert_eq!(c.b, 50);
731    }
732
733    #[test]
734    fn test_rgb_to_256_midrange() {
735        let c = Rgb::new(128, 128, 128);
736        let idx = c.to_256();
737        // Should be in the 216-color cube range (16-231)
738        assert!((16..=231).contains(&idx));
739    }
740
741    #[test]
742    fn test_rgb_to_16_near_black_no_dominant() {
743        // All channels below 40 -> black (0)
744        assert_eq!(Rgb::new(10, 10, 10).to_16(), 0);
745    }
746
747    #[test]
748    fn test_styled_display_256color() {
749        let styled = Styled::new("hello", ColorMode::Color256).fg(Rgb::new(255, 0, 0));
750        let output = styled.to_string();
751        assert!(output.contains("\x1b[38;5;"));
752        assert!(output.contains("hello"));
753        assert!(output.ends_with("\x1b[0m"));
754    }
755
756    #[test]
757    fn test_styled_display_16color_bright() {
758        let styled = Styled::new("bright", ColorMode::Color16).fg(Rgb::new(255, 50, 50));
759        let output = styled.to_string();
760        // Bright red -> code 9 -> \x1b[91m
761        assert!(output.contains("\x1b[9"));
762        assert!(output.contains("bright"));
763    }
764
765    #[test]
766    fn test_styled_display_16color_dark() {
767        let styled = Styled::new("dark", ColorMode::Color16).fg(Rgb::new(0, 0, 100));
768        let output = styled.to_string();
769        // Dark blue -> code 4 -> \x1b[34m
770        assert!(output.contains("\x1b[3"));
771        assert!(output.contains("dark"));
772    }
773
774    #[test]
775    fn test_styled_bold() {
776        let styled = Styled::new("bold", ColorMode::TrueColor).bold();
777        let output = styled.to_string();
778        assert!(output.contains("\x1b[1m"));
779        assert!(output.contains("bold"));
780        assert!(output.ends_with("\x1b[0m"));
781    }
782
783    #[test]
784    fn test_styled_bold_and_fg() {
785        let styled = Styled::new("both", ColorMode::TrueColor).fg(Rgb::new(0, 255, 0)).bold();
786        let output = styled.to_string();
787        assert!(output.contains("\x1b[1m"));
788        assert!(output.contains("\x1b[38;2;0;255;0m"));
789    }
790
791    #[test]
792    fn test_styled_no_color_no_bold() {
793        let styled = Styled::new("plain", ColorMode::TrueColor);
794        let output = styled.to_string();
795        // No styles applied, no escape codes
796        assert_eq!(output, "plain");
797    }
798
799    #[test]
800    fn test_training_palette_new() {
801        let palette = TrainingPalette::new(ColorMode::TrueColor);
802        assert_eq!(palette.mode, ColorMode::TrueColor);
803    }
804
805    #[test]
806    fn test_training_palette_style() {
807        let palette = TrainingPalette::new(ColorMode::Mono);
808        let styled = palette.style("text").fg(TrainingPalette::SUCCESS);
809        let output = styled.to_string();
810        assert_eq!(output, "text"); // Mono mode, no escape codes
811    }
812
813    #[test]
814    fn test_vram_color_thresholds() {
815        assert_eq!(TrainingPalette::vram_color(30.0), TrainingPalette::SUCCESS);
816        assert_eq!(TrainingPalette::vram_color(60.0), TrainingPalette::INFO);
817        assert_eq!(TrainingPalette::vram_color(80.0), TrainingPalette::WARNING);
818        assert_eq!(TrainingPalette::vram_color(95.0), TrainingPalette::ERROR);
819    }
820
821    #[test]
822    fn test_power_color_thresholds() {
823        assert_eq!(TrainingPalette::power_color(40.0), TrainingPalette::SUCCESS);
824        assert_eq!(TrainingPalette::power_color(70.0), TrainingPalette::INFO);
825        assert_eq!(TrainingPalette::power_color(90.0), TrainingPalette::WARNING);
826        assert_eq!(TrainingPalette::power_color(99.0), TrainingPalette::ERROR);
827    }
828
829    #[test]
830    fn test_loss_color_equal_min_max() {
831        // When max <= min, should return INFO
832        let color = TrainingPalette::loss_color(0.5, 1.0, 1.0);
833        assert_eq!(color, TrainingPalette::INFO);
834    }
835
836    #[test]
837    fn test_loss_color_at_min() {
838        let color = TrainingPalette::loss_color(0.0, 0.0, 10.0);
839        // Should be greenish (low loss)
840        assert!(color.g > color.r);
841    }
842
843    #[test]
844    fn test_loss_color_at_max() {
845        let color = TrainingPalette::loss_color(10.0, 0.0, 10.0);
846        // Should be reddish (high loss)
847        assert!(color.r > color.g);
848    }
849
850    #[test]
851    fn test_loss_color_at_midpoint() {
852        let color = TrainingPalette::loss_color(5.0, 0.0, 10.0);
853        // At 0.5 normalized -> yellow range, high R and G
854        assert!(color.r > 200);
855        assert!(color.g > 150);
856    }
857
858    #[test]
859    fn test_loss_trend_color_all_variants() {
860        assert_eq!(
861            TrainingPalette::loss_trend_color(&LossTrend::Decreasing),
862            TrainingPalette::SUCCESS
863        );
864        assert_eq!(TrainingPalette::loss_trend_color(&LossTrend::Stable), TrainingPalette::INFO);
865        assert_eq!(
866            TrainingPalette::loss_trend_color(&LossTrend::Increasing),
867            TrainingPalette::ERROR
868        );
869        assert_eq!(TrainingPalette::loss_trend_color(&LossTrend::Unknown), TrainingPalette::MUTED);
870    }
871
872    #[test]
873    fn test_progress_color_thresholds() {
874        assert_eq!(TrainingPalette::progress_color(50.0), TrainingPalette::INFO);
875        assert_eq!(TrainingPalette::progress_color(75.0), TrainingPalette::INFO);
876        assert_eq!(TrainingPalette::progress_color(90.0), TrainingPalette::SUCCESS);
877        assert_eq!(TrainingPalette::progress_color(100.0), TrainingPalette::PRIMARY);
878    }
879
880    #[test]
881    fn test_progress_color_boundary_at_75() {
882        assert_eq!(TrainingPalette::progress_color(75.0), TrainingPalette::INFO);
883        assert_eq!(TrainingPalette::progress_color(75.01), TrainingPalette::SUCCESS);
884    }
885
886    #[test]
887    fn test_colored_bar_zero_value() {
888        let bar = colored_bar(0.0, 100.0, 10, TrainingPalette::SUCCESS, ColorMode::Mono);
889        assert_eq!(bar.chars().filter(|&c| c == '░').count(), 10);
890        assert_eq!(bar.chars().filter(|&c| c == '█').count(), 0);
891    }
892
893    #[test]
894    fn test_colored_bar_full_value() {
895        let bar = colored_bar(100.0, 100.0, 10, TrainingPalette::SUCCESS, ColorMode::Mono);
896        assert_eq!(bar.chars().filter(|&c| c == '█').count(), 10);
897        assert_eq!(bar.chars().filter(|&c| c == '░').count(), 0);
898    }
899
900    #[test]
901    fn test_colored_bar_zero_max() {
902        let bar = colored_bar(50.0, 0.0, 10, TrainingPalette::SUCCESS, ColorMode::Mono);
903        // Zero max -> 0% fill
904        assert_eq!(bar.chars().filter(|&c| c == '░').count(), 10);
905    }
906
907    #[test]
908    fn test_colored_bar_truecolor() {
909        let bar = colored_bar(50.0, 100.0, 10, TrainingPalette::SUCCESS, ColorMode::TrueColor);
910        // Should contain ANSI escape sequences
911        assert!(bar.contains("\x1b["));
912    }
913
914    #[test]
915    fn test_colored_value_mono() {
916        let s = colored_value(42, Rgb::new(255, 0, 0), ColorMode::Mono);
917        assert_eq!(s, "42");
918    }
919
920    #[test]
921    fn test_colored_value_truecolor() {
922        let s = colored_value(3.14, Rgb::new(0, 255, 0), ColorMode::TrueColor);
923        assert!(s.contains("3.14"));
924        assert!(s.contains("\x1b[38;2;0;255;0m"));
925    }
926
927    #[test]
928    fn test_threshold_color_below_first() {
929        let color = TrainingPalette::threshold_color(
930            5.0,
931            &[(10.0, Rgb::new(1, 2, 3)), (20.0, Rgb::new(4, 5, 6))],
932            Rgb::new(7, 8, 9),
933        );
934        assert_eq!(color, Rgb::new(1, 2, 3));
935    }
936
937    #[test]
938    fn test_threshold_color_between_thresholds() {
939        let color = TrainingPalette::threshold_color(
940            15.0,
941            &[(10.0, Rgb::new(1, 2, 3)), (20.0, Rgb::new(4, 5, 6))],
942            Rgb::new(7, 8, 9),
943        );
944        assert_eq!(color, Rgb::new(4, 5, 6));
945    }
946
947    #[test]
948    fn test_threshold_color_above_all() {
949        let color = TrainingPalette::threshold_color(
950            25.0,
951            &[(10.0, Rgb::new(1, 2, 3)), (20.0, Rgb::new(4, 5, 6))],
952            Rgb::new(7, 8, 9),
953        );
954        assert_eq!(color, Rgb::new(7, 8, 9));
955    }
956
957    #[test]
958    fn test_threshold_color_empty_thresholds() {
959        let color = TrainingPalette::threshold_color(5.0, &[], Rgb::new(7, 8, 9));
960        assert_eq!(color, Rgb::new(7, 8, 9));
961    }
962
963    #[test]
964    fn test_gpu_util_color_clamping() {
965        // Below 0 should clamp
966        assert_eq!(TrainingPalette::gpu_util_color(-10.0), TrainingPalette::MUTED);
967        // Above 100 should clamp
968        assert_eq!(TrainingPalette::gpu_util_color(150.0), TrainingPalette::PRIMARY);
969    }
970
971    #[test]
972    fn test_temp_color_clamping() {
973        assert_eq!(TrainingPalette::temp_color(-20.0), TrainingPalette::SUCCESS);
974        assert_eq!(TrainingPalette::temp_color(250.0), TrainingPalette::ERROR);
975    }
976
977    #[test]
978    fn test_colored_bar_overflow_value() {
979        // Value > max should clamp
980        let bar = colored_bar(200.0, 100.0, 10, TrainingPalette::SUCCESS, ColorMode::Mono);
981        assert_eq!(bar.chars().filter(|&c| c == '█').count(), 10);
982    }
983
984    #[test]
985    fn test_styled_display_mono_with_bold_and_fg() {
986        // In Mono mode, bold and fg are ignored
987        let styled = Styled::new("mono", ColorMode::Mono).fg(Rgb::new(255, 0, 0)).bold();
988        let output = styled.to_string();
989        assert_eq!(output, "mono"); // No escape codes
990    }
991
992    #[test]
993    fn test_semantic_color_constants_distinct() {
994        // Verify all semantic colors are distinct
995        let colors = [
996            TrainingPalette::SUCCESS,
997            TrainingPalette::WARNING,
998            TrainingPalette::ERROR,
999            TrainingPalette::INFO,
1000            TrainingPalette::MUTED,
1001            TrainingPalette::PRIMARY,
1002        ];
1003        for i in 0..colors.len() {
1004            for j in (i + 1)..colors.len() {
1005                assert_ne!(colors[i], colors[j], "colors at {i} and {j} should differ");
1006            }
1007        }
1008    }
1009}