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 contract_pre_render!();
160
161 let mut result = String::new();
162 let mut remaining_width = self.width;
163
164 for &val in self.values.iter() {
165 let proportion = val / total;
166 let char_count =
167 ((proportion * self.width as f64).round() as usize).min(remaining_width);
168
169 if char_count == 0 {
170 continue;
171 }
172
173 let ch = match self.style {
174 BarStyle::Blocks => {
175 if val > 70.0 {
176 '█'
177 } else if val > 40.0 {
178 '▓'
179 } else if val > 20.0 {
180 '▒'
181 } else if val > 5.0 {
182 '░'
183 } else {
184 ' '
185 }
186 }
187 BarStyle::Gradient => {
188 let level = ((val / 100.0) * 7.0).round() as usize;
190 ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'][level.min(7)]
191 }
192 BarStyle::Dots => {
193 if val > 50.0 {
194 '●'
195 } else {
196 '○'
197 }
198 }
199 BarStyle::Segments => '█',
200 };
201
202 for _ in 0..char_count {
203 result.push(ch);
204 }
205 remaining_width = remaining_width.saturating_sub(char_count);
206 }
207
208 while result.chars().count() < self.width {
210 result.push('░');
211 }
212
213 result
214 }
215
216 pub fn paint(&self, canvas: &mut dyn Canvas, pos: Point) {
218 let total: f64 = self.values.iter().sum();
219 if total <= 0.0 || self.width == 0 {
220 return;
221 }
222
223 let mut x = pos.x;
224
225 for &val in self.values.iter() {
226 let proportion = val / total;
227 let char_count = (proportion * self.width as f64).round() as usize;
228
229 if char_count == 0 {
230 continue;
231 }
232
233 let color = self.scheme.color_for_percent(val);
235
236 let ch = match self.style {
237 BarStyle::Blocks => '█',
238 BarStyle::Gradient => {
239 let level = ((val / 100.0) * 7.0).round() as usize;
240 ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'][level.min(7)]
241 }
242 BarStyle::Dots => '●',
243 BarStyle::Segments => '█',
244 };
245
246 let segment: String = std::iter::repeat_n(ch, char_count).collect();
247 canvas.draw_text(
248 &segment,
249 Point::new(x, pos.y),
250 &TextStyle {
251 color,
252 ..Default::default()
253 },
254 );
255
256 x += char_count as f32;
257 }
258
259 let remaining = self.width.saturating_sub((x - pos.x) as usize);
261 if remaining > 0 {
262 let bg: String = std::iter::repeat_n('░', remaining).collect();
263 canvas.draw_text(
264 &bg,
265 Point::new(x, pos.y),
266 &TextStyle {
267 color: Color::new(0.2, 0.2, 0.2, 1.0),
268 ..Default::default()
269 },
270 );
271 }
272 }
273}
274
275#[derive(Debug)]
278pub struct CompactBreakdown {
279 values: Vec<f64>,
281 labels: Vec<String>,
283 scheme: HeatScheme,
285}
286
287impl CompactBreakdown {
288 pub fn new(labels: &[&str], values: &[f64]) -> Self {
289 Self {
290 values: values.to_vec(),
291 labels: labels.iter().map(|s| (*s).to_string()).collect(),
292 scheme: HeatScheme::Thermal,
293 }
294 }
295
296 pub fn with_scheme(mut self, scheme: HeatScheme) -> Self {
297 self.scheme = scheme;
298 self
299 }
300
301 pub fn render_text(&self, _width: usize) -> String {
303 contract_pre_render!();
304 let parts: Vec<String> = self
305 .labels
306 .iter()
307 .zip(self.values.iter())
308 .map(|(l, v)| format!("{}:{:.0}", l, v))
309 .collect();
310
311 parts.join(" ")
312 }
313
314 pub fn paint(&self, canvas: &mut dyn Canvas, pos: Point) {
316 let mut x = pos.x;
317
318 for (label, &val) in self.labels.iter().zip(self.values.iter()) {
319 let color = self.scheme.color_for_percent(val);
320 let text = format!("{}:{:.0} ", label, val);
321
322 canvas.draw_text(
323 &text,
324 Point::new(x, pos.y),
325 &TextStyle {
326 color,
327 ..Default::default()
328 },
329 );
330
331 x += text.chars().count() as f32;
332 }
333 }
334}
335
336#[cfg(test)]
337mod tests {
338 use super::*;
339 use crate::direct::{CellBuffer, DirectTerminalCanvas};
340
341 #[test]
346 fn test_heat_scheme_default() {
347 assert_eq!(HeatScheme::default(), HeatScheme::Thermal);
348 }
349
350 #[test]
351 fn test_heat_scheme_thermal() {
352 let scheme = HeatScheme::Thermal;
353
354 let low = scheme.color_for_percent(10.0);
355 let high = scheme.color_for_percent(90.0);
356
357 assert!(low.g > low.r);
359 assert!(high.r > high.g);
360 }
361
362 #[test]
363 fn test_heat_scheme_thermal_midpoint() {
364 let scheme = HeatScheme::Thermal;
365 let mid = scheme.color_for_percent(50.0);
366 assert!(mid.r > 0.5);
368 assert!(mid.g > 0.5);
369 }
370
371 #[test]
372 fn test_heat_scheme_cool() {
373 let scheme = HeatScheme::Cool;
374 let low = scheme.color_for_percent(10.0);
375 let high = scheme.color_for_percent(90.0);
376
377 assert!(low.b > low.r);
379 assert!(high.b > high.r);
380 assert!(high.g > low.g);
382 }
383
384 #[test]
385 fn test_heat_scheme_warm() {
386 let scheme = HeatScheme::Warm;
387 let low = scheme.color_for_percent(10.0);
388 let high = scheme.color_for_percent(90.0);
389
390 assert!(low.g > high.g);
392 assert_eq!(low.r, 1.0);
393 assert_eq!(high.r, 1.0);
394 }
395
396 #[test]
397 fn test_heat_scheme_mono() {
398 let scheme = HeatScheme::Mono;
399 let low = scheme.color_for_percent(10.0);
400 let high = scheme.color_for_percent(90.0);
401
402 assert_eq!(low.r, low.g);
404 assert_eq!(low.g, low.b);
405 assert_eq!(high.r, high.g);
406 assert_eq!(high.g, high.b);
407
408 assert!(low.r > high.r);
410 }
411
412 #[test]
413 fn test_heat_scheme_clamps_values() {
414 let scheme = HeatScheme::Thermal;
415
416 let neg = scheme.color_for_percent(-50.0);
418 let over = scheme.color_for_percent(150.0);
419
420 let zero = scheme.color_for_percent(0.0);
422 let hundred = scheme.color_for_percent(100.0);
423
424 assert_eq!(neg.r, zero.r);
425 assert_eq!(over.r, hundred.r);
426 }
427
428 #[test]
433 fn test_bar_style_default() {
434 assert_eq!(BarStyle::default(), BarStyle::Blocks);
435 }
436
437 #[test]
442 fn test_micro_heat_bar_new() {
443 let bar = MicroHeatBar::new(&[50.0, 30.0, 20.0]);
444 assert_eq!(bar.values.len(), 3);
445 assert_eq!(bar.width, 20);
446 assert!(!bar.show_values);
447 }
448
449 #[test]
450 fn test_micro_heat_bar_with_labels() {
451 let bar = MicroHeatBar::new(&[50.0, 50.0]).with_labels(&["A", "B"]);
452 assert_eq!(bar.labels.len(), 2);
453 assert_eq!(bar.labels[0], "A");
454 }
455
456 #[test]
457 fn test_micro_heat_bar_with_scheme() {
458 let bar = MicroHeatBar::new(&[50.0]).with_scheme(HeatScheme::Cool);
459 assert_eq!(bar.scheme, HeatScheme::Cool);
460 }
461
462 #[test]
463 fn test_micro_heat_bar_with_style() {
464 let bar = MicroHeatBar::new(&[50.0]).with_style(BarStyle::Gradient);
465 assert_eq!(bar.style, BarStyle::Gradient);
466 }
467
468 #[test]
469 fn test_micro_heat_bar_with_width() {
470 let bar = MicroHeatBar::new(&[50.0]).with_width(40);
471 assert_eq!(bar.width, 40);
472 }
473
474 #[test]
475 fn test_micro_heat_bar_with_values() {
476 let bar = MicroHeatBar::new(&[50.0]).with_values(true);
477 assert!(bar.show_values);
478 }
479
480 #[test]
481 fn test_micro_heat_bar_render() {
482 let bar = MicroHeatBar::new(&[54.0, 19.0, 4.0, 23.0]).with_width(20);
483
484 let rendered = bar.render_string();
485 assert_eq!(rendered.chars().count(), 20);
486 }
487
488 #[test]
489 fn test_micro_heat_bar_render_empty() {
490 let bar = MicroHeatBar::new(&[]).with_width(10);
491 let rendered = bar.render_string();
492 assert_eq!(rendered, "░░░░░░░░░░");
493 }
494
495 #[test]
496 fn test_micro_heat_bar_render_zero_width() {
497 let bar = MicroHeatBar::new(&[50.0]).with_width(0);
498 let rendered = bar.render_string();
499 assert_eq!(rendered, "");
500 }
501
502 #[test]
503 fn test_micro_heat_bar_render_all_zeros() {
504 let bar = MicroHeatBar::new(&[0.0, 0.0, 0.0]).with_width(10);
505 let rendered = bar.render_string();
506 assert_eq!(rendered, "░░░░░░░░░░");
507 }
508
509 #[test]
510 fn test_micro_heat_bar_render_blocks_style() {
511 let bar = MicroHeatBar::new(&[80.0, 50.0, 30.0, 10.0, 2.0])
512 .with_style(BarStyle::Blocks)
513 .with_width(10);
514 let rendered = bar.render_string();
515 assert!(rendered.contains('█') || rendered.contains('▓') || rendered.contains('▒'));
517 }
518
519 #[test]
520 fn test_micro_heat_bar_render_gradient_style() {
521 let bar = MicroHeatBar::new(&[50.0, 50.0])
522 .with_style(BarStyle::Gradient)
523 .with_width(10);
524 let rendered = bar.render_string();
525 assert!(rendered
527 .chars()
528 .any(|c| matches!(c, '▁' | '▂' | '▃' | '▄' | '▅' | '▆' | '▇' | '█')));
529 }
530
531 #[test]
532 fn test_micro_heat_bar_render_dots_style() {
533 let bar = MicroHeatBar::new(&[60.0, 40.0])
534 .with_style(BarStyle::Dots)
535 .with_width(10);
536 let rendered = bar.render_string();
537 assert!(rendered.contains('●') || rendered.contains('○'));
539 }
540
541 #[test]
542 fn test_micro_heat_bar_render_segments_style() {
543 let bar = MicroHeatBar::new(&[50.0, 50.0])
544 .with_style(BarStyle::Segments)
545 .with_width(10);
546 let rendered = bar.render_string();
547 assert!(rendered.contains('█'));
548 }
549
550 #[test]
551 fn test_micro_heat_bar_paint() {
552 let mut buffer = CellBuffer::new(30, 5);
553 let mut canvas = DirectTerminalCanvas::new(&mut buffer);
554
555 let bar = MicroHeatBar::new(&[54.0, 19.0, 4.0, 23.0]).with_width(20);
556 bar.paint(&mut canvas, Point::new(0.0, 0.0));
557 }
558
559 #[test]
560 fn test_micro_heat_bar_paint_empty() {
561 let mut buffer = CellBuffer::new(30, 5);
562 let mut canvas = DirectTerminalCanvas::new(&mut buffer);
563
564 let bar = MicroHeatBar::new(&[]).with_width(10);
565 bar.paint(&mut canvas, Point::new(0.0, 0.0));
566 }
568
569 #[test]
570 fn test_micro_heat_bar_paint_gradient() {
571 let mut buffer = CellBuffer::new(30, 5);
572 let mut canvas = DirectTerminalCanvas::new(&mut buffer);
573
574 let bar = MicroHeatBar::new(&[50.0, 50.0])
575 .with_style(BarStyle::Gradient)
576 .with_width(20);
577 bar.paint(&mut canvas, Point::new(0.0, 0.0));
578 }
579
580 #[test]
581 fn test_micro_heat_bar_paint_dots() {
582 let mut buffer = CellBuffer::new(30, 5);
583 let mut canvas = DirectTerminalCanvas::new(&mut buffer);
584
585 let bar = MicroHeatBar::new(&[60.0, 40.0])
586 .with_style(BarStyle::Dots)
587 .with_width(20);
588 bar.paint(&mut canvas, Point::new(0.0, 0.0));
589 }
590
591 #[test]
592 fn test_micro_heat_bar_paint_with_remaining() {
593 let mut buffer = CellBuffer::new(50, 5);
594 let mut canvas = DirectTerminalCanvas::new(&mut buffer);
595
596 let bar = MicroHeatBar::new(&[10.0]).with_width(30);
598 bar.paint(&mut canvas, Point::new(0.0, 0.0));
599 }
601
602 #[test]
607 fn test_compact_breakdown_new() {
608 let breakdown = CompactBreakdown::new(&["U", "S", "I"], &[50.0, 30.0, 20.0]);
609 assert_eq!(breakdown.labels.len(), 3);
610 assert_eq!(breakdown.values.len(), 3);
611 }
612
613 #[test]
614 fn test_compact_breakdown_with_scheme() {
615 let breakdown = CompactBreakdown::new(&["A"], &[50.0]).with_scheme(HeatScheme::Cool);
616 assert_eq!(breakdown.scheme, HeatScheme::Cool);
617 }
618
619 #[test]
620 fn test_compact_breakdown_render_text() {
621 let breakdown = CompactBreakdown::new(&["U", "S", "I", "Id"], &[54.0, 19.0, 4.0, 23.0]);
622
623 let text = breakdown.render_text(40);
624 assert!(text.contains("U:54"));
625 assert!(text.contains("S:19"));
626 assert!(text.contains("I:4"));
627 assert!(text.contains("Id:23"));
628 }
629
630 #[test]
631 fn test_compact_breakdown_paint() {
632 let mut buffer = CellBuffer::new(50, 5);
633 let mut canvas = DirectTerminalCanvas::new(&mut buffer);
634
635 let breakdown = CompactBreakdown::new(&["U", "S"], &[60.0, 40.0]);
636 breakdown.paint(&mut canvas, Point::new(0.0, 0.0));
637 }
638
639 #[test]
640 fn test_compact_breakdown_paint_with_scheme() {
641 let mut buffer = CellBuffer::new(50, 5);
642 let mut canvas = DirectTerminalCanvas::new(&mut buffer);
643
644 let breakdown =
645 CompactBreakdown::new(&["A", "B"], &[80.0, 20.0]).with_scheme(HeatScheme::Warm);
646 breakdown.paint(&mut canvas, Point::new(0.0, 0.0));
647 }
648}