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)>, 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), Constraint::Length(3), Constraint::Min(6), ]
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 let files_remaining = state.total_files - state.current_file_index;
175 let processed_bytes = format_size(state.total_bytes);
176
177 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 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 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 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), Constraint::Percentage(30), Constraint::Percentage(30), ]
270 .as_ref(),
271 )
272 .split(area);
273
274 let mut current_items: Vec<ListItem> = Vec::new();
276
277 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 let remaining_height = area.height as usize - 3; let files_that_fit = remaining_height.saturating_sub(current_items.len());
305
306 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 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 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 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 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}