Skip to main content

presentar_terminal/widgets/
multi_bar.rs

1//! Multi-bar graph widget for showing multiple values as side-by-side bars.
2//!
3//! Useful for displaying per-core CPU usage, multiple metrics, etc.
4
5use crate::theme::Gradient;
6use presentar_core::{
7    Brick, BrickAssertion, BrickBudget, BrickVerification, Canvas, Color, Constraints, Event,
8    LayoutResult, Point, Rect, Size, TextStyle, TypeId, Widget,
9};
10use std::any::Any;
11use std::time::Duration;
12
13/// Display mode for multi-bar graph.
14#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
15pub enum MultiBarMode {
16    /// Vertical bars (default) - each bar grows upward.
17    #[default]
18    Vertical,
19    /// Horizontal bars - each bar grows rightward.
20    Horizontal,
21}
22
23/// A multi-bar graph widget showing multiple values as side-by-side bars.
24///
25/// Each bar can have its own color based on its value using a gradient.
26#[derive(Debug, Clone)]
27pub struct MultiBarGraph {
28    /// Values for each bar (0.0-1.0 normalized).
29    values: Vec<f64>,
30    /// Base color (used if no gradient).
31    color: Color,
32    /// Gradient for value-based coloring.
33    gradient: Option<Gradient>,
34    /// Display mode.
35    mode: MultiBarMode,
36    /// Optional labels for each bar.
37    labels: Option<Vec<String>>,
38    /// Layout bounds.
39    bounds: Rect,
40    /// Gap between bars (in characters).
41    gap: u16,
42}
43
44impl MultiBarGraph {
45    /// Create a new multi-bar graph with the given values.
46    /// Values should be normalized to 0.0-1.0 range.
47    #[must_use]
48    pub fn new(values: Vec<f64>) -> Self {
49        Self {
50            values,
51            color: Color::GREEN,
52            gradient: None,
53            mode: MultiBarMode::default(),
54            labels: None,
55            bounds: Rect::new(0.0, 0.0, 0.0, 0.0),
56            gap: 0,
57        }
58    }
59
60    /// Set the base color.
61    #[must_use]
62    pub fn with_color(mut self, color: Color) -> Self {
63        self.color = color;
64        self
65    }
66
67    /// Set a gradient for value-based coloring.
68    #[must_use]
69    pub fn with_gradient(mut self, gradient: Gradient) -> Self {
70        self.gradient = Some(gradient);
71        self
72    }
73
74    /// Set the display mode.
75    #[must_use]
76    pub fn with_mode(mut self, mode: MultiBarMode) -> Self {
77        self.mode = mode;
78        self
79    }
80
81    /// Set labels for each bar.
82    #[must_use]
83    pub fn with_labels(mut self, labels: Vec<String>) -> Self {
84        self.labels = Some(labels);
85        self
86    }
87
88    /// Set gap between bars.
89    #[must_use]
90    pub fn with_gap(mut self, gap: u16) -> Self {
91        self.gap = gap;
92        self
93    }
94
95    /// Update values.
96    pub fn set_values(&mut self, values: Vec<f64>) {
97        self.values = values;
98    }
99
100    /// Get color for a value.
101    fn color_for_value(&self, value: f64) -> Color {
102        match &self.gradient {
103            Some(gradient) => gradient.sample(value.clamp(0.0, 1.0)),
104            None => self.color,
105        }
106    }
107
108    fn render_vertical(&self, canvas: &mut dyn Canvas) {
109        let width = self.bounds.width as usize;
110        let height = self.bounds.height as usize;
111        if width == 0 || height == 0 || self.values.is_empty() {
112            return;
113        }
114
115        let bar_count = self.values.len();
116        let total_gap = self.gap as usize * bar_count.saturating_sub(1);
117        let available_width = width.saturating_sub(total_gap);
118        let bar_width = (available_width / bar_count).max(1);
119
120        // Block characters for sub-row resolution (8 levels)
121        let blocks = [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
122
123        for (i, &value) in self.values.iter().enumerate() {
124            let value = value.clamp(0.0, 1.0);
125            let bar_x = i * (bar_width + self.gap as usize);
126            if bar_x >= width {
127                break;
128            }
129
130            let color = self.color_for_value(value);
131            let style = TextStyle {
132                color,
133                ..Default::default()
134            };
135
136            // Calculate bar height with sub-character precision
137            let total_eighths = (value * height as f64 * 8.0).round() as usize;
138            let full_rows = total_eighths / 8;
139            let partial_eighths = total_eighths % 8;
140
141            for row in 0..height {
142                let y = height - 1 - row; // Draw from bottom up
143                let ch = if row < full_rows {
144                    '█'
145                } else if row == full_rows && partial_eighths > 0 {
146                    blocks[partial_eighths]
147                } else {
148                    ' '
149                };
150
151                // Draw bar_width characters for this bar
152                for bx in 0..bar_width {
153                    let x = bar_x + bx;
154                    if x < width {
155                        canvas.draw_text(
156                            &ch.to_string(),
157                            Point::new(self.bounds.x + x as f32, self.bounds.y + y as f32),
158                            &style,
159                        );
160                    }
161                }
162            }
163        }
164    }
165
166    fn render_horizontal(&self, canvas: &mut dyn Canvas) {
167        let width = self.bounds.width as usize;
168        let height = self.bounds.height as usize;
169        if width == 0 || height == 0 || self.values.is_empty() {
170            return;
171        }
172
173        let bar_count = self.values.len();
174        let total_gap = self.gap as usize * bar_count.saturating_sub(1);
175        let available_height = height.saturating_sub(total_gap);
176        let bar_height = (available_height / bar_count).max(1);
177
178        for (i, &value) in self.values.iter().enumerate() {
179            let value = value.clamp(0.0, 1.0);
180            let bar_y = i * (bar_height + self.gap as usize);
181            if bar_y >= height {
182                break;
183            }
184
185            let color = self.color_for_value(value);
186            let style = TextStyle {
187                color,
188                ..Default::default()
189            };
190
191            let filled_cols = (value * width as f64).round() as usize;
192
193            for row in 0..bar_height {
194                let y = bar_y + row;
195                if y >= height {
196                    break;
197                }
198
199                for col in 0..width {
200                    let ch = if col < filled_cols { '█' } else { '░' };
201                    canvas.draw_text(
202                        &ch.to_string(),
203                        Point::new(self.bounds.x + col as f32, self.bounds.y + y as f32),
204                        &style,
205                    );
206                }
207            }
208        }
209    }
210}
211
212impl Brick for MultiBarGraph {
213    fn brick_name(&self) -> &'static str {
214        "multi_bar_graph"
215    }
216
217    fn assertions(&self) -> &[BrickAssertion] {
218        static ASSERTIONS: &[BrickAssertion] = &[BrickAssertion::max_latency_ms(16)];
219        ASSERTIONS
220    }
221
222    fn budget(&self) -> BrickBudget {
223        BrickBudget::uniform(16)
224    }
225
226    fn verify(&self) -> BrickVerification {
227        BrickVerification {
228            passed: vec![BrickAssertion::max_latency_ms(16)],
229            failed: vec![],
230            verification_time: Duration::from_micros(10),
231        }
232    }
233
234    fn to_html(&self) -> String {
235        String::new()
236    }
237
238    fn to_css(&self) -> String {
239        String::new()
240    }
241}
242
243impl Widget for MultiBarGraph {
244    fn type_id(&self) -> TypeId {
245        TypeId::of::<Self>()
246    }
247
248    fn measure(&self, constraints: Constraints) -> Size {
249        let width = constraints.max_width.max(self.values.len() as f32);
250        let height = constraints.max_height.max(3.0);
251        constraints.constrain(Size::new(width, height))
252    }
253
254    fn layout(&mut self, bounds: Rect) -> LayoutResult {
255        self.bounds = bounds;
256        LayoutResult {
257            size: Size::new(bounds.width, bounds.height),
258        }
259    }
260
261    fn paint(&self, canvas: &mut dyn Canvas) {
262        match self.mode {
263            MultiBarMode::Vertical => self.render_vertical(canvas),
264            MultiBarMode::Horizontal => self.render_horizontal(canvas),
265        }
266    }
267
268    fn event(&mut self, _event: &Event) -> Option<Box<dyn Any + Send>> {
269        None
270    }
271
272    fn children(&self) -> &[Box<dyn Widget>] {
273        &[]
274    }
275
276    fn children_mut(&mut self) -> &mut [Box<dyn Widget>] {
277        &mut []
278    }
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284
285    struct MockCanvas {
286        texts: Vec<(String, Point, Color)>,
287    }
288
289    impl MockCanvas {
290        fn new() -> Self {
291            Self { texts: vec![] }
292        }
293    }
294
295    impl Canvas for MockCanvas {
296        fn fill_rect(&mut self, _rect: Rect, _color: Color) {}
297        fn stroke_rect(&mut self, _rect: Rect, _color: Color, _width: f32) {}
298        fn draw_text(&mut self, text: &str, position: Point, style: &TextStyle) {
299            self.texts.push((text.to_string(), position, style.color));
300        }
301        fn draw_line(&mut self, _from: Point, _to: Point, _color: Color, _width: f32) {}
302        fn fill_circle(&mut self, _center: Point, _radius: f32, _color: Color) {}
303        fn stroke_circle(&mut self, _center: Point, _radius: f32, _color: Color, _width: f32) {}
304        fn fill_arc(
305            &mut self,
306            _center: Point,
307            _radius: f32,
308            _start: f32,
309            _end: f32,
310            _color: Color,
311        ) {
312        }
313        fn draw_path(&mut self, _points: &[Point], _color: Color, _width: f32) {}
314        fn fill_polygon(&mut self, _points: &[Point], _color: Color) {}
315        fn push_clip(&mut self, _rect: Rect) {}
316        fn pop_clip(&mut self) {}
317        fn push_transform(&mut self, _transform: presentar_core::Transform2D) {}
318        fn pop_transform(&mut self) {}
319    }
320
321    #[test]
322    fn test_multi_bar_creation() {
323        let graph = MultiBarGraph::new(vec![0.5, 0.75, 0.25]);
324        assert_eq!(graph.values.len(), 3);
325    }
326
327    #[test]
328    fn test_multi_bar_with_color() {
329        let graph = MultiBarGraph::new(vec![0.5]).with_color(Color::RED);
330        assert_eq!(graph.color, Color::RED);
331    }
332
333    #[test]
334    fn test_multi_bar_with_gradient() {
335        let gradient = Gradient::from_hex(&["#00FF00", "#FF0000"]);
336        let graph = MultiBarGraph::new(vec![0.5]).with_gradient(gradient);
337        assert!(graph.gradient.is_some());
338    }
339
340    #[test]
341    fn test_multi_bar_with_mode() {
342        let graph = MultiBarGraph::new(vec![0.5]).with_mode(MultiBarMode::Horizontal);
343        assert_eq!(graph.mode, MultiBarMode::Horizontal);
344    }
345
346    #[test]
347    fn test_multi_bar_with_gap() {
348        let graph = MultiBarGraph::new(vec![0.5]).with_gap(1);
349        assert_eq!(graph.gap, 1);
350    }
351
352    #[test]
353    fn test_multi_bar_set_values() {
354        let mut graph = MultiBarGraph::new(vec![0.5]);
355        graph.set_values(vec![0.1, 0.2, 0.3]);
356        assert_eq!(graph.values.len(), 3);
357    }
358
359    #[test]
360    fn test_multi_bar_paint_vertical() {
361        let mut graph = MultiBarGraph::new(vec![0.5, 1.0, 0.25]);
362        graph.bounds = Rect::new(0.0, 0.0, 6.0, 4.0);
363        let mut canvas = MockCanvas::new();
364        graph.paint(&mut canvas);
365        assert!(!canvas.texts.is_empty());
366    }
367
368    #[test]
369    fn test_multi_bar_paint_horizontal() {
370        let mut graph =
371            MultiBarGraph::new(vec![0.5, 1.0, 0.25]).with_mode(MultiBarMode::Horizontal);
372        graph.bounds = Rect::new(0.0, 0.0, 10.0, 6.0);
373        let mut canvas = MockCanvas::new();
374        graph.paint(&mut canvas);
375        assert!(!canvas.texts.is_empty());
376    }
377
378    #[test]
379    fn test_multi_bar_gradient_coloring() {
380        let gradient = Gradient::from_hex(&["#00FF00", "#FF0000"]);
381        let mut graph = MultiBarGraph::new(vec![0.0, 0.5, 1.0]).with_gradient(gradient);
382        graph.bounds = Rect::new(0.0, 0.0, 6.0, 4.0);
383        let mut canvas = MockCanvas::new();
384        graph.paint(&mut canvas);
385
386        // Different values should produce different colors
387        let colors: Vec<Color> = canvas.texts.iter().map(|(_, _, c)| *c).collect();
388        assert!(!colors.is_empty());
389    }
390
391    #[test]
392    fn test_multi_bar_empty_bounds() {
393        let mut graph = MultiBarGraph::new(vec![0.5]);
394        graph.bounds = Rect::new(0.0, 0.0, 0.0, 0.0);
395        let mut canvas = MockCanvas::new();
396        graph.paint(&mut canvas);
397        assert!(canvas.texts.is_empty());
398    }
399
400    #[test]
401    fn test_multi_bar_empty_values() {
402        let mut graph = MultiBarGraph::new(vec![]);
403        graph.bounds = Rect::new(0.0, 0.0, 10.0, 5.0);
404        let mut canvas = MockCanvas::new();
405        graph.paint(&mut canvas);
406        assert!(canvas.texts.is_empty());
407    }
408
409    #[test]
410    fn test_multi_bar_brick_name() {
411        let graph = MultiBarGraph::new(vec![0.5]);
412        assert_eq!(graph.brick_name(), "multi_bar_graph");
413    }
414
415    #[test]
416    fn test_multi_bar_assertions_not_empty() {
417        let graph = MultiBarGraph::new(vec![0.5]);
418        assert!(!graph.assertions().is_empty());
419    }
420
421    #[test]
422    fn test_multi_bar_verify() {
423        let graph = MultiBarGraph::new(vec![0.5]);
424        assert!(graph.verify().is_valid());
425    }
426
427    #[test]
428    fn test_multi_bar_measure() {
429        let graph = MultiBarGraph::new(vec![0.5, 0.5, 0.5]);
430        let constraints = Constraints::new(0.0, 100.0, 0.0, 50.0);
431        let size = graph.measure(constraints);
432        assert!(size.width >= 3.0);
433        assert!(size.height >= 3.0);
434    }
435
436    #[test]
437    fn test_multi_bar_mode_default() {
438        assert_eq!(MultiBarMode::default(), MultiBarMode::Vertical);
439    }
440
441    #[test]
442    fn test_multi_bar_many_values() {
443        // Simulate 48 CPU cores
444        let values: Vec<f64> = (0..48).map(|i| i as f64 / 48.0).collect();
445        let mut graph = MultiBarGraph::new(values);
446        graph.bounds = Rect::new(0.0, 0.0, 96.0, 6.0);
447        let mut canvas = MockCanvas::new();
448        graph.paint(&mut canvas);
449        assert!(!canvas.texts.is_empty());
450    }
451
452    #[test]
453    fn test_multi_bar_layout() {
454        let mut graph = MultiBarGraph::new(vec![0.5, 0.75]);
455        let result = graph.layout(Rect::new(0.0, 0.0, 40.0, 10.0));
456        assert_eq!(result.size.width, 40.0);
457        assert_eq!(result.size.height, 10.0);
458    }
459
460    #[test]
461    fn test_multi_bar_event() {
462        let mut graph = MultiBarGraph::new(vec![0.5]);
463        let event = Event::Resize {
464            width: 80.0,
465            height: 24.0,
466        };
467        assert!(graph.event(&event).is_none());
468    }
469
470    #[test]
471    fn test_multi_bar_children() {
472        let graph = MultiBarGraph::new(vec![0.5]);
473        assert!(graph.children().is_empty());
474    }
475
476    #[test]
477    fn test_multi_bar_children_mut() {
478        let mut graph = MultiBarGraph::new(vec![0.5]);
479        assert!(graph.children_mut().is_empty());
480    }
481
482    #[test]
483    fn test_multi_bar_type_id() {
484        let graph = MultiBarGraph::new(vec![0.5]);
485        let tid = Widget::type_id(&graph);
486        assert_eq!(tid, TypeId::of::<MultiBarGraph>());
487    }
488
489    #[test]
490    fn test_multi_bar_budget() {
491        let graph = MultiBarGraph::new(vec![0.5]);
492        let budget = graph.budget();
493        assert!(budget.layout_ms > 0);
494    }
495
496    #[test]
497    fn test_multi_bar_to_html() {
498        let graph = MultiBarGraph::new(vec![0.5]);
499        assert!(graph.to_html().is_empty());
500    }
501
502    #[test]
503    fn test_multi_bar_to_css() {
504        let graph = MultiBarGraph::new(vec![0.5]);
505        assert!(graph.to_css().is_empty());
506    }
507
508    #[test]
509    fn test_multi_bar_clone() {
510        let graph = MultiBarGraph::new(vec![0.5, 0.75]).with_gap(2);
511        let cloned = graph.clone();
512        assert_eq!(cloned.values.len(), graph.values.len());
513        assert_eq!(cloned.gap, graph.gap);
514    }
515
516    #[test]
517    fn test_multi_bar_debug() {
518        let graph = MultiBarGraph::new(vec![0.5]);
519        let debug = format!("{graph:?}");
520        assert!(debug.contains("MultiBarGraph"));
521    }
522
523    #[test]
524    fn test_multi_bar_mode_debug() {
525        let mode = MultiBarMode::Vertical;
526        let debug = format!("{mode:?}");
527        assert!(debug.contains("Vertical"));
528    }
529
530    #[test]
531    fn test_multi_bar_mode_clone() {
532        let mode = MultiBarMode::Horizontal;
533        let cloned = mode;
534        assert_eq!(cloned, MultiBarMode::Horizontal);
535    }
536
537    #[test]
538    fn test_multi_bar_with_labels() {
539        let graph =
540            MultiBarGraph::new(vec![0.5]).with_labels(vec!["CPU0".to_string(), "CPU1".to_string()]);
541        assert!(graph.labels.is_some());
542        assert_eq!(graph.labels.unwrap().len(), 2);
543    }
544
545    #[test]
546    fn test_multi_bar_color_for_value_no_gradient() {
547        let graph = MultiBarGraph::new(vec![0.5]).with_color(Color::BLUE);
548        let color = graph.color_for_value(0.75);
549        assert_eq!(color, Color::BLUE);
550    }
551
552    #[test]
553    fn test_multi_bar_color_for_value_with_gradient() {
554        let gradient = Gradient::from_hex(&["#00FF00", "#FF0000"]);
555        let graph = MultiBarGraph::new(vec![0.5]).with_gradient(gradient);
556        let color = graph.color_for_value(0.5);
557        // Should be somewhere between green and red
558        assert!(color.r > 0.0 || color.g > 0.0);
559    }
560
561    #[test]
562    fn test_multi_bar_vertical_overflow() {
563        // More bars than width
564        let mut graph = MultiBarGraph::new(vec![0.5; 20]);
565        graph.bounds = Rect::new(0.0, 0.0, 10.0, 5.0);
566        let mut canvas = MockCanvas::new();
567        graph.paint(&mut canvas);
568        // Should handle gracefully
569    }
570
571    #[test]
572    fn test_multi_bar_horizontal_overflow() {
573        // More bars than height
574        let mut graph = MultiBarGraph::new(vec![0.5; 20]).with_mode(MultiBarMode::Horizontal);
575        graph.bounds = Rect::new(0.0, 0.0, 10.0, 5.0);
576        let mut canvas = MockCanvas::new();
577        graph.paint(&mut canvas);
578        // Should handle gracefully
579    }
580
581    #[test]
582    fn test_multi_bar_vertical_with_gap() {
583        let mut graph = MultiBarGraph::new(vec![0.5, 0.75, 1.0]).with_gap(1);
584        graph.bounds = Rect::new(0.0, 0.0, 12.0, 5.0);
585        let mut canvas = MockCanvas::new();
586        graph.paint(&mut canvas);
587        assert!(!canvas.texts.is_empty());
588    }
589
590    #[test]
591    fn test_multi_bar_horizontal_with_gap() {
592        let mut graph = MultiBarGraph::new(vec![0.5, 0.75, 1.0])
593            .with_mode(MultiBarMode::Horizontal)
594            .with_gap(1);
595        graph.bounds = Rect::new(0.0, 0.0, 10.0, 12.0);
596        let mut canvas = MockCanvas::new();
597        graph.paint(&mut canvas);
598        assert!(!canvas.texts.is_empty());
599    }
600
601    #[test]
602    fn test_multi_bar_horizontal_empty() {
603        let mut graph = MultiBarGraph::new(vec![]).with_mode(MultiBarMode::Horizontal);
604        graph.bounds = Rect::new(0.0, 0.0, 10.0, 5.0);
605        let mut canvas = MockCanvas::new();
606        graph.paint(&mut canvas);
607        assert!(canvas.texts.is_empty());
608    }
609
610    #[test]
611    fn test_multi_bar_horizontal_zero_bounds() {
612        let mut graph = MultiBarGraph::new(vec![0.5]).with_mode(MultiBarMode::Horizontal);
613        graph.bounds = Rect::new(0.0, 0.0, 0.0, 0.0);
614        let mut canvas = MockCanvas::new();
615        graph.paint(&mut canvas);
616        assert!(canvas.texts.is_empty());
617    }
618
619    #[test]
620    fn test_multi_bar_clamped_values() {
621        // Values outside 0-1 range should be clamped
622        let mut graph = MultiBarGraph::new(vec![-0.5, 1.5, 2.0]);
623        graph.bounds = Rect::new(0.0, 0.0, 9.0, 5.0);
624        let mut canvas = MockCanvas::new();
625        graph.paint(&mut canvas);
626        // Should handle gracefully with clamped values
627    }
628}