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 => Some(Command::RefreshAll),
257        Action::Connect => app.selected_device().map(|device| Command::Connect {
258            device_id: device.id.clone(),
259        }),
260        Action::ConnectAll => {
261            // Connect to all disconnected devices one by one
262            // Find first disconnected device and connect
263            let first_disconnected = app
264                .devices
265                .iter()
266                .find(|d| matches!(d.status, ConnectionStatus::Disconnected))
267                .map(|d| d.id.clone());
268            let count = app
269                .devices
270                .iter()
271                .filter(|d| matches!(d.status, ConnectionStatus::Disconnected))
272                .count();
273
274            if let Some(device_id) = first_disconnected {
275                app.push_status_message(format!("Connecting... ({} remaining)", count));
276                return Some(Command::Connect { device_id });
277            } else {
278                app.push_status_message("All devices already connected".to_string());
279            }
280            None
281        }
282        Action::Disconnect => {
283            if let Some(device) = app.selected_device()
284                && matches!(device.status, ConnectionStatus::Connected)
285            {
286                let action = PendingAction::Disconnect {
287                    device_id: device.id.clone(),
288                    device_name: device.name.clone().unwrap_or_else(|| device.id.clone()),
289                };
290                app.request_confirmation(action);
291            }
292            None
293        }
294        Action::SyncHistory => app.selected_device().map(|device| Command::SyncHistory {
295            device_id: device.id.clone(),
296        }),
297        Action::SelectNext => {
298            if app.active_tab == Tab::Settings {
299                app.select_next_setting();
300            } else {
301                app.select_next_device();
302            }
303            None
304        }
305        Action::SelectPrevious => {
306            if app.active_tab == Tab::Settings {
307                app.select_previous_setting();
308            } else {
309                app.select_previous_device();
310            }
311            None
312        }
313        Action::NextTab => {
314            app.active_tab = match app.active_tab {
315                Tab::Dashboard => Tab::History,
316                Tab::History => Tab::Settings,
317                Tab::Settings => Tab::Dashboard,
318            };
319            None
320        }
321        Action::PreviousTab => {
322            app.active_tab = match app.active_tab {
323                Tab::Dashboard => Tab::Settings,
324                Tab::History => Tab::Dashboard,
325                Tab::Settings => Tab::History,
326            };
327            None
328        }
329        Action::ToggleHelp => {
330            app.show_help = !app.show_help;
331            None
332        }
333        Action::ToggleLogging => {
334            app.toggle_logging();
335            None
336        }
337        Action::ToggleBell => {
338            app.bell_enabled = !app.bell_enabled;
339            app.push_status_message(format!(
340                "Bell notifications {}",
341                if app.bell_enabled {
342                    "enabled"
343                } else {
344                    "disabled"
345                }
346            ));
347            None
348        }
349        Action::DismissAlert => {
350            // Close help overlay if open
351            if app.show_help {
352                app.show_help = false;
353            // Close error popup if open
354            } else if app.show_error_details {
355                app.show_error_details = false;
356            } else if let Some(device) = app.selected_device() {
357                let device_id = device.id.clone();
358                app.dismiss_alert(&device_id);
359            }
360            None
361        }
362        Action::ScrollUp => {
363            if app.active_tab == Tab::History {
364                app.scroll_history_up();
365            }
366            None
367        }
368        Action::ScrollDown => {
369            if app.active_tab == Tab::History {
370                app.scroll_history_down();
371            }
372            None
373        }
374        Action::SetHistoryFilter(filter) => {
375            if app.active_tab == Tab::History {
376                app.set_history_filter(filter);
377            }
378            None
379        }
380        Action::IncreaseThreshold => {
381            if app.active_tab == Tab::Settings {
382                match app.selected_setting {
383                    1 => app.increase_co2_threshold(),
384                    2 => app.increase_radon_threshold(),
385                    _ => {}
386                }
387                app.push_status_message(format!(
388                    "CO2: {} ppm, Radon: {} Bq/m³",
389                    app.co2_alert_threshold, app.radon_alert_threshold
390                ));
391            }
392            None
393        }
394        Action::DecreaseThreshold => {
395            if app.active_tab == Tab::Settings {
396                match app.selected_setting {
397                    1 => app.decrease_co2_threshold(),
398                    2 => app.decrease_radon_threshold(),
399                    _ => {}
400                }
401                app.push_status_message(format!(
402                    "CO2: {} ppm, Radon: {} Bq/m³",
403                    app.co2_alert_threshold, app.radon_alert_threshold
404                ));
405            }
406            None
407        }
408        Action::ChangeSetting => {
409            if app.active_tab == Tab::Settings && app.selected_setting == 0 {
410                // Interval setting
411                if let Some((device_id, new_interval)) = app.cycle_interval() {
412                    return Some(Command::SetInterval {
413                        device_id,
414                        interval_secs: new_interval,
415                    });
416                }
417            }
418            None
419        }
420        Action::ExportHistory => {
421            if let Some(path) = app.export_history() {
422                app.push_status_message(format!("Exported to {}", path));
423            } else {
424                app.push_status_message("No history to export".to_string());
425            }
426            None
427        }
428        Action::ToggleAlertHistory => {
429            app.toggle_alert_history();
430            None
431        }
432        Action::ToggleStickyAlerts => {
433            app.toggle_sticky_alerts();
434            None
435        }
436        Action::CycleDeviceFilter => {
437            app.cycle_device_filter();
438            None
439        }
440        Action::ToggleSidebar => {
441            app.toggle_sidebar();
442            app.push_status_message(
443                if app.show_sidebar {
444                    "Sidebar shown"
445                } else {
446                    "Sidebar hidden"
447                }
448                .to_string(),
449            );
450            None
451        }
452        Action::ToggleSidebarWidth => {
453            app.toggle_sidebar_width();
454            app.push_status_message(format!("Sidebar width: {}", app.sidebar_width));
455            None
456        }
457        Action::MouseClick { x, y } => {
458            // Tab bar is at y=1-3, clicking on a tab switches to it
459            if (1..=3).contains(&y) {
460                // Simple tab detection based on x position
461                if x < 15 {
462                    app.active_tab = Tab::Dashboard;
463                } else if x < 30 {
464                    app.active_tab = Tab::History;
465                } else if x < 45 {
466                    app.active_tab = Tab::Settings;
467                }
468            }
469            // Device list is in the left sidebar (x < ~25, y > 4)
470            else if x < 25 && y > 4 {
471                let device_row = (y as usize).saturating_sub(5);
472                if device_row < app.devices.len() {
473                    app.selected_device = device_row;
474                }
475            }
476            None
477        }
478        Action::Confirm => {
479            if app.pending_confirmation.is_some() {
480                return app.confirm_action();
481            }
482            None
483        }
484        Action::Cancel => {
485            if app.pending_confirmation.is_some() {
486                app.cancel_confirmation();
487            }
488            None
489        }
490        Action::ToggleChart => {
491            app.toggle_fullscreen_chart();
492            None
493        }
494        Action::EditAlias => {
495            app.start_alias_edit();
496            None
497        }
498        Action::TextInput(c) => {
499            if app.editing_alias {
500                app.alias_input_char(c);
501            }
502            None
503        }
504        Action::TextBackspace => {
505            if app.editing_alias {
506                app.alias_input_backspace();
507            }
508            None
509        }
510        Action::TextSubmit => {
511            if app.editing_alias {
512                app.save_alias();
513            }
514            None
515        }
516        Action::TextCancel => {
517            if app.editing_alias {
518                app.cancel_alias_edit();
519            }
520            None
521        }
522        Action::ToggleComparison => {
523            app.toggle_comparison();
524            None
525        }
526        Action::NextComparisonDevice => {
527            app.cycle_comparison_device(true);
528            None
529        }
530        Action::PrevComparisonDevice => {
531            app.cycle_comparison_device(false);
532            None
533        }
534        Action::ShowErrorDetails => {
535            app.toggle_error_details();
536            None
537        }
538        Action::ToggleTheme => {
539            app.toggle_theme();
540            let theme_name = match app.theme {
541                Theme::Dark => "dark",
542                Theme::Light => "light",
543            };
544            app.push_status_message(format!("Theme: {}", theme_name));
545            None
546        }
547        Action::ToggleChartTemp => {
548            app.toggle_chart_metric(App::METRIC_TEMP);
549            let status = if app.chart_shows(App::METRIC_TEMP) {
550                "shown"
551            } else {
552                "hidden"
553            };
554            app.push_status_message(format!("Temperature on chart: {}", status));
555            None
556        }
557        Action::ToggleChartHumidity => {
558            app.toggle_chart_metric(App::METRIC_HUMIDITY);
559            let status = if app.chart_shows(App::METRIC_HUMIDITY) {
560                "shown"
561            } else {
562                "hidden"
563            };
564            app.push_status_message(format!("Humidity on chart: {}", status));
565            None
566        }
567        Action::ToggleBleRange => {
568            app.toggle_ble_range();
569            None
570        }
571        Action::ToggleSmartHome => {
572            app.toggle_smart_home();
573            None
574        }
575        Action::None => None,
576    }
577}