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/// Handle navigation actions: tab switching, scrolling, item selection, mouse clicks.
237fn apply_navigation_action(app: &mut App, action: Action) -> Option<Command> {
238    match action {
239        Action::SelectNext => {
240            if app.active_tab == Tab::Settings {
241                app.select_next_setting();
242            } else {
243                app.select_next_device();
244            }
245            None
246        }
247        Action::SelectPrevious => {
248            if app.active_tab == Tab::Settings {
249                app.select_previous_setting();
250            } else {
251                app.select_previous_device();
252            }
253            None
254        }
255        Action::NextTab => {
256            app.active_tab = match app.active_tab {
257                Tab::Dashboard => Tab::History,
258                Tab::History => Tab::Settings,
259                Tab::Settings => Tab::Service,
260                Tab::Service => Tab::Dashboard,
261            };
262            None
263        }
264        Action::PreviousTab => {
265            app.active_tab = match app.active_tab {
266                Tab::Dashboard => Tab::Service,
267                Tab::History => Tab::Dashboard,
268                Tab::Settings => Tab::History,
269                Tab::Service => Tab::Settings,
270            };
271            None
272        }
273        Action::ScrollUp => {
274            if app.active_tab == Tab::History {
275                app.scroll_history_up();
276            }
277            None
278        }
279        Action::ScrollDown => {
280            if app.active_tab == Tab::History {
281                app.scroll_history_down();
282            }
283            None
284        }
285        Action::SetHistoryFilter(filter) => {
286            if app.active_tab == Tab::History {
287                app.set_history_filter(filter);
288            }
289            None
290        }
291        Action::MouseClick { x, y } => {
292            // Tab bar is at y=1-3, clicking on a tab switches to it
293            if (1..=3).contains(&y) {
294                // Simple tab detection based on x position
295                if x < 15 {
296                    app.active_tab = Tab::Dashboard;
297                } else if x < 30 {
298                    app.active_tab = Tab::History;
299                } else if x < 45 {
300                    app.active_tab = Tab::Settings;
301                } else if x < 60 {
302                    app.active_tab = Tab::Service;
303                }
304            }
305            // Device list is in the left sidebar (x < ~25, y > 4)
306            else if x < 25 && y > 4 {
307                let device_row = (y as usize).saturating_sub(5);
308                app.select_filtered_row(device_row);
309            }
310            None
311        }
312        _ => None,
313    }
314}
315
316/// Handle device actions: connect, disconnect, scan, refresh, sync, confirm/cancel.
317fn apply_device_action(app: &mut App, action: Action) -> Option<Command> {
318    match action {
319        Action::Scan => Some(Command::Scan {
320            duration: Duration::from_secs(5),
321        }),
322        Action::Refresh => {
323            if app.active_tab == Tab::Service {
324                Some(Command::RefreshServiceStatus)
325            } else {
326                Some(Command::RefreshAll)
327            }
328        }
329        Action::Connect => app.selected_device().map(|device| Command::Connect {
330            device_id: device.id.clone(),
331        }),
332        Action::ConnectAll => {
333            let first_disconnected = app
334                .devices
335                .iter()
336                .find(|d| matches!(d.status, ConnectionStatus::Disconnected))
337                .map(|d| d.id.clone());
338            let count = app
339                .devices
340                .iter()
341                .filter(|d| matches!(d.status, ConnectionStatus::Disconnected))
342                .count();
343
344            if let Some(device_id) = first_disconnected {
345                app.push_status_message(format!("Connecting... ({} remaining)", count));
346                return Some(Command::Connect { device_id });
347            } else {
348                app.push_status_message("All devices already connected".to_string());
349            }
350            None
351        }
352        Action::Disconnect => {
353            if let Some(device) = app.selected_device()
354                && matches!(device.status, ConnectionStatus::Connected)
355            {
356                let action = PendingAction::Disconnect {
357                    device_id: device.id.clone(),
358                    device_name: device.name.clone().unwrap_or_else(|| device.id.clone()),
359                };
360                app.request_confirmation(action);
361            }
362            None
363        }
364        Action::SyncHistory => app.selected_device().map(|device| Command::SyncHistory {
365            device_id: device.id.clone(),
366        }),
367        Action::Confirm => {
368            if app.pending_confirmation.is_some() {
369                return app.confirm_action();
370            }
371            None
372        }
373        Action::Cancel => {
374            if app.pending_confirmation.is_some() {
375                app.cancel_confirmation();
376            }
377            None
378        }
379        Action::EditAlias => {
380            app.start_alias_edit();
381            None
382        }
383        Action::TextInput(c) => {
384            if app.editing_alias {
385                app.alias_input_char(c);
386            }
387            None
388        }
389        Action::TextBackspace => {
390            if app.editing_alias {
391                app.alias_input_backspace();
392            }
393            None
394        }
395        Action::TextSubmit => {
396            if app.editing_alias {
397                app.save_alias();
398            }
399            None
400        }
401        Action::TextCancel => {
402            if app.editing_alias {
403                app.cancel_alias_edit();
404            }
405            None
406        }
407        Action::ExportHistory => {
408            if let Some(path) = app.export_history() {
409                app.push_status_message(format!("Exported to {}", path));
410            } else {
411                app.push_status_message("No history to export".to_string());
412            }
413            None
414        }
415        Action::CycleDeviceFilter => {
416            app.cycle_device_filter();
417            None
418        }
419        _ => None,
420    }
421}
422
423/// Handle settings actions: threshold adjustments, interval changes, service control.
424fn apply_settings_action(app: &mut App, action: Action) -> Option<Command> {
425    match action {
426        Action::IncreaseThreshold => {
427            if app.active_tab == Tab::Settings {
428                match app.selected_setting {
429                    1 => app.increase_co2_threshold(),
430                    2 => app.increase_radon_threshold(),
431                    _ => {}
432                }
433                app.push_status_message(format!(
434                    "CO2: {} ppm, Radon: {} Bq/m³",
435                    app.co2_alert_threshold, app.radon_alert_threshold
436                ));
437            }
438            None
439        }
440        Action::DecreaseThreshold => {
441            if app.active_tab == Tab::Settings {
442                match app.selected_setting {
443                    1 => app.decrease_co2_threshold(),
444                    2 => app.decrease_radon_threshold(),
445                    _ => {}
446                }
447                app.push_status_message(format!(
448                    "CO2: {} ppm, Radon: {} Bq/m³",
449                    app.co2_alert_threshold, app.radon_alert_threshold
450                ));
451            }
452            None
453        }
454        Action::ChangeSetting => {
455            if app.active_tab == Tab::Service {
456                if let Some(ref status) = app.service_status {
457                    if status.reachable {
458                        if status.collector_running {
459                            return Some(Command::StopServiceCollector);
460                        } else {
461                            return Some(Command::StartServiceCollector);
462                        }
463                    } else {
464                        app.push_status_message("Service not reachable".to_string());
465                    }
466                } else {
467                    app.push_status_message(
468                        "Service status unknown - press 'r' to refresh".to_string(),
469                    );
470                }
471                None
472            } else if app.active_tab == Tab::Settings && app.selected_setting == 0 {
473                if let Some((device_id, new_interval)) = app.cycle_interval() {
474                    return Some(Command::SetInterval {
475                        device_id,
476                        interval_secs: new_interval,
477                    });
478                }
479                None
480            } else {
481                None
482            }
483        }
484        Action::ToggleLogging => {
485            app.toggle_logging();
486            None
487        }
488        Action::ToggleBell => {
489            app.bell_enabled = !app.bell_enabled;
490            app.push_status_message(format!(
491                "Bell notifications {}",
492                if app.bell_enabled {
493                    "enabled"
494                } else {
495                    "disabled"
496                }
497            ));
498            None
499        }
500        Action::ToggleAlertHistory => {
501            app.toggle_alert_history();
502            None
503        }
504        Action::ToggleStickyAlerts => {
505            app.toggle_sticky_alerts();
506            None
507        }
508        Action::ToggleBleRange => {
509            app.toggle_ble_range();
510            None
511        }
512        Action::ToggleSmartHome => {
513            app.toggle_smart_home();
514            None
515        }
516        Action::ToggleDoNotDisturb => {
517            app.toggle_do_not_disturb();
518            None
519        }
520        Action::ToggleExportFormat => {
521            app.toggle_export_format();
522            None
523        }
524        _ => None,
525    }
526}
527
528/// Handle view/display actions: theme, help, sidebar, chart, comparison, errors.
529fn apply_view_action(app: &mut App, action: Action) -> Option<Command> {
530    match action {
531        Action::ToggleHelp => {
532            app.show_help = !app.show_help;
533            None
534        }
535        Action::DismissAlert => {
536            if app.show_help {
537                app.show_help = false;
538            } else if app.show_error_details {
539                app.show_error_details = false;
540            } else if let Some(device) = app.selected_device() {
541                let device_id = device.id.clone();
542                app.dismiss_alert(&device_id);
543            }
544            None
545        }
546        Action::ToggleSidebar => {
547            app.toggle_sidebar();
548            app.push_status_message(
549                if app.show_sidebar {
550                    "Sidebar shown"
551                } else {
552                    "Sidebar hidden"
553                }
554                .to_string(),
555            );
556            None
557        }
558        Action::ToggleSidebarWidth => {
559            app.toggle_sidebar_width();
560            app.push_status_message(format!("Sidebar width: {}", app.sidebar_width));
561            None
562        }
563        Action::ToggleChart => {
564            app.toggle_fullscreen_chart();
565            None
566        }
567        Action::ToggleComparison => {
568            app.toggle_comparison();
569            None
570        }
571        Action::NextComparisonDevice => {
572            app.cycle_comparison_device(true);
573            None
574        }
575        Action::PrevComparisonDevice => {
576            app.cycle_comparison_device(false);
577            None
578        }
579        Action::ShowErrorDetails => {
580            app.toggle_error_details();
581            None
582        }
583        Action::ToggleTheme => {
584            app.toggle_theme();
585            let theme_name = match app.theme {
586                Theme::Dark => "dark",
587                Theme::Light => "light",
588            };
589            app.push_status_message(format!("Theme: {}", theme_name));
590            None
591        }
592        Action::ToggleChartTemp => {
593            app.toggle_chart_metric(App::METRIC_TEMP);
594            let status = if app.chart_shows(App::METRIC_TEMP) {
595                "shown"
596            } else {
597                "hidden"
598            };
599            app.push_status_message(format!("Temperature on chart: {}", status));
600            None
601        }
602        Action::ToggleChartHumidity => {
603            app.toggle_chart_metric(App::METRIC_HUMIDITY);
604            let status = if app.chart_shows(App::METRIC_HUMIDITY) {
605                "shown"
606            } else {
607                "hidden"
608            };
609            app.push_status_message(format!("Humidity on chart: {}", status));
610            None
611        }
612        _ => None,
613    }
614}
615
616/// Apply an action to the application state.
617///
618/// This function handles both UI-only actions (which modify app state directly)
619/// and command actions (which return a command to be sent to the background worker).
620/// Actions are dispatched to focused handler functions by category:
621/// navigation, device, settings, and view actions.
622///
623/// # Arguments
624///
625/// * `app` - Mutable reference to the application state
626/// * `action` - The action to apply
627/// * `_command_tx` - Channel for sending commands (used for reference, actual sending done by caller)
628///
629/// # Returns
630///
631/// `Some(Command)` if an async command should be sent to the background worker,
632/// `None` if the action was handled entirely within the UI.
633pub fn apply_action(
634    app: &mut App,
635    action: Action,
636    _command_tx: &mpsc::Sender<Command>,
637) -> Option<Command> {
638    match action {
639        Action::Quit => {
640            app.should_quit = true;
641            None
642        }
643        Action::None => None,
644
645        // Navigation: tab switching, scrolling, item selection, mouse clicks
646        Action::SelectNext
647        | Action::SelectPrevious
648        | Action::NextTab
649        | Action::PreviousTab
650        | Action::ScrollUp
651        | Action::ScrollDown
652        | Action::SetHistoryFilter(_)
653        | Action::MouseClick { .. } => apply_navigation_action(app, action),
654
655        // Device: scan, connect, disconnect, sync, confirm/cancel, alias, export, filter
656        Action::Scan
657        | Action::Refresh
658        | Action::Connect
659        | Action::ConnectAll
660        | Action::Disconnect
661        | Action::SyncHistory
662        | Action::Confirm
663        | Action::Cancel
664        | Action::EditAlias
665        | Action::TextInput(_)
666        | Action::TextBackspace
667        | Action::TextSubmit
668        | Action::TextCancel
669        | Action::ExportHistory
670        | Action::CycleDeviceFilter => apply_device_action(app, action),
671
672        // Settings: thresholds, intervals, toggles for logging/bell/alerts/BLE/smart home
673        Action::IncreaseThreshold
674        | Action::DecreaseThreshold
675        | Action::ChangeSetting
676        | Action::ToggleLogging
677        | Action::ToggleBell
678        | Action::ToggleAlertHistory
679        | Action::ToggleStickyAlerts
680        | Action::ToggleBleRange
681        | Action::ToggleSmartHome
682        | Action::ToggleDoNotDisturb
683        | Action::ToggleExportFormat => apply_settings_action(app, action),
684
685        // View: theme, help, sidebar, chart, comparison, error details
686        Action::ToggleHelp
687        | Action::DismissAlert
688        | Action::ToggleSidebar
689        | Action::ToggleSidebarWidth
690        | Action::ToggleChart
691        | Action::ToggleComparison
692        | Action::NextComparisonDevice
693        | Action::PrevComparisonDevice
694        | Action::ShowErrorDetails
695        | Action::ToggleTheme
696        | Action::ToggleChartTemp
697        | Action::ToggleChartHumidity => apply_view_action(app, action),
698    }
699}