Skip to main content

entrenar/monitor/tui/render/
bars.rs

1//! Bar rendering, sparklines, and trend indicators.
2
3use super::super::color::{ColorMode, Styled, TrainingPalette};
4
5pub(crate) const BLOCK_FULL: char = '\u{2588}';
6pub(crate) const BLOCK_LIGHT: char = '\u{2591}';
7const ARROW_UP: &str = "\u{2191}";
8const ARROW_DOWN: &str = "\u{2193}";
9const ARROW_FLAT: &str = "\u{2192}";
10pub(crate) const BRAILLE_BASE: u32 = 0x2800;
11pub(crate) const BRAILLE_DOTS: [u32; 8] = [0x01, 0x02, 0x04, 0x40, 0x08, 0x10, 0x20, 0x80];
12
13#[allow(clippy::cast_precision_loss)]
14pub fn build_block_bar(percent: f32, width: usize) -> String {
15    let pct = percent.clamp(0.0, 100.0);
16    let filled_f = ((pct / 100.0) * width as f32).clamp(0.0, width as f32);
17    let filled = filled_f as usize;
18    let empty = width.saturating_sub(filled);
19    format!("{}{}", BLOCK_FULL.to_string().repeat(filled), BLOCK_LIGHT.to_string().repeat(empty))
20}
21
22#[allow(clippy::cast_precision_loss)]
23pub fn build_colored_block_bar(percent: f32, width: usize, color_mode: ColorMode) -> String {
24    let pct = percent.clamp(0.0, 100.0);
25    let filled_f = ((pct / 100.0) * width as f32).clamp(0.0, width as f32);
26    let filled = filled_f as usize;
27    let empty = width.saturating_sub(filled);
28
29    let color = pct_color(pct);
30    let filled_str = BLOCK_FULL.to_string().repeat(filled);
31    let empty_str = BLOCK_LIGHT.to_string().repeat(empty);
32
33    if color_mode == ColorMode::Mono {
34        format!("{filled_str}{empty_str}")
35    } else {
36        format!(
37            "{}{}",
38            Styled::new(&filled_str, color_mode).fg(color),
39            Styled::new(&empty_str, color_mode).fg((60, 60, 60))
40        )
41    }
42}
43
44/// Safely convert an f32 in [0.0, 255.0] to u8, clamping to valid range.
45#[inline]
46fn f32_to_u8(v: f32) -> u8 {
47    u8::try_from(v.clamp(0.0, 255.0) as u32).unwrap_or(u8::MAX)
48}
49
50pub fn pct_color(pct: f32) -> (u8, u8, u8) {
51    let p = pct.clamp(0.0, 100.0);
52    if p >= 90.0 {
53        (255, 64, 64)
54    } else if p >= 75.0 {
55        let t = (p - 75.0) / 15.0;
56        (255, f32_to_u8(180.0 - t * 116.0), 64)
57    } else if p >= 50.0 {
58        let t = (p - 50.0) / 25.0;
59        (255, f32_to_u8(220.0 - t * 40.0), 64)
60    } else if p >= 25.0 {
61        let t = (p - 25.0) / 25.0;
62        (f32_to_u8(100.0 + t * 155.0), 220, f32_to_u8(100.0 - t * 36.0))
63    } else {
64        let t = p / 25.0;
65        (f32_to_u8(64.0 + t * 36.0), f32_to_u8(180.0 + t * 40.0), f32_to_u8(220.0 - t * 120.0))
66    }
67}
68
69#[allow(clippy::cast_precision_loss)]
70pub fn render_sparkline(data: &[f32], width: usize, color_mode: ColorMode) -> String {
71    if data.is_empty() {
72        return " ".repeat(width);
73    }
74
75    let min = data.iter().copied().filter(|v| v.is_finite()).fold(f32::INFINITY, f32::min);
76    let max = data.iter().copied().filter(|v| v.is_finite()).fold(f32::NEG_INFINITY, f32::max);
77    let range = (max - min).max(0.001);
78
79    let mut result = String::new();
80    for i in 0..width {
81        let idx = (i * data.len()) / width.max(1);
82        let idx2 = ((i * 2 + 1) * data.len()) / (width * 2).max(1);
83
84        let v1 = data.get(idx).copied().unwrap_or(min);
85        let v2 = data.get(idx2).copied().unwrap_or(v1);
86
87        let h1 =
88            if v1.is_finite() { (((v1 - min) / range) * 3.99).clamp(0.0, 3.0) as usize } else { 0 };
89        let h2 =
90            if v2.is_finite() { (((v2 - min) / range) * 3.99).clamp(0.0, 3.0) as usize } else { 0 };
91
92        let mut code: u32 = 0;
93        for y in 0..=h1.min(3) {
94            code |= BRAILLE_DOTS[3 - y];
95        }
96        for y in 0..=h2.min(3) {
97            code |= BRAILLE_DOTS[7 - y];
98        }
99
100        result.push(char::from_u32(BRAILLE_BASE + code).unwrap_or('\u{28FF}'));
101    }
102
103    let trend_color = if data.len() > 1 {
104        let first = data.first().copied().unwrap_or(0.0);
105        let last = data.last().copied().unwrap_or(0.0);
106        if last < first * 0.95 {
107            TrainingPalette::SUCCESS
108        } else if last > first * 1.05 {
109            TrainingPalette::ERROR
110        } else {
111            TrainingPalette::INFO
112        }
113    } else {
114        TrainingPalette::INFO
115    };
116
117    if color_mode == ColorMode::Mono {
118        result
119    } else {
120        Styled::new(&result, color_mode).fg(trend_color).to_string()
121    }
122}
123
124pub fn trend_arrow(data: &[f32]) -> &'static str {
125    if data.len() < 2 {
126        return ARROW_FLAT;
127    }
128    let recent: Vec<f32> = data.iter().rev().take(5).copied().collect();
129    if recent.len() < 2 {
130        return ARROW_FLAT;
131    }
132    // recent.len() is at most 5, so no precision loss converting to f32
133    let avg_recent: f32 = recent.iter().sum::<f32>() / (recent.len().max(1) as f32);
134    let old_count = data.len().saturating_sub(5).clamp(1, 5);
135    // old_count is at most 5, so no precision loss converting to f32
136    let avg_old: f32 = data.iter().rev().skip(5).take(5).copied().sum::<f32>() / (old_count as f32);
137
138    if avg_recent < avg_old * 0.95 {
139        ARROW_DOWN
140    } else if avg_recent > avg_old * 1.05 {
141        ARROW_UP
142    } else {
143        ARROW_FLAT
144    }
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150
151    #[test]
152    fn test_block_bar() {
153        let bar = build_block_bar(50.0, 10);
154        assert_eq!(bar.chars().count(), 10);
155        assert!(bar.contains(BLOCK_FULL));
156        assert!(bar.contains(BLOCK_LIGHT));
157    }
158
159    #[test]
160    fn test_pct_color_gradient() {
161        let mut prev = pct_color(0.0);
162        for i in 1..=100 {
163            let curr = pct_color(i as f32);
164            let dr = (i32::from(curr.0) - i32::from(prev.0)).abs();
165            let dg = (i32::from(curr.1) - i32::from(prev.1)).abs();
166            let db = (i32::from(curr.2) - i32::from(prev.2)).abs();
167            assert!(dr < 50 && dg < 50 && db < 50, "Color jump at {i}%");
168            prev = curr;
169        }
170    }
171
172    #[test]
173    fn test_sparkline() {
174        let data = vec![1.0, 2.0, 3.0, 2.0, 1.0];
175        let spark = render_sparkline(&data, 5, ColorMode::Mono);
176        assert!(!spark.is_empty());
177    }
178
179    #[test]
180    fn test_trend_arrow() {
181        let increasing = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0];
182        assert_eq!(trend_arrow(&increasing), ARROW_UP);
183
184        let decreasing = vec![10.0, 9.0, 8.0, 7.0, 6.0, 5.0, 4.0, 3.0, 2.0, 1.0];
185        assert_eq!(trend_arrow(&decreasing), ARROW_DOWN);
186    }
187}