Skip to main content

file_organizer/ui/
progress.rs

1use crate::models::FileType;
2use crossterm::{
3    event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode},
4    execute,
5    terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
6};
7use ratatui::{
8    Frame, Terminal,
9    backend::CrosstermBackend,
10    layout::{Constraint, Direction, Layout, Rect},
11    style::{Color, Modifier, Style},
12    text::{Line, Span},
13    widgets::{Block, Borders, Gauge, List, ListItem, Paragraph},
14};
15use std::{
16    io,
17    sync::mpsc,
18    time::{Duration, Instant},
19};
20
21pub struct ProgressState {
22    pub total_files: u64,
23    pub current_file_index: u64,
24    pub current_file: String,
25    pub current_file_size: u64,
26    pub current_file_progress: u64,
27    pub start_time: Instant,
28    pub bytes_per_second: f64,
29    pub is_stopping: bool,
30    pub recent_files: Vec<String>,
31    pub total_bytes: u64,
32    pub estimated_time: Option<f64>,
33    pub file_queue: Vec<(String, u64)>, // (filename, size) pairs for upcoming files
34    pub last_file: Option<String>,
35}
36
37impl ProgressState {
38    pub fn new(total_files: u64) -> Self {
39        ProgressState {
40            total_files,
41            current_file_index: 0,
42            current_file: String::new(),
43            current_file_size: 0,
44            current_file_progress: 0,
45            start_time: Instant::now(),
46            bytes_per_second: 0.0,
47            is_stopping: false,
48            recent_files: Vec::new(),
49            total_bytes: 0,
50            estimated_time: None,
51            file_queue: Vec::new(),
52            last_file: None,
53        }
54    }
55
56    pub fn set_file_queue(&mut self, files: Vec<(String, u64)>) {
57        self.file_queue = files;
58    }
59
60    pub fn update_file_progress(
61        &mut self,
62        file_name: String,
63        size: u64,
64        progress: u64,
65        index: u64,
66        bps: f64,
67        total_bytes: u64,
68        estimated_time: Option<f64>,
69    ) {
70        self.current_file = file_name.clone();
71        self.current_file_size = size;
72        self.current_file_progress = progress;
73        self.current_file_index = index;
74        self.bytes_per_second = bps;
75        self.total_bytes = total_bytes;
76        self.estimated_time = estimated_time;
77        if self.last_file.is_some() && self.last_file != Some(file_name.clone()) {
78            self.recent_files.push(self.last_file.take().unwrap());
79        }
80        self.last_file = Some(file_name);
81    }
82}
83
84pub struct ProgressUI {
85    terminal: Terminal<CrosstermBackend<io::Stdout>>,
86    state: ProgressState,
87}
88
89impl ProgressUI {
90    pub fn new(total_files: u64) -> io::Result<Self> {
91        enable_raw_mode()?;
92        let mut stdout = io::stdout();
93        execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
94        let backend = CrosstermBackend::new(stdout);
95        let terminal = Terminal::new(backend)?;
96
97        Ok(Self {
98            terminal,
99            state: ProgressState::new(total_files),
100        })
101    }
102
103    pub fn run(&mut self, rx: mpsc::Receiver<ProgressUpdate>) -> io::Result<()> {
104        loop {
105            let ui_state = &self.state;
106            self.terminal.draw(|f| {
107                let chunks = Layout::default()
108                    .direction(Direction::Vertical)
109                    .margin(2)
110                    .constraints(
111                        [
112                            Constraint::Length(3), // Total progress
113                            Constraint::Length(3), // File progress
114                            Constraint::Min(6),    // Files and Stats section
115                        ]
116                        .as_ref(),
117                    )
118                    .split(f.size());
119
120                Self::render_total_progress(ui_state, f, chunks[0]);
121                Self::render_file_progress(ui_state, f, chunks[1]);
122                Self::render_recent_files(ui_state, f, chunks[2]);
123            })?;
124
125            if event::poll(Duration::from_millis(100))? {
126                if let Event::Key(key) = event::read()? {
127                    if key.code == KeyCode::Char('q') {
128                        return Ok(());
129                    }
130                }
131            }
132
133            if let Ok(update) = rx.try_recv() {
134                match update {
135                    ProgressUpdate::File {
136                        name,
137                        size,
138                        progress,
139                        index,
140                        bytes_per_second,
141                        total_bytes,
142                        estimated_time,
143                    } => {
144                        self.state.update_file_progress(
145                            name,
146                            size,
147                            progress,
148                            index,
149                            bytes_per_second,
150                            total_bytes,
151                            estimated_time,
152                        );
153                    }
154                    ProgressUpdate::Stop => {
155                        self.state.is_stopping = true;
156                    }
157                    ProgressUpdate::Complete => {
158                        return Ok(());
159                    }
160                }
161            }
162        }
163    }
164
165    pub fn set_file_queue(&mut self, files: Vec<(String, u64)>) {
166        self.state.set_file_queue(files);
167    }
168
169    fn render_total_progress(state: &ProgressState, f: &mut Frame, area: Rect) {
170        let ratio = state.current_file_index as f64 / state.total_files as f64;
171        let percentage = (ratio * 100.0) as u64;
172
173        // Calculate processed and remaining files
174        let files_remaining = state.total_files - state.current_file_index;
175        let processed_bytes = format_size(state.total_bytes);
176
177        // Calculate average speed and estimated total time
178        let elapsed = state.start_time.elapsed().as_secs_f64();
179        let avg_speed = if elapsed > 0.0 {
180            state.total_bytes as f64 / elapsed
181        } else {
182            0.0
183        };
184
185        // Format the progress details
186        let progress_text = format!(
187            "{}/{} files ({}%) | Processed: {} | Avg Speed: {}/s | Remaining: {} files",
188            state.current_file_index,
189            state.total_files,
190            percentage,
191            processed_bytes,
192            format_size(avg_speed as u64),
193            files_remaining
194        );
195
196        let gauge = Gauge::default()
197            .block(
198                Block::default()
199                    .title(Span::styled(
200                        "Total Progress",
201                        Style::default()
202                            .fg(Color::Cyan)
203                            .add_modifier(Modifier::BOLD),
204                    ))
205                    .borders(Borders::ALL),
206            )
207            .gauge_style(
208                Style::default()
209                    .fg(Color::Cyan)
210                    .bg(Color::DarkGray)
211                    .add_modifier(Modifier::BOLD),
212            )
213            .ratio(ratio)
214            .label(progress_text);
215        f.render_widget(gauge, area);
216    }
217
218    fn render_file_progress(state: &ProgressState, f: &mut Frame, area: Rect) {
219        let ratio = if state.current_file_size > 0 {
220            state.current_file_progress as f64 / state.current_file_size as f64
221        } else {
222            0.0
223        };
224
225        let title = if state.is_stopping {
226            "Current File (Stopping...)"
227        } else {
228            "Current File"
229        };
230
231        // Get file extension and determine file type
232        let file_ext = std::path::Path::new(&state.current_file)
233            .extension()
234            .and_then(|ext| ext.to_str())
235            .unwrap_or("");
236        let file_type = FileType::from_extension(file_ext);
237
238        // Format progress details
239        let progress_text = format!(
240            "{} [{:?}] {}/{} ({}%)",
241            state.current_file,
242            file_type,
243            format_size(state.current_file_progress),
244            format_size(state.current_file_size),
245            ((ratio * 100.0) as u64)
246        );
247
248        let gauge = Gauge::default()
249            .block(Block::default().title(title).borders(Borders::ALL))
250            .gauge_style(
251                Style::default()
252                    .fg(Color::Green)
253                    .bg(Color::DarkGray)
254                    .add_modifier(Modifier::BOLD),
255            )
256            .ratio(ratio)
257            .label(progress_text);
258        f.render_widget(gauge, area);
259    }
260
261    fn render_recent_files(state: &ProgressState, f: &mut Frame, area: Rect) {
262        let chunks = Layout::default()
263            .direction(Direction::Horizontal)
264            .constraints(
265                [
266                    Constraint::Percentage(40), // Current & Upcoming
267                    Constraint::Percentage(30), // Completed
268                    Constraint::Percentage(30), // Stats
269                ]
270                .as_ref(),
271            )
272            .split(area);
273
274        // Left section: Current and upcoming files
275        let mut current_items: Vec<ListItem> = Vec::new();
276
277        // Add current file if it exists
278        if !state.current_file.is_empty() {
279            let progress_percentage = if state.current_file_size > 0 {
280                (state.current_file_progress as f64 / state.current_file_size as f64 * 100.0) as u64
281            } else {
282                0
283            };
284
285            current_items.push(ListItem::new(Line::from(vec![
286                Span::styled("⟳ ", Style::default().fg(Color::Yellow)),
287                Span::raw(&state.current_file),
288                Span::styled(
289                    format!(
290                        " ({}/{})",
291                        format_size(state.current_file_progress),
292                        format_size(state.current_file_size)
293                    ),
294                    Style::default().fg(Color::Yellow),
295                ),
296                Span::styled(
297                    format!(" {}%", progress_percentage),
298                    Style::default().fg(Color::Yellow),
299                ),
300            ])));
301
302            // Calculate remaining space for upcoming files
303            let remaining_height = area.height as usize - 3; // Account for borders and title
304            let files_that_fit = remaining_height.saturating_sub(current_items.len());
305
306            // Show upcoming files from the queue
307            let start_idx = state.current_file_index as usize;
308            for (filename, size) in state
309                .file_queue
310                .iter()
311                .skip(start_idx + 1)
312                .take(files_that_fit)
313            {
314                current_items.push(ListItem::new(Line::from(vec![
315                    Span::styled("• ", Style::default().fg(Color::DarkGray)),
316                    Span::raw(filename),
317                    Span::styled(
318                        format!(" ({})", format_size(*size)),
319                        Style::default().fg(Color::DarkGray),
320                    ),
321                ])));
322            }
323        }
324
325        // If no files at all, show a message
326        if current_items.is_empty() {
327            current_items.push(ListItem::new(Line::from(vec![Span::styled(
328                "No files in queue...",
329                Style::default().fg(Color::DarkGray),
330            )])));
331        }
332
333        let current_list = List::new(current_items)
334            .block(
335                Block::default()
336                    .title(Span::styled(
337                        "Current & Upcoming Files",
338                        Style::default()
339                            .fg(Color::Cyan)
340                            .add_modifier(Modifier::BOLD),
341                    ))
342                    .borders(Borders::ALL),
343            )
344            .highlight_style(Style::default().add_modifier(Modifier::BOLD));
345
346        // Middle section: Completed files
347        let mut completed_items: Vec<ListItem> = Vec::new();
348        let completed_height = area.height as usize - 3;
349
350        for file in state.recent_files.iter().rev().take(completed_height) {
351            completed_items.push(ListItem::new(Line::from(vec![
352                Span::styled("✓ ", Style::default().fg(Color::Green)),
353                Span::raw(file),
354                Span::styled(" (100%)", Style::default().fg(Color::Green)),
355            ])));
356        }
357
358        if completed_items.is_empty() {
359            completed_items.push(ListItem::new(Line::from(vec![Span::styled(
360                "No files completed yet...",
361                Style::default().fg(Color::DarkGray),
362            )])));
363        }
364
365        let completed_list = List::new(completed_items)
366            .block(
367                Block::default()
368                    .title(Span::styled(
369                        "Completed Files",
370                        Style::default()
371                            .fg(Color::Cyan)
372                            .add_modifier(Modifier::BOLD),
373                    ))
374                    .borders(Borders::ALL),
375            )
376            .highlight_style(Style::default().add_modifier(Modifier::BOLD));
377
378        // Right section: Stats
379        let elapsed = state.start_time.elapsed();
380        let elapsed_secs = elapsed.as_secs_f64();
381
382        let files_per_second = if elapsed_secs > 0.0 {
383            state.current_file_index as f64 / elapsed_secs
384        } else {
385            0.0
386        };
387
388        let avg_file_size = if state.current_file_index > 0 {
389            state.total_bytes as f64 / state.current_file_index as f64
390        } else {
391            0.0
392        };
393
394        let peak_speed = state.bytes_per_second.max(0.0);
395        let current_speed = state.bytes_per_second;
396
397        let stats = Paragraph::new(vec![
398            Line::from(vec![
399                Span::styled("Current Speed: ", Style::default().fg(Color::Yellow)),
400                Span::raw(format!("{}/s", format_size(current_speed as u64))),
401            ]),
402            Line::from(vec![
403                Span::styled("Peak Speed: ", Style::default().fg(Color::Yellow)),
404                Span::raw(format!("{}/s", format_size(peak_speed as u64))),
405            ]),
406            Line::from(vec![
407                Span::styled("Elapsed: ", Style::default().fg(Color::Yellow)),
408                Span::raw(format!(
409                    "{}:{:02}:{:02}",
410                    elapsed.as_secs() / 3600,
411                    (elapsed.as_secs() % 3600) / 60,
412                    elapsed.as_secs() % 60
413                )),
414            ]),
415            Line::from(vec![
416                Span::styled("ETA: ", Style::default().fg(Color::Yellow)),
417                Span::raw(match state.estimated_time {
418                    Some(eta) => format!(
419                        "{}:{:02}:{:02}",
420                        (eta as u64) / 3600,
421                        ((eta as u64) % 3600) / 60,
422                        (eta as u64) % 60
423                    ),
424                    None => "calculating...".to_string(),
425                }),
426            ]),
427            Line::from(vec![
428                Span::styled("Files/sec: ", Style::default().fg(Color::Yellow)),
429                Span::raw(format!("{:.2}", files_per_second)),
430            ]),
431            Line::from(vec![
432                Span::styled("Avg File Size: ", Style::default().fg(Color::Yellow)),
433                Span::raw(format_size(avg_file_size as u64)),
434            ]),
435        ])
436        .block(
437            Block::default()
438                .title(Span::styled(
439                    "Statistics",
440                    Style::default()
441                        .fg(Color::Cyan)
442                        .add_modifier(Modifier::BOLD),
443                ))
444                .borders(Borders::ALL),
445        );
446
447        // Render all sections
448        f.render_widget(current_list, chunks[0]);
449        f.render_widget(completed_list, chunks[1]);
450        f.render_widget(stats, chunks[2]);
451    }
452}
453
454impl Drop for ProgressUI {
455    fn drop(&mut self) {
456        disable_raw_mode().unwrap();
457        execute!(
458            self.terminal.backend_mut(),
459            LeaveAlternateScreen,
460            DisableMouseCapture,
461        )
462        .unwrap();
463    }
464}
465
466pub enum ProgressUpdate {
467    File {
468        name: String,
469        size: u64,
470        progress: u64,
471        index: u64,
472        bytes_per_second: f64,
473        total_bytes: u64,
474        estimated_time: Option<f64>,
475    },
476    Stop,
477    Complete,
478}
479
480pub fn format_size(size: u64) -> String {
481    const UNITS: [&str; 6] = ["B", "KB", "MB", "GB", "TB", "PB"];
482    let mut size = size as f64;
483    let mut unit_index = 0;
484
485    while size >= 1024.0 && unit_index < UNITS.len() - 1 {
486        size /= 1024.0;
487        unit_index += 1;
488    }
489
490    if unit_index == 0 {
491        format!("{:.0} {}", size, UNITS[unit_index])
492    } else {
493        format!("{:.2} {}", size, UNITS[unit_index])
494    }
495}