aranet_cli/tui/ui/
widgets.rs1use ratatui::prelude::*;
7
8use aranet_core::settings::{DeviceSettings, RadonUnit, TemperatureUnit};
9use aranet_types::HistoryRecord;
10
11#[inline]
13fn celsius_to_fahrenheit(celsius: f32) -> f32 {
14 celsius * 9.0 / 5.0 + 32.0
15}
16
17#[inline]
19fn bq_to_pci(bq: u32) -> f32 {
20 bq as f32 * 0.027
21}
22
23#[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#[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#[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#[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#[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 history
101 .iter()
102 .filter_map(|record| record.radon)
103 .map(u64::from)
104 .collect()
105 }
106 Some(DeviceType::AranetRadiation) => {
107 history
109 .iter()
110 .filter_map(|record| record.radiation_rate)
111 .map(|r| r as u64)
112 .collect()
113 }
114 _ => {
115 history
117 .iter()
118 .filter(|record| record.co2 > 0)
119 .map(|record| u64::from(record.co2))
120 .collect()
121 }
122 }
123}
124
125#[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 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 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
179pub fn trend_indicator(current: i32, previous: i32, threshold: i32) -> (&'static str, Color) {
182 let diff = current - previous;
183 if diff > threshold {
184 ("↑", Color::Red) } else if diff < -threshold {
186 ("↓", Color::Green) } else {
188 ("→", Color::DarkGray) }
190}
191
192pub 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 #[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 let result = celsius_to_fahrenheit(-40.0);
221 assert!((result - (-40.0)).abs() < 0.01);
222 }
223
224 #[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 let result = bq_to_pci(100);
238 assert!((result - 2.7).abs() < 0.01);
239 }
240
241 #[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 assert_eq!(result, "68.0°F");
270 }
271
272 #[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 assert_eq!(result, "2.70 pCi/L");
301 }
302
303 #[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 assert_eq!(result[0], 100);
340 assert_eq!(result[1], 200);
341 }
342
343 #[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}