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