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    ];
154
155    // Connected count
156    let connected = app.connected_count();
157    let total = app.devices.len();
158    let conn_color = if connected == 0 {
159        theme.danger
160    } else {
161        theme.success
162    };
163    spans.push(Span::styled(
164        format!(" *{}/{} ", connected, total),
165        Style::default().fg(conn_color),
166    ));
167
168    // Average CO2 if available
169    if let Some(avg_co2) = app.average_co2() {
170        let co2_color = co2_color(avg_co2);
171        spans.push(Span::styled(
172            format!(" CO2:{} ", avg_co2),
173            Style::default().fg(co2_color),
174        ));
175    }
176
177    // Alert count
178    let alert_count = app.alerts.len();
179    if alert_count > 0 {
180        spans.push(Span::styled(
181            format!(" !{} ", alert_count),
182            Style::default()
183                .fg(theme.danger)
184                .add_modifier(Modifier::BOLD),
185        ));
186    }
187
188    // Sticky indicator
189    if app.sticky_alerts {
190        spans.push(Span::styled(" STICKY ", Style::default().fg(theme.warning)));
191    }
192
193    // Bell indicator
194    if app.bell_enabled {
195        spans.push(Span::styled(" BELL ", Style::default().fg(theme.warning)));
196    }
197
198    // Error indicator
199    if app.last_error.is_some() && !app.show_error_details {
200        spans.push(Span::styled(" ERR ", Style::default().fg(theme.danger)));
201    }
202
203    // Theme indicator
204    if matches!(app.theme, Theme::Light) {
205        spans.push(Span::styled(" LIGHT ", Style::default().fg(theme.warning)));
206    } else {
207        spans.push(Span::styled(" DARK ", Style::default().fg(theme.info)));
208    }
209
210    // Smart Home indicator
211    if app.smart_home_enabled {
212        spans.push(Span::styled(" HOME ", Style::default().fg(theme.success)));
213    }
214
215    let header = Paragraph::new(Line::from(spans)).style(theme.header_style());
216
217    frame.render_widget(header, area);
218}
219
220/// Get context-sensitive help hints based on current state.
221fn context_hints(app: &App) -> Vec<(&'static str, &'static str)> {
222    let mut hints = Vec::new();
223
224    // Always show help key
225    hints.push(("?", "help"));
226
227    match app.active_tab {
228        Tab::Dashboard => {
229            if app.devices.is_empty() {
230                hints.push(("s", "scan"));
231            } else {
232                hints.push(("j/k", "select"));
233                if app.selected_device().is_some() {
234                    if app
235                        .selected_device()
236                        .map(|d| matches!(d.status, super::app::ConnectionStatus::Connected))
237                        .unwrap_or(false)
238                    {
239                        hints.push(("r", "refresh"));
240                        hints.push(("d", "disconnect"));
241                        hints.push(("S", "sync"));
242                    } else {
243                        hints.push(("c", "connect"));
244                    }
245                }
246                hints.push(("s", "scan"));
247            }
248        }
249        Tab::History => {
250            hints.push(("S", "sync"));
251            hints.push(("0-4", "filter"));
252            hints.push(("PgUp/Dn", "scroll"));
253            hints.push(("g", "fullscreen"));
254        }
255        Tab::Settings => {
256            hints.push(("+/-", "adjust"));
257            hints.push(("n", "alias"));
258        }
259        Tab::Service => {
260            hints.push(("r", "refresh"));
261            hints.push(("Enter", "start/stop"));
262            hints.push(("j/k", "select"));
263        }
264    }
265
266    hints.push(("q", "quit"));
267    hints
268}
269
270/// Draw the status bar with context-sensitive help.
271fn draw_status_bar(frame: &mut Frame, area: Rect, app: &App) {
272    let theme = app.app_theme();
273    let time_str = Local::now().format("%H:%M:%S").to_string();
274
275    // Build left content with context-sensitive hints
276    let left_spans = if app.scanning {
277        vec![
278            Span::styled(
279                format!("{} ", app.spinner_char()),
280                Style::default().fg(theme.primary),
281            ),
282            Span::styled("Scanning...", Style::default().fg(theme.text_secondary)),
283        ]
284    } else if app.is_any_connecting() {
285        vec![
286            Span::styled(
287                format!("{} ", app.spinner_char()),
288                Style::default().fg(theme.primary),
289            ),
290            Span::styled("Connecting...", Style::default().fg(theme.text_secondary)),
291        ]
292    } else if app.is_syncing() {
293        vec![
294            Span::styled(
295                format!("{} ", app.spinner_char()),
296                Style::default().fg(theme.primary),
297            ),
298            Span::styled("Syncing...", Style::default().fg(theme.text_secondary)),
299        ]
300    } else if let Some(msg) = app.current_status_message() {
301        vec![Span::styled(
302            format!(" {}", msg),
303            Style::default().fg(theme.text_secondary),
304        )]
305    } else {
306        // Context-sensitive hints with styled keys
307        let hints = context_hints(app);
308        let mut spans = vec![Span::raw(" ")];
309        for (i, (key, desc)) in hints.iter().enumerate() {
310            if i > 0 {
311                spans.push(Span::styled(" | ", Style::default().fg(theme.text_muted)));
312            }
313            spans.push(Span::styled(
314                *key,
315                Style::default()
316                    .fg(theme.primary)
317                    .add_modifier(Modifier::BOLD),
318            ));
319            spans.push(Span::styled(
320                format!(" {}", desc),
321                Style::default().fg(theme.text_muted),
322            ));
323        }
324        spans
325    };
326
327    // Split status bar into left (hints), indicators, and right (time)
328    let logging_width = if app.logging_enabled { 5 } else { 0 };
329    let status_layout = Layout::default()
330        .direction(Direction::Horizontal)
331        .constraints([
332            Constraint::Min(1),
333            Constraint::Length(logging_width),
334            Constraint::Length(10),
335        ])
336        .split(area);
337
338    let left = Paragraph::new(Line::from(left_spans));
339    frame.render_widget(left, status_layout[0]);
340
341    // Logging indicator
342    if app.logging_enabled {
343        let log_indicator = Paragraph::new(" REC").style(
344            Style::default()
345                .fg(theme.danger)
346                .add_modifier(Modifier::BOLD),
347        );
348        frame.render_widget(log_indicator, status_layout[1]);
349    }
350
351    let right = Paragraph::new(time_str)
352        .style(Style::default().fg(theme.text_muted))
353        .alignment(Alignment::Right);
354
355    frame.render_widget(right, status_layout[2]);
356}
357
358/// Draw the tab bar with modern styling.
359fn draw_tab_bar(frame: &mut Frame, area: Rect, app: &App) {
360    let theme = app.app_theme();
361
362    let tabs = [
363        ("Dashboard", Tab::Dashboard),
364        ("History", Tab::History),
365        ("Settings", Tab::Settings),
366        ("Service", Tab::Service),
367    ];
368
369    // Build custom tab line with underline indicator for active tab
370    let tab_titles: Vec<Line> = tabs
371        .iter()
372        .map(|(name, tab)| {
373            let is_active = *tab == app.active_tab;
374            let style = if is_active {
375                Style::default()
376                    .fg(theme.primary)
377                    .add_modifier(Modifier::BOLD)
378            } else {
379                Style::default().fg(theme.text_muted)
380            };
381            // Add underline to active tab for visual emphasis
382            let styled_name = if is_active {
383                Span::styled(
384                    format!(" {} ", name),
385                    style.add_modifier(Modifier::UNDERLINED),
386                )
387            } else {
388                Span::styled(format!(" {} ", name), style)
389            };
390            Line::from(styled_name)
391        })
392        .collect();
393
394    let tabs_widget = ratatui::widgets::Tabs::new(tab_titles)
395        .block(
396            Block::default()
397                .borders(Borders::BOTTOM)
398                .border_type(BORDER_TYPE)
399                .border_style(Style::default().fg(theme.border_inactive)),
400        )
401        .highlight_style(Style::default().fg(theme.primary))
402        .divider(Span::styled(" | ", Style::default().fg(theme.text_muted)))
403        .select(match app.active_tab {
404            Tab::Dashboard => 0,
405            Tab::History => 1,
406            Tab::Settings => 2,
407            Tab::Service => 3,
408        });
409
410    frame.render_widget(tabs_widget, area);
411}