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 Span::styled("• rye.dev ", Style::default().fg(theme.text_muted)),
154 ];
155
156 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 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 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 if app.sticky_alerts {
191 spans.push(Span::styled(" STICKY ", Style::default().fg(theme.warning)));
192 }
193
194 if app.bell_enabled {
196 spans.push(Span::styled(" BELL ", Style::default().fg(theme.warning)));
197 }
198
199 if app.last_error.is_some() && !app.show_error_details {
201 spans.push(Span::styled(" ERR ", Style::default().fg(theme.danger)));
202 }
203
204 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 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
221fn context_hints(app: &App) -> Vec<(&'static str, &'static str)> {
223 let mut hints = Vec::new();
224
225 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
271fn 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 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 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 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 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
359fn 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 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 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}