garmin_cli/sync/
ui.rs

1//! Fancy terminal UI using Ratatui for sync progress visualization
2
3use 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
22/// Color theme for the TUI
23pub 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
51/// Terminal UI for sync progress
52pub struct SyncUI {
53    progress: SharedProgress,
54    theme: Theme,
55}
56
57impl SyncUI {
58    /// Create a new sync UI
59    pub fn new(progress: SharedProgress) -> Self {
60        Self {
61            progress,
62            theme: Theme::default(),
63        }
64    }
65
66    /// Draw the UI to the terminal frame
67    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),  // Header
73                Constraint::Length(12), // Progress bars (3 lines each x 4)
74                Constraint::Length(4),  // Stats
75                Constraint::Length(3),  // Latest task
76            ])
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    /// Draw the header section
86    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    /// Draw progress bars for each stream
113    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    /// Draw a single progress gauge with visual bar
155    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    /// Draw statistics section with sparkline
199    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        // Rate sparkline
206        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        // Stats text
223        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    /// Draw the latest task section
266    fn draw_latest(&self, frame: &mut Frame, area: Rect) {
267        // Find the most recently updated stream
268        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    /// Get the most recently updated item description
285    fn get_latest_item(&self) -> String {
286        // Check each stream for the latest item
287        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
305/// Setup terminal for TUI mode
306pub 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
314/// Restore terminal to normal mode
315pub 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
322/// Run the TUI event loop (call from tokio::spawn)
323pub 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        // Poll for events with timeout
331        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                        // Handle Ctrl+C
339                        KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
340                            break;
341                        }
342                        _ => {}
343                    }
344                }
345            }
346        }
347
348        // Update rate history every second
349        progress.update_rate_history();
350
351        // Check if sync is complete
352        if progress.is_complete() {
353            // Give user a moment to see final state
354            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
365/// Handle TUI cleanup on panic or signal
366pub 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}