Skip to main content

revue/widget/display/
gauge.rs

1//! Gauge widget for displaying metrics and progress
2//!
3//! Advanced progress indicators with various styles including
4//! speedometer, arc, battery, and more.
5
6use crate::render::{Cell, Modifier};
7use crate::style::Color;
8use crate::widget::traits::{RenderContext, View, WidgetProps};
9use crate::{impl_props_builders, impl_styled_view};
10
11/// Gauge style
12#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
13pub enum GaugeStyle {
14    /// Horizontal bar with segments
15    #[default]
16    Bar,
17    /// Battery indicator
18    Battery,
19    /// Thermometer style
20    Thermometer,
21    /// Circular/arc (text-based)
22    Arc,
23    /// Percentage circle
24    Circle,
25    /// Vertical bar
26    Vertical,
27    /// Segmented blocks
28    Segments,
29    /// Dot indicator
30    Dots,
31}
32
33/// Gauge label position
34#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
35pub enum LabelPosition {
36    /// No label
37    None,
38    /// Inside the gauge
39    #[default]
40    Inside,
41    /// Left of gauge
42    Left,
43    /// Right of gauge
44    Right,
45    /// Above gauge
46    Above,
47    /// Below gauge
48    Below,
49}
50
51/// Gauge widget
52pub struct Gauge {
53    /// Current value (0.0 - 1.0)
54    value: f64,
55    /// Minimum value for display
56    min: f64,
57    /// Maximum value for display
58    max: f64,
59    /// Visual style
60    style: GaugeStyle,
61    /// Width (for horizontal styles)
62    width: u16,
63    /// Height (for vertical styles)
64    height: u16,
65    /// Label format
66    label: Option<String>,
67    /// Label position
68    label_position: LabelPosition,
69    /// Show percentage
70    show_percent: bool,
71    /// Filled color
72    fill_color: Color,
73    /// Empty/track color
74    empty_color: Color,
75    /// Border color
76    border_color: Option<Color>,
77    /// Warning threshold (0.0-1.0)
78    warning_threshold: Option<f64>,
79    /// Critical threshold (0.0-1.0)
80    critical_threshold: Option<f64>,
81    /// Warning color
82    warning_color: Color,
83    /// Critical color
84    critical_color: Color,
85    /// Segments count (for segmented style)
86    segments: u16,
87    /// Title
88    title: Option<String>,
89    /// Widget properties
90    props: WidgetProps,
91}
92
93impl Gauge {
94    /// Create a new gauge
95    pub fn new() -> Self {
96        Self {
97            value: 0.0,
98            min: 0.0,
99            max: 100.0,
100            style: GaugeStyle::Bar,
101            width: 20,
102            height: 5,
103            label: None,
104            label_position: LabelPosition::Inside,
105            show_percent: true,
106            fill_color: Color::GREEN,
107            empty_color: Color::rgb(60, 60, 60),
108            border_color: None,
109            warning_threshold: None,
110            critical_threshold: None,
111            warning_color: Color::YELLOW,
112            critical_color: Color::RED,
113            segments: 10,
114            title: None,
115            props: WidgetProps::new(),
116        }
117    }
118
119    /// Set value (0.0 - 1.0)
120    pub fn value(mut self, value: f64) -> Self {
121        self.value = value.clamp(0.0, 1.0);
122        self
123    }
124
125    /// Set value with custom range
126    ///
127    /// If min >= max, they will be swapped to ensure valid range.
128    /// If min == max after potential swap, value defaults to 0.0.
129    pub fn value_range(mut self, value: f64, min: f64, max: f64) -> Self {
130        // Ensure min < max
131        let (min, max) = if min < max { (min, max) } else { (max, min) };
132
133        self.min = min;
134        self.max = max;
135
136        // Avoid division by zero
137        let range = max - min;
138        if range.abs() < f64::EPSILON {
139            self.value = 0.0;
140        } else {
141            self.value = ((value - min) / range).clamp(0.0, 1.0);
142        }
143        self
144    }
145
146    /// Set percentage (0-100)
147    pub fn percent(mut self, percent: f64) -> Self {
148        self.value = (percent / 100.0).clamp(0.0, 1.0);
149        self
150    }
151
152    /// Set style
153    pub fn style(mut self, style: GaugeStyle) -> Self {
154        self.style = style;
155        self
156    }
157
158    /// Set width
159    pub fn width(mut self, width: u16) -> Self {
160        self.width = width.max(4);
161        self
162    }
163
164    /// Set height
165    pub fn height(mut self, height: u16) -> Self {
166        self.height = height.max(1);
167        self
168    }
169
170    /// Set custom label
171    pub fn label(mut self, label: impl Into<String>) -> Self {
172        self.label = Some(label.into());
173        self
174    }
175
176    /// Set label position
177    pub fn label_position(mut self, position: LabelPosition) -> Self {
178        self.label_position = position;
179        self
180    }
181
182    /// Show/hide percentage
183    pub fn show_percent(mut self, show: bool) -> Self {
184        self.show_percent = show;
185        self
186    }
187
188    /// Set fill color
189    pub fn fill_color(mut self, color: Color) -> Self {
190        self.fill_color = color;
191        self
192    }
193
194    /// Set empty color
195    pub fn empty_color(mut self, color: Color) -> Self {
196        self.empty_color = color;
197        self
198    }
199
200    /// Set border color
201    pub fn border(mut self, color: Color) -> Self {
202        self.border_color = Some(color);
203        self
204    }
205
206    /// Set thresholds for color changes
207    ///
208    /// Warning threshold should be less than critical threshold.
209    /// If warning >= critical, they will be swapped.
210    pub fn thresholds(mut self, warning: f64, critical: f64) -> Self {
211        let warning = warning.clamp(0.0, 1.0);
212        let critical = critical.clamp(0.0, 1.0);
213        // Ensure warning < critical
214        let (warning, critical) = if warning < critical {
215            (warning, critical)
216        } else {
217            (critical, warning)
218        };
219        self.warning_threshold = Some(warning);
220        self.critical_threshold = Some(critical);
221        self
222    }
223
224    /// Set warning color
225    pub fn warning_color(mut self, color: Color) -> Self {
226        self.warning_color = color;
227        self
228    }
229
230    /// Set critical color
231    pub fn critical_color(mut self, color: Color) -> Self {
232        self.critical_color = color;
233        self
234    }
235
236    /// Set segments count
237    pub fn segments(mut self, count: u16) -> Self {
238        self.segments = count.max(2);
239        self
240    }
241
242    /// Set title
243    pub fn title(mut self, title: impl Into<String>) -> Self {
244        self.title = Some(title.into());
245        self
246    }
247
248    /// Get current display color based on thresholds
249    fn current_color(&self) -> Color {
250        if let Some(critical) = self.critical_threshold {
251            if self.value >= critical {
252                return self.critical_color;
253            }
254        }
255        if let Some(warning) = self.warning_threshold {
256            if self.value >= warning {
257                return self.warning_color;
258            }
259        }
260        self.fill_color
261    }
262
263    /// Get label text
264    fn get_label(&self) -> String {
265        if let Some(ref label) = self.label {
266            label.clone()
267        } else if self.show_percent {
268            format!("{:.0}%", self.value * 100.0)
269        } else {
270            let display_value = self.min + self.value * (self.max - self.min);
271            format!("{:.0}", display_value)
272        }
273    }
274
275    /// Update value
276    pub fn set_value(&mut self, value: f64) {
277        self.value = value.clamp(0.0, 1.0);
278    }
279
280    /// Get current value
281    pub fn get_value(&self) -> f64 {
282        self.value
283    }
284
285    /// Render bar style
286    fn render_bar(&self, ctx: &mut RenderContext) {
287        let area = ctx.area;
288        let width = self.width.min(area.width);
289        let filled = (self.value * width as f64).round() as u16;
290        let color = self.current_color();
291
292        // Draw bar
293        for x in 0..width {
294            let ch = if x < filled { '█' } else { '░' };
295            let fg = if x < filled { color } else { self.empty_color };
296
297            let mut cell = Cell::new(ch);
298            cell.fg = Some(fg);
299            ctx.buffer.set(area.x + x, area.y, cell);
300        }
301
302        // Draw label inside
303        if matches!(self.label_position, LabelPosition::Inside) {
304            let label = self.get_label();
305            let label_x = area.x + (width.saturating_sub(label.len() as u16)) / 2;
306            for (i, ch) in label.chars().enumerate() {
307                let x = label_x + i as u16;
308                if x < area.x + width {
309                    let mut cell = Cell::new(ch);
310                    cell.fg = Some(Color::WHITE);
311                    cell.modifier |= Modifier::BOLD;
312                    ctx.buffer.set(x, area.y, cell);
313                }
314            }
315        }
316    }
317
318    /// Render battery style
319    fn render_battery(&self, ctx: &mut RenderContext) {
320        let area = ctx.area;
321        let width = self.width.min(area.width).max(6);
322        let inner_width = width - 3; // Account for borders and cap
323        let filled = (self.value * inner_width as f64).round() as u16;
324        let color = self.current_color();
325
326        // Battery body
327        let mut left = Cell::new('[');
328        left.fg = Some(Color::WHITE);
329        ctx.buffer.set(area.x, area.y, left);
330
331        for x in 0..inner_width {
332            let ch = if x < filled { '█' } else { ' ' };
333            let fg = if x < filled { color } else { self.empty_color };
334            let mut cell = Cell::new(ch);
335            cell.fg = Some(fg);
336            ctx.buffer.set(area.x + 1 + x, area.y, cell);
337        }
338
339        let mut right = Cell::new(']');
340        right.fg = Some(Color::WHITE);
341        ctx.buffer.set(area.x + 1 + inner_width, area.y, right);
342
343        // Battery cap
344        let mut cap = Cell::new('▌');
345        cap.fg = Some(Color::WHITE);
346        ctx.buffer.set(area.x + 2 + inner_width, area.y, cap);
347    }
348
349    /// Render thermometer style
350    fn render_thermometer(&self, ctx: &mut RenderContext) {
351        let area = ctx.area;
352        let height = self.height.min(area.height).max(3);
353        let filled = (self.value * (height - 1) as f64).round() as u16;
354        let color = self.current_color();
355
356        // Bulb at bottom
357        let mut bulb = Cell::new('●');
358        bulb.fg = Some(color);
359        ctx.buffer.set(area.x, area.y + height - 1, bulb);
360
361        // Tube
362        for y in 0..height - 1 {
363            let from_bottom = height - 2 - y;
364            let ch = if from_bottom < filled { '█' } else { '│' };
365            let fg = if from_bottom < filled {
366                color
367            } else {
368                self.empty_color
369            };
370            let mut cell = Cell::new(ch);
371            cell.fg = Some(fg);
372            ctx.buffer.set(area.x, area.y + y, cell);
373        }
374    }
375
376    /// Render arc style
377    fn render_arc(&self, ctx: &mut RenderContext) {
378        let area = ctx.area;
379        let color = self.current_color();
380
381        // Simple text-based arc: ╭───────╮
382        //                        │ 75%   │
383        //                        ╰───────╯
384        let width = self.width.min(area.width).max(8);
385
386        // Top arc
387        let mut tl = Cell::new('╭');
388        tl.fg = Some(color);
389        ctx.buffer.set(area.x, area.y, tl);
390
391        for x in 1..width - 1 {
392            let progress = (x - 1) as f64 / (width - 3) as f64;
393            let ch = if progress <= self.value { '━' } else { '─' };
394            let fg = if progress <= self.value {
395                color
396            } else {
397                self.empty_color
398            };
399            let mut cell = Cell::new(ch);
400            cell.fg = Some(fg);
401            ctx.buffer.set(area.x + x, area.y, cell);
402        }
403
404        let mut tr = Cell::new('╮');
405        tr.fg = Some(color);
406        ctx.buffer.set(area.x + width - 1, area.y, tr);
407
408        // Middle with label
409        if area.height > 1 {
410            let label = self.get_label();
411            let label_x = area.x + (width.saturating_sub(label.len() as u16)) / 2;
412
413            let mut left = Cell::new('│');
414            left.fg = Some(color);
415            ctx.buffer.set(area.x, area.y + 1, left);
416
417            for (i, ch) in label.chars().enumerate() {
418                let mut cell = Cell::new(ch);
419                cell.fg = Some(Color::WHITE);
420                cell.modifier |= Modifier::BOLD;
421                ctx.buffer.set(label_x + i as u16, area.y + 1, cell);
422            }
423
424            let mut right = Cell::new('│');
425            right.fg = Some(color);
426            ctx.buffer.set(area.x + width - 1, area.y + 1, right);
427        }
428
429        // Bottom arc
430        if area.height > 2 {
431            let mut bl = Cell::new('╰');
432            bl.fg = Some(color);
433            ctx.buffer.set(area.x, area.y + 2, bl);
434
435            for x in 1..width - 1 {
436                let mut cell = Cell::new('─');
437                cell.fg = Some(self.empty_color);
438                ctx.buffer.set(area.x + x, area.y + 2, cell);
439            }
440
441            let mut br = Cell::new('╯');
442            br.fg = Some(color);
443            ctx.buffer.set(area.x + width - 1, area.y + 2, br);
444        }
445    }
446
447    /// Render circle style (text-based)
448    fn render_circle(&self, ctx: &mut RenderContext) {
449        let area = ctx.area;
450        let color = self.current_color();
451
452        // Braille-based circle approximation
453        // ⠀⢀⣴⣾⣿⣷⣦⡀⠀
454        // ⠀⣿⣿⣿⣿⣿⣿⣿⠀
455        // ⠀⠻⣿⣿⣿⣿⣿⠟⠀
456
457        let label = self.get_label();
458
459        // Simple representation: (●●●○○) 60%
460        let segments = 5u16;
461        let filled = (self.value * segments as f64).round() as u16;
462
463        let mut open = Cell::new('(');
464        open.fg = Some(Color::WHITE);
465        ctx.buffer.set(area.x, area.y, open);
466
467        for i in 0..segments {
468            let ch = if i < filled { '●' } else { '○' };
469            let fg = if i < filled { color } else { self.empty_color };
470            let mut cell = Cell::new(ch);
471            cell.fg = Some(fg);
472            ctx.buffer.set(area.x + 1 + i, area.y, cell);
473        }
474
475        let mut close = Cell::new(')');
476        close.fg = Some(Color::WHITE);
477        ctx.buffer.set(area.x + 1 + segments, area.y, close);
478
479        // Label
480        let label_x = area.x + 3 + segments;
481        for (i, ch) in label.chars().enumerate() {
482            let mut cell = Cell::new(ch);
483            cell.fg = Some(Color::WHITE);
484            ctx.buffer.set(label_x + i as u16, area.y, cell);
485        }
486    }
487
488    /// Render vertical style
489    fn render_vertical(&self, ctx: &mut RenderContext) {
490        let area = ctx.area;
491        let height = self.height.min(area.height);
492        let filled = (self.value * height as f64).round() as u16;
493        let color = self.current_color();
494
495        for y in 0..height {
496            let from_bottom = height - 1 - y;
497            let ch = if from_bottom < filled { '█' } else { '░' };
498            let fg = if from_bottom < filled {
499                color
500            } else {
501                self.empty_color
502            };
503            let mut cell = Cell::new(ch);
504            cell.fg = Some(fg);
505            ctx.buffer.set(area.x, area.y + y, cell);
506        }
507    }
508
509    /// Render segments style
510    fn render_segments(&self, ctx: &mut RenderContext) {
511        let area = ctx.area;
512        let segments = self.segments.min(area.width / 2);
513        let filled = (self.value * segments as f64).round() as u16;
514        let color = self.current_color();
515
516        for i in 0..segments {
517            let ch = if i < filled { '▰' } else { '▱' };
518            let fg = if i < filled { color } else { self.empty_color };
519            let mut cell = Cell::new(ch);
520            cell.fg = Some(fg);
521            ctx.buffer.set(area.x + i * 2, area.y, cell);
522        }
523    }
524
525    /// Render dots style
526    fn render_dots(&self, ctx: &mut RenderContext) {
527        let area = ctx.area;
528        let dots = self.segments.min(area.width);
529        let filled = (self.value * dots as f64).round() as u16;
530        let color = self.current_color();
531
532        for i in 0..dots {
533            let ch = if i < filled { '●' } else { '○' };
534            let fg = if i < filled { color } else { self.empty_color };
535            let mut cell = Cell::new(ch);
536            cell.fg = Some(fg);
537            ctx.buffer.set(area.x + i, area.y, cell);
538        }
539    }
540}
541
542impl Default for Gauge {
543    fn default() -> Self {
544        Self::new()
545    }
546}
547
548impl View for Gauge {
549    crate::impl_view_meta!("Gauge");
550
551    fn render(&self, ctx: &mut RenderContext) {
552        let area = ctx.area;
553        if area.width == 0 || area.height == 0 {
554            return;
555        }
556
557        // Draw title if present
558        let mut y_offset = 0u16;
559        if let Some(ref title) = self.title {
560            for (i, ch) in title.chars().enumerate() {
561                if i as u16 >= area.width {
562                    break;
563                }
564                let mut cell = Cell::new(ch);
565                cell.fg = Some(Color::WHITE);
566                cell.modifier |= Modifier::BOLD;
567                ctx.buffer.set(area.x + i as u16, area.y, cell);
568            }
569            y_offset = 1;
570        }
571
572        let adjusted_area = crate::layout::Rect::new(
573            area.x,
574            area.y + y_offset,
575            area.width,
576            area.height.saturating_sub(y_offset),
577        );
578
579        let mut adjusted_ctx = RenderContext::new(ctx.buffer, adjusted_area);
580
581        match self.style {
582            GaugeStyle::Bar => self.render_bar(&mut adjusted_ctx),
583            GaugeStyle::Battery => self.render_battery(&mut adjusted_ctx),
584            GaugeStyle::Thermometer => self.render_thermometer(&mut adjusted_ctx),
585            GaugeStyle::Arc => self.render_arc(&mut adjusted_ctx),
586            GaugeStyle::Circle => self.render_circle(&mut adjusted_ctx),
587            GaugeStyle::Vertical => self.render_vertical(&mut adjusted_ctx),
588            GaugeStyle::Segments => self.render_segments(&mut adjusted_ctx),
589            GaugeStyle::Dots => self.render_dots(&mut adjusted_ctx),
590        }
591    }
592}
593
594impl_styled_view!(Gauge);
595impl_props_builders!(Gauge);
596
597/// Helper to create a gauge
598pub fn gauge() -> Gauge {
599    Gauge::new()
600}
601
602/// Helper to create a percentage gauge
603pub fn percentage(value: f64) -> Gauge {
604    Gauge::new().percent(value)
605}
606
607/// Helper to create a battery gauge
608pub fn battery(level: f64) -> Gauge {
609    Gauge::new()
610        .percent(level)
611        .style(GaugeStyle::Battery)
612        .thresholds(0.5, 0.2)
613}
614
615// Most tests moved to tests/widget_tests.rs
616// Tests below access private fields and must stay inline
617
618#[cfg(test)]
619mod tests {
620    use super::*;
621
622    #[test]
623    fn test_gauge_new() {
624        let g = Gauge::new();
625        assert_eq!(g.value, 0.0);
626    }
627
628    #[test]
629    fn test_gauge_value() {
630        let g = Gauge::new().value(0.5);
631        assert_eq!(g.value, 0.5);
632    }
633
634    #[test]
635    fn test_gauge_percent() {
636        let g = Gauge::new().percent(75.0);
637        assert_eq!(g.value, 0.75);
638    }
639
640    #[test]
641    fn test_gauge_value_clamp() {
642        let g1 = Gauge::new().value(1.5);
643        assert_eq!(g1.value, 1.0);
644
645        let g2 = Gauge::new().value(-0.5);
646        assert_eq!(g2.value, 0.0);
647    }
648
649    #[test]
650    fn test_gauge_value_range() {
651        let g = Gauge::new().value_range(50.0, 0.0, 100.0);
652        assert_eq!(g.value, 0.5);
653    }
654
655    #[test]
656    fn test_gauge_style() {
657        let g = Gauge::new().style(GaugeStyle::Battery);
658        assert!(matches!(g.style, GaugeStyle::Battery));
659    }
660
661    #[test]
662    fn test_gauge_thresholds() {
663        let g = Gauge::new().thresholds(0.7, 0.9).value(0.95);
664
665        assert_eq!(g.current_color(), g.critical_color);
666    }
667
668    #[test]
669    fn test_gauge_warning_color() {
670        let g = Gauge::new().thresholds(0.7, 0.9).value(0.75);
671
672        assert_eq!(g.current_color(), g.warning_color);
673    }
674
675    #[test]
676    fn test_gauge_normal_color() {
677        let g = Gauge::new().thresholds(0.7, 0.9).value(0.5);
678
679        assert_eq!(g.current_color(), g.fill_color);
680    }
681
682    #[test]
683    fn test_gauge_get_label() {
684        let g = Gauge::new().percent(50.0);
685        assert_eq!(g.get_label(), "50%");
686    }
687
688    #[test]
689    fn test_gauge_custom_label() {
690        let g = Gauge::new().label("Custom");
691        assert_eq!(g.get_label(), "Custom");
692    }
693
694    #[test]
695    fn test_gauge_helper_value() {
696        let g = gauge().percent(50.0);
697        assert_eq!(g.value, 0.5);
698    }
699
700    #[test]
701    fn test_percentage_helper_value() {
702        let g = percentage(75.0);
703        assert_eq!(g.value, 0.75);
704    }
705
706    #[test]
707    fn test_battery_helper_fields() {
708        let g = battery(80.0);
709        assert!(matches!(g.style, GaugeStyle::Battery));
710        assert_eq!(g.value, 0.8);
711    }
712
713    #[test]
714    fn test_value_range_validation() {
715        // Normal case
716        let g = Gauge::new().value_range(50.0, 0.0, 100.0);
717        assert_eq!(g.value, 0.5);
718
719        // Swapped min/max
720        let g = Gauge::new().value_range(50.0, 100.0, 0.0);
721        assert_eq!(g.min, 0.0);
722        assert_eq!(g.max, 100.0);
723        assert_eq!(g.value, 0.5);
724
725        // Equal min/max (division by zero case)
726        let g = Gauge::new().value_range(50.0, 50.0, 50.0);
727        assert_eq!(g.value, 0.0);
728    }
729
730    #[test]
731    fn test_thresholds_validation() {
732        // Normal case
733        let g = Gauge::new().thresholds(0.5, 0.8);
734        assert_eq!(g.warning_threshold, Some(0.5));
735        assert_eq!(g.critical_threshold, Some(0.8));
736
737        // Swapped thresholds
738        let g = Gauge::new().thresholds(0.8, 0.5);
739        assert_eq!(g.warning_threshold, Some(0.5));
740        assert_eq!(g.critical_threshold, Some(0.8));
741    }
742}