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![
91 Line::from(vec![
92 Span::styled(" Profile: ", Style::default().fg(self.theme.dim)),
93 Span::styled(&profile, Style::default().fg(self.theme.title).bold()),
94 Span::raw(" "),
95 Span::styled(&date_range, Style::default().fg(self.theme.dim)),
96 ]),
97 ];
98
99 let header = Paragraph::new(header_text).block(
100 Block::default()
101 .borders(Borders::ALL)
102 .border_style(Style::default().fg(self.theme.border))
103 .title(Span::styled(
104 " Garmin Sync ",
105 Style::default()
106 .fg(self.theme.title)
107 .add_modifier(Modifier::BOLD),
108 )),
109 );
110
111 frame.render_widget(header, area);
112 }
113
114 fn draw_progress_bars(&self, frame: &mut Frame, area: Rect) {
116 let chunks = Layout::default()
117 .direction(Direction::Vertical)
118 .constraints([
119 Constraint::Length(3),
120 Constraint::Length(3),
121 Constraint::Length(3),
122 Constraint::Length(3),
123 ])
124 .split(area);
125
126 self.draw_gauge(frame, chunks[0], &self.progress.activities, self.theme.activities, "Activities");
127 self.draw_gauge(frame, chunks[1], &self.progress.gpx, self.theme.gpx, "GPX Downloads");
128 self.draw_gauge(frame, chunks[2], &self.progress.health, self.theme.health, "Health");
129 self.draw_gauge(frame, chunks[3], &self.progress.performance, self.theme.performance, "Performance");
130 }
131
132 fn draw_gauge(&self, frame: &mut Frame, area: Rect, stream: &StreamProgress, color: Color, title: &str) {
134 let total = stream.get_total();
135 let completed = stream.get_completed();
136 let failed = stream.get_failed();
137 let percent = stream.percent();
138
139 let label = if total == 0 {
140 "waiting...".to_string()
141 } else if failed > 0 {
142 format!("{}/{} ({} failed) {}%", completed, total, failed, percent)
143 } else {
144 format!("{}/{} {}%", completed, total, percent)
145 };
146
147 let gauge = Gauge::default()
148 .block(
149 Block::default()
150 .borders(Borders::ALL)
151 .border_style(Style::default().fg(self.theme.border))
152 .title(Span::styled(
153 format!(" {} ", title),
154 Style::default().fg(color).add_modifier(Modifier::BOLD),
155 )),
156 )
157 .gauge_style(Style::default().fg(color).bg(Color::DarkGray))
158 .percent(percent)
159 .label(Span::styled(label, Style::default().fg(Color::White).add_modifier(Modifier::BOLD)));
160
161 frame.render_widget(gauge, area);
162 }
163
164 fn draw_stats(&self, frame: &mut Frame, area: Rect) {
166 let chunks = Layout::default()
167 .direction(Direction::Horizontal)
168 .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
169 .split(area);
170
171 let rate_history = self.progress.rate_history.lock().unwrap();
173 let data: Vec<u64> = rate_history.iter().map(|&x| x as u64).collect();
174 drop(rate_history);
175
176 let sparkline = Sparkline::default()
177 .block(
178 Block::default()
179 .borders(Borders::ALL)
180 .border_style(Style::default().fg(self.theme.border))
181 .title(" Rate "),
182 )
183 .data(&data)
184 .style(Style::default().fg(self.theme.success));
185
186 frame.render_widget(sparkline, chunks[0]);
187
188 let rpm = self.progress.requests_per_minute();
190 let elapsed = self.progress.elapsed_str();
191 let eta = self.progress.eta_str();
192 let errors = self.progress.total_failed();
193
194 let stats_text = vec![
195 Line::from(vec![
196 Span::styled(" Rate: ", Style::default().fg(self.theme.dim)),
197 Span::styled(format!("{} req/min", rpm), Style::default().fg(self.theme.title)),
198 Span::raw(" "),
199 Span::styled("Elapsed: ", Style::default().fg(self.theme.dim)),
200 Span::styled(&elapsed, Style::default().fg(self.theme.title)),
201 ]),
202 Line::from(vec![
203 Span::styled(" ETA: ", Style::default().fg(self.theme.dim)),
204 Span::styled(&eta, Style::default().fg(self.theme.title)),
205 Span::raw(" "),
206 Span::styled("Errors: ", Style::default().fg(self.theme.dim)),
207 Span::styled(
208 errors.to_string(),
209 Style::default().fg(if errors > 0 {
210 self.theme.error
211 } else {
212 self.theme.success
213 }),
214 ),
215 ]),
216 ];
217
218 let stats = Paragraph::new(stats_text).block(
219 Block::default()
220 .borders(Borders::ALL)
221 .border_style(Style::default().fg(self.theme.border))
222 .title(" Stats "),
223 );
224
225 frame.render_widget(stats, chunks[1]);
226 }
227
228 fn draw_latest(&self, frame: &mut Frame, area: Rect) {
230 let latest = self.get_latest_item();
232
233 let text = vec![Line::from(vec![
234 Span::styled(" [Latest] ", Style::default().fg(self.theme.dim)),
235 Span::styled(&latest, Style::default().fg(self.theme.title)),
236 ])];
237
238 let latest_widget = Paragraph::new(text).block(
239 Block::default()
240 .borders(Borders::ALL)
241 .border_style(Style::default().fg(self.theme.border)),
242 );
243
244 frame.render_widget(latest_widget, area);
245 }
246
247 fn get_latest_item(&self) -> String {
249 let streams = [
251 &self.progress.activities,
252 &self.progress.gpx,
253 &self.progress.health,
254 &self.progress.performance,
255 ];
256
257 for stream in streams.iter().rev() {
258 let item = stream.get_last_item();
259 if !item.is_empty() {
260 return format!("{}: {}", stream.name, item);
261 }
262 }
263
264 "Waiting for tasks...".to_string()
265 }
266}
267
268pub fn setup_terminal() -> io::Result<Terminal<CrosstermBackend<Stdout>>> {
270 enable_raw_mode()?;
271 let mut stdout = stdout();
272 execute!(stdout, EnterAlternateScreen)?;
273 let backend = CrosstermBackend::new(stdout);
274 Terminal::new(backend)
275}
276
277pub fn restore_terminal(terminal: &mut Terminal<CrosstermBackend<Stdout>>) -> io::Result<()> {
279 disable_raw_mode()?;
280 execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
281 terminal.show_cursor()?;
282 Ok(())
283}
284
285pub async fn run_tui(progress: SharedProgress) -> io::Result<()> {
287 let mut terminal = setup_terminal()?;
288 let ui = SyncUI::new(progress.clone());
289
290 loop {
291 terminal.draw(|f| ui.draw(f))?;
292
293 if event::poll(Duration::from_millis(100))? {
295 if let Event::Key(key) = event::read()? {
296 if key.kind == KeyEventKind::Press {
297 match key.code {
298 KeyCode::Char('q') | KeyCode::Esc => {
299 break;
300 }
301 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
303 break;
304 }
305 _ => {}
306 }
307 }
308 }
309 }
310
311 progress.update_rate_history();
313
314 if progress.is_complete() {
316 tokio::time::sleep(Duration::from_secs(1)).await;
318 break;
319 }
320
321 tokio::time::sleep(Duration::from_millis(100)).await;
322 }
323
324 restore_terminal(&mut terminal)?;
325 Ok(())
326}
327
328pub struct TuiCleanupGuard {
330 pub terminal: Option<Terminal<CrosstermBackend<Stdout>>>,
331}
332
333impl Drop for TuiCleanupGuard {
334 fn drop(&mut self) {
335 if let Some(mut terminal) = self.terminal.take() {
336 let _ = restore_terminal(&mut terminal);
337 }
338 }
339}