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