1use std::io::{self, stdout, Stdout};
4use std::time::Duration;
5
6use crossterm::{
7 event::{self, Event, KeyCode, KeyEventKind, KeyModifiers},
8 execute,
9 terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
10};
11use ratatui::{
12 layout::{Constraint, Direction, Layout, Rect},
13 prelude::CrosstermBackend,
14 style::{Color, Modifier, Style, Stylize},
15 text::{Line, Span},
16 widgets::{Block, Borders, Gauge, Paragraph, Sparkline},
17 Frame, Terminal,
18};
19
20use super::progress::{SharedProgress, StreamProgress};
21
22pub struct Theme {
24 pub activities: Color,
25 pub gpx: Color,
26 pub health: Color,
27 pub performance: Color,
28 pub error: Color,
29 pub success: Color,
30 pub border: Color,
31 pub title: Color,
32 pub dim: Color,
33}
34
35impl Default for Theme {
36 fn default() -> Self {
37 Self {
38 activities: Color::Cyan,
39 gpx: Color::Blue,
40 health: Color::Green,
41 performance: Color::Magenta,
42 error: Color::Red,
43 success: Color::Green,
44 border: Color::DarkGray,
45 title: Color::White,
46 dim: Color::Gray,
47 }
48 }
49}
50
51pub struct SyncUI {
53 progress: SharedProgress,
54 theme: Theme,
55}
56
57impl SyncUI {
58 pub fn new(progress: SharedProgress) -> Self {
60 Self {
61 progress,
62 theme: Theme::default(),
63 }
64 }
65
66 pub fn draw(&self, frame: &mut Frame) {
68 let chunks = Layout::default()
69 .direction(Direction::Vertical)
70 .margin(1)
71 .constraints([
72 Constraint::Length(3), Constraint::Length(12), Constraint::Length(4), Constraint::Length(3), ])
77 .split(frame.area());
78
79 self.draw_header(frame, chunks[0]);
80 self.draw_progress_bars(frame, chunks[1]);
81 self.draw_stats(frame, chunks[2]);
82 self.draw_latest(frame, chunks[3]);
83 }
84
85 fn draw_header(&self, frame: &mut Frame, area: Rect) {
87 let profile = self.progress.get_profile();
88 let date_range = self.progress.get_date_range();
89
90 let header_text = vec![Line::from(vec![
91 Span::styled(" Profile: ", Style::default().fg(self.theme.dim)),
92 Span::styled(&profile, Style::default().fg(self.theme.title).bold()),
93 Span::raw(" "),
94 Span::styled(&date_range, Style::default().fg(self.theme.dim)),
95 ])];
96
97 let header = Paragraph::new(header_text).block(
98 Block::default()
99 .borders(Borders::ALL)
100 .border_style(Style::default().fg(self.theme.border))
101 .title(Span::styled(
102 " Garmin Sync ",
103 Style::default()
104 .fg(self.theme.title)
105 .add_modifier(Modifier::BOLD),
106 )),
107 );
108
109 frame.render_widget(header, area);
110 }
111
112 fn draw_progress_bars(&self, frame: &mut Frame, area: Rect) {
114 let chunks = Layout::default()
115 .direction(Direction::Vertical)
116 .constraints([
117 Constraint::Length(3),
118 Constraint::Length(3),
119 Constraint::Length(3),
120 Constraint::Length(3),
121 ])
122 .split(area);
123
124 self.draw_gauge(
125 frame,
126 chunks[0],
127 &self.progress.activities,
128 self.theme.activities,
129 "Activities",
130 );
131 self.draw_gauge(
132 frame,
133 chunks[1],
134 &self.progress.gpx,
135 self.theme.gpx,
136 "GPX Downloads",
137 );
138 self.draw_gauge(
139 frame,
140 chunks[2],
141 &self.progress.health,
142 self.theme.health,
143 "Health",
144 );
145 self.draw_gauge(
146 frame,
147 chunks[3],
148 &self.progress.performance,
149 self.theme.performance,
150 "Performance",
151 );
152 }
153
154 fn draw_gauge(
156 &self,
157 frame: &mut Frame,
158 area: Rect,
159 stream: &StreamProgress,
160 color: Color,
161 title: &str,
162 ) {
163 let total = stream.get_total();
164 let completed = stream.get_completed();
165 let failed = stream.get_failed();
166 let percent = stream.percent();
167
168 let label = if total == 0 {
169 "waiting...".to_string()
170 } else if failed > 0 {
171 format!("{}/{} ({} failed) {}%", completed, total, failed, percent)
172 } else {
173 format!("{}/{} {}%", completed, total, percent)
174 };
175
176 let gauge = Gauge::default()
177 .block(
178 Block::default()
179 .borders(Borders::ALL)
180 .border_style(Style::default().fg(self.theme.border))
181 .title(Span::styled(
182 format!(" {} ", title),
183 Style::default().fg(color).add_modifier(Modifier::BOLD),
184 )),
185 )
186 .gauge_style(Style::default().fg(color).bg(Color::DarkGray))
187 .percent(percent)
188 .label(Span::styled(
189 label,
190 Style::default()
191 .fg(Color::White)
192 .add_modifier(Modifier::BOLD),
193 ));
194
195 frame.render_widget(gauge, area);
196 }
197
198 fn draw_stats(&self, frame: &mut Frame, area: Rect) {
200 let chunks = Layout::default()
201 .direction(Direction::Horizontal)
202 .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
203 .split(area);
204
205 let rate_history = self.progress.rate_history.lock().unwrap();
207 let data: Vec<u64> = rate_history.iter().map(|&x| x as u64).collect();
208 drop(rate_history);
209
210 let sparkline = Sparkline::default()
211 .block(
212 Block::default()
213 .borders(Borders::ALL)
214 .border_style(Style::default().fg(self.theme.border))
215 .title(" Rate "),
216 )
217 .data(&data)
218 .style(Style::default().fg(self.theme.success));
219
220 frame.render_widget(sparkline, chunks[0]);
221
222 let rpm = self.progress.requests_per_minute();
224 let elapsed = self.progress.elapsed_str();
225 let eta = self.progress.eta_str();
226 let errors = self.progress.total_failed();
227
228 let stats_text = vec![
229 Line::from(vec![
230 Span::styled(" Rate: ", Style::default().fg(self.theme.dim)),
231 Span::styled(
232 format!("{} req/min", rpm),
233 Style::default().fg(self.theme.title),
234 ),
235 Span::raw(" "),
236 Span::styled("Elapsed: ", Style::default().fg(self.theme.dim)),
237 Span::styled(&elapsed, Style::default().fg(self.theme.title)),
238 ]),
239 Line::from(vec![
240 Span::styled(" ETA: ", Style::default().fg(self.theme.dim)),
241 Span::styled(&eta, Style::default().fg(self.theme.title)),
242 Span::raw(" "),
243 Span::styled("Errors: ", Style::default().fg(self.theme.dim)),
244 Span::styled(
245 errors.to_string(),
246 Style::default().fg(if errors > 0 {
247 self.theme.error
248 } else {
249 self.theme.success
250 }),
251 ),
252 ]),
253 ];
254
255 let stats = Paragraph::new(stats_text).block(
256 Block::default()
257 .borders(Borders::ALL)
258 .border_style(Style::default().fg(self.theme.border))
259 .title(" Stats "),
260 );
261
262 frame.render_widget(stats, chunks[1]);
263 }
264
265 fn draw_latest(&self, frame: &mut Frame, area: Rect) {
267 let latest = self.get_latest_item();
269
270 let text = vec![Line::from(vec![
271 Span::styled(" [Latest] ", Style::default().fg(self.theme.dim)),
272 Span::styled(&latest, Style::default().fg(self.theme.title)),
273 ])];
274
275 let latest_widget = Paragraph::new(text).block(
276 Block::default()
277 .borders(Borders::ALL)
278 .border_style(Style::default().fg(self.theme.border)),
279 );
280
281 frame.render_widget(latest_widget, area);
282 }
283
284 fn get_latest_item(&self) -> String {
286 let streams = [
288 &self.progress.activities,
289 &self.progress.gpx,
290 &self.progress.health,
291 &self.progress.performance,
292 ];
293
294 for stream in streams.iter().rev() {
295 let item = stream.get_last_item();
296 if !item.is_empty() {
297 return format!("{}: {}", stream.name, item);
298 }
299 }
300
301 "Waiting for tasks...".to_string()
302 }
303}
304
305pub fn setup_terminal() -> io::Result<Terminal<CrosstermBackend<Stdout>>> {
307 enable_raw_mode()?;
308 let mut stdout = stdout();
309 execute!(stdout, EnterAlternateScreen)?;
310 let backend = CrosstermBackend::new(stdout);
311 Terminal::new(backend)
312}
313
314pub fn restore_terminal(terminal: &mut Terminal<CrosstermBackend<Stdout>>) -> io::Result<()> {
316 disable_raw_mode()?;
317 execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
318 terminal.show_cursor()?;
319 Ok(())
320}
321
322pub async fn run_tui(progress: SharedProgress) -> io::Result<()> {
324 let mut terminal = setup_terminal()?;
325 let ui = SyncUI::new(progress.clone());
326
327 loop {
328 terminal.draw(|f| ui.draw(f))?;
329
330 if event::poll(Duration::from_millis(100))? {
332 if let Event::Key(key) = event::read()? {
333 if key.kind == KeyEventKind::Press {
334 match key.code {
335 KeyCode::Char('q') | KeyCode::Esc => {
336 break;
337 }
338 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
340 break;
341 }
342 _ => {}
343 }
344 }
345 }
346 }
347
348 progress.update_rate_history();
350
351 if progress.is_complete() {
353 tokio::time::sleep(Duration::from_secs(1)).await;
355 break;
356 }
357
358 tokio::time::sleep(Duration::from_millis(100)).await;
359 }
360
361 restore_terminal(&mut terminal)?;
362 Ok(())
363}
364
365pub struct TuiCleanupGuard {
367 pub terminal: Option<Terminal<CrosstermBackend<Stdout>>>,
368}
369
370impl Drop for TuiCleanupGuard {
371 fn drop(&mut self) {
372 if let Some(mut terminal) = self.terminal.take() {
373 let _ = restore_terminal(&mut terminal);
374 }
375 }
376}