Skip to main content

aranet_cli/tui/
input.rs

1//! Keyboard input handling for the TUI.
2//!
3//! This module provides key mapping and action handling for the terminal
4//! user interface. It translates keyboard events into high-level actions
5//! and applies those actions to the application state.
6//!
7//! # Key Bindings
8//!
9//! | Key       | Action          |
10//! |-----------|-----------------|
11//! | `q`       | Quit            |
12//! | `s`       | Scan            |
13//! | `r`       | Refresh         |
14//! | `c`       | Connect         |
15//! | `d`       | Disconnect      |
16//! | `y`       | Sync history    |
17//! | `↓` / `j` | Select next     |
18//! | `↑` / `k` | Select previous |
19//! | `Tab` / `l` | Next tab      |
20//! | `BackTab` / `h` | Previous tab |
21//! | `?`       | Toggle help     |
22
23use 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/// User actions that can be triggered by keyboard input.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum Action {
34    /// Quit the application.
35    Quit,
36    /// Start scanning for devices.
37    Scan,
38    /// Refresh readings for all connected devices.
39    Refresh,
40    /// Connect to the currently selected device.
41    Connect,
42    /// Connect to all devices.
43    ConnectAll,
44    /// Disconnect from the currently selected device.
45    Disconnect,
46    /// Sync history from the currently selected device.
47    SyncHistory,
48    /// Select the next item in the list.
49    SelectNext,
50    /// Select the previous item in the list.
51    SelectPrevious,
52    /// Switch to the next tab.
53    NextTab,
54    /// Switch to the previous tab.
55    PreviousTab,
56    /// Toggle the help overlay.
57    ToggleHelp,
58    /// Toggle data logging.
59    ToggleLogging,
60    /// Toggle terminal bell for alerts.
61    ToggleBell,
62    /// Dismiss current alert.
63    DismissAlert,
64    /// Scroll history up.
65    ScrollUp,
66    /// Scroll history down.
67    ScrollDown,
68    /// Set history filter.
69    SetHistoryFilter(HistoryFilter),
70    /// Increase threshold value.
71    IncreaseThreshold,
72    /// Decrease threshold value.
73    DecreaseThreshold,
74    /// Change setting value (in Settings tab).
75    ChangeSetting,
76    /// Export history to CSV file.
77    ExportHistory,
78    /// Toggle alert history view.
79    ToggleAlertHistory,
80    /// Cycle device filter.
81    CycleDeviceFilter,
82    /// Toggle sidebar visibility.
83    ToggleSidebar,
84    /// Toggle sidebar width.
85    ToggleSidebarWidth,
86    /// Mouse click at coordinates.
87    MouseClick { x: u16, y: u16 },
88    /// Confirm pending action.
89    Confirm,
90    /// Cancel pending action.
91    Cancel,
92    /// Toggle full-screen chart view.
93    ToggleChart,
94    /// Start editing device alias.
95    EditAlias,
96    /// Input character for text input.
97    TextInput(char),
98    /// Backspace for text input.
99    TextBackspace,
100    /// Submit text input.
101    TextSubmit,
102    /// Cancel text input.
103    TextCancel,
104    /// Toggle sticky alerts.
105    ToggleStickyAlerts,
106    /// Toggle comparison view.
107    ToggleComparison,
108    /// Cycle comparison device forward.
109    NextComparisonDevice,
110    /// Cycle comparison device backward.
111    PrevComparisonDevice,
112    /// Show error details popup.
113    ShowErrorDetails,
114    /// Toggle theme.
115    ToggleTheme,
116    /// Toggle temperature on chart.
117    ToggleChartTemp,
118    /// Toggle humidity on chart.
119    ToggleChartHumidity,
120    /// Toggle Bluetooth range.
121    ToggleBleRange,
122    /// Toggle Smart Home mode.
123    ToggleSmartHome,
124    /// No action (unrecognized key).
125    None,
126}
127
128/// Map a key code to an action.
129///
130/// # Arguments
131///
132/// * `key` - The key code from a keyboard event
133/// * `editing_text` - Whether the user is currently editing text input
134/// * `has_pending_confirmation` - Whether there is a pending confirmation dialog
135///
136/// # Returns
137///
138/// The corresponding action for the key, or [`Action::None`] if the key
139/// is not mapped to any action.
140pub fn handle_key(key: KeyCode, editing_text: bool, has_pending_confirmation: bool) -> Action {
141    // If editing text, handle text input specially
142    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    // When a confirmation dialog is active, only handle Y/N keys
153    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
208/// Handle mouse events and return corresponding action.
209///
210/// # Arguments
211///
212/// * `event` - The mouse event from crossterm
213///
214/// # Returns
215///
216/// The corresponding action for the mouse event, or [`Action::None`] if the event
217/// is not mapped to any action.
218pub 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
228/// Apply an action to the application state.
229///
230/// This function handles both UI-only actions (which modify app state directly)
231/// and command actions (which return a command to be sent to the background worker).
232///
233/// # Arguments
234///
235/// * `app` - Mutable reference to the application state
236/// * `action` - The action to apply
237/// * `_command_tx` - Channel for sending commands (used for reference, actual sending done by caller)
238///
239/// # Returns
240///
241/// `Some(Command)` if an async command should be sent to the background worker,
242/// `None` if the action was handled entirely within the UI.
243pub 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                // In Service tab, refresh service status
259                Some(Command::RefreshServiceStatus)
260            } else {
261                // In other tabs, refresh sensor readings
262                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            // Connect to all disconnected devices one by one
270            // Find first disconnected device and connect
271            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            // Close help overlay if open
361            if app.show_help {
362                app.show_help = false;
363            // Close error popup if open
364            } 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                // In Service tab, Enter toggles collector start/stop
421                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                // Interval setting
439                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            // Tab bar is at y=1-3, clicking on a tab switches to it
489            if (1..=3).contains(&y) {
490                // Simple tab detection based on x position
491                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            // Device list is in the left sidebar (x < ~25, y > 4)
502            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}