1pub 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
27pub(crate) fn rssi_display(rssi: i16) -> (&'static str, Color) {
29 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
48pub fn draw(frame: &mut Frame, app: &App) {
53 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 if app.show_fullscreen_chart {
63 overlays::draw_fullscreen_chart(frame, app);
64 return; }
66
67 if app.show_comparison {
69 overlays::draw_comparison_view(frame, app);
70 return; }
72
73 let main_layout = Layout::default()
74 .direction(Direction::Vertical)
75 .constraints([
76 Constraint::Length(1), Constraint::Length(3), Constraint::Min(1), Constraint::Length(1), ])
81 .split(frame.area());
82
83 draw_header(frame, main_layout[0], app);
84 draw_tab_bar(frame, main_layout[1], app);
85
86 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), Constraint::Min(1), ]
96 } else {
97 vec![
98 Constraint::Length(0), Constraint::Min(1), ]
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 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 if app.show_help {
123 overlays::draw_help_overlay(frame);
124 }
125
126 overlays::draw_alert_history(frame, app);
128
129 overlays::draw_alias_editor(frame, app);
131
132 overlays::draw_error_popup(frame, app);
134
135 overlays::draw_confirmation_dialog(frame, app);
137}
138
139fn 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 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 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 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 if app.sticky_alerts {
188 spans.push(Span::styled(" STICKY ", Style::default().fg(theme.warning)));
189 }
190
191 if app.bell_enabled {
193 spans.push(Span::styled(" BELL ", Style::default().fg(theme.warning)));
194 }
195
196 if app.last_error.is_some() && !app.show_error_details {
198 spans.push(Span::styled(" ERR ", Style::default().fg(theme.danger)));
199 }
200
201 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 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
218fn context_hints(app: &App) -> Vec<(&'static str, &'static str)> {
220 let mut hints = Vec::new();
221
222 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
263fn 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 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 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 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 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
351fn 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 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 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}