1use presentar_core::{Canvas, Color, Point, TextStyle};
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
28pub enum HeatScheme {
29 #[default]
31 Thermal,
32 Cool,
34 Warm,
36 Mono,
38}
39
40impl HeatScheme {
41 pub fn color_for_percent(&self, pct: f64) -> Color {
43 let p = pct.clamp(0.0, 100.0) / 100.0;
44
45 match self {
46 Self::Thermal => {
47 if p < 0.5 {
49 let t = p * 2.0;
51 Color::new(t as f32, 0.8, 0.2, 1.0)
52 } else {
53 let t = (p - 0.5) * 2.0;
55 Color::new(1.0, (0.8 - t * 0.6) as f32, 0.2, 1.0)
56 }
57 }
58 Self::Cool => {
59 Color::new(0.2, 0.4 + (p * 0.4) as f32, 0.9, 1.0)
61 }
62 Self::Warm => {
63 Color::new(1.0, (0.9 - p * 0.7) as f32, 0.1, 1.0)
65 }
66 Self::Mono => {
67 let v = (0.9 - p * 0.7) as f32;
69 Color::new(v, v, v, 1.0)
70 }
71 }
72 }
73}
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
77pub enum BarStyle {
78 #[default]
80 Blocks,
81 Gradient,
83 Dots,
85 Segments,
87}
88
89#[derive(Debug, Clone)]
95pub struct MicroHeatBar {
96 values: Vec<f64>,
98 labels: Vec<String>,
100 scheme: HeatScheme,
102 style: BarStyle,
104 width: usize,
106 show_values: bool,
108}
109
110impl MicroHeatBar {
111 pub fn new(values: &[f64]) -> Self {
113 Self {
114 values: values.to_vec(),
115 labels: Vec::new(),
116 scheme: HeatScheme::Thermal,
117 style: BarStyle::Blocks,
118 width: 20,
119 show_values: false,
120 }
121 }
122
123 pub fn with_labels(mut self, labels: &[&str]) -> Self {
125 self.labels = labels.iter().map(|s| (*s).to_string()).collect();
126 self
127 }
128
129 pub fn with_scheme(mut self, scheme: HeatScheme) -> Self {
131 self.scheme = scheme;
132 self
133 }
134
135 pub fn with_style(mut self, style: BarStyle) -> Self {
137 self.style = style;
138 self
139 }
140
141 pub fn with_width(mut self, width: usize) -> Self {
143 self.width = width;
144 self
145 }
146
147 pub fn with_values(mut self, show: bool) -> Self {
149 self.show_values = show;
150 self
151 }
152
153 pub fn render_string(&self) -> String {
155 let total: f64 = self.values.iter().sum();
156 if total <= 0.0 || self.width == 0 {
157 return "░".repeat(self.width);
158 }
159
160 let mut result = String::new();
161 let mut remaining_width = self.width;
162
163 for &val in self.values.iter() {
164 let proportion = val / total;
165 let char_count =
166 ((proportion * self.width as f64).round() as usize).min(remaining_width);
167
168 if char_count == 0 {
169 continue;
170 }
171
172 let ch = match self.style {
173 BarStyle::Blocks => {
174 if val > 70.0 {
175 '█'
176 } else if val > 40.0 {
177 '▓'
178 } else if val > 20.0 {
179 '▒'
180 } else if val > 5.0 {
181 '░'
182 } else {
183 ' '
184 }
185 }
186 BarStyle::Gradient => {
187 let level = ((val / 100.0) * 7.0).round() as usize;
189 ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'][level.min(7)]
190 }
191 BarStyle::Dots => {
192 if val > 50.0 {
193 '●'
194 } else {
195 '○'
196 }
197 }
198 BarStyle::Segments => '█',
199 };
200
201 for _ in 0..char_count {
202 result.push(ch);
203 }
204 remaining_width = remaining_width.saturating_sub(char_count);
205 }
206
207 while result.chars().count() < self.width {
209 result.push('░');
210 }
211
212 result
213 }
214
215 pub fn paint(&self, canvas: &mut dyn Canvas, pos: Point) {
217 let total: f64 = self.values.iter().sum();
218 if total <= 0.0 || self.width == 0 {
219 return;
220 }
221
222 let mut x = pos.x;
223
224 for &val in self.values.iter() {
225 let proportion = val / total;
226 let char_count = (proportion * self.width as f64).round() as usize;
227
228 if char_count == 0 {
229 continue;
230 }
231
232 let color = self.scheme.color_for_percent(val);
234
235 let ch = match self.style {
236 BarStyle::Blocks => '█',
237 BarStyle::Gradient => {
238 let level = ((val / 100.0) * 7.0).round() as usize;
239 ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'][level.min(7)]
240 }
241 BarStyle::Dots => '●',
242 BarStyle::Segments => '█',
243 };
244
245 let segment: String = std::iter::repeat(ch).take(char_count).collect();
246 canvas.draw_text(
247 &segment,
248 Point::new(x, pos.y),
249 &TextStyle {
250 color,
251 ..Default::default()
252 },
253 );
254
255 x += char_count as f32;
256 }
257
258 let remaining = self.width.saturating_sub((x - pos.x) as usize);
260 if remaining > 0 {
261 let bg: String = std::iter::repeat('░').take(remaining).collect();
262 canvas.draw_text(
263 &bg,
264 Point::new(x, pos.y),
265 &TextStyle {
266 color: Color::new(0.2, 0.2, 0.2, 1.0),
267 ..Default::default()
268 },
269 );
270 }
271 }
272}
273
274pub struct CompactBreakdown {
277 values: Vec<f64>,
279 labels: Vec<String>,
281 scheme: HeatScheme,
283}
284
285impl CompactBreakdown {
286 pub fn new(labels: &[&str], values: &[f64]) -> Self {
287 Self {
288 values: values.to_vec(),
289 labels: labels.iter().map(|s| (*s).to_string()).collect(),
290 scheme: HeatScheme::Thermal,
291 }
292 }
293
294 pub fn with_scheme(mut self, scheme: HeatScheme) -> Self {
295 self.scheme = scheme;
296 self
297 }
298
299 pub fn render_text(&self, _width: usize) -> String {
301 let parts: Vec<String> = self
302 .labels
303 .iter()
304 .zip(self.values.iter())
305 .map(|(l, v)| format!("{}:{:.0}", l, v))
306 .collect();
307
308 parts.join(" ")
309 }
310
311 pub fn paint(&self, canvas: &mut dyn Canvas, pos: Point) {
313 let mut x = pos.x;
314
315 for (label, &val) in self.labels.iter().zip(self.values.iter()) {
316 let color = self.scheme.color_for_percent(val);
317 let text = format!("{}:{:.0} ", label, val);
318
319 canvas.draw_text(
320 &text,
321 Point::new(x, pos.y),
322 &TextStyle {
323 color,
324 ..Default::default()
325 },
326 );
327
328 x += text.chars().count() as f32;
329 }
330 }
331}
332
333#[cfg(test)]
334mod tests {
335 use super::*;
336 use crate::direct::{CellBuffer, DirectTerminalCanvas};
337
338 #[test]
343 fn test_heat_scheme_default() {
344 assert_eq!(HeatScheme::default(), HeatScheme::Thermal);
345 }
346
347 #[test]
348 fn test_heat_scheme_thermal() {
349 let scheme = HeatScheme::Thermal;
350
351 let low = scheme.color_for_percent(10.0);
352 let high = scheme.color_for_percent(90.0);
353
354 assert!(low.g > low.r);
356 assert!(high.r > high.g);
357 }
358
359 #[test]
360 fn test_heat_scheme_thermal_midpoint() {
361 let scheme = HeatScheme::Thermal;
362 let mid = scheme.color_for_percent(50.0);
363 assert!(mid.r > 0.5);
365 assert!(mid.g > 0.5);
366 }
367
368 #[test]
369 fn test_heat_scheme_cool() {
370 let scheme = HeatScheme::Cool;
371 let low = scheme.color_for_percent(10.0);
372 let high = scheme.color_for_percent(90.0);
373
374 assert!(low.b > low.r);
376 assert!(high.b > high.r);
377 assert!(high.g > low.g);
379 }
380
381 #[test]
382 fn test_heat_scheme_warm() {
383 let scheme = HeatScheme::Warm;
384 let low = scheme.color_for_percent(10.0);
385 let high = scheme.color_for_percent(90.0);
386
387 assert!(low.g > high.g);
389 assert_eq!(low.r, 1.0);
390 assert_eq!(high.r, 1.0);
391 }
392
393 #[test]
394 fn test_heat_scheme_mono() {
395 let scheme = HeatScheme::Mono;
396 let low = scheme.color_for_percent(10.0);
397 let high = scheme.color_for_percent(90.0);
398
399 assert_eq!(low.r, low.g);
401 assert_eq!(low.g, low.b);
402 assert_eq!(high.r, high.g);
403 assert_eq!(high.g, high.b);
404
405 assert!(low.r > high.r);
407 }
408
409 #[test]
410 fn test_heat_scheme_clamps_values() {
411 let scheme = HeatScheme::Thermal;
412
413 let neg = scheme.color_for_percent(-50.0);
415 let over = scheme.color_for_percent(150.0);
416
417 let zero = scheme.color_for_percent(0.0);
419 let hundred = scheme.color_for_percent(100.0);
420
421 assert_eq!(neg.r, zero.r);
422 assert_eq!(over.r, hundred.r);
423 }
424
425 #[test]
430 fn test_bar_style_default() {
431 assert_eq!(BarStyle::default(), BarStyle::Blocks);
432 }
433
434 #[test]
439 fn test_micro_heat_bar_new() {
440 let bar = MicroHeatBar::new(&[50.0, 30.0, 20.0]);
441 assert_eq!(bar.values.len(), 3);
442 assert_eq!(bar.width, 20);
443 assert!(!bar.show_values);
444 }
445
446 #[test]
447 fn test_micro_heat_bar_with_labels() {
448 let bar = MicroHeatBar::new(&[50.0, 50.0]).with_labels(&["A", "B"]);
449 assert_eq!(bar.labels.len(), 2);
450 assert_eq!(bar.labels[0], "A");
451 }
452
453 #[test]
454 fn test_micro_heat_bar_with_scheme() {
455 let bar = MicroHeatBar::new(&[50.0]).with_scheme(HeatScheme::Cool);
456 assert_eq!(bar.scheme, HeatScheme::Cool);
457 }
458
459 #[test]
460 fn test_micro_heat_bar_with_style() {
461 let bar = MicroHeatBar::new(&[50.0]).with_style(BarStyle::Gradient);
462 assert_eq!(bar.style, BarStyle::Gradient);
463 }
464
465 #[test]
466 fn test_micro_heat_bar_with_width() {
467 let bar = MicroHeatBar::new(&[50.0]).with_width(40);
468 assert_eq!(bar.width, 40);
469 }
470
471 #[test]
472 fn test_micro_heat_bar_with_values() {
473 let bar = MicroHeatBar::new(&[50.0]).with_values(true);
474 assert!(bar.show_values);
475 }
476
477 #[test]
478 fn test_micro_heat_bar_render() {
479 let bar = MicroHeatBar::new(&[54.0, 19.0, 4.0, 23.0]).with_width(20);
480
481 let rendered = bar.render_string();
482 assert_eq!(rendered.chars().count(), 20);
483 }
484
485 #[test]
486 fn test_micro_heat_bar_render_empty() {
487 let bar = MicroHeatBar::new(&[]).with_width(10);
488 let rendered = bar.render_string();
489 assert_eq!(rendered, "░░░░░░░░░░");
490 }
491
492 #[test]
493 fn test_micro_heat_bar_render_zero_width() {
494 let bar = MicroHeatBar::new(&[50.0]).with_width(0);
495 let rendered = bar.render_string();
496 assert_eq!(rendered, "");
497 }
498
499 #[test]
500 fn test_micro_heat_bar_render_all_zeros() {
501 let bar = MicroHeatBar::new(&[0.0, 0.0, 0.0]).with_width(10);
502 let rendered = bar.render_string();
503 assert_eq!(rendered, "░░░░░░░░░░");
504 }
505
506 #[test]
507 fn test_micro_heat_bar_render_blocks_style() {
508 let bar = MicroHeatBar::new(&[80.0, 50.0, 30.0, 10.0, 2.0])
509 .with_style(BarStyle::Blocks)
510 .with_width(10);
511 let rendered = bar.render_string();
512 assert!(rendered.contains('█') || rendered.contains('▓') || rendered.contains('▒'));
514 }
515
516 #[test]
517 fn test_micro_heat_bar_render_gradient_style() {
518 let bar = MicroHeatBar::new(&[50.0, 50.0])
519 .with_style(BarStyle::Gradient)
520 .with_width(10);
521 let rendered = bar.render_string();
522 assert!(rendered
524 .chars()
525 .any(|c| matches!(c, '▁' | '▂' | '▃' | '▄' | '▅' | '▆' | '▇' | '█')));
526 }
527
528 #[test]
529 fn test_micro_heat_bar_render_dots_style() {
530 let bar = MicroHeatBar::new(&[60.0, 40.0])
531 .with_style(BarStyle::Dots)
532 .with_width(10);
533 let rendered = bar.render_string();
534 assert!(rendered.contains('●') || rendered.contains('○'));
536 }
537
538 #[test]
539 fn test_micro_heat_bar_render_segments_style() {
540 let bar = MicroHeatBar::new(&[50.0, 50.0])
541 .with_style(BarStyle::Segments)
542 .with_width(10);
543 let rendered = bar.render_string();
544 assert!(rendered.contains('█'));
545 }
546
547 #[test]
548 fn test_micro_heat_bar_paint() {
549 let mut buffer = CellBuffer::new(30, 5);
550 let mut canvas = DirectTerminalCanvas::new(&mut buffer);
551
552 let bar = MicroHeatBar::new(&[54.0, 19.0, 4.0, 23.0]).with_width(20);
553 bar.paint(&mut canvas, Point::new(0.0, 0.0));
554 }
555
556 #[test]
557 fn test_micro_heat_bar_paint_empty() {
558 let mut buffer = CellBuffer::new(30, 5);
559 let mut canvas = DirectTerminalCanvas::new(&mut buffer);
560
561 let bar = MicroHeatBar::new(&[]).with_width(10);
562 bar.paint(&mut canvas, Point::new(0.0, 0.0));
563 }
565
566 #[test]
567 fn test_micro_heat_bar_paint_gradient() {
568 let mut buffer = CellBuffer::new(30, 5);
569 let mut canvas = DirectTerminalCanvas::new(&mut buffer);
570
571 let bar = MicroHeatBar::new(&[50.0, 50.0])
572 .with_style(BarStyle::Gradient)
573 .with_width(20);
574 bar.paint(&mut canvas, Point::new(0.0, 0.0));
575 }
576
577 #[test]
578 fn test_micro_heat_bar_paint_dots() {
579 let mut buffer = CellBuffer::new(30, 5);
580 let mut canvas = DirectTerminalCanvas::new(&mut buffer);
581
582 let bar = MicroHeatBar::new(&[60.0, 40.0])
583 .with_style(BarStyle::Dots)
584 .with_width(20);
585 bar.paint(&mut canvas, Point::new(0.0, 0.0));
586 }
587
588 #[test]
589 fn test_micro_heat_bar_paint_with_remaining() {
590 let mut buffer = CellBuffer::new(50, 5);
591 let mut canvas = DirectTerminalCanvas::new(&mut buffer);
592
593 let bar = MicroHeatBar::new(&[10.0]).with_width(30);
595 bar.paint(&mut canvas, Point::new(0.0, 0.0));
596 }
598
599 #[test]
604 fn test_compact_breakdown_new() {
605 let breakdown = CompactBreakdown::new(&["U", "S", "I"], &[50.0, 30.0, 20.0]);
606 assert_eq!(breakdown.labels.len(), 3);
607 assert_eq!(breakdown.values.len(), 3);
608 }
609
610 #[test]
611 fn test_compact_breakdown_with_scheme() {
612 let breakdown = CompactBreakdown::new(&["A"], &[50.0]).with_scheme(HeatScheme::Cool);
613 assert_eq!(breakdown.scheme, HeatScheme::Cool);
614 }
615
616 #[test]
617 fn test_compact_breakdown_render_text() {
618 let breakdown = CompactBreakdown::new(&["U", "S", "I", "Id"], &[54.0, 19.0, 4.0, 23.0]);
619
620 let text = breakdown.render_text(40);
621 assert!(text.contains("U:54"));
622 assert!(text.contains("S:19"));
623 assert!(text.contains("I:4"));
624 assert!(text.contains("Id:23"));
625 }
626
627 #[test]
628 fn test_compact_breakdown_paint() {
629 let mut buffer = CellBuffer::new(50, 5);
630 let mut canvas = DirectTerminalCanvas::new(&mut buffer);
631
632 let breakdown = CompactBreakdown::new(&["U", "S"], &[60.0, 40.0]);
633 breakdown.paint(&mut canvas, Point::new(0.0, 0.0));
634 }
635
636 #[test]
637 fn test_compact_breakdown_paint_with_scheme() {
638 let mut buffer = CellBuffer::new(50, 5);
639 let mut canvas = DirectTerminalCanvas::new(&mut buffer);
640
641 let breakdown =
642 CompactBreakdown::new(&["A", "B"], &[80.0, 20.0]).with_scheme(HeatScheme::Warm);
643 breakdown.paint(&mut canvas, Point::new(0.0, 0.0));
644 }
645}