1pub 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
27pub fn draw(frame: &mut Frame, app: &App) {
32 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 if app.show_fullscreen_chart {
42 overlays::draw_fullscreen_chart(frame, app);
43 return; }
45
46 if app.show_comparison {
48 overlays::draw_comparison_view(frame, app);
49 return; }
51
52 let main_layout = Layout::default()
53 .direction(Direction::Vertical)
54 .constraints([
55 Constraint::Length(1), Constraint::Length(3), Constraint::Min(1), Constraint::Length(1), ])
60 .split(frame.area());
61
62 draw_header(frame, main_layout[0], app);
63 draw_tab_bar(frame, main_layout[1], app);
64
65 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), Constraint::Min(1), ]
75 } else {
76 vec![
77 Constraint::Length(0), Constraint::Min(1), ]
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 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 if app.show_help {
103 overlays::draw_help_overlay(frame);
104 }
105
106 overlays::draw_alert_history(frame, app);
108
109 overlays::draw_alias_editor(frame, app);
111
112 overlays::draw_error_popup(frame, app);
114
115 overlays::draw_confirmation_dialog(frame, app);
117}
118
119fn 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 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 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 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 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
205fn context_hints(app: &App) -> Vec<(&'static str, &'static str)> {
207 let mut hints = Vec::new();
208
209 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
255fn 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(
263 &time::format_description::parse_borrowed::<1>("[hour]:[minute]:[second]")
264 .unwrap_or_default(),
265 )
266 .unwrap_or_default()
267 };
268
269 let left_spans = if app.scanning {
271 vec![
272 Span::styled(
273 format!("{} ", app.spinner_char()),
274 Style::default().fg(theme.primary),
275 ),
276 Span::styled("Scanning...", Style::default().fg(theme.text_secondary)),
277 ]
278 } else if app.is_any_connecting() {
279 vec![
280 Span::styled(
281 format!("{} ", app.spinner_char()),
282 Style::default().fg(theme.primary),
283 ),
284 Span::styled("Connecting...", Style::default().fg(theme.text_secondary)),
285 ]
286 } else if app.is_syncing() {
287 vec![
288 Span::styled(
289 format!("{} ", app.spinner_char()),
290 Style::default().fg(theme.primary),
291 ),
292 Span::styled("Syncing...", Style::default().fg(theme.text_secondary)),
293 ]
294 } else if let Some(msg) = app.current_status_message() {
295 vec![Span::styled(
296 format!(" {}", msg),
297 Style::default().fg(theme.text_secondary),
298 )]
299 } else {
300 let hints = context_hints(app);
302 let hints: Vec<_> = if width < 46 {
303 let mut compact = vec![hints[0]];
304 if hints.len() > 2 {
305 compact.push(hints[1]);
306 }
307 if let Some(last) = hints.last().copied()
308 && compact.last().copied() != Some(last)
309 {
310 compact.push(last);
311 }
312 compact
313 } else if width < 72 {
314 let mut compact = vec![hints[0]];
315 compact.extend(hints.iter().skip(1).take(2).copied());
316 if let Some(last) = hints.last().copied()
317 && compact.last().copied() != Some(last)
318 {
319 compact.push(last);
320 }
321 compact
322 } else {
323 hints
324 };
325 let mut spans = vec![Span::raw(" ")];
326 for (i, (key, desc)) in hints.iter().enumerate() {
327 if i > 0 {
328 spans.push(Span::styled(" | ", Style::default().fg(theme.text_muted)));
329 }
330 spans.push(Span::styled(
331 *key,
332 Style::default()
333 .fg(theme.primary)
334 .add_modifier(Modifier::BOLD),
335 ));
336 spans.push(Span::styled(
337 format!(" {}", desc),
338 Style::default().fg(theme.text_muted),
339 ));
340 }
341 spans
342 };
343
344 let logging_width = if app.logging_enabled { 5 } else { 0 };
346 let status_layout = Layout::default()
347 .direction(Direction::Horizontal)
348 .constraints([
349 Constraint::Min(1),
350 Constraint::Length(logging_width),
351 Constraint::Length(10),
352 ])
353 .split(area);
354
355 let left = Paragraph::new(Line::from(left_spans));
356 frame.render_widget(left, status_layout[0]);
357
358 if app.logging_enabled {
360 let log_indicator = Paragraph::new(" REC").style(
361 Style::default()
362 .fg(theme.danger)
363 .add_modifier(Modifier::BOLD),
364 );
365 frame.render_widget(log_indicator, status_layout[1]);
366 }
367
368 let right = Paragraph::new(time_str)
369 .style(Style::default().fg(theme.text_muted))
370 .alignment(Alignment::Right);
371
372 frame.render_widget(right, status_layout[2]);
373}
374
375fn draw_tab_bar(frame: &mut Frame, area: Rect, app: &App) {
377 let theme = app.app_theme();
378
379 let tabs = [
380 ("Dashboard", Tab::Dashboard),
381 ("History", Tab::History),
382 ("Settings", Tab::Settings),
383 ("Service", Tab::Service),
384 ];
385
386 let tab_titles: Vec<Line> = tabs
388 .iter()
389 .map(|(name, tab)| {
390 let is_active = *tab == app.active_tab;
391 let style = if is_active {
392 Style::default()
393 .fg(theme.primary)
394 .add_modifier(Modifier::BOLD)
395 } else {
396 Style::default().fg(theme.text_muted)
397 };
398 let styled_name = if is_active {
400 Span::styled(
401 format!(" {} ", name),
402 style.add_modifier(Modifier::UNDERLINED),
403 )
404 } else {
405 Span::styled(format!(" {} ", name), style)
406 };
407 Line::from(styled_name)
408 })
409 .collect();
410
411 let tabs_widget = ratatui::widgets::Tabs::new(tab_titles)
412 .block(
413 Block::default()
414 .borders(Borders::BOTTOM)
415 .border_type(BORDER_TYPE)
416 .border_style(Style::default().fg(theme.border_inactive)),
417 )
418 .highlight_style(Style::default().fg(theme.primary))
419 .divider(Span::styled(" | ", Style::default().fg(theme.text_muted)))
420 .select(match app.active_tab {
421 Tab::Dashboard => 0,
422 Tab::History => 1,
423 Tab::Settings => 2,
424 Tab::Service => 3,
425 });
426
427 frame.render_widget(tabs_widget, area);
428}