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![
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    /// Draw progress bars for each stream
115    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    /// Draw a single progress gauge with visual bar
133    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    /// Draw statistics section with sparkline
165    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        // Rate sparkline
172        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        // Stats text
189        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    /// Draw the latest task section
229    fn draw_latest(&self, frame: &mut Frame, area: Rect) {
230        // Find the most recently updated stream
231        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    /// Get the most recently updated item description
248    fn get_latest_item(&self) -> String {
249        // Check each stream for the latest item
250        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
268/// Setup terminal for TUI mode
269pub 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
277/// Restore terminal to normal mode
278pub 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
285/// Run the TUI event loop (call from tokio::spawn)
286pub 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        // Poll for events with timeout
294        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                        // Handle Ctrl+C
302                        KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
303                            break;
304                        }
305                        _ => {}
306                    }
307                }
308            }
309        }
310
311        // Update rate history every second
312        progress.update_rate_history();
313
314        // Check if sync is complete
315        if progress.is_complete() {
316            // Give user a moment to see final state
317            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
328/// Handle TUI cleanup on panic or signal
329pub 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}