Skip to main content

presentar_terminal/widgets/
micro_heat_bar.rs

1//! `MicroHeatBar` - Tufte-inspired proportional breakdown visualization
2//!
3//! A showcase widget demonstrating presentar's data science capabilities:
4//! - Heatmap color intensity encoding
5//! - Proportional area encoding (Tufte data-ink ratio)
6//! - Multi-category display in minimal space
7//! - Optional trend indicators
8//!
9//! # Design Principles (Tufte, 1983)
10//! 1. **Data-Ink Ratio**: Every pixel conveys information
11//! 2. **Layering**: Color intensity + width = two dimensions in one row
12//! 3. **Small Multiples**: Consistent encoding across all instances
13//!
14//! # Example
15//! ```
16//! use presentar_terminal::{MicroHeatBar, HeatScheme};
17//!
18//! // CPU breakdown: usr=54%, sys=19%, io=4%, idle=23%
19//! let bar = MicroHeatBar::new(&[54.0, 19.0, 4.0, 23.0])
20//!     .with_labels(&["U", "S", "I", "Id"])
21//!     .with_scheme(HeatScheme::Thermal);
22//! ```
23
24use presentar_core::{Canvas, Color, Point, TextStyle};
25
26/// Color scheme for heat intensity encoding
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
28pub enum HeatScheme {
29    /// Thermal: green → yellow → orange → red (for CPU/load)
30    #[default]
31    Thermal,
32    /// Cool: light blue → dark blue (for memory)
33    Cool,
34    /// Warm: yellow → orange → red (for temperature)
35    Warm,
36    /// Mono: grayscale (for accessibility)
37    Mono,
38}
39
40impl HeatScheme {
41    /// Map a percentage (0-100) to a color
42    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                // Green → Yellow → Orange → Red
48                if p < 0.5 {
49                    // Green to Yellow
50                    let t = p * 2.0;
51                    Color::new(t as f32, 0.8, 0.2, 1.0)
52                } else {
53                    // Yellow to Red
54                    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                // Light blue to dark blue
60                Color::new(0.2, 0.4 + (p * 0.4) as f32, 0.9, 1.0)
61            }
62            Self::Warm => {
63                // Yellow to red
64                Color::new(1.0, (0.9 - p * 0.7) as f32, 0.1, 1.0)
65            }
66            Self::Mono => {
67                // White to dark gray
68                let v = (0.9 - p * 0.7) as f32;
69                Color::new(v, v, v, 1.0)
70            }
71        }
72    }
73}
74
75/// Style for the micro heat bar rendering
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
77pub enum BarStyle {
78    /// Solid blocks: ████▓▓░░
79    #[default]
80    Blocks,
81    /// Gradient shading: uses 8-level Unicode blocks
82    Gradient,
83    /// Dots/circles: ●●●○○○
84    Dots,
85    /// Segments with gaps: █ █ █ ░ ░
86    Segments,
87}
88
89/// A micro heatmap-style proportional bar for category breakdowns
90///
91/// Renders categories as colored segments where:
92/// - Width ∝ percentage (proportional encoding)
93/// - Color intensity ∝ "heat" of that category (heatmap encoding)
94#[derive(Debug, Clone)]
95pub struct MicroHeatBar {
96    /// Percentages for each category (should sum to ~100)
97    values: Vec<f64>,
98    /// Optional short labels for each category
99    labels: Vec<String>,
100    /// Color scheme
101    scheme: HeatScheme,
102    /// Rendering style
103    style: BarStyle,
104    /// Total width in characters
105    width: usize,
106    /// Show numeric values
107    show_values: bool,
108}
109
110impl MicroHeatBar {
111    /// Create a new MicroHeatBar with the given percentages
112    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    /// Set category labels (short, 1-2 chars recommended)
124    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    /// Set the color scheme
130    pub fn with_scheme(mut self, scheme: HeatScheme) -> Self {
131        self.scheme = scheme;
132        self
133    }
134
135    /// Set the rendering style
136    pub fn with_style(mut self, style: BarStyle) -> Self {
137        self.style = style;
138        self
139    }
140
141    /// Set the total width in characters
142    pub fn with_width(mut self, width: usize) -> Self {
143        self.width = width;
144        self
145    }
146
147    /// Show numeric values inline
148    pub fn with_values(mut self, show: bool) -> Self {
149        self.show_values = show;
150        self
151    }
152
153    /// Render the bar to a string (for simple display)
154    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                    // 8-level gradient based on value intensity
189                    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        // Fill remaining with empty
209        while result.chars().count() < self.width {
210            result.push('░');
211        }
212
213        result
214    }
215
216    /// Paint the bar to a canvas at the given position
217    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            // Color intensity based on the value itself (heatmap principle)
234            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        // Fill remaining width with dim background
260        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/// Compact breakdown showing label + micro bar + percentage
276/// Example: "U:54 S:19 I:4 ████▓▓░░"
277#[derive(Debug)]
278pub struct CompactBreakdown {
279    /// Category values
280    values: Vec<f64>,
281    /// Category labels
282    labels: Vec<String>,
283    /// Color scheme
284    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    /// Render as a compact string: "U:54 S:19 I:4 Id:23"
302    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    /// Paint with colors to canvas
315    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    // =========================================================================
342    // HEAT SCHEME TESTS
343    // =========================================================================
344
345    #[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        // Low should be more green, high should be more red
358        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        // At 50%, should be transitioning (yellow-ish)
367        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        // Cool scheme should be blue-ish
378        assert!(low.b > low.r);
379        assert!(high.b > high.r);
380        // Higher values should have more green component
381        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        // Low should be yellow-ish (high green), high should be red
391        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        // Mono should have equal RGB components (grayscale)
403        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        // Lower values should be brighter (closer to white)
409        assert!(low.r > high.r);
410    }
411
412    #[test]
413    fn test_heat_scheme_clamps_values() {
414        let scheme = HeatScheme::Thermal;
415
416        // Values outside 0-100 should be clamped
417        let neg = scheme.color_for_percent(-50.0);
418        let over = scheme.color_for_percent(150.0);
419
420        // Same as 0% and 100%
421        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    // =========================================================================
429    // BAR STYLE TESTS
430    // =========================================================================
431
432    #[test]
433    fn test_bar_style_default() {
434        assert_eq!(BarStyle::default(), BarStyle::Blocks);
435    }
436
437    // =========================================================================
438    // MICRO HEAT BAR TESTS
439    // =========================================================================
440
441    #[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        // High values should use denser blocks
516        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        // Gradient uses different block heights
526        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        // Dots style uses filled or empty circles
538        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        // Should return early without error
567    }
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        // Small values that won't fill the whole width
597        let bar = MicroHeatBar::new(&[10.0]).with_width(30);
598        bar.paint(&mut canvas, Point::new(0.0, 0.0));
599        // Should fill remaining with dim background
600    }
601
602    // =========================================================================
603    // COMPACT BREAKDOWN TESTS
604    // =========================================================================
605
606    #[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}