aranet_cli/tui/
mod.rs

1//! Main entry point for the TUI dashboard.
2//!
3//! This module ties together all the TUI components and provides the main
4//! event loop for the terminal user interface. It handles:
5//!
6//! - Terminal setup and restoration
7//! - Channel creation for worker communication
8//! - The main event loop with input handling and rendering
9//! - Graceful shutdown coordination
10
11pub mod app;
12pub mod errors;
13pub mod input;
14pub mod messages;
15pub mod ui;
16pub mod worker;
17
18pub use app::App;
19pub use messages::{Command, SensorEvent};
20pub use worker::SensorWorker;
21
22use std::io::{self, stdout};
23use std::time::Duration;
24
25use anyhow::Result;
26use crossterm::{
27    ExecutableCommand,
28    event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyEventKind},
29    terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
30};
31use ratatui::prelude::*;
32use tokio::sync::mpsc;
33use tracing::info;
34
35use aranet_store::default_db_path;
36
37/// Set up the terminal for TUI rendering.
38///
39/// Enables raw mode, mouse capture, and switches to the alternate screen buffer.
40pub fn setup_terminal() -> Result<Terminal<CrosstermBackend<io::Stdout>>> {
41    enable_raw_mode()?;
42    stdout().execute(EnterAlternateScreen)?;
43    stdout().execute(EnableMouseCapture)?;
44    let backend = CrosstermBackend::new(stdout());
45    let terminal = Terminal::new(backend)?;
46    Ok(terminal)
47}
48
49/// Restore the terminal to its original state.
50///
51/// Disables mouse capture, raw mode and returns to the main screen buffer.
52pub fn restore_terminal() -> Result<()> {
53    stdout().execute(DisableMouseCapture)?;
54    disable_raw_mode()?;
55    stdout().execute(LeaveAlternateScreen)?;
56    Ok(())
57}
58
59/// Run the TUI application.
60///
61/// This is the main entry point for the TUI. It:
62/// 1. Creates communication channels between UI and worker
63/// 2. Gets the store path (if available)
64/// 3. Spawns the background sensor worker
65/// 4. Runs the main event loop
66/// 5. Ensures graceful shutdown
67pub async fn run() -> Result<()> {
68    // Create communication channels
69    let (cmd_tx, cmd_rx) = mpsc::channel::<Command>(32);
70    let (event_tx, event_rx) = mpsc::channel::<SensorEvent>(32);
71
72    // Get the store path for persistence
73    let store_path = default_db_path();
74    info!("Store path: {:?}", store_path);
75
76    // Create and spawn the background worker
77    let worker = SensorWorker::new(cmd_rx, event_tx, store_path);
78    let worker_handle = tokio::spawn(worker.run());
79
80    // Create the application
81    let mut app = App::new(cmd_tx.clone(), event_rx);
82
83    // Set up terminal
84    let mut terminal = setup_terminal()?;
85
86    // Load cached devices from store first (shows data immediately)
87    let _ = cmd_tx.try_send(Command::LoadCachedData);
88
89    // Then auto-scan for live devices
90    let _ = cmd_tx.try_send(Command::Scan {
91        duration: Duration::from_secs(5),
92    });
93
94    // Run the main event loop
95    let result = run_event_loop(&mut terminal, &mut app, &cmd_tx).await;
96
97    // Send shutdown command to worker
98    let _ = cmd_tx.try_send(Command::Shutdown);
99
100    // Restore terminal
101    restore_terminal()?;
102
103    // Wait for worker to complete
104    let _ = worker_handle.await;
105
106    result
107}
108
109/// Main event loop for the TUI.
110async fn run_event_loop(
111    terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
112    app: &mut App,
113    command_tx: &mpsc::Sender<Command>,
114) -> Result<()> {
115    while !app.should_quit() {
116        // Tick spinner animation
117        app.tick_spinner();
118        app.clean_expired_messages();
119
120        // Draw the UI
121        terminal.draw(|f| ui::draw(f, app))?;
122
123        // Poll for keyboard and mouse events with timeout
124        if event::poll(Duration::from_millis(100))? {
125            match event::read()? {
126                Event::Key(key) => {
127                    if key.kind == KeyEventKind::Press {
128                        let action = input::handle_key(
129                            key.code,
130                            app.editing_alias,
131                            app.pending_confirmation.is_some(),
132                        );
133                        if let Some(cmd) = input::apply_action(app, action, command_tx) {
134                            let _ = command_tx.try_send(cmd);
135                        }
136                    }
137                }
138                Event::Mouse(mouse_event) => {
139                    let action = input::handle_mouse(mouse_event);
140                    if let Some(cmd) = input::apply_action(app, action, command_tx) {
141                        let _ = command_tx.try_send(cmd);
142                    }
143                }
144                _ => {}
145            }
146        }
147
148        // Non-blocking receive of sensor events
149        while let Ok(event) = app.event_rx.try_recv() {
150            // Handle event and send any auto-commands (auto-connect, auto-sync)
151            let auto_commands = app.handle_sensor_event(event);
152            for cmd in auto_commands {
153                let _ = command_tx.try_send(cmd);
154            }
155        }
156
157        // Check for auto-refresh of connected devices
158        let devices_to_refresh = app.check_auto_refresh();
159        for device_id in devices_to_refresh {
160            let _ = command_tx.try_send(Command::RefreshReading { device_id });
161        }
162    }
163
164    Ok(())
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170    use crossterm::event::KeyCode;
171
172    #[test]
173    fn test_terminal_functions_exist() {
174        // Just verify the functions compile correctly
175        // Actual terminal tests require a real terminal
176        let _ = restore_terminal;
177        let _ = setup_terminal;
178    }
179
180    #[test]
181    fn test_input_handling_quit() {
182        let action = input::handle_key(KeyCode::Char('q'), false, false);
183        assert_eq!(action, input::Action::Quit);
184    }
185
186    #[test]
187    fn test_input_handling_scan() {
188        let action = input::handle_key(KeyCode::Char('s'), false, false);
189        assert_eq!(action, input::Action::Scan);
190    }
191
192    #[test]
193    fn test_input_handling_connect_all() {
194        // Lowercase 'c' connects selected device
195        let action = input::handle_key(KeyCode::Char('c'), false, false);
196        assert_eq!(action, input::Action::Connect);
197
198        // Uppercase 'C' connects all devices
199        let action = input::handle_key(KeyCode::Char('C'), false, false);
200        assert_eq!(action, input::Action::ConnectAll);
201    }
202
203    #[test]
204    fn test_input_handling_other_keys() {
205        let action = input::handle_key(KeyCode::Char('a'), false, false);
206        // 'a' is now mapped to ToggleAlertHistory
207        assert_eq!(action, input::Action::ToggleAlertHistory);
208
209        // Enter is now mapped to ChangeSetting
210        let action = input::handle_key(KeyCode::Enter, false, false);
211        assert_eq!(action, input::Action::ChangeSetting);
212    }
213
214    #[test]
215    fn test_input_handling_confirmation() {
216        // When confirmation is pending, only Y/N keys work
217        let action = input::handle_key(KeyCode::Char('y'), false, true);
218        assert_eq!(action, input::Action::Confirm);
219
220        let action = input::handle_key(KeyCode::Char('n'), false, true);
221        assert_eq!(action, input::Action::Cancel);
222
223        let action = input::handle_key(KeyCode::Esc, false, true);
224        assert_eq!(action, input::Action::Cancel);
225
226        // Other keys are ignored during confirmation
227        let action = input::handle_key(KeyCode::Char('q'), false, true);
228        assert_eq!(action, input::Action::None);
229    }
230}