1pub 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
28pub(crate) fn rssi_display(rssi: i16) -> (&'static str, Color) {
30 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
49pub fn draw(frame: &mut Frame, app: &App) {
54 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 if app.show_fullscreen_chart {
64 overlays::draw_fullscreen_chart(frame, app);
65 return; }
67
68 if app.show_comparison {
70 overlays::draw_comparison_view(frame, app);
71 return; }
73
74 let main_layout = Layout::default()
75 .direction(Direction::Vertical)
76 .constraints([
77 Constraint::Length(1), Constraint::Length(3), Constraint::Min(1), Constraint::Length(1), ])
82 .split(frame.area());
83
84 draw_header(frame, main_layout[0], app);
85 draw_tab_bar(frame, main_layout[1], app);
86
87 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), Constraint::Min(1), ]
97 } else {
98 vec![
99 Constraint::Length(0), Constraint::Min(1), ]
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 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 if app.show_help {
125 overlays::draw_help_overlay(frame);
126 }
127
128 overlays::draw_alert_history(frame, app);
130
131 overlays::draw_alias_editor(frame, app);
133
134 overlays::draw_error_popup(frame, app);
136
137 overlays::draw_confirmation_dialog(frame, app);
139}
140
141fn 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 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 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 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 if app.sticky_alerts {
190 spans.push(Span::styled(" STICKY ", Style::default().fg(theme.warning)));
191 }
192
193 if app.bell_enabled {
195 spans.push(Span::styled(" BELL ", Style::default().fg(theme.warning)));
196 }
197
198 if app.last_error.is_some() && !app.show_error_details {
200 spans.push(Span::styled(" ERR ", Style::default().fg(theme.danger)));
201 }
202
203 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 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
220fn context_hints(app: &App) -> Vec<(&'static str, &'static str)> {
222 let mut hints = Vec::new();
223
224 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
270fn 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 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 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 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 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
358fn 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 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 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}