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(&time::format_description::parse("[hour]:[minute]:[second]").unwrap_or_default())
263 .unwrap_or_default()
264 };
265
266 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 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 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 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
372fn 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 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 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}