entrenar/monitor/tui/render/
bars.rs1use 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#[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 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 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}