Skip to main content

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