Skip to main content

presentar_terminal/widgets/
sensors_panel.rs

1//! `SensorsPanel` widget for hardware sensor monitoring.
2//!
3//! Displays temperature sensors, fan speeds, and voltages from hwmon.
4//! Supports status coloring based on thresholds.
5
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/// Sensor reading status.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
15pub enum SensorStatus {
16    #[default]
17    Normal,
18    Warning,
19    Critical,
20}
21
22impl SensorStatus {
23    /// Get color for status.
24    pub fn color(&self) -> Color {
25        match self {
26            Self::Normal => Color::new(0.4, 0.9, 0.4, 1.0), // Green
27            Self::Warning => Color::new(1.0, 0.8, 0.2, 1.0), // Yellow
28            Self::Critical => Color::new(1.0, 0.3, 0.3, 1.0), // Red
29        }
30    }
31
32    /// Get indicator character.
33    pub fn indicator(&self) -> char {
34        match self {
35            Self::Normal => '●',
36            Self::Warning => '◐',
37            Self::Critical => '○',
38        }
39    }
40}
41
42/// A sensor reading.
43#[derive(Debug, Clone)]
44pub struct SensorReading {
45    /// Sensor label (e.g., "CPU", "GPU", "`NVMe`").
46    pub label: String,
47    /// Current value.
48    pub value: f64,
49    /// Unit string.
50    pub unit: String,
51    /// Critical threshold.
52    pub critical: Option<f64>,
53    /// Warning threshold.
54    pub warning: Option<f64>,
55    /// Current status.
56    pub status: SensorStatus,
57}
58
59impl SensorReading {
60    /// Create a temperature reading.
61    #[must_use]
62    pub fn temperature(label: impl Into<String>, celsius: f64) -> Self {
63        let status = if celsius >= 90.0 {
64            SensorStatus::Critical
65        } else if celsius >= 75.0 {
66            SensorStatus::Warning
67        } else {
68            SensorStatus::Normal
69        };
70
71        Self {
72            label: label.into(),
73            value: celsius,
74            unit: "°C".to_string(),
75            critical: Some(95.0),
76            warning: Some(80.0),
77            status,
78        }
79    }
80
81    /// Create a fan reading.
82    #[must_use]
83    pub fn fan(label: impl Into<String>, rpm: f64) -> Self {
84        Self {
85            label: label.into(),
86            value: rpm,
87            unit: "RPM".to_string(),
88            critical: None,
89            warning: None,
90            status: SensorStatus::Normal,
91        }
92    }
93
94    /// Create a voltage reading.
95    #[must_use]
96    pub fn voltage(label: impl Into<String>, volts: f64) -> Self {
97        Self {
98            label: label.into(),
99            value: volts,
100            unit: "V".to_string(),
101            critical: None,
102            warning: None,
103            status: SensorStatus::Normal,
104        }
105    }
106
107    /// Set status explicitly.
108    #[must_use]
109    pub fn with_status(mut self, status: SensorStatus) -> Self {
110        self.status = status;
111        self
112    }
113
114    /// Set thresholds.
115    #[must_use]
116    pub fn with_thresholds(mut self, warning: Option<f64>, critical: Option<f64>) -> Self {
117        self.warning = warning;
118        self.critical = critical;
119        // Recalculate status
120        if let Some(crit) = critical {
121            if self.value >= crit {
122                self.status = SensorStatus::Critical;
123                return self;
124            }
125        }
126        if let Some(warn) = warning {
127            if self.value >= warn {
128                self.status = SensorStatus::Warning;
129                return self;
130            }
131        }
132        self.status = SensorStatus::Normal;
133        self
134    }
135
136    /// Format value for display.
137    pub fn value_display(&self) -> String {
138        if self.unit == "RPM" {
139            format!("{:.0} {}", self.value, self.unit)
140        } else {
141            format!("{:.1}{}", self.value, self.unit)
142        }
143    }
144}
145
146/// Sensors panel displaying temperature, fan, and voltage readings.
147#[derive(Debug, Clone)]
148pub struct SensorsPanel {
149    /// Temperature readings.
150    temperatures: Vec<SensorReading>,
151    /// Fan readings.
152    fans: Vec<SensorReading>,
153    /// Voltage readings.
154    voltages: Vec<SensorReading>,
155    /// Show mini bar for temperatures.
156    show_bars: bool,
157    /// Max items per category.
158    max_per_category: usize,
159    /// Cached bounds.
160    bounds: Rect,
161}
162
163impl Default for SensorsPanel {
164    fn default() -> Self {
165        Self::new()
166    }
167}
168
169impl SensorsPanel {
170    /// Create a new sensors panel.
171    #[must_use]
172    pub fn new() -> Self {
173        Self {
174            temperatures: Vec::new(),
175            fans: Vec::new(),
176            voltages: Vec::new(),
177            show_bars: true,
178            max_per_category: 4,
179            bounds: Rect::default(),
180        }
181    }
182
183    /// Add a temperature reading.
184    pub fn add_temperature(&mut self, reading: SensorReading) {
185        self.temperatures.push(reading);
186    }
187
188    /// Add a fan reading.
189    pub fn add_fan(&mut self, reading: SensorReading) {
190        self.fans.push(reading);
191    }
192
193    /// Add a voltage reading.
194    pub fn add_voltage(&mut self, reading: SensorReading) {
195        self.voltages.push(reading);
196    }
197
198    /// Set all temperature readings.
199    #[must_use]
200    pub fn with_temperatures(mut self, readings: Vec<SensorReading>) -> Self {
201        self.temperatures = readings;
202        self
203    }
204
205    /// Set all fan readings.
206    #[must_use]
207    pub fn with_fans(mut self, readings: Vec<SensorReading>) -> Self {
208        self.fans = readings;
209        self
210    }
211
212    /// Toggle mini bars.
213    #[must_use]
214    pub fn show_bars(mut self, show: bool) -> Self {
215        self.show_bars = show;
216        self
217    }
218
219    /// Set max items per category.
220    #[must_use]
221    pub fn max_per_category(mut self, max: usize) -> Self {
222        self.max_per_category = max;
223        self
224    }
225
226    /// Get max temperature.
227    pub fn max_temperature(&self) -> Option<f64> {
228        self.temperatures.iter().map(|r| r.value).reduce(f64::max)
229    }
230
231    /// Check if any sensor is critical.
232    pub fn has_critical(&self) -> bool {
233        self.temperatures
234            .iter()
235            .any(|r| r.status == SensorStatus::Critical)
236    }
237
238    /// Draw a temperature with mini bar.
239    fn draw_temp_bar(
240        &self,
241        canvas: &mut dyn Canvas,
242        reading: &SensorReading,
243        x: f32,
244        y: f32,
245        width: f32,
246    ) {
247        // Label (left-aligned)
248        let label = if reading.label.len() > 8 {
249            format!("{}:", &reading.label[..8])
250        } else {
251            format!("{}:", reading.label)
252        };
253
254        canvas.draw_text(
255            &label,
256            Point::new(x, y),
257            &TextStyle {
258                color: Color::WHITE,
259                ..Default::default()
260            },
261        );
262
263        // Mini bar (percentage of 100°C max)
264        if self.show_bars {
265            let bar_x = x + 9.0;
266            let bar_width = (width - 18.0) as usize;
267            let pct = (reading.value / 100.0).min(1.0);
268            let filled = (pct * bar_width as f64) as usize;
269
270            let mut bar = String::new();
271            for i in 0..bar_width {
272                if i < filled {
273                    bar.push('█');
274                } else {
275                    bar.push('░');
276                }
277            }
278
279            canvas.draw_text(
280                &bar,
281                Point::new(bar_x, y),
282                &TextStyle {
283                    color: reading.status.color(),
284                    ..Default::default()
285                },
286            );
287        }
288
289        // Value (right-aligned)
290        canvas.draw_text(
291            &reading.value_display(),
292            Point::new(x + width - 7.0, y),
293            &TextStyle {
294                color: reading.status.color(),
295                ..Default::default()
296            },
297        );
298    }
299
300    /// Draw a fan reading.
301    fn draw_fan(&self, canvas: &mut dyn Canvas, reading: &SensorReading, x: f32, y: f32) {
302        let line = format!("{}: {}", reading.label, reading.value_display());
303        canvas.draw_text(
304            &line,
305            Point::new(x, y),
306            &TextStyle {
307                color: Color::new(0.6, 0.8, 1.0, 1.0),
308                ..Default::default()
309            },
310        );
311    }
312}
313
314impl Brick for SensorsPanel {
315    fn brick_name(&self) -> &'static str {
316        "sensors_panel"
317    }
318
319    fn assertions(&self) -> &[BrickAssertion] {
320        static ASSERTIONS: &[BrickAssertion] = &[BrickAssertion::max_latency_ms(8)];
321        ASSERTIONS
322    }
323
324    fn budget(&self) -> BrickBudget {
325        BrickBudget::uniform(8)
326    }
327
328    fn verify(&self) -> BrickVerification {
329        BrickVerification {
330            passed: vec![BrickAssertion::max_latency_ms(8)],
331            failed: vec![],
332            verification_time: Duration::from_micros(25),
333        }
334    }
335
336    fn to_html(&self) -> String {
337        String::new()
338    }
339
340    fn to_css(&self) -> String {
341        String::new()
342    }
343}
344
345impl Widget for SensorsPanel {
346    fn type_id(&self) -> TypeId {
347        TypeId::of::<Self>()
348    }
349
350    fn measure(&self, constraints: Constraints) -> Size {
351        let temp_lines = self.temperatures.len().min(self.max_per_category);
352        let fan_lines = self.fans.len().min(self.max_per_category);
353        let height = (temp_lines + fan_lines) as f32;
354        Size::new(constraints.max_width, height.min(constraints.max_height))
355    }
356
357    fn layout(&mut self, bounds: Rect) -> LayoutResult {
358        self.bounds = bounds;
359        LayoutResult {
360            size: Size::new(bounds.width, bounds.height),
361        }
362    }
363
364    fn paint(&self, canvas: &mut dyn Canvas) {
365        if self.bounds.width < 10.0 || self.bounds.height < 1.0 {
366            return;
367        }
368
369        let mut y = self.bounds.y;
370        let x = self.bounds.x;
371
372        // Draw temperature readings
373        for reading in self.temperatures.iter().take(self.max_per_category) {
374            if y >= self.bounds.y + self.bounds.height {
375                break;
376            }
377            self.draw_temp_bar(canvas, reading, x, y, self.bounds.width);
378            y += 1.0;
379        }
380
381        // Draw fan readings
382        for reading in self.fans.iter().take(self.max_per_category) {
383            if y >= self.bounds.y + self.bounds.height {
384                break;
385            }
386            self.draw_fan(canvas, reading, x, y);
387            y += 1.0;
388        }
389    }
390
391    fn event(&mut self, _event: &Event) -> Option<Box<dyn Any + Send>> {
392        None
393    }
394
395    fn children(&self) -> &[Box<dyn Widget>] {
396        &[]
397    }
398
399    fn children_mut(&mut self) -> &mut [Box<dyn Widget>] {
400        &mut []
401    }
402}
403
404#[cfg(test)]
405mod tests {
406    use super::*;
407
408    #[test]
409    fn test_sensor_reading_temperature() {
410        let reading = SensorReading::temperature("CPU", 45.0);
411        assert_eq!(reading.status, SensorStatus::Normal);
412        assert_eq!(reading.value_display(), "45.0°C");
413    }
414
415    #[test]
416    fn test_sensor_reading_warning() {
417        let reading = SensorReading::temperature("GPU", 78.0);
418        assert_eq!(reading.status, SensorStatus::Warning);
419    }
420
421    #[test]
422    fn test_sensor_reading_critical() {
423        let reading = SensorReading::temperature("NVMe", 92.0);
424        assert_eq!(reading.status, SensorStatus::Critical);
425    }
426
427    #[test]
428    fn test_sensor_reading_fan() {
429        let reading = SensorReading::fan("Fan 1", 1200.0);
430        assert_eq!(reading.value_display(), "1200 RPM");
431    }
432
433    #[test]
434    fn test_panel_max_temperature() {
435        let mut panel = SensorsPanel::new();
436        panel.add_temperature(SensorReading::temperature("CPU", 45.0));
437        panel.add_temperature(SensorReading::temperature("GPU", 72.0));
438        panel.add_temperature(SensorReading::temperature("NVMe", 55.0));
439
440        assert_eq!(panel.max_temperature(), Some(72.0));
441    }
442
443    #[test]
444    fn test_panel_has_critical() {
445        let mut panel = SensorsPanel::new();
446        panel.add_temperature(SensorReading::temperature("CPU", 45.0));
447        assert!(!panel.has_critical());
448
449        panel.add_temperature(SensorReading::temperature("GPU", 95.0));
450        assert!(panel.has_critical());
451    }
452
453    #[test]
454    fn test_status_color() {
455        assert_eq!(SensorStatus::Normal.indicator(), '●');
456        assert_eq!(SensorStatus::Warning.indicator(), '◐');
457        assert_eq!(SensorStatus::Critical.indicator(), '○');
458    }
459
460    #[test]
461    fn test_sensor_status_colors() {
462        let normal = SensorStatus::Normal.color();
463        let warning = SensorStatus::Warning.color();
464        let critical = SensorStatus::Critical.color();
465
466        // Normal is green
467        assert!(normal.g > normal.r);
468        // Warning is yellow
469        assert!(warning.r > 0.8 && warning.g > 0.6);
470        // Critical is red
471        assert!(critical.r > critical.g);
472    }
473
474    #[test]
475    fn test_sensor_reading_voltage() {
476        let reading = SensorReading::voltage("Vcore", 1.25);
477        assert_eq!(reading.value_display(), "1.2V"); // 1.25 rounds to 1.2 with .1f format
478        assert_eq!(reading.status, SensorStatus::Normal);
479    }
480
481    #[test]
482    fn test_sensor_reading_with_status() {
483        let reading = SensorReading::fan("Fan", 1000.0).with_status(SensorStatus::Warning);
484        assert_eq!(reading.status, SensorStatus::Warning);
485    }
486
487    #[test]
488    fn test_sensor_reading_with_thresholds_normal() {
489        let reading =
490            SensorReading::temperature("CPU", 50.0).with_thresholds(Some(70.0), Some(90.0));
491        assert_eq!(reading.status, SensorStatus::Normal);
492    }
493
494    #[test]
495    fn test_sensor_reading_with_thresholds_warning() {
496        let reading =
497            SensorReading::temperature("CPU", 75.0).with_thresholds(Some(70.0), Some(90.0));
498        assert_eq!(reading.status, SensorStatus::Warning);
499    }
500
501    #[test]
502    fn test_sensor_reading_with_thresholds_critical() {
503        let reading =
504            SensorReading::temperature("CPU", 95.0).with_thresholds(Some(70.0), Some(90.0));
505        assert_eq!(reading.status, SensorStatus::Critical);
506    }
507
508    #[test]
509    fn test_panel_with_temperatures() {
510        let readings = vec![
511            SensorReading::temperature("CPU", 45.0),
512            SensorReading::temperature("GPU", 60.0),
513        ];
514        let panel = SensorsPanel::new().with_temperatures(readings);
515        assert_eq!(panel.temperatures.len(), 2);
516    }
517
518    #[test]
519    fn test_panel_with_fans() {
520        let readings = vec![
521            SensorReading::fan("Fan1", 1200.0),
522            SensorReading::fan("Fan2", 800.0),
523        ];
524        let panel = SensorsPanel::new().with_fans(readings);
525        assert_eq!(panel.fans.len(), 2);
526    }
527
528    #[test]
529    fn test_panel_add_voltage() {
530        let mut panel = SensorsPanel::new();
531        panel.add_voltage(SensorReading::voltage("Vcore", 1.2));
532        assert_eq!(panel.voltages.len(), 1);
533    }
534
535    #[test]
536    fn test_panel_show_bars() {
537        let panel = SensorsPanel::new().show_bars(false);
538        assert!(!panel.show_bars);
539    }
540
541    #[test]
542    fn test_panel_max_per_category() {
543        let panel = SensorsPanel::new().max_per_category(2);
544        assert_eq!(panel.max_per_category, 2);
545    }
546
547    #[test]
548    fn test_sensors_panel_brick_traits() {
549        let panel = SensorsPanel::new();
550        assert_eq!(panel.brick_name(), "sensors_panel");
551        assert!(!panel.assertions().is_empty());
552        assert!(panel.budget().paint_ms > 0);
553        assert!(panel.verify().is_valid());
554        assert!(panel.to_html().is_empty());
555        assert!(panel.to_css().is_empty());
556    }
557
558    #[test]
559    fn test_sensors_panel_widget_traits() {
560        let mut panel = SensorsPanel::new()
561            .with_temperatures(vec![SensorReading::temperature("CPU", 50.0)])
562            .with_fans(vec![SensorReading::fan("Fan1", 1000.0)]);
563
564        // Measure
565        let size = panel.measure(Constraints {
566            min_width: 0.0,
567            min_height: 0.0,
568            max_width: 80.0,
569            max_height: 20.0,
570        });
571        assert!(size.width > 0.0);
572        assert!(size.height > 0.0);
573
574        // Layout
575        let result = panel.layout(Rect::new(0.0, 0.0, 80.0, 10.0));
576        assert_eq!(result.size.width, 80.0);
577
578        // Type ID
579        assert_eq!(Widget::type_id(&panel), TypeId::of::<SensorsPanel>());
580
581        // Event
582        assert!(panel
583            .event(&Event::key_down(presentar_core::Key::Enter))
584            .is_none());
585
586        // Children
587        assert!(panel.children().is_empty());
588        assert!(panel.children_mut().is_empty());
589    }
590
591    #[test]
592    fn test_sensors_panel_paint_with_bars() {
593        use crate::direct::{CellBuffer, DirectTerminalCanvas};
594
595        let mut panel = SensorsPanel::new()
596            .with_temperatures(vec![
597                SensorReading::temperature("CPU", 55.0),
598                SensorReading::temperature("GPU", 72.0),
599                SensorReading::temperature("VeryLongSensorName", 80.0),
600            ])
601            .with_fans(vec![SensorReading::fan("Fan1", 1200.0)])
602            .show_bars(true);
603
604        panel.layout(Rect::new(0.0, 0.0, 60.0, 10.0));
605
606        let mut buffer = CellBuffer::new(60, 10);
607        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
608        panel.paint(&mut canvas);
609    }
610
611    #[test]
612    fn test_sensors_panel_paint_without_bars() {
613        use crate::direct::{CellBuffer, DirectTerminalCanvas};
614
615        let mut panel = SensorsPanel::new()
616            .with_temperatures(vec![SensorReading::temperature("CPU", 55.0)])
617            .show_bars(false);
618
619        panel.layout(Rect::new(0.0, 0.0, 60.0, 10.0));
620
621        let mut buffer = CellBuffer::new(60, 10);
622        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
623        panel.paint(&mut canvas);
624    }
625
626    #[test]
627    fn test_sensors_panel_paint_small_bounds() {
628        use crate::direct::{CellBuffer, DirectTerminalCanvas};
629
630        let mut panel =
631            SensorsPanel::new().with_temperatures(vec![SensorReading::temperature("CPU", 55.0)]);
632
633        panel.layout(Rect::new(0.0, 0.0, 5.0, 0.5)); // Too small
634
635        let mut buffer = CellBuffer::new(5, 1);
636        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
637        panel.paint(&mut canvas); // Should early return
638    }
639
640    #[test]
641    fn test_sensors_panel_default() {
642        let panel = SensorsPanel::default();
643        assert!(panel.temperatures.is_empty());
644        assert!(panel.fans.is_empty());
645        assert!(panel.voltages.is_empty());
646        assert!(panel.show_bars);
647        assert_eq!(panel.max_per_category, 4);
648    }
649
650    #[test]
651    fn test_sensor_status_default() {
652        let status = SensorStatus::default();
653        assert_eq!(status, SensorStatus::Normal);
654    }
655
656    #[test]
657    fn test_panel_max_temperature_empty() {
658        let panel = SensorsPanel::new();
659        assert!(panel.max_temperature().is_none());
660    }
661
662    #[test]
663    fn test_panel_has_critical_empty() {
664        let panel = SensorsPanel::new();
665        assert!(!panel.has_critical());
666    }
667
668    #[test]
669    fn test_sensors_panel_exceeds_max_per_category() {
670        use crate::direct::{CellBuffer, DirectTerminalCanvas};
671
672        let readings: Vec<SensorReading> = (0..10)
673            .map(|i| SensorReading::temperature(format!("Sensor{}", i), 40.0 + i as f64))
674            .collect();
675
676        let mut panel = SensorsPanel::new()
677            .with_temperatures(readings)
678            .max_per_category(3);
679
680        panel.layout(Rect::new(0.0, 0.0, 60.0, 10.0));
681
682        let mut buffer = CellBuffer::new(60, 10);
683        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
684        panel.paint(&mut canvas);
685    }
686
687    #[test]
688    fn test_sensor_reading_with_thresholds_no_thresholds() {
689        // Test with None thresholds
690        let reading = SensorReading::temperature("CPU", 100.0).with_thresholds(None, None);
691        assert_eq!(reading.status, SensorStatus::Normal);
692    }
693
694    #[test]
695    fn test_sensor_reading_temperature_edge_cases() {
696        // Exactly at 75 (warning threshold)
697        let at_75 = SensorReading::temperature("CPU", 75.0);
698        assert_eq!(at_75.status, SensorStatus::Warning);
699
700        // Exactly at 90 (critical threshold)
701        let at_90 = SensorReading::temperature("CPU", 90.0);
702        assert_eq!(at_90.status, SensorStatus::Critical);
703
704        // Just below warning
705        let below_75 = SensorReading::temperature("CPU", 74.9);
706        assert_eq!(below_75.status, SensorStatus::Normal);
707    }
708}