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
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                    // 8-level gradient based on value intensity
188                    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        // Fill remaining with empty
208        while result.chars().count() < self.width {
209            result.push('░');
210        }
211
212        result
213    }
214
215    /// Paint the bar to a canvas at the given position
216    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            // Color intensity based on the value itself (heatmap principle)
233            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        // Fill remaining width with dim background
259        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
274/// Compact breakdown showing label + micro bar + percentage
275/// Example: "U:54 S:19 I:4 ████▓▓░░"
276pub struct CompactBreakdown {
277    /// Category values
278    values: Vec<f64>,
279    /// Category labels
280    labels: Vec<String>,
281    /// Color scheme
282    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    /// Render as a compact string: "U:54 S:19 I:4 Id:23"
300    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    /// Paint with colors to canvas
312    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    // =========================================================================
339    // HEAT SCHEME TESTS
340    // =========================================================================
341
342    #[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        // Low should be more green, high should be more red
355        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        // At 50%, should be transitioning (yellow-ish)
364        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        // Cool scheme should be blue-ish
375        assert!(low.b > low.r);
376        assert!(high.b > high.r);
377        // Higher values should have more green component
378        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        // Low should be yellow-ish (high green), high should be red
388        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        // Mono should have equal RGB components (grayscale)
400        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        // Lower values should be brighter (closer to white)
406        assert!(low.r > high.r);
407    }
408
409    #[test]
410    fn test_heat_scheme_clamps_values() {
411        let scheme = HeatScheme::Thermal;
412
413        // Values outside 0-100 should be clamped
414        let neg = scheme.color_for_percent(-50.0);
415        let over = scheme.color_for_percent(150.0);
416
417        // Same as 0% and 100%
418        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    // =========================================================================
426    // BAR STYLE TESTS
427    // =========================================================================
428
429    #[test]
430    fn test_bar_style_default() {
431        assert_eq!(BarStyle::default(), BarStyle::Blocks);
432    }
433
434    // =========================================================================
435    // MICRO HEAT BAR TESTS
436    // =========================================================================
437
438    #[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        // High values should use denser blocks
513        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        // Gradient uses different block heights
523        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        // Dots style uses filled or empty circles
535        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        // Should return early without error
564    }
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        // Small values that won't fill the whole width
594        let bar = MicroHeatBar::new(&[10.0]).with_width(30);
595        bar.paint(&mut canvas, Point::new(0.0, 0.0));
596        // Should fill remaining with dim background
597    }
598
599    // =========================================================================
600    // COMPACT BREAKDOWN TESTS
601    // =========================================================================
602
603    #[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}