Skip to main content

aranet_cli/tui/ui/
widgets.rs

1//! Reusable widget components for the TUI.
2//!
3//! This module provides helper functions for creating styled widget components
4//! used throughout the terminal user interface.
5
6use ratatui::prelude::*;
7
8use aranet_core::settings::{DeviceSettings, RadonUnit, TemperatureUnit};
9use aranet_types::HistoryRecord;
10
11/// Convert Celsius to Fahrenheit.
12#[inline]
13fn celsius_to_fahrenheit(celsius: f32) -> f32 {
14    celsius * 9.0 / 5.0 + 32.0
15}
16
17/// Convert Bq/m³ to pCi/L (1 Bq/m³ = 0.027 pCi/L).
18#[inline]
19fn bq_to_pci(bq: u32) -> f32 {
20    bq as f32 * 0.027
21}
22
23/// Format temperature value based on device settings.
24///
25/// Uses the device's temperature unit setting if available, otherwise defaults to Celsius.
26#[must_use]
27pub fn format_temp_for_device(celsius: f32, settings: Option<&DeviceSettings>) -> String {
28    let use_fahrenheit = settings
29        .map(|s| s.temperature_unit == TemperatureUnit::Fahrenheit)
30        .unwrap_or(false);
31
32    if use_fahrenheit {
33        format!("{:.1}°F", celsius_to_fahrenheit(celsius))
34    } else {
35        format!("{:.1}°C", celsius)
36    }
37}
38
39/// Format radon value based on device settings.
40///
41/// Uses the device's radon unit setting if available, otherwise defaults to Bq/m³.
42#[must_use]
43pub fn format_radon_for_device(bq: u32, settings: Option<&DeviceSettings>) -> String {
44    let use_pci = settings
45        .map(|s| s.radon_unit == RadonUnit::PciL)
46        .unwrap_or(false);
47
48    if use_pci {
49        format!("{:.2} pCi/L", bq_to_pci(bq))
50    } else {
51        format!("{} Bq/m³", bq)
52    }
53}
54
55/// Get the radon unit string based on device settings.
56#[must_use]
57pub fn radon_unit_for_device(settings: Option<&DeviceSettings>) -> &'static str {
58    let use_pci = settings
59        .map(|s| s.radon_unit == RadonUnit::PciL)
60        .unwrap_or(false);
61
62    if use_pci { "pCi/L" } else { "Bq/m³" }
63}
64
65/// Convert radon value for display based on device settings.
66///
67/// Returns the value converted to the appropriate unit.
68#[must_use]
69pub fn convert_radon_for_device(bq: u32, settings: Option<&DeviceSettings>) -> f32 {
70    let use_pci = settings
71        .map(|s| s.radon_unit == RadonUnit::PciL)
72        .unwrap_or(false);
73
74    if use_pci { bq_to_pci(bq) } else { bq as f32 }
75}
76
77/// Extracts primary sensor values from history records for use in a sparkline widget.
78///
79/// Returns CO2 for Aranet4, radon for AranetRadon, or attempts both for unknown types.
80///
81/// # Arguments
82///
83/// * `history` - Slice of history records
84/// * `device_type` - Optional device type to determine which values to extract
85///
86/// # Returns
87///
88/// A [`Vec<u64>`] containing the primary sensor values.
89/// Returns an empty vector if no valid data is found.
90#[must_use]
91pub fn sparkline_data(
92    history: &[HistoryRecord],
93    device_type: Option<aranet_types::DeviceType>,
94) -> Vec<u64> {
95    use aranet_types::DeviceType;
96
97    match device_type {
98        Some(DeviceType::AranetRadon) => {
99            // For radon devices, extract radon values
100            history
101                .iter()
102                .filter_map(|record| record.radon)
103                .map(u64::from)
104                .collect()
105        }
106        Some(DeviceType::AranetRadiation) => {
107            // For radiation devices, extract radiation rate if available
108            history
109                .iter()
110                .filter_map(|record| record.radiation_rate)
111                .map(|r| r as u64)
112                .collect()
113        }
114        _ => {
115            // For Aranet4 and others, use CO2
116            history
117                .iter()
118                .filter(|record| record.co2 > 0)
119                .map(|record| u64::from(record.co2))
120                .collect()
121        }
122    }
123}
124
125/// Resample sparkline data to fit a target width.
126///
127/// If the data has fewer points than the target width, it will be upsampled
128/// by repeating values to fill the space. If it has more points, it will
129/// be downsampled by averaging values into buckets.
130///
131/// # Arguments
132///
133/// * `data` - The original sparkline data
134/// * `target_width` - The desired number of data points (typically the screen width)
135///
136/// # Returns
137///
138/// A [`Vec<u64>`] with exactly `target_width` data points.
139#[must_use]
140pub fn resample_sparkline_data(data: &[u64], target_width: usize) -> Vec<u64> {
141    if data.is_empty() || target_width == 0 {
142        return Vec::new();
143    }
144
145    if data.len() == target_width {
146        return data.to_vec();
147    }
148
149    let mut result = Vec::with_capacity(target_width);
150
151    if data.len() < target_width {
152        // Upsample: repeat values to fill the space
153        // Use linear interpolation-like approach for smoother visualization
154        for i in 0..target_width {
155            let src_idx = i * (data.len() - 1) / (target_width - 1).max(1);
156            result.push(data[src_idx.min(data.len() - 1)]);
157        }
158    } else {
159        // Downsample: average values into buckets
160        let bucket_size = data.len() as f64 / target_width as f64;
161        for i in 0..target_width {
162            let start = (i as f64 * bucket_size) as usize;
163            let end = ((i + 1) as f64 * bucket_size) as usize;
164            let end = end.min(data.len());
165
166            if start < end {
167                let sum: u64 = data[start..end].iter().sum();
168                let avg = sum / (end - start) as u64;
169                result.push(avg);
170            } else if start < data.len() {
171                result.push(data[start]);
172            }
173        }
174    }
175
176    result
177}
178
179/// Calculate trend indicator based on current and previous values.
180/// Returns (arrow character, color) tuple.
181pub fn trend_indicator(current: i32, previous: i32, threshold: i32) -> (&'static str, Color) {
182    let diff = current - previous;
183    if diff > threshold {
184        ("↑", Color::Red) // Rising (bad for CO2)
185    } else if diff < -threshold {
186        ("↓", Color::Green) // Falling (good for CO2)
187    } else {
188        ("→", Color::DarkGray) // Stable
189    }
190}
191
192/// Calculate trend for CO2 readings.
193pub fn co2_trend(current: u16, previous: Option<u16>) -> Option<(&'static str, Color)> {
194    previous.map(|prev| trend_indicator(current as i32, prev as i32, 20))
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    // ========================================================================
202    // celsius_to_fahrenheit tests
203    // ========================================================================
204
205    #[test]
206    fn test_celsius_to_fahrenheit_freezing() {
207        let result = celsius_to_fahrenheit(0.0);
208        assert!((result - 32.0).abs() < 0.01);
209    }
210
211    #[test]
212    fn test_celsius_to_fahrenheit_boiling() {
213        let result = celsius_to_fahrenheit(100.0);
214        assert!((result - 212.0).abs() < 0.01);
215    }
216
217    #[test]
218    fn test_celsius_to_fahrenheit_negative() {
219        // -40 is where C and F are equal
220        let result = celsius_to_fahrenheit(-40.0);
221        assert!((result - (-40.0)).abs() < 0.01);
222    }
223
224    // ========================================================================
225    // bq_to_pci tests
226    // ========================================================================
227
228    #[test]
229    fn test_bq_to_pci_zero() {
230        let result = bq_to_pci(0);
231        assert!((result - 0.0).abs() < 0.001);
232    }
233
234    #[test]
235    fn test_bq_to_pci_100() {
236        // 100 Bq/m3 = 2.7 pCi/L
237        let result = bq_to_pci(100);
238        assert!((result - 2.7).abs() < 0.01);
239    }
240
241    // ========================================================================
242    // format_temp_for_device tests
243    // ========================================================================
244
245    #[test]
246    fn test_format_temp_no_settings_defaults_celsius() {
247        let result = format_temp_for_device(20.5, None);
248        assert_eq!(result, "20.5°C");
249    }
250
251    #[test]
252    fn test_format_temp_celsius_setting() {
253        let settings = DeviceSettings {
254            temperature_unit: TemperatureUnit::Celsius,
255            ..Default::default()
256        };
257        let result = format_temp_for_device(20.5, Some(&settings));
258        assert_eq!(result, "20.5°C");
259    }
260
261    #[test]
262    fn test_format_temp_fahrenheit_setting() {
263        let settings = DeviceSettings {
264            temperature_unit: TemperatureUnit::Fahrenheit,
265            ..Default::default()
266        };
267        let result = format_temp_for_device(20.0, Some(&settings));
268        // 20C = 68F
269        assert_eq!(result, "68.0°F");
270    }
271
272    // ========================================================================
273    // format_radon_for_device tests
274    // ========================================================================
275
276    #[test]
277    fn test_format_radon_no_settings_defaults_bq() {
278        let result = format_radon_for_device(150, None);
279        assert_eq!(result, "150 Bq/m³");
280    }
281
282    #[test]
283    fn test_format_radon_bq_setting() {
284        let settings = DeviceSettings {
285            radon_unit: RadonUnit::BqM3,
286            ..Default::default()
287        };
288        let result = format_radon_for_device(150, Some(&settings));
289        assert_eq!(result, "150 Bq/m³");
290    }
291
292    #[test]
293    fn test_format_radon_pci_setting() {
294        let settings = DeviceSettings {
295            radon_unit: RadonUnit::PciL,
296            ..Default::default()
297        };
298        let result = format_radon_for_device(100, Some(&settings));
299        // 100 Bq/m3 = 2.70 pCi/L
300        assert_eq!(result, "2.70 pCi/L");
301    }
302
303    // ========================================================================
304    // resample_sparkline_data tests
305    // ========================================================================
306
307    #[test]
308    fn test_resample_empty_data() {
309        let result = resample_sparkline_data(&[], 10);
310        assert!(result.is_empty());
311    }
312
313    #[test]
314    fn test_resample_zero_width() {
315        let result = resample_sparkline_data(&[1, 2, 3], 0);
316        assert!(result.is_empty());
317    }
318
319    #[test]
320    fn test_resample_same_size() {
321        let data = vec![1, 2, 3, 4, 5];
322        let result = resample_sparkline_data(&data, 5);
323        assert_eq!(result, data);
324    }
325
326    #[test]
327    fn test_resample_upsample() {
328        let data = vec![100, 200];
329        let result = resample_sparkline_data(&data, 4);
330        assert_eq!(result.len(), 4);
331    }
332
333    #[test]
334    fn test_resample_downsample() {
335        let data = vec![100, 100, 200, 200];
336        let result = resample_sparkline_data(&data, 2);
337        assert_eq!(result.len(), 2);
338        // Should average buckets
339        assert_eq!(result[0], 100);
340        assert_eq!(result[1], 200);
341    }
342
343    // ========================================================================
344    // trend_indicator tests
345    // ========================================================================
346
347    #[test]
348    fn test_trend_indicator_rising() {
349        let (arrow, color) = trend_indicator(500, 400, 20);
350        assert_eq!(arrow, "↑");
351        assert_eq!(color, Color::Red);
352    }
353
354    #[test]
355    fn test_trend_indicator_falling() {
356        let (arrow, color) = trend_indicator(400, 500, 20);
357        assert_eq!(arrow, "↓");
358        assert_eq!(color, Color::Green);
359    }
360
361    #[test]
362    fn test_trend_indicator_stable() {
363        let (arrow, color) = trend_indicator(500, 505, 20);
364        assert_eq!(arrow, "→");
365        assert_eq!(color, Color::DarkGray);
366    }
367
368    // ========================================================================
369    // co2_trend tests
370    // ========================================================================
371
372    #[test]
373    fn test_co2_trend_no_previous() {
374        let result = co2_trend(800, None);
375        assert!(result.is_none());
376    }
377
378    #[test]
379    fn test_co2_trend_rising() {
380        let result = co2_trend(850, Some(800));
381        assert!(result.is_some());
382        let (arrow, color) = result.unwrap();
383        assert_eq!(arrow, "↑");
384        assert_eq!(color, Color::Red);
385    }
386
387    #[test]
388    fn test_co2_trend_falling() {
389        let result = co2_trend(750, Some(800));
390        assert!(result.is_some());
391        let (arrow, color) = result.unwrap();
392        assert_eq!(arrow, "↓");
393        assert_eq!(color, Color::Green);
394    }
395
396    #[test]
397    fn test_co2_trend_stable() {
398        let result = co2_trend(805, Some(800));
399        assert!(result.is_some());
400        let (arrow, _) = result.unwrap();
401        assert_eq!(arrow, "→");
402    }
403
404    // ========================================================================
405    // radon_unit_for_device tests
406    // ========================================================================
407
408    #[test]
409    fn test_radon_unit_no_settings() {
410        let result = radon_unit_for_device(None);
411        assert_eq!(result, "Bq/m³");
412    }
413
414    #[test]
415    fn test_radon_unit_bq_setting() {
416        let settings = DeviceSettings {
417            radon_unit: RadonUnit::BqM3,
418            ..Default::default()
419        };
420        let result = radon_unit_for_device(Some(&settings));
421        assert_eq!(result, "Bq/m³");
422    }
423
424    #[test]
425    fn test_radon_unit_pci_setting() {
426        let settings = DeviceSettings {
427            radon_unit: RadonUnit::PciL,
428            ..Default::default()
429        };
430        let result = radon_unit_for_device(Some(&settings));
431        assert_eq!(result, "pCi/L");
432    }
433
434    // ========================================================================
435    // convert_radon_for_device tests
436    // ========================================================================
437
438    #[test]
439    fn test_convert_radon_no_settings() {
440        let result = convert_radon_for_device(100, None);
441        assert_eq!(result, 100.0);
442    }
443
444    #[test]
445    fn test_convert_radon_bq_setting() {
446        let settings = DeviceSettings {
447            radon_unit: RadonUnit::BqM3,
448            ..Default::default()
449        };
450        let result = convert_radon_for_device(100, Some(&settings));
451        assert_eq!(result, 100.0);
452    }
453
454    #[test]
455    fn test_convert_radon_pci_setting() {
456        let settings = DeviceSettings {
457            radon_unit: RadonUnit::PciL,
458            ..Default::default()
459        };
460        let result = convert_radon_for_device(100, Some(&settings));
461        // 100 Bq/m3 = 2.7 pCi/L
462        assert!((result - 2.7).abs() < 0.01);
463    }
464
465    // ========================================================================
466    // sparkline_data tests
467    // ========================================================================
468
469    #[test]
470    fn test_sparkline_data_empty() {
471        let result = sparkline_data(&[], None);
472        assert!(result.is_empty());
473    }
474
475    #[test]
476    fn test_sparkline_data_aranet4() {
477        use aranet_types::DeviceType;
478        use time::OffsetDateTime;
479
480        let history = vec![
481            HistoryRecord {
482                timestamp: OffsetDateTime::now_utc(),
483                co2: 800,
484                temperature: 22.5,
485                humidity: 45,
486                pressure: 1013.0,
487                radon: None,
488                radiation_rate: None,
489                radiation_total: None,
490            },
491            HistoryRecord {
492                timestamp: OffsetDateTime::now_utc(),
493                co2: 850,
494                temperature: 22.5,
495                humidity: 45,
496                pressure: 1013.0,
497                radon: None,
498                radiation_rate: None,
499                radiation_total: None,
500            },
501        ];
502
503        let result = sparkline_data(&history, Some(DeviceType::Aranet4));
504        assert_eq!(result, vec![800, 850]);
505    }
506
507    #[test]
508    fn test_sparkline_data_radon() {
509        use aranet_types::DeviceType;
510        use time::OffsetDateTime;
511
512        let history = vec![
513            HistoryRecord {
514                timestamp: OffsetDateTime::now_utc(),
515                co2: 0,
516                temperature: 22.5,
517                humidity: 45,
518                pressure: 1013.0,
519                radon: Some(100),
520                radiation_rate: None,
521                radiation_total: None,
522            },
523            HistoryRecord {
524                timestamp: OffsetDateTime::now_utc(),
525                co2: 0,
526                temperature: 22.5,
527                humidity: 45,
528                pressure: 1013.0,
529                radon: Some(150),
530                radiation_rate: None,
531                radiation_total: None,
532            },
533        ];
534
535        let result = sparkline_data(&history, Some(DeviceType::AranetRadon));
536        assert_eq!(result, vec![100, 150]);
537    }
538
539    #[test]
540    fn test_sparkline_data_filters_zero_co2() {
541        use time::OffsetDateTime;
542
543        let history = vec![
544            HistoryRecord {
545                timestamp: OffsetDateTime::now_utc(),
546                co2: 0, // Should be filtered out
547                temperature: 22.5,
548                humidity: 45,
549                pressure: 1013.0,
550                radon: None,
551                radiation_rate: None,
552                radiation_total: None,
553            },
554            HistoryRecord {
555                timestamp: OffsetDateTime::now_utc(),
556                co2: 800,
557                temperature: 22.5,
558                humidity: 45,
559                pressure: 1013.0,
560                radon: None,
561                radiation_rate: None,
562                radiation_total: None,
563            },
564        ];
565
566        let result = sparkline_data(&history, None);
567        assert_eq!(result, vec![800]); // Zero CO2 filtered out
568    }
569}