Skip to main content

presentar_terminal/widgets/
meter.rs

1//! Horizontal meter/gauge widget.
2
3use presentar_core::{
4    Brick, BrickAssertion, BrickBudget, BrickVerification, Canvas, Color, Constraints, Event,
5    LayoutResult, Point, Rect, Size, TextStyle, TypeId, Widget,
6};
7use std::any::Any;
8use std::time::Duration;
9
10/// Horizontal meter widget displaying a percentage value.
11#[derive(Debug, Clone)]
12pub struct Meter {
13    value: f64,
14    max: f64,
15    label: String,
16    fill_color: Color,
17    gradient_end: Option<Color>,
18    show_percentage: bool,
19    bounds: Rect,
20}
21
22impl Meter {
23    /// Create a new meter with value and max.
24    #[must_use]
25    pub fn new(value: f64, max: f64) -> Self {
26        Self {
27            value,
28            max,
29            label: String::new(),
30            fill_color: Color::GREEN,
31            gradient_end: None,
32            show_percentage: true,
33            bounds: Rect::new(0.0, 0.0, 0.0, 0.0),
34        }
35    }
36
37    /// Create a percentage meter (0-100).
38    #[must_use]
39    pub fn percentage(value: f64) -> Self {
40        Self::new(value, 100.0)
41    }
42
43    /// Set the label.
44    #[must_use]
45    pub fn with_label(mut self, label: impl Into<String>) -> Self {
46        self.label = label.into();
47        self
48    }
49
50    /// Set the fill color.
51    #[must_use]
52    pub fn with_color(mut self, color: Color) -> Self {
53        self.fill_color = color;
54        self
55    }
56
57    /// Set a gradient (start to end color).
58    #[must_use]
59    pub fn with_gradient(mut self, start: Color, end: Color) -> Self {
60        self.fill_color = start;
61        self.gradient_end = Some(end);
62        self
63    }
64
65    /// Set whether to show percentage text.
66    #[must_use]
67    pub fn with_percentage_text(mut self, show: bool) -> Self {
68        self.show_percentage = show;
69        self
70    }
71
72    /// Update the value.
73    pub fn set_value(&mut self, value: f64) {
74        self.value = value.clamp(0.0, self.max);
75    }
76
77    /// Get the current value.
78    #[must_use]
79    pub fn value(&self) -> f64 {
80        self.value
81    }
82
83    /// Get the fill ratio (0.0-1.0).
84    #[must_use]
85    pub fn ratio(&self) -> f64 {
86        if self.max == 0.0 {
87            0.0
88        } else {
89            (self.value / self.max).clamp(0.0, 1.0)
90        }
91    }
92
93    fn color_at(&self, t: f64) -> Color {
94        match self.gradient_end {
95            Some(end) => self.fill_color.lerp(&end, t as f32),
96            None => self.fill_color,
97        }
98    }
99}
100
101impl Brick for Meter {
102    fn brick_name(&self) -> &'static str {
103        "meter"
104    }
105
106    fn assertions(&self) -> &[BrickAssertion] {
107        static ASSERTIONS: &[BrickAssertion] = &[BrickAssertion::max_latency_ms(16)];
108        ASSERTIONS
109    }
110
111    fn budget(&self) -> BrickBudget {
112        BrickBudget::uniform(16)
113    }
114
115    fn verify(&self) -> BrickVerification {
116        let mut passed = Vec::new();
117        let mut failed = Vec::new();
118
119        // Check value is in range
120        if self.value >= 0.0 && self.value <= self.max {
121            passed.push(BrickAssertion::max_latency_ms(16));
122        } else {
123            failed.push((
124                BrickAssertion::max_latency_ms(16),
125                format!("Value {} outside range [0, {}]", self.value, self.max),
126            ));
127        }
128
129        BrickVerification {
130            passed,
131            failed,
132            verification_time: Duration::from_micros(10),
133        }
134    }
135
136    fn to_html(&self) -> String {
137        String::new()
138    }
139
140    fn to_css(&self) -> String {
141        String::new()
142    }
143}
144
145impl Widget for Meter {
146    fn type_id(&self) -> TypeId {
147        TypeId::of::<Self>()
148    }
149
150    fn measure(&self, constraints: Constraints) -> Size {
151        let width = constraints.max_width.max(10.0);
152        let height = 1.0;
153        constraints.constrain(Size::new(width, height))
154    }
155
156    fn layout(&mut self, bounds: Rect) -> LayoutResult {
157        self.bounds = bounds;
158        LayoutResult {
159            size: Size::new(bounds.width, bounds.height.max(1.0)),
160        }
161    }
162
163    fn paint(&self, canvas: &mut dyn Canvas) {
164        let width = self.bounds.width as usize;
165        if width == 0 {
166            return;
167        }
168
169        let label_width = if self.label.is_empty() {
170            0
171        } else {
172            self.label.len() + 1
173        };
174
175        let pct_text = if self.show_percentage {
176            format!("{:5.1}%", self.ratio() * 100.0)
177        } else {
178            String::new()
179        };
180        let pct_width = pct_text.len();
181
182        let bar_width = width.saturating_sub(label_width + pct_width + 2);
183        if bar_width == 0 {
184            return;
185        }
186
187        // Draw label
188        if !self.label.is_empty() {
189            canvas.draw_text(
190                &self.label,
191                Point::new(self.bounds.x, self.bounds.y),
192                &TextStyle::default(),
193            );
194        }
195
196        let filled = ((self.ratio() * bar_width as f64).round() as usize).min(bar_width);
197
198        let mut bar = String::with_capacity(bar_width + 2);
199        bar.push('[');
200        for i in 0..bar_width {
201            if i < filled {
202                bar.push('█');
203            } else {
204                bar.push(' ');
205            }
206        }
207        bar.push(']');
208
209        let bar_x = self.bounds.x + label_width as f32;
210        let style = TextStyle {
211            color: self.color_at(0.5),
212            ..Default::default()
213        };
214        canvas.draw_text(&bar, Point::new(bar_x, self.bounds.y), &style);
215
216        if self.show_percentage {
217            let pct_x = bar_x + bar_width as f32 + 2.0;
218            canvas.draw_text(
219                &pct_text,
220                Point::new(pct_x, self.bounds.y),
221                &TextStyle::default(),
222            );
223        }
224    }
225
226    fn event(&mut self, _event: &Event) -> Option<Box<dyn Any + Send>> {
227        None
228    }
229
230    fn children(&self) -> &[Box<dyn Widget>] {
231        &[]
232    }
233
234    fn children_mut(&mut self) -> &mut [Box<dyn Widget>] {
235        &mut []
236    }
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242    use presentar_core::{Canvas, TextStyle};
243
244    struct MockCanvas {
245        texts: Vec<(String, Point)>,
246        rects: Vec<Rect>,
247    }
248
249    impl MockCanvas {
250        fn new() -> Self {
251            Self {
252                texts: vec![],
253                rects: vec![],
254            }
255        }
256    }
257
258    impl Canvas for MockCanvas {
259        fn fill_rect(&mut self, rect: Rect, _color: Color) {
260            self.rects.push(rect);
261        }
262        fn stroke_rect(&mut self, _rect: Rect, _color: Color, _width: f32) {}
263        fn draw_text(&mut self, text: &str, position: Point, _style: &TextStyle) {
264            self.texts.push((text.to_string(), position));
265        }
266        fn draw_line(&mut self, _from: Point, _to: Point, _color: Color, _width: f32) {}
267        fn fill_circle(&mut self, _center: Point, _radius: f32, _color: Color) {}
268        fn stroke_circle(&mut self, _center: Point, _radius: f32, _color: Color, _width: f32) {}
269        fn fill_arc(
270            &mut self,
271            _center: Point,
272            _radius: f32,
273            _start: f32,
274            _end: f32,
275            _color: Color,
276        ) {
277        }
278        fn draw_path(&mut self, _points: &[Point], _color: Color, _width: f32) {}
279        fn fill_polygon(&mut self, _points: &[Point], _color: Color) {}
280        fn push_clip(&mut self, _rect: Rect) {}
281        fn pop_clip(&mut self) {}
282        fn push_transform(&mut self, _transform: presentar_core::Transform2D) {}
283        fn pop_transform(&mut self) {}
284    }
285
286    #[test]
287    fn test_meter_creation() {
288        let meter = Meter::new(50.0, 100.0);
289        assert_eq!(meter.value, 50.0);
290        assert_eq!(meter.max, 100.0);
291    }
292
293    #[test]
294    fn test_meter_ratio() {
295        let meter = Meter::percentage(75.0);
296        assert!((meter.ratio() - 0.75).abs() < f64::EPSILON);
297    }
298
299    #[test]
300    fn test_meter_assertions_not_empty() {
301        let meter = Meter::percentage(50.0);
302        assert!(!meter.assertions().is_empty());
303    }
304
305    #[test]
306    fn test_meter_verify_pass() {
307        let meter = Meter::percentage(50.0);
308        assert!(meter.verify().is_valid());
309    }
310
311    #[test]
312    fn test_meter_percentage() {
313        let meter = Meter::percentage(80.0);
314        assert_eq!(meter.max, 100.0);
315        assert_eq!(meter.value(), 80.0);
316    }
317
318    #[test]
319    fn test_meter_with_label() {
320        let meter = Meter::percentage(50.0).with_label("CPU");
321        assert_eq!(meter.label, "CPU");
322    }
323
324    #[test]
325    fn test_meter_with_color() {
326        let meter = Meter::percentage(50.0).with_color(Color::RED);
327        assert_eq!(meter.fill_color, Color::RED);
328    }
329
330    #[test]
331    fn test_meter_with_gradient() {
332        let meter = Meter::percentage(50.0).with_gradient(Color::GREEN, Color::RED);
333        assert_eq!(meter.fill_color, Color::GREEN);
334        assert_eq!(meter.gradient_end, Some(Color::RED));
335    }
336
337    #[test]
338    fn test_meter_with_percentage_text() {
339        let meter = Meter::percentage(50.0).with_percentage_text(false);
340        assert!(!meter.show_percentage);
341    }
342
343    #[test]
344    fn test_meter_set_value() {
345        let mut meter = Meter::percentage(50.0);
346        meter.set_value(75.0);
347        assert_eq!(meter.value(), 75.0);
348    }
349
350    #[test]
351    fn test_meter_set_value_clamped() {
352        let mut meter = Meter::percentage(50.0);
353        meter.set_value(150.0);
354        assert_eq!(meter.value(), 100.0);
355
356        meter.set_value(-10.0);
357        assert_eq!(meter.value(), 0.0);
358    }
359
360    #[test]
361    fn test_meter_ratio_zero_max() {
362        let meter = Meter::new(50.0, 0.0);
363        assert_eq!(meter.ratio(), 0.0);
364    }
365
366    #[test]
367    fn test_meter_ratio_clamped() {
368        let meter = Meter::new(150.0, 100.0);
369        assert_eq!(meter.ratio(), 1.0);
370    }
371
372    #[test]
373    fn test_meter_color_at_no_gradient() {
374        let meter = Meter::percentage(50.0).with_color(Color::BLUE);
375        let color = meter.color_at(0.5);
376        assert_eq!(color, Color::BLUE);
377    }
378
379    #[test]
380    fn test_meter_color_at_with_gradient() {
381        let meter = Meter::percentage(50.0).with_gradient(Color::GREEN, Color::RED);
382        let color = meter.color_at(0.0);
383        assert_eq!(color, Color::GREEN);
384        let color = meter.color_at(1.0);
385        assert_eq!(color, Color::RED);
386    }
387
388    #[test]
389    fn test_meter_verify_out_of_range() {
390        let mut meter = Meter::new(50.0, 100.0);
391        meter.value = -10.0;
392        assert!(!meter.verify().is_valid());
393    }
394
395    #[test]
396    fn test_meter_measure() {
397        let meter = Meter::percentage(50.0);
398        let constraints = Constraints::new(0.0, 100.0, 0.0, 10.0);
399        let size = meter.measure(constraints);
400        assert!(size.width >= 10.0);
401        assert_eq!(size.height, 1.0);
402    }
403
404    #[test]
405    fn test_meter_layout() {
406        let mut meter = Meter::percentage(50.0);
407        let bounds = Rect::new(0.0, 0.0, 80.0, 1.0);
408        let result = meter.layout(bounds);
409        assert_eq!(result.size.width, 80.0);
410        assert_eq!(result.size.height, 1.0);
411    }
412
413    #[test]
414    fn test_meter_paint() {
415        let mut meter = Meter::percentage(50.0).with_label("Test");
416        meter.bounds = Rect::new(0.0, 0.0, 40.0, 1.0);
417        let mut canvas = MockCanvas::new();
418        meter.paint(&mut canvas);
419        assert!(!canvas.texts.is_empty());
420    }
421
422    #[test]
423    fn test_meter_paint_without_label() {
424        let mut meter = Meter::percentage(50.0);
425        meter.bounds = Rect::new(0.0, 0.0, 40.0, 1.0);
426        let mut canvas = MockCanvas::new();
427        meter.paint(&mut canvas);
428        assert!(!canvas.texts.is_empty());
429    }
430
431    #[test]
432    fn test_meter_paint_without_percentage() {
433        let mut meter = Meter::percentage(50.0).with_percentage_text(false);
434        meter.bounds = Rect::new(0.0, 0.0, 40.0, 1.0);
435        let mut canvas = MockCanvas::new();
436        meter.paint(&mut canvas);
437        assert!(!canvas.texts.is_empty());
438    }
439
440    #[test]
441    fn test_meter_paint_zero_width() {
442        let mut meter = Meter::percentage(50.0);
443        meter.bounds = Rect::new(0.0, 0.0, 0.0, 1.0);
444        let mut canvas = MockCanvas::new();
445        meter.paint(&mut canvas);
446        assert!(canvas.texts.is_empty());
447    }
448
449    #[test]
450    fn test_meter_paint_tiny_bar() {
451        let mut meter = Meter::percentage(50.0).with_label("Very Long Label");
452        meter.bounds = Rect::new(0.0, 0.0, 10.0, 1.0);
453        let mut canvas = MockCanvas::new();
454        meter.paint(&mut canvas);
455    }
456
457    #[test]
458    fn test_meter_event() {
459        let mut meter = Meter::percentage(50.0);
460        let event = Event::KeyDown {
461            key: presentar_core::Key::Enter,
462        };
463        assert!(meter.event(&event).is_none());
464    }
465
466    #[test]
467    fn test_meter_children() {
468        let meter = Meter::percentage(50.0);
469        assert!(meter.children().is_empty());
470    }
471
472    #[test]
473    fn test_meter_children_mut() {
474        let mut meter = Meter::percentage(50.0);
475        assert!(meter.children_mut().is_empty());
476    }
477
478    #[test]
479    fn test_meter_type_id() {
480        let meter = Meter::percentage(50.0);
481        assert_eq!(Widget::type_id(&meter), TypeId::of::<Meter>());
482    }
483
484    #[test]
485    fn test_meter_brick_name() {
486        let meter = Meter::percentage(50.0);
487        assert_eq!(meter.brick_name(), "meter");
488    }
489
490    #[test]
491    fn test_meter_budget() {
492        let meter = Meter::percentage(50.0);
493        let budget = meter.budget();
494        assert!(budget.measure_ms > 0);
495    }
496
497    #[test]
498    fn test_meter_to_html() {
499        let meter = Meter::percentage(50.0);
500        assert!(meter.to_html().is_empty());
501    }
502
503    #[test]
504    fn test_meter_to_css() {
505        let meter = Meter::percentage(50.0);
506        assert!(meter.to_css().is_empty());
507    }
508
509    #[test]
510    fn test_meter_full() {
511        let mut meter = Meter::percentage(100.0);
512        meter.bounds = Rect::new(0.0, 0.0, 50.0, 1.0);
513        let mut canvas = MockCanvas::new();
514        meter.paint(&mut canvas);
515        assert!(!canvas.texts.is_empty());
516    }
517
518    #[test]
519    fn test_meter_empty() {
520        let mut meter = Meter::percentage(0.0);
521        meter.bounds = Rect::new(0.0, 0.0, 50.0, 1.0);
522        let mut canvas = MockCanvas::new();
523        meter.paint(&mut canvas);
524        assert!(!canvas.texts.is_empty());
525    }
526
527    #[test]
528    fn test_meter_verify_value_over_max() {
529        let mut meter = Meter::new(50.0, 100.0);
530        meter.value = 150.0;
531        assert!(!meter.verify().is_valid());
532    }
533
534    #[test]
535    fn test_meter_layout_with_small_height() {
536        let mut meter = Meter::percentage(50.0);
537        let bounds = Rect::new(0.0, 0.0, 80.0, 0.5);
538        let result = meter.layout(bounds);
539        assert_eq!(result.size.height, 1.0);
540    }
541
542    #[test]
543    fn test_meter_paint_with_gradient() {
544        let mut meter = Meter::percentage(50.0).with_gradient(Color::GREEN, Color::RED);
545        meter.bounds = Rect::new(0.0, 0.0, 50.0, 1.0);
546        let mut canvas = MockCanvas::new();
547        meter.paint(&mut canvas);
548        assert!(!canvas.texts.is_empty());
549    }
550
551    #[test]
552    fn test_meter_clone() {
553        let meter = Meter::percentage(75.0)
554            .with_label("Clone Test")
555            .with_color(Color::BLUE);
556        let cloned = meter.clone();
557        assert_eq!(cloned.value, 75.0);
558        assert_eq!(cloned.label, "Clone Test");
559        assert_eq!(cloned.fill_color, Color::BLUE);
560    }
561
562    #[test]
563    fn test_meter_debug() {
564        let meter = Meter::percentage(50.0);
565        let debug = format!("{:?}", meter);
566        assert!(debug.contains("Meter"));
567        assert!(debug.contains("value"));
568    }
569
570    #[test]
571    fn test_meter_measure_small_constraints() {
572        let meter = Meter::percentage(50.0);
573        let constraints = Constraints::new(0.0, 5.0, 0.0, 10.0);
574        let size = meter.measure(constraints);
575        // max(5.0, 10.0) = 10.0, then constrained to 5.0
576        assert_eq!(size.width, 5.0);
577    }
578}