aranet_cli/tui/ui/
mod.rs

1//! Main UI layout and rendering for the TUI dashboard.
2//!
3//! This module provides the primary layout structure and draw functions for
4//! the Aranet TUI dashboard. The layout consists of:
5//!
6//! - **Header**: Title and current time display
7//! - **Main content**: Device list (left) and readings panel (right)
8//! - **Status bar**: Help text and status messages
9
10pub mod colors;
11pub mod theme;
12pub mod widgets;
13
14mod dashboard;
15mod history;
16mod overlays;
17mod settings;
18
19use chrono::Local;
20use ratatui::prelude::*;
21use ratatui::widgets::{Block, Borders, Paragraph};
22
23use super::app::{App, Tab, Theme};
24use colors::co2_color;
25use theme::BORDER_TYPE;
26
27/// Get signal strength bars based on RSSI value.
28pub(crate) fn rssi_display(rssi: i16) -> (&'static str, Color) {
29    // Typical RSSI ranges:
30    // -30 to -50: Excellent (4 bars)
31    // -50 to -60: Good (3 bars)
32    // -60 to -70: Fair (2 bars)
33    // -70 to -80: Weak (1 bar)
34    // Below -80: Very weak (0 bars)
35    if rssi >= -50 {
36        ("▂▄▆█", Color::Green)
37    } else if rssi >= -60 {
38        ("▂▄▆░", Color::Green)
39    } else if rssi >= -70 {
40        ("▂▄░░", Color::Yellow)
41    } else if rssi >= -80 {
42        ("▂░░░", Color::Red)
43    } else {
44        ("░░░░", Color::DarkGray)
45    }
46}
47
48/// Draw the complete TUI interface.
49///
50/// This function creates the main layout and delegates rendering to helper
51/// functions for each area.
52pub fn draw(frame: &mut Frame, app: &App) {
53    // Apply theme background
54    if matches!(app.theme, Theme::Light) {
55        frame.render_widget(
56            Block::default().style(Style::default().bg(app.theme.bg())),
57            frame.area(),
58        );
59    }
60
61    // Full-screen chart view
62    if app.show_fullscreen_chart {
63        overlays::draw_fullscreen_chart(frame, app);
64        return; // Don't render anything else
65    }
66
67    // Comparison view
68    if app.show_comparison {
69        overlays::draw_comparison_view(frame, app);
70        return; // Don't render anything else
71    }
72
73    let main_layout = Layout::default()
74        .direction(Direction::Vertical)
75        .constraints([
76            Constraint::Length(1), // Header bar
77            Constraint::Length(3), // Tab bar
78            Constraint::Min(1),    // Main content
79            Constraint::Length(1), // Status bar
80        ])
81        .split(frame.area());
82
83    draw_header(frame, main_layout[0], app);
84    draw_tab_bar(frame, main_layout[1], app);
85
86    // Responsive layout: hide sidebar on narrow terminals or when toggled off
87    let area = main_layout[2];
88    let is_narrow = area.width < 80;
89    let show_sidebar = app.show_sidebar && !is_narrow;
90
91    let content_constraints = if show_sidebar {
92        vec![
93            Constraint::Length(app.sidebar_width), // Device list sidebar
94            Constraint::Min(1),                    // Main content
95        ]
96    } else {
97        vec![
98            Constraint::Length(0), // Hidden sidebar
99            Constraint::Min(1),    // Full width content
100        ]
101    };
102
103    let content_layout = Layout::default()
104        .direction(Direction::Horizontal)
105        .constraints(content_constraints)
106        .split(area);
107
108    if show_sidebar {
109        dashboard::draw_device_list(frame, content_layout[0], app);
110    }
111
112    // Render different content based on active tab
113    match app.active_tab {
114        Tab::Dashboard => dashboard::draw_readings_panel(frame, content_layout[1], app),
115        Tab::History => history::draw_history_panel(frame, content_layout[1], app),
116        Tab::Settings => settings::draw_settings_panel(frame, content_layout[1], app),
117    }
118
119    draw_status_bar(frame, main_layout[3], app);
120
121    // Draw help overlay if active
122    if app.show_help {
123        overlays::draw_help_overlay(frame);
124    }
125
126    // Alert history overlay
127    overlays::draw_alert_history(frame, app);
128
129    // Alias editor overlay
130    overlays::draw_alias_editor(frame, app);
131
132    // Error details popup
133    overlays::draw_error_popup(frame, app);
134
135    // Confirmation dialog (on top of everything)
136    overlays::draw_confirmation_dialog(frame, app);
137}
138
139/// Draw the header bar with app title, quick stats, and indicators.
140fn draw_header(frame: &mut Frame, area: Rect, app: &App) {
141    let theme = app.app_theme();
142
143    let mut spans = vec![
144        Span::styled(
145            " Aranet Monitor ",
146            Style::default()
147                .fg(theme.primary)
148                .add_modifier(Modifier::BOLD),
149        ),
150        Span::styled("v0.3.0 ", Style::default().fg(theme.text_muted)),
151    ];
152
153    // Connected count
154    let connected = app.connected_count();
155    let total = app.devices.len();
156    let conn_color = if connected == 0 {
157        theme.danger
158    } else {
159        theme.success
160    };
161    spans.push(Span::styled(
162        format!(" *{}/{} ", connected, total),
163        Style::default().fg(conn_color),
164    ));
165
166    // Average CO2 if available
167    if let Some(avg_co2) = app.average_co2() {
168        let co2_color = co2_color(avg_co2);
169        spans.push(Span::styled(
170            format!(" CO2:{} ", avg_co2),
171            Style::default().fg(co2_color),
172        ));
173    }
174
175    // Alert count
176    let alert_count = app.alerts.len();
177    if alert_count > 0 {
178        spans.push(Span::styled(
179            format!(" !{} ", alert_count),
180            Style::default()
181                .fg(theme.danger)
182                .add_modifier(Modifier::BOLD),
183        ));
184    }
185
186    // Sticky indicator
187    if app.sticky_alerts {
188        spans.push(Span::styled(" STICKY ", Style::default().fg(theme.warning)));
189    }
190
191    // Bell indicator
192    if app.bell_enabled {
193        spans.push(Span::styled(" BELL ", Style::default().fg(theme.warning)));
194    }
195
196    // Error indicator
197    if app.last_error.is_some() && !app.show_error_details {
198        spans.push(Span::styled(" ERR ", Style::default().fg(theme.danger)));
199    }
200
201    // Theme indicator
202    if matches!(app.theme, Theme::Light) {
203        spans.push(Span::styled(" LIGHT ", Style::default().fg(theme.warning)));
204    } else {
205        spans.push(Span::styled(" DARK ", Style::default().fg(theme.info)));
206    }
207
208    // Smart Home indicator
209    if app.smart_home_enabled {
210        spans.push(Span::styled(" HOME ", Style::default().fg(theme.success)));
211    }
212
213    let header = Paragraph::new(Line::from(spans)).style(theme.header_style());
214
215    frame.render_widget(header, area);
216}
217
218/// Get context-sensitive help hints based on current state.
219fn context_hints(app: &App) -> Vec<(&'static str, &'static str)> {
220    let mut hints = Vec::new();
221
222    // Always show help key
223    hints.push(("?", "help"));
224
225    match app.active_tab {
226        Tab::Dashboard => {
227            if app.devices.is_empty() {
228                hints.push(("s", "scan"));
229            } else {
230                hints.push(("j/k", "select"));
231                if app.selected_device().is_some() {
232                    if app
233                        .selected_device()
234                        .map(|d| matches!(d.status, super::app::ConnectionStatus::Connected))
235                        .unwrap_or(false)
236                    {
237                        hints.push(("r", "refresh"));
238                        hints.push(("d", "disconnect"));
239                        hints.push(("S", "sync"));
240                    } else {
241                        hints.push(("c", "connect"));
242                    }
243                }
244                hints.push(("s", "scan"));
245            }
246        }
247        Tab::History => {
248            hints.push(("S", "sync"));
249            hints.push(("0-4", "filter"));
250            hints.push(("PgUp/Dn", "scroll"));
251            hints.push(("g", "fullscreen"));
252        }
253        Tab::Settings => {
254            hints.push(("+/-", "adjust"));
255            hints.push(("n", "alias"));
256        }
257    }
258
259    hints.push(("q", "quit"));
260    hints
261}
262
263/// Draw the status bar with context-sensitive help.
264fn draw_status_bar(frame: &mut Frame, area: Rect, app: &App) {
265    let theme = app.app_theme();
266    let time_str = Local::now().format("%H:%M:%S").to_string();
267
268    // Build left content with context-sensitive hints
269    let left_spans = if app.scanning {
270        vec![
271            Span::styled(
272                format!("{} ", app.spinner_char()),
273                Style::default().fg(theme.primary),
274            ),
275            Span::styled("Scanning...", Style::default().fg(theme.text_secondary)),
276        ]
277    } else if app.is_any_connecting() {
278        vec![
279            Span::styled(
280                format!("{} ", app.spinner_char()),
281                Style::default().fg(theme.primary),
282            ),
283            Span::styled("Connecting...", Style::default().fg(theme.text_secondary)),
284        ]
285    } else if app.is_syncing() {
286        vec![
287            Span::styled(
288                format!("{} ", app.spinner_char()),
289                Style::default().fg(theme.primary),
290            ),
291            Span::styled("Syncing...", Style::default().fg(theme.text_secondary)),
292        ]
293    } else if let Some(msg) = app.current_status_message() {
294        vec![Span::styled(
295            format!(" {}", msg),
296            Style::default().fg(theme.text_secondary),
297        )]
298    } else {
299        // Context-sensitive hints with styled keys
300        let hints = context_hints(app);
301        let mut spans = vec![Span::raw(" ")];
302        for (i, (key, desc)) in hints.iter().enumerate() {
303            if i > 0 {
304                spans.push(Span::styled(" | ", Style::default().fg(theme.text_muted)));
305            }
306            spans.push(Span::styled(
307                *key,
308                Style::default()
309                    .fg(theme.primary)
310                    .add_modifier(Modifier::BOLD),
311            ));
312            spans.push(Span::styled(
313                format!(" {}", desc),
314                Style::default().fg(theme.text_muted),
315            ));
316        }
317        spans
318    };
319
320    // Split status bar into left (hints), indicators, and right (time)
321    let logging_width = if app.logging_enabled { 5 } else { 0 };
322    let status_layout = Layout::default()
323        .direction(Direction::Horizontal)
324        .constraints([
325            Constraint::Min(1),
326            Constraint::Length(logging_width),
327            Constraint::Length(10),
328        ])
329        .split(area);
330
331    let left = Paragraph::new(Line::from(left_spans));
332    frame.render_widget(left, status_layout[0]);
333
334    // Logging indicator
335    if app.logging_enabled {
336        let log_indicator = Paragraph::new(" REC").style(
337            Style::default()
338                .fg(theme.danger)
339                .add_modifier(Modifier::BOLD),
340        );
341        frame.render_widget(log_indicator, status_layout[1]);
342    }
343
344    let right = Paragraph::new(time_str)
345        .style(Style::default().fg(theme.text_muted))
346        .alignment(Alignment::Right);
347
348    frame.render_widget(right, status_layout[2]);
349}
350
351/// Draw the tab bar with modern styling.
352fn draw_tab_bar(frame: &mut Frame, area: Rect, app: &App) {
353    let theme = app.app_theme();
354
355    let tabs = [
356        ("Dashboard", Tab::Dashboard),
357        ("History", Tab::History),
358        ("Settings", Tab::Settings),
359    ];
360
361    // Build custom tab line with underline indicator for active tab
362    let tab_titles: Vec<Line> = tabs
363        .iter()
364        .map(|(name, tab)| {
365            let is_active = *tab == app.active_tab;
366            let style = if is_active {
367                Style::default()
368                    .fg(theme.primary)
369                    .add_modifier(Modifier::BOLD)
370            } else {
371                Style::default().fg(theme.text_muted)
372            };
373            // Add underline to active tab for visual emphasis
374            let styled_name = if is_active {
375                Span::styled(
376                    format!(" {} ", name),
377                    style.add_modifier(Modifier::UNDERLINED),
378                )
379            } else {
380                Span::styled(format!(" {} ", name), style)
381            };
382            Line::from(styled_name)
383        })
384        .collect();
385
386    let tabs_widget = ratatui::widgets::Tabs::new(tab_titles)
387        .block(
388            Block::default()
389                .borders(Borders::BOTTOM)
390                .border_type(BORDER_TYPE)
391                .border_style(Style::default().fg(theme.border_inactive)),
392        )
393        .highlight_style(Style::default().fg(theme.primary))
394        .divider(Span::styled(" | ", Style::default().fg(theme.text_muted)))
395        .select(match app.active_tab {
396            Tab::Dashboard => 0,
397            Tab::History => 1,
398            Tab::Settings => 2,
399        });
400
401    frame.render_widget(tabs_widget, area);
402}