use std::{collections::HashMap, time::Instant, sync::{Arc, Mutex, atomic::{AtomicBool, Ordering}}};
use ratatui::{
backend::CrosstermBackend,
layout::{Constraint, Direction, Layout},
style::{Color, Modifier, Style},
widgets::{Block, Borders, Paragraph, Table, Row, TableState},
Terminal,
};
use crossterm::{
execute, cursor, terminal, ExecutableCommand,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
event::{self, Event, KeyCode, KeyEventKind, KeyModifiers},
};
use std::io;
use crate::core::types::FailedOperationInfo;
use webbrowser;
#[derive(Clone)]
pub enum OperationStatus {
Preprocessing,
Ongoing(f64),
Completed,
Failed(FailedOperationInfo),
}
pub struct UIState {
pub total_files: usize,
pub processed_files: usize,
pub processed_bytes: u64,
pub total_bytes: u64,
pub start_time: Instant,
pub all_operations: HashMap<String, OperationStatus>,
pub album_id: Option<String>,
pub file_sizes: HashMap<String, u64>,
pub completed_urls: HashMap<String, String>,
}
impl UIState {
pub fn new(total_files: usize, album_id: Option<String>, total_bytes: u64) -> Self {
Self {
total_files,
processed_files: 0,
processed_bytes: 0,
total_bytes,
start_time: Instant::now(),
all_operations: HashMap::new(),
album_id,
file_sizes: HashMap::new(),
completed_urls: HashMap::new(),
}
}
pub fn add_current_operation(&mut self, name: String, progress: f64, size: u64) {
self.all_operations.insert(name.clone(), OperationStatus::Ongoing(progress));
self.file_sizes.insert(name, size);
}
pub fn update_progress(&mut self, name: &str, progress: f64) {
if let Some(OperationStatus::Ongoing(ref mut p)) = self.all_operations.get_mut(name) {
*p = progress;
}
}
pub fn remove_current_operation(&mut self, name: &str, url: Option<&str>) {
self.all_operations.insert(name.to_string(), OperationStatus::Completed);
self.processed_files += 1;
if let Some(url) = url {
self.completed_urls.insert(name.to_string(), url.to_string());
}
}
pub fn add_processed_bytes(&mut self, bytes: u64) {
self.processed_bytes += bytes;
}
pub fn add_failed_operation(&mut self, name: String, info: FailedOperationInfo) {
self.all_operations.insert(name, OperationStatus::Failed(info));
}
pub fn add_preprocessing(&mut self, name: String, size: u64) {
self.file_sizes.insert(name.clone(), size);
self.all_operations.insert(name, OperationStatus::Preprocessing);
}
pub fn remove_operation(&mut self, name: &str) {
self.all_operations.remove(name);
self.file_sizes.remove(name);
}
pub fn add_to_total_files(&mut self, count: usize) {
self.total_files += count;
}
}
fn format_size(size: u64) -> String {
if size >= 1024 * 1024 * 1024 {
format!("{:.1} GB", size as f64 / (1024.0 * 1024.0 * 1024.0))
} else if size >= 1024 * 1024 {
format!("{:.1} MB", size as f64 / (1024.0 * 1024.0))
} else if size >= 1024 {
format!("{:.1} KB", size as f64 / 1024.0)
} else {
format!("{} B", size)
}
}
pub struct UI {
terminal: Terminal<CrosstermBackend<io::Stdout>>,
table_state: TableState,
previous_row_count: usize,
}
impl UI {
pub fn new() -> Result<Self, Box<dyn std::error::Error>> {
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let terminal = Terminal::new(backend)?;
Ok(Self { terminal, table_state: TableState::default(), previous_row_count: 0 })
}
pub fn draw(&mut self, state: &UIState) -> Result<(), Box<dyn std::error::Error>> {
self.terminal.draw(|f| {
let size = f.area();
let elapsed = state.start_time.elapsed().as_secs_f64();
let bytes_per_sec = if elapsed > 0.0 { state.processed_bytes as f64 / elapsed } else { 0.0 };
let speed_mb_s = bytes_per_sec / 1_000_000.0;
let remaining_bytes: u64 = state.total_bytes.saturating_sub(state.processed_bytes);
let eta_str = if remaining_bytes > 0 && bytes_per_sec > 0.0 {
let time_left_seconds = remaining_bytes as f64 / bytes_per_sec;
if time_left_seconds < 60.0 {
format!(" | ETA: {:.0}s", time_left_seconds)
} else if time_left_seconds < 3600.0 {
format!(" | ETA: {:.0}m", time_left_seconds / 60.0)
} else {
format!(" | ETA: {:.1}h", time_left_seconds / 3600.0)
}
} else {
String::new()
};
let header_text = if let Some(album) = &state.album_id {
format!("Bunkr Client | Album: {} | Processed: {}/{} | Speed: {:.2} MB/s{}", album, state.processed_files, state.total_files, speed_mb_s, eta_str)
} else {
format!("Bunkr Client | Processed: {}/{} | Speed: {:.2} MB/s{}", state.processed_files, state.total_files, speed_mb_s, eta_str)
};
let header = Paragraph::new(header_text)
.block(Block::default().borders(Borders::ALL).title("Header"))
.style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD));
let header_height = 3;
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(header_height), Constraint::Min(0)])
.split(size);
f.render_widget(header, chunks[0]);
let list_area = chunks[1];
let mut all_items_vec: Vec<(&String, &OperationStatus)> = state.all_operations.iter().collect();
all_items_vec.sort_by(|a, b| a.0.cmp(b.0));
let current_row_count = all_items_vec.len();
let rows: Vec<Row> = all_items_vec.iter().map(|(name, status)| {
let file_name = std::path::Path::new(name).file_name().unwrap_or(std::ffi::OsStr::new(name)).to_string_lossy();
let size = match status {
OperationStatus::Failed(info) => info.file_size,
_ => *state.file_sizes.get(*name).unwrap_or(&0),
};
let size_str = format_size(size);
let (progress_str, status_str, url_str) = match status {
OperationStatus::Preprocessing => ("".to_string(), "Preprocessing".to_string(), "".to_string()),
OperationStatus::Ongoing(progress) => (format!("{:.0}%", progress * 100.0), "Ongoing".to_string(), "".to_string()),
OperationStatus::Completed => {
let url = state.completed_urls.get(*name).cloned().unwrap_or_else(|| "".to_string());
("100%".to_string(), "Completed".to_string(), url)
}
OperationStatus::Failed(info) => {
let status_str_inner = if let Some(code) = info.status_code {
format!(" (HTTP {})", code)
} else {
String::new()
};
("".to_string(), format!("Failed{}: {}", status_str_inner, info.error), "".to_string())
}
};
Row::new(vec![file_name.to_string(), size_str, progress_str, status_str, url_str])
}).collect();
let widths = [
Constraint::Percentage(25),
Constraint::Percentage(12),
Constraint::Percentage(12),
Constraint::Percentage(25),
Constraint::Percentage(26),
];
let table = Table::new(rows, widths)
.block(Block::default().borders(Borders::ALL).title("Operations"))
.header(
Row::new(vec!["File", "Size", "Progress", "Status", "URL"])
.style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD))
)
.row_highlight_style(Style::default().add_modifier(Modifier::REVERSED));
if let Some(selected) = self.table_state.selected() {
if selected == self.previous_row_count.saturating_sub(1) && current_row_count > self.previous_row_count {
self.table_state.select(Some(current_row_count - 1));
}
}
self.previous_row_count = current_row_count;
f.render_stateful_widget(table, list_area, &mut self.table_state);
})?;
Ok(())
}
pub fn restore(&mut self) -> Result<(), Box<dyn std::error::Error>> {
disable_raw_mode()?;
execute!(self.terminal.backend_mut(), LeaveAlternateScreen)?;
self.terminal.show_cursor()?;
Ok(())
}
}
pub fn start_ui(ui_state: Arc<Mutex<UIState>>) -> (std::thread::JoinHandle<()>, Arc<AtomicBool>) {
let running = Arc::new(AtomicBool::new(true));
let running_clone = running.clone();
let ui_state_clone = ui_state.clone();
let handle = std::thread::spawn(move || {
let mut ui = UI::new().unwrap();
while running_clone.load(Ordering::Relaxed) {
if event::poll(std::time::Duration::from_millis(0)).unwrap_or(false) {
if let Ok(Event::Key(key_event)) = event::read() {
if key_event.kind == KeyEventKind::Press || key_event.kind == KeyEventKind::Repeat {
match key_event.code {
KeyCode::Char('c') if key_event.modifiers.contains(KeyModifiers::CONTROL) => {
running_clone.store(false, Ordering::Relaxed);
break;
}
KeyCode::Up => {
let selected = ui.table_state.selected().unwrap_or(0);
if selected > 0 {
ui.table_state.select(Some(selected - 1));
}
}
KeyCode::Down => {
let selected = ui.table_state.selected().unwrap_or(0);
ui.table_state.select(Some(selected + 1));
}
KeyCode::Enter => {
let state = ui_state_clone.lock().unwrap();
let mut all_items_vec: Vec<(&String, &OperationStatus)> = state.all_operations.iter().collect();
all_items_vec.sort_by(|a, b| a.0.cmp(b.0));
if let Some(selected) = ui.table_state.selected() {
if selected < all_items_vec.len() {
let (name, status) = &all_items_vec[selected];
if let OperationStatus::Completed = status {
if let Some(url) = state.completed_urls.get(*name) {
let _ = webbrowser::open(url);
}
}
}
}
}
_ => {}
}
}
}
}
{
let state = ui_state_clone.lock().unwrap();
ui.draw(&state).unwrap();
}
std::thread::sleep(std::time::Duration::from_millis(16));
}
ui.restore().unwrap();
});
(handle, running)
}
pub fn stop_ui(handle: std::thread::JoinHandle<()>, running: Arc<AtomicBool>) {
running.store(false, std::sync::atomic::Ordering::Relaxed);
handle.join().unwrap();
let mut stdout = io::stdout();
stdout.execute(terminal::Clear(terminal::ClearType::All)).unwrap();
stdout.execute(cursor::MoveTo(0, 0)).unwrap();
}