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}