aranet_cli/
tui.rs

1//! TUI module for Aranet terminal dashboard.
2//!
3//! This module provides an interactive terminal user interface for monitoring
4//! Aranet environmental sensors. It can be used standalone (when only the `tui`
5//! feature is enabled) or as a subcommand of the CLI (when both `cli` and `tui`
6//! features are enabled).
7
8use std::io::{self, stdout};
9use std::time::Duration;
10
11use anyhow::Result;
12use crossterm::{
13    ExecutableCommand,
14    event::{self, Event, KeyCode, KeyEventKind},
15    terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
16};
17use ratatui::{
18    prelude::*,
19    widgets::{Block, Borders, Paragraph},
20};
21
22/// Application state for the TUI
23pub struct App {
24    should_quit: bool,
25}
26
27impl App {
28    /// Create a new App instance
29    pub fn new() -> Self {
30        Self { should_quit: false }
31    }
32
33    /// Handle a key press event
34    pub fn handle_key(&mut self, key: KeyCode) {
35        if key == KeyCode::Char('q') {
36            self.should_quit = true;
37        }
38    }
39
40    /// Check if the app should quit
41    pub fn should_quit(&self) -> bool {
42        self.should_quit
43    }
44}
45
46impl Default for App {
47    fn default() -> Self {
48        Self::new()
49    }
50}
51
52/// Set up the terminal for TUI rendering
53pub fn setup_terminal() -> Result<Terminal<CrosstermBackend<io::Stdout>>> {
54    enable_raw_mode()?;
55    stdout().execute(EnterAlternateScreen)?;
56    let backend = CrosstermBackend::new(stdout());
57    let terminal = Terminal::new(backend)?;
58    Ok(terminal)
59}
60
61/// Restore the terminal to its original state
62pub fn restore_terminal() -> Result<()> {
63    disable_raw_mode()?;
64    stdout().execute(LeaveAlternateScreen)?;
65    Ok(())
66}
67
68/// Draw the UI
69pub fn draw(frame: &mut Frame) {
70    let area = frame.area();
71
72    let block = Block::default()
73        .title(" Aranet TUI ")
74        .borders(Borders::ALL)
75        .border_style(Style::default().fg(Color::Cyan));
76
77    let message = Paragraph::new("Aranet TUI - Coming Soon\n\nPress 'q' to quit")
78        .alignment(Alignment::Center)
79        .block(block);
80
81    // Center the message vertically
82    let vertical_center = Layout::default()
83        .direction(Direction::Vertical)
84        .constraints([
85            Constraint::Percentage(40),
86            Constraint::Length(5),
87            Constraint::Percentage(40),
88        ])
89        .split(area);
90
91    frame.render_widget(message, vertical_center[1]);
92}
93
94/// Main event loop for the TUI
95pub fn run_loop(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
96    let mut app = App::new();
97
98    while !app.should_quit() {
99        terminal.draw(draw)?;
100
101        // Poll for events with a timeout
102        if event::poll(Duration::from_millis(100))?
103            && let Event::Key(key) = event::read()?
104            && key.kind == KeyEventKind::Press
105        {
106            app.handle_key(key.code);
107        }
108    }
109
110    Ok(())
111}
112
113/// Run the TUI application
114///
115/// This is the main entry point for the TUI. It sets up the terminal,
116/// runs the event loop, and ensures the terminal is restored on exit.
117pub async fn run() -> Result<()> {
118    let mut terminal = setup_terminal()?;
119
120    // Run the app and ensure terminal is restored even on error
121    let result = run_loop(&mut terminal);
122
123    restore_terminal()?;
124
125    result
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131
132    #[test]
133    fn test_app_new() {
134        let app = App::new();
135        assert!(!app.should_quit());
136    }
137
138    #[test]
139    fn test_app_default() {
140        let app = App::default();
141        assert!(!app.should_quit());
142    }
143
144    #[test]
145    fn test_app_handle_key_q_quits() {
146        let mut app = App::new();
147        assert!(!app.should_quit());
148
149        app.handle_key(KeyCode::Char('q'));
150        assert!(app.should_quit());
151    }
152
153    #[test]
154    fn test_app_handle_key_other_does_not_quit() {
155        let mut app = App::new();
156
157        app.handle_key(KeyCode::Char('a'));
158        assert!(!app.should_quit());
159
160        app.handle_key(KeyCode::Enter);
161        assert!(!app.should_quit());
162
163        app.handle_key(KeyCode::Esc);
164        assert!(!app.should_quit());
165
166        app.handle_key(KeyCode::Up);
167        assert!(!app.should_quit());
168    }
169
170    #[test]
171    fn test_app_handle_key_uppercase_q_does_not_quit() {
172        let mut app = App::new();
173
174        // Only lowercase 'q' should quit
175        app.handle_key(KeyCode::Char('Q'));
176        assert!(!app.should_quit());
177    }
178
179    #[test]
180    fn test_app_should_quit_returns_correct_state() {
181        let mut app = App::new();
182
183        // Initial state
184        assert!(!app.should_quit);
185        assert!(!app.should_quit());
186
187        // After setting manually
188        app.should_quit = true;
189        assert!(app.should_quit);
190        assert!(app.should_quit());
191    }
192}