1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
15pub enum SensorStatus {
16 #[default]
17 Normal,
18 Warning,
19 Critical,
20}
21
22impl SensorStatus {
23 pub fn color(&self) -> Color {
25 match self {
26 Self::Normal => Color::new(0.4, 0.9, 0.4, 1.0), Self::Warning => Color::new(1.0, 0.8, 0.2, 1.0), Self::Critical => Color::new(1.0, 0.3, 0.3, 1.0), }
30 }
31
32 pub fn indicator(&self) -> char {
34 match self {
35 Self::Normal => '●',
36 Self::Warning => '◐',
37 Self::Critical => '○',
38 }
39 }
40}
41
42#[derive(Debug, Clone)]
44pub struct SensorReading {
45 pub label: String,
47 pub value: f64,
49 pub unit: String,
51 pub critical: Option<f64>,
53 pub warning: Option<f64>,
55 pub status: SensorStatus,
57}
58
59impl SensorReading {
60 #[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 #[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 #[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 #[must_use]
109 pub fn with_status(mut self, status: SensorStatus) -> Self {
110 self.status = status;
111 self
112 }
113
114 #[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 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 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#[derive(Debug, Clone)]
148pub struct SensorsPanel {
149 temperatures: Vec<SensorReading>,
151 fans: Vec<SensorReading>,
153 voltages: Vec<SensorReading>,
155 show_bars: bool,
157 max_per_category: usize,
159 bounds: Rect,
161}
162
163impl Default for SensorsPanel {
164 fn default() -> Self {
165 Self::new()
166 }
167}
168
169impl SensorsPanel {
170 #[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 pub fn add_temperature(&mut self, reading: SensorReading) {
185 self.temperatures.push(reading);
186 }
187
188 pub fn add_fan(&mut self, reading: SensorReading) {
190 self.fans.push(reading);
191 }
192
193 pub fn add_voltage(&mut self, reading: SensorReading) {
195 self.voltages.push(reading);
196 }
197
198 #[must_use]
200 pub fn with_temperatures(mut self, readings: Vec<SensorReading>) -> Self {
201 self.temperatures = readings;
202 self
203 }
204
205 #[must_use]
207 pub fn with_fans(mut self, readings: Vec<SensorReading>) -> Self {
208 self.fans = readings;
209 self
210 }
211
212 #[must_use]
214 pub fn show_bars(mut self, show: bool) -> Self {
215 self.show_bars = show;
216 self
217 }
218
219 #[must_use]
221 pub fn max_per_category(mut self, max: usize) -> Self {
222 self.max_per_category = max;
223 self
224 }
225
226 pub fn max_temperature(&self) -> Option<f64> {
228 self.temperatures.iter().map(|r| r.value).reduce(f64::max)
229 }
230
231 pub fn has_critical(&self) -> bool {
233 self.temperatures
234 .iter()
235 .any(|r| r.status == SensorStatus::Critical)
236 }
237
238 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 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 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 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 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 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 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 assert!(normal.g > normal.r);
468 assert!(warning.r > 0.8 && warning.g > 0.6);
470 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"); 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 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 let result = panel.layout(Rect::new(0.0, 0.0, 80.0, 10.0));
576 assert_eq!(result.size.width, 80.0);
577
578 assert_eq!(Widget::type_id(&panel), TypeId::of::<SensorsPanel>());
580
581 assert!(panel
583 .event(&Event::key_down(presentar_core::Key::Enter))
584 .is_none());
585
586 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)); let mut buffer = CellBuffer::new(5, 1);
636 let mut canvas = DirectTerminalCanvas::new(&mut buffer);
637 panel.paint(&mut canvas); }
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 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 let at_75 = SensorReading::temperature("CPU", 75.0);
698 assert_eq!(at_75.status, SensorStatus::Warning);
699
700 let at_90 = SensorReading::temperature("CPU", 90.0);
702 assert_eq!(at_90.status, SensorStatus::Critical);
703
704 let below_75 = SensorReading::temperature("CPU", 74.9);
706 assert_eq!(below_75.status, SensorStatus::Normal);
707 }
708}