Skip to main content

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