1use std::time::Duration;
24
25use crossterm::event::{KeyCode, MouseButton, MouseEvent, MouseEventKind};
26use tokio::sync::mpsc;
27
28use super::app::{App, ConnectionStatus, HistoryFilter, PendingAction, Tab, Theme};
29use super::messages::Command;
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum Action {
34 Quit,
36 Scan,
38 Refresh,
40 Connect,
42 ConnectAll,
44 Disconnect,
46 SyncHistory,
48 SelectNext,
50 SelectPrevious,
52 NextTab,
54 PreviousTab,
56 ToggleHelp,
58 ToggleLogging,
60 ToggleBell,
62 DismissAlert,
64 ScrollUp,
66 ScrollDown,
68 SetHistoryFilter(HistoryFilter),
70 IncreaseThreshold,
72 DecreaseThreshold,
74 ChangeSetting,
76 ExportHistory,
78 ToggleAlertHistory,
80 CycleDeviceFilter,
82 ToggleSidebar,
84 ToggleSidebarWidth,
86 MouseClick { x: u16, y: u16 },
88 Confirm,
90 Cancel,
92 ToggleChart,
94 EditAlias,
96 TextInput(char),
98 TextBackspace,
100 TextSubmit,
102 TextCancel,
104 ToggleStickyAlerts,
106 ToggleComparison,
108 NextComparisonDevice,
110 PrevComparisonDevice,
112 ShowErrorDetails,
114 ToggleTheme,
116 ToggleChartTemp,
118 ToggleChartHumidity,
120 ToggleBleRange,
122 ToggleSmartHome,
124 None,
126}
127
128pub fn handle_key(key: KeyCode, editing_text: bool, has_pending_confirmation: bool) -> Action {
141 if editing_text {
143 return match key {
144 KeyCode::Enter => Action::TextSubmit,
145 KeyCode::Esc => Action::TextCancel,
146 KeyCode::Backspace => Action::TextBackspace,
147 KeyCode::Char(c) => Action::TextInput(c),
148 _ => Action::None,
149 };
150 }
151
152 if has_pending_confirmation {
154 return match key {
155 KeyCode::Char('y') | KeyCode::Char('Y') => Action::Confirm,
156 KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => Action::Cancel,
157 _ => Action::None,
158 };
159 }
160
161 match key {
162 KeyCode::Char('q') => Action::Quit,
163 KeyCode::Char('s') => Action::Scan,
164 KeyCode::Char('r') => Action::Refresh,
165 KeyCode::Char('c') => Action::Connect,
166 KeyCode::Char('C') => Action::ConnectAll,
167 KeyCode::Char('d') => Action::Disconnect,
168 KeyCode::Char('S') | KeyCode::Char('y') => Action::SyncHistory,
169 KeyCode::Down | KeyCode::Char('j') => Action::SelectNext,
170 KeyCode::Up | KeyCode::Char('k') => Action::SelectPrevious,
171 KeyCode::Tab | KeyCode::Char('l') => Action::NextTab,
172 KeyCode::BackTab | KeyCode::Char('h') => Action::PreviousTab,
173 KeyCode::Char('?') => Action::ToggleHelp,
174 KeyCode::Char('L') => Action::ToggleLogging,
175 KeyCode::Char('b') => Action::ToggleBell,
176 KeyCode::Char('n') => Action::EditAlias,
177 KeyCode::Esc => Action::DismissAlert,
178 KeyCode::PageUp => Action::ScrollUp,
179 KeyCode::PageDown => Action::ScrollDown,
180 KeyCode::Char('0') => Action::SetHistoryFilter(HistoryFilter::All),
181 KeyCode::Char('1') => Action::SetHistoryFilter(HistoryFilter::Today),
182 KeyCode::Char('2') => Action::SetHistoryFilter(HistoryFilter::Last24Hours),
183 KeyCode::Char('3') => Action::SetHistoryFilter(HistoryFilter::Last7Days),
184 KeyCode::Char('4') => Action::SetHistoryFilter(HistoryFilter::Last30Days),
185 KeyCode::Char('+') | KeyCode::Char('=') => Action::IncreaseThreshold,
186 KeyCode::Char('-') | KeyCode::Char('_') => Action::DecreaseThreshold,
187 KeyCode::Enter => Action::ChangeSetting,
188 KeyCode::Char('e') => Action::ExportHistory,
189 KeyCode::Char('a') => Action::ToggleAlertHistory,
190 KeyCode::Char('f') => Action::CycleDeviceFilter,
191 KeyCode::Char('[') => Action::ToggleSidebar,
192 KeyCode::Char(']') => Action::ToggleSidebarWidth,
193 KeyCode::Char('g') => Action::ToggleChart,
194 KeyCode::Char('A') => Action::ToggleStickyAlerts,
195 KeyCode::Char('v') => Action::ToggleComparison,
196 KeyCode::Char('<') => Action::PrevComparisonDevice,
197 KeyCode::Char('>') => Action::NextComparisonDevice,
198 KeyCode::Char('E') => Action::ShowErrorDetails,
199 KeyCode::Char('t') => Action::ToggleTheme,
200 KeyCode::Char('T') => Action::ToggleChartTemp,
201 KeyCode::Char('H') => Action::ToggleChartHumidity,
202 KeyCode::Char('B') => Action::ToggleBleRange,
203 KeyCode::Char('I') => Action::ToggleSmartHome,
204 _ => Action::None,
205 }
206}
207
208pub fn handle_mouse(event: MouseEvent) -> Action {
219 match event.kind {
220 MouseEventKind::Down(MouseButton::Left) => Action::MouseClick {
221 x: event.column,
222 y: event.row,
223 },
224 _ => Action::None,
225 }
226}
227
228pub fn apply_action(
244 app: &mut App,
245 action: Action,
246 _command_tx: &mpsc::Sender<Command>,
247) -> Option<Command> {
248 match action {
249 Action::Quit => {
250 app.should_quit = true;
251 None
252 }
253 Action::Scan => Some(Command::Scan {
254 duration: Duration::from_secs(5),
255 }),
256 Action::Refresh => {
257 if app.active_tab == Tab::Service {
258 Some(Command::RefreshServiceStatus)
260 } else {
261 Some(Command::RefreshAll)
263 }
264 }
265 Action::Connect => app.selected_device().map(|device| Command::Connect {
266 device_id: device.id.clone(),
267 }),
268 Action::ConnectAll => {
269 let first_disconnected = app
272 .devices
273 .iter()
274 .find(|d| matches!(d.status, ConnectionStatus::Disconnected))
275 .map(|d| d.id.clone());
276 let count = app
277 .devices
278 .iter()
279 .filter(|d| matches!(d.status, ConnectionStatus::Disconnected))
280 .count();
281
282 if let Some(device_id) = first_disconnected {
283 app.push_status_message(format!("Connecting... ({} remaining)", count));
284 return Some(Command::Connect { device_id });
285 } else {
286 app.push_status_message("All devices already connected".to_string());
287 }
288 None
289 }
290 Action::Disconnect => {
291 if let Some(device) = app.selected_device()
292 && matches!(device.status, ConnectionStatus::Connected)
293 {
294 let action = PendingAction::Disconnect {
295 device_id: device.id.clone(),
296 device_name: device.name.clone().unwrap_or_else(|| device.id.clone()),
297 };
298 app.request_confirmation(action);
299 }
300 None
301 }
302 Action::SyncHistory => app.selected_device().map(|device| Command::SyncHistory {
303 device_id: device.id.clone(),
304 }),
305 Action::SelectNext => {
306 if app.active_tab == Tab::Settings {
307 app.select_next_setting();
308 } else {
309 app.select_next_device();
310 }
311 None
312 }
313 Action::SelectPrevious => {
314 if app.active_tab == Tab::Settings {
315 app.select_previous_setting();
316 } else {
317 app.select_previous_device();
318 }
319 None
320 }
321 Action::NextTab => {
322 app.active_tab = match app.active_tab {
323 Tab::Dashboard => Tab::History,
324 Tab::History => Tab::Settings,
325 Tab::Settings => Tab::Service,
326 Tab::Service => Tab::Dashboard,
327 };
328 None
329 }
330 Action::PreviousTab => {
331 app.active_tab = match app.active_tab {
332 Tab::Dashboard => Tab::Service,
333 Tab::History => Tab::Dashboard,
334 Tab::Settings => Tab::History,
335 Tab::Service => Tab::Settings,
336 };
337 None
338 }
339 Action::ToggleHelp => {
340 app.show_help = !app.show_help;
341 None
342 }
343 Action::ToggleLogging => {
344 app.toggle_logging();
345 None
346 }
347 Action::ToggleBell => {
348 app.bell_enabled = !app.bell_enabled;
349 app.push_status_message(format!(
350 "Bell notifications {}",
351 if app.bell_enabled {
352 "enabled"
353 } else {
354 "disabled"
355 }
356 ));
357 None
358 }
359 Action::DismissAlert => {
360 if app.show_help {
362 app.show_help = false;
363 } else if app.show_error_details {
365 app.show_error_details = false;
366 } else if let Some(device) = app.selected_device() {
367 let device_id = device.id.clone();
368 app.dismiss_alert(&device_id);
369 }
370 None
371 }
372 Action::ScrollUp => {
373 if app.active_tab == Tab::History {
374 app.scroll_history_up();
375 }
376 None
377 }
378 Action::ScrollDown => {
379 if app.active_tab == Tab::History {
380 app.scroll_history_down();
381 }
382 None
383 }
384 Action::SetHistoryFilter(filter) => {
385 if app.active_tab == Tab::History {
386 app.set_history_filter(filter);
387 }
388 None
389 }
390 Action::IncreaseThreshold => {
391 if app.active_tab == Tab::Settings {
392 match app.selected_setting {
393 1 => app.increase_co2_threshold(),
394 2 => app.increase_radon_threshold(),
395 _ => {}
396 }
397 app.push_status_message(format!(
398 "CO2: {} ppm, Radon: {} Bq/m³",
399 app.co2_alert_threshold, app.radon_alert_threshold
400 ));
401 }
402 None
403 }
404 Action::DecreaseThreshold => {
405 if app.active_tab == Tab::Settings {
406 match app.selected_setting {
407 1 => app.decrease_co2_threshold(),
408 2 => app.decrease_radon_threshold(),
409 _ => {}
410 }
411 app.push_status_message(format!(
412 "CO2: {} ppm, Radon: {} Bq/m³",
413 app.co2_alert_threshold, app.radon_alert_threshold
414 ));
415 }
416 None
417 }
418 Action::ChangeSetting => {
419 if app.active_tab == Tab::Service {
420 if let Some(ref status) = app.service_status {
422 if status.reachable {
423 if status.collector_running {
424 return Some(Command::StopServiceCollector);
425 } else {
426 return Some(Command::StartServiceCollector);
427 }
428 } else {
429 app.push_status_message("Service not reachable".to_string());
430 }
431 } else {
432 app.push_status_message(
433 "Service status unknown - press 'r' to refresh".to_string(),
434 );
435 }
436 None
437 } else if app.active_tab == Tab::Settings && app.selected_setting == 0 {
438 if let Some((device_id, new_interval)) = app.cycle_interval() {
440 return Some(Command::SetInterval {
441 device_id,
442 interval_secs: new_interval,
443 });
444 }
445 None
446 } else {
447 None
448 }
449 }
450 Action::ExportHistory => {
451 if let Some(path) = app.export_history() {
452 app.push_status_message(format!("Exported to {}", path));
453 } else {
454 app.push_status_message("No history to export".to_string());
455 }
456 None
457 }
458 Action::ToggleAlertHistory => {
459 app.toggle_alert_history();
460 None
461 }
462 Action::ToggleStickyAlerts => {
463 app.toggle_sticky_alerts();
464 None
465 }
466 Action::CycleDeviceFilter => {
467 app.cycle_device_filter();
468 None
469 }
470 Action::ToggleSidebar => {
471 app.toggle_sidebar();
472 app.push_status_message(
473 if app.show_sidebar {
474 "Sidebar shown"
475 } else {
476 "Sidebar hidden"
477 }
478 .to_string(),
479 );
480 None
481 }
482 Action::ToggleSidebarWidth => {
483 app.toggle_sidebar_width();
484 app.push_status_message(format!("Sidebar width: {}", app.sidebar_width));
485 None
486 }
487 Action::MouseClick { x, y } => {
488 if (1..=3).contains(&y) {
490 if x < 15 {
492 app.active_tab = Tab::Dashboard;
493 } else if x < 30 {
494 app.active_tab = Tab::History;
495 } else if x < 45 {
496 app.active_tab = Tab::Settings;
497 } else if x < 60 {
498 app.active_tab = Tab::Service;
499 }
500 }
501 else if x < 25 && y > 4 {
503 let device_row = (y as usize).saturating_sub(5);
504 if device_row < app.devices.len() {
505 app.selected_device = device_row;
506 }
507 }
508 None
509 }
510 Action::Confirm => {
511 if app.pending_confirmation.is_some() {
512 return app.confirm_action();
513 }
514 None
515 }
516 Action::Cancel => {
517 if app.pending_confirmation.is_some() {
518 app.cancel_confirmation();
519 }
520 None
521 }
522 Action::ToggleChart => {
523 app.toggle_fullscreen_chart();
524 None
525 }
526 Action::EditAlias => {
527 app.start_alias_edit();
528 None
529 }
530 Action::TextInput(c) => {
531 if app.editing_alias {
532 app.alias_input_char(c);
533 }
534 None
535 }
536 Action::TextBackspace => {
537 if app.editing_alias {
538 app.alias_input_backspace();
539 }
540 None
541 }
542 Action::TextSubmit => {
543 if app.editing_alias {
544 app.save_alias();
545 }
546 None
547 }
548 Action::TextCancel => {
549 if app.editing_alias {
550 app.cancel_alias_edit();
551 }
552 None
553 }
554 Action::ToggleComparison => {
555 app.toggle_comparison();
556 None
557 }
558 Action::NextComparisonDevice => {
559 app.cycle_comparison_device(true);
560 None
561 }
562 Action::PrevComparisonDevice => {
563 app.cycle_comparison_device(false);
564 None
565 }
566 Action::ShowErrorDetails => {
567 app.toggle_error_details();
568 None
569 }
570 Action::ToggleTheme => {
571 app.toggle_theme();
572 let theme_name = match app.theme {
573 Theme::Dark => "dark",
574 Theme::Light => "light",
575 };
576 app.push_status_message(format!("Theme: {}", theme_name));
577 None
578 }
579 Action::ToggleChartTemp => {
580 app.toggle_chart_metric(App::METRIC_TEMP);
581 let status = if app.chart_shows(App::METRIC_TEMP) {
582 "shown"
583 } else {
584 "hidden"
585 };
586 app.push_status_message(format!("Temperature on chart: {}", status));
587 None
588 }
589 Action::ToggleChartHumidity => {
590 app.toggle_chart_metric(App::METRIC_HUMIDITY);
591 let status = if app.chart_shows(App::METRIC_HUMIDITY) {
592 "shown"
593 } else {
594 "hidden"
595 };
596 app.push_status_message(format!("Humidity on chart: {}", status));
597 None
598 }
599 Action::ToggleBleRange => {
600 app.toggle_ble_range();
601 None
602 }
603 Action::ToggleSmartHome => {
604 app.toggle_smart_home();
605 None
606 }
607 Action::None => None,
608 }
609}