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