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//! | `D`       | Do Not Disturb    |
23//! | `F`       | Toggle export fmt |
24
25use 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/// User actions that can be triggered by keyboard input.
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum Action {
36    /// Quit the application.
37    Quit,
38    /// Start scanning for devices.
39    Scan,
40    /// Refresh readings for all connected devices.
41    Refresh,
42    /// Connect to the currently selected device.
43    Connect,
44    /// Connect to all devices.
45    ConnectAll,
46    /// Disconnect from the currently selected device.
47    Disconnect,
48    /// Sync history from the currently selected device.
49    SyncHistory,
50    /// Select the next item in the list.
51    SelectNext,
52    /// Select the previous item in the list.
53    SelectPrevious,
54    /// Switch to the next tab.
55    NextTab,
56    /// Switch to the previous tab.
57    PreviousTab,
58    /// Toggle the help overlay.
59    ToggleHelp,
60    /// Toggle data logging.
61    ToggleLogging,
62    /// Toggle terminal bell for alerts.
63    ToggleBell,
64    /// Dismiss current alert.
65    DismissAlert,
66    /// Scroll history up.
67    ScrollUp,
68    /// Scroll history down.
69    ScrollDown,
70    /// Set history filter.
71    SetHistoryFilter(HistoryFilter),
72    /// Increase threshold value.
73    IncreaseThreshold,
74    /// Decrease threshold value.
75    DecreaseThreshold,
76    /// Change setting value (in Settings tab).
77    ChangeSetting,
78    /// Export history to CSV file.
79    ExportHistory,
80    /// Toggle alert history view.
81    ToggleAlertHistory,
82    /// Cycle device filter.
83    CycleDeviceFilter,
84    /// Toggle sidebar visibility.
85    ToggleSidebar,
86    /// Toggle sidebar width.
87    ToggleSidebarWidth,
88    /// Mouse click at coordinates.
89    MouseClick { x: u16, y: u16 },
90    /// Confirm pending action.
91    Confirm,
92    /// Cancel pending action.
93    Cancel,
94    /// Toggle full-screen chart view.
95    ToggleChart,
96    /// Start editing device alias.
97    EditAlias,
98    /// Input character for text input.
99    TextInput(char),
100    /// Backspace for text input.
101    TextBackspace,
102    /// Submit text input.
103    TextSubmit,
104    /// Cancel text input.
105    TextCancel,
106    /// Toggle sticky alerts.
107    ToggleStickyAlerts,
108    /// Toggle comparison view.
109    ToggleComparison,
110    /// Cycle comparison device forward.
111    NextComparisonDevice,
112    /// Cycle comparison device backward.
113    PrevComparisonDevice,
114    /// Show error details popup.
115    ShowErrorDetails,
116    /// Toggle theme.
117    ToggleTheme,
118    /// Toggle temperature on chart.
119    ToggleChartTemp,
120    /// Toggle humidity on chart.
121    ToggleChartHumidity,
122    /// Toggle Bluetooth range.
123    ToggleBleRange,
124    /// Toggle Smart Home mode.
125    ToggleSmartHome,
126    /// Toggle Do Not Disturb mode.
127    ToggleDoNotDisturb,
128    /// Toggle export format (CSV/JSON).
129    ToggleExportFormat,
130    /// No action (unrecognized key).
131    None,
132}
133
134/// Map a key code to an action.
135///
136/// # Arguments
137///
138/// * `key` - The key code from a keyboard event
139/// * `editing_text` - Whether the user is currently editing text input
140/// * `has_pending_confirmation` - Whether there is a pending confirmation dialog
141///
142/// # Returns
143///
144/// The corresponding action for the key, or [`Action::None`] if the key
145/// is not mapped to any action.
146pub fn handle_key(key: KeyCode, editing_text: bool, has_pending_confirmation: bool) -> Action {
147    // If editing text, handle text input specially
148    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    // When a confirmation dialog is active, only handle Y/N keys
159    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
216/// Handle mouse events and return corresponding action.
217///
218/// # Arguments
219///
220/// * `event` - The mouse event from crossterm
221///
222/// # Returns
223///
224/// The corresponding action for the mouse event, or [`Action::None`] if the event
225/// is not mapped to any action.
226pub 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
236/// Apply an action to the application state.
237///
238/// This function handles both UI-only actions (which modify app state directly)
239/// and command actions (which return a command to be sent to the background worker).
240///
241/// # Arguments
242///
243/// * `app` - Mutable reference to the application state
244/// * `action` - The action to apply
245/// * `_command_tx` - Channel for sending commands (used for reference, actual sending done by caller)
246///
247/// # Returns
248///
249/// `Some(Command)` if an async command should be sent to the background worker,
250/// `None` if the action was handled entirely within the UI.
251pub 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                // In Service tab, refresh service status
267                Some(Command::RefreshServiceStatus)
268            } else {
269                // In other tabs, refresh sensor readings
270                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            // Connect to all disconnected devices one by one
278            // Find first disconnected device and connect
279            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            // Close help overlay if open
369            if app.show_help {
370                app.show_help = false;
371            // Close error popup if open
372            } 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                // In Service tab, Enter toggles collector start/stop
429                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                // Interval setting
447                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            // Tab bar is at y=1-3, clicking on a tab switches to it
497            if (1..=3).contains(&y) {
498                // Simple tab detection based on x position
499                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            // Device list is in the left sidebar (x < ~25, y > 4)
510            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}