mirror of
https://github.com/veeso/termscp.git
synced 2026-09-26 22:11:26 -07:00
fix(progress): rework transfer progress panel (#424)
Migrate the transfer progress UI to tuirealm 4, where the stdlib `ProgressBar` widget was dropped, by rebuilding the dual-bar panel on top of `Gauge`. - Restore the unified two-bar look: the full bar (top) and partial bar (bottom) draw joined borders so they read as a single panel; a single file shows one fully-bordered bar. - Redraw on every file boundary in the send/recv queue loops so the full bar's (N/total) counter advances even for small files that finish within one in-loop redraw interval. - Track progress with a single `TransferProgress` (exact file count from the pre-scan, lazy partial/full computation) and consolidate the theme progress-bar fields.
This commit is contained in:
@@ -7,31 +7,29 @@ use std::time::Instant;
|
||||
|
||||
use bytesize::ByteSize;
|
||||
|
||||
/// Tracks overall transfer progress with byte-level estimation.
|
||||
/// Tracks transfer progress with two exact levels.
|
||||
///
|
||||
/// For single-file transfers, progress is exact (known file size).
|
||||
/// For multi-file transfers, uses lazy accumulation: as each file starts,
|
||||
/// its size is added to `known_total_bytes`, and the remaining files'
|
||||
/// total is estimated from the running average file size.
|
||||
/// - Partial: the current file's byte progress (`cur_written / cur_size`).
|
||||
/// - Full: file-count weighted, `(files_completed + cur_fraction) / files_total`.
|
||||
///
|
||||
/// No total-size estimation: `files_total` is exact (from the pre-scan).
|
||||
pub struct TransferProgress {
|
||||
files_completed: usize,
|
||||
files_total: usize,
|
||||
bytes_written: usize,
|
||||
known_total_bytes: usize,
|
||||
nonzero_files_started: usize,
|
||||
files_started: usize,
|
||||
files_completed: usize,
|
||||
cur_file_size: usize,
|
||||
cur_file_written: usize,
|
||||
total_bytes_written: usize,
|
||||
pub(crate) started: Instant,
|
||||
}
|
||||
|
||||
impl Default for TransferProgress {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
files_completed: 0,
|
||||
files_total: 0,
|
||||
bytes_written: 0,
|
||||
known_total_bytes: 0,
|
||||
nonzero_files_started: 0,
|
||||
files_started: 0,
|
||||
files_completed: 0,
|
||||
cur_file_size: 0,
|
||||
cur_file_written: 0,
|
||||
total_bytes_written: 0,
|
||||
started: Instant::now(),
|
||||
}
|
||||
}
|
||||
@@ -41,101 +39,83 @@ impl fmt::Display for TransferProgress {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let eta = match self.calc_eta() {
|
||||
0 => String::from("--:--"),
|
||||
seconds => format!(
|
||||
"{:0width$}:{:0width$}",
|
||||
seconds / 60,
|
||||
seconds % 60,
|
||||
width = 2
|
||||
),
|
||||
seconds => format!("{:02}:{:02}", seconds / 60, seconds % 60),
|
||||
};
|
||||
if self.is_single_file() {
|
||||
write!(
|
||||
f,
|
||||
"{} / {} — {:.1}% — ETA {} ({}/s)",
|
||||
ByteSize(self.bytes_written as u64),
|
||||
ByteSize(self.known_total_bytes as u64),
|
||||
self.calc_progress() * 100.0,
|
||||
eta,
|
||||
ByteSize(self.calc_bytes_per_second()),
|
||||
)
|
||||
} else {
|
||||
write!(
|
||||
f,
|
||||
"{} transferred — ~{:.1}% — ETA {} ({}/s)",
|
||||
ByteSize(self.bytes_written as u64),
|
||||
self.calc_progress() * 100.0,
|
||||
eta,
|
||||
ByteSize(self.calc_bytes_per_second()),
|
||||
)
|
||||
}
|
||||
write!(
|
||||
f,
|
||||
"{} / {} — {:.1}% — ETA {} ({}/s)",
|
||||
ByteSize(self.cur_file_written as u64),
|
||||
ByteSize(self.cur_file_size as u64),
|
||||
self.calc_partial_progress() * 100.0,
|
||||
eta,
|
||||
ByteSize(self.calc_bytes_per_second()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl TransferProgress {
|
||||
/// Initialize for a new transfer batch.
|
||||
pub fn init(&mut self, total_files: usize) {
|
||||
/// Initialize for a new transfer batch with an exact file count.
|
||||
pub fn init(&mut self, files_total: usize) {
|
||||
self.files_total = files_total;
|
||||
self.files_completed = 0;
|
||||
self.files_total = total_files;
|
||||
self.bytes_written = 0;
|
||||
self.known_total_bytes = 0;
|
||||
self.nonzero_files_started = 0;
|
||||
self.files_started = 0;
|
||||
self.cur_file_size = 0;
|
||||
self.cur_file_written = 0;
|
||||
self.total_bytes_written = 0;
|
||||
self.started = Instant::now();
|
||||
}
|
||||
|
||||
/// Update files_total without resetting byte accumulators.
|
||||
/// Used by recursive directory transfers that re-discover file counts.
|
||||
pub fn set_files_total(&mut self, total: usize) {
|
||||
self.files_total = total;
|
||||
/// Begin a new file with a known size.
|
||||
pub fn start_file(&mut self, size: usize) {
|
||||
self.cur_file_size = size;
|
||||
self.cur_file_written = 0;
|
||||
}
|
||||
|
||||
/// Register a file that is about to be transferred.
|
||||
pub fn register_file(&mut self, size: usize) {
|
||||
self.files_started += 1;
|
||||
if size > 0 {
|
||||
self.nonzero_files_started += 1;
|
||||
self.known_total_bytes += size;
|
||||
}
|
||||
}
|
||||
|
||||
/// Register a file that was skipped (unchanged).
|
||||
/// Atomically registers, adds bytes, and increments completion.
|
||||
pub fn register_skipped_file(&mut self, size: usize) {
|
||||
self.register_file(size);
|
||||
self.add_bytes(size);
|
||||
self.increment();
|
||||
}
|
||||
|
||||
/// Add transferred bytes.
|
||||
/// Add bytes written to the current file.
|
||||
pub fn add_bytes(&mut self, delta: usize) {
|
||||
self.bytes_written += delta;
|
||||
self.cur_file_written += delta;
|
||||
self.total_bytes_written += delta;
|
||||
}
|
||||
|
||||
/// Mark one file as completed.
|
||||
pub fn increment(&mut self) {
|
||||
/// Mark the current file as fully transferred.
|
||||
///
|
||||
/// Clears the current-file byte counters so a finished file is only ever
|
||||
/// counted via `files_completed` and never double-counted as an in-progress
|
||||
/// fraction in [`Self::calc_full_progress`].
|
||||
pub fn finish_file(&mut self) {
|
||||
self.files_completed += 1;
|
||||
self.cur_file_size = 0;
|
||||
self.cur_file_written = 0;
|
||||
}
|
||||
|
||||
/// Mark an unchanged file as done (counts toward the full bar, no bytes).
|
||||
pub fn skip_file(&mut self) {
|
||||
self.files_completed += 1;
|
||||
}
|
||||
|
||||
/// Estimate the total transfer size in bytes.
|
||||
pub fn estimated_total(&self) -> usize {
|
||||
if self.files_total <= 1 || self.files_started >= self.files_total {
|
||||
return self.known_total_bytes;
|
||||
/// Fraction of the current file written (0.0..=1.0). Zero-byte file => 1.0.
|
||||
pub fn calc_partial_progress(&self) -> f64 {
|
||||
if self.cur_file_size == 0 {
|
||||
return 1.0;
|
||||
}
|
||||
if self.nonzero_files_started == 0 {
|
||||
return self.known_total_bytes;
|
||||
}
|
||||
let avg = self.known_total_bytes / self.nonzero_files_started;
|
||||
let remaining = self.files_total - self.files_started;
|
||||
self.known_total_bytes + avg * remaining
|
||||
(self.cur_file_written as f64 / self.cur_file_size as f64).min(1.0)
|
||||
}
|
||||
|
||||
/// Calculate progress as 0.0..=1.0.
|
||||
pub fn calc_progress(&self) -> f64 {
|
||||
let total = self.estimated_total();
|
||||
if total == 0 {
|
||||
/// Overall progress (0.0..=1.0): file-weighted with intra-file interpolation.
|
||||
///
|
||||
/// The current file only contributes a fraction while it is genuinely in
|
||||
/// progress (`cur_file_size > 0` and not all files completed). A finished
|
||||
/// file clears `cur_file_size` (see [`Self::finish_file`]) so it is counted
|
||||
/// exactly once via `files_completed`.
|
||||
pub fn calc_full_progress(&self) -> f64 {
|
||||
if self.files_total == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
(self.bytes_written as f64 / total as f64).min(1.0)
|
||||
let cur_fraction = if self.cur_file_size == 0 || self.files_completed >= self.files_total {
|
||||
0.0
|
||||
} else {
|
||||
self.calc_partial_progress()
|
||||
};
|
||||
((self.files_completed as f64 + cur_fraction) / self.files_total as f64).min(1.0)
|
||||
}
|
||||
|
||||
pub fn is_single_file(&self) -> bool {
|
||||
@@ -143,8 +123,8 @@ impl TransferProgress {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn bytes_written(&self) -> usize {
|
||||
self.bytes_written
|
||||
pub fn total_bytes_written(&self) -> usize {
|
||||
self.total_bytes_written
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -152,41 +132,32 @@ impl TransferProgress {
|
||||
self.files_completed
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn files_started(&self) -> usize {
|
||||
self.files_started
|
||||
}
|
||||
|
||||
/// Calculate bytes per second based on elapsed time.
|
||||
/// Bytes per second over the whole transfer.
|
||||
pub fn calc_bytes_per_second(&self) -> u64 {
|
||||
let elapsed_secs = self.started.elapsed().as_secs();
|
||||
match elapsed_secs {
|
||||
0 => {
|
||||
if self.bytes_written > 0 && self.bytes_written >= self.estimated_total() {
|
||||
self.bytes_written as u64
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
_ => self.bytes_written as u64 / elapsed_secs,
|
||||
0 => self.total_bytes_written as u64,
|
||||
_ => self.total_bytes_written as u64 / elapsed_secs,
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate ETA in seconds.
|
||||
/// ETA in seconds based on full progress.
|
||||
pub fn calc_eta(&self) -> u64 {
|
||||
let elapsed_secs = self.started.elapsed().as_secs();
|
||||
let percent = self.calc_progress() * 100.0;
|
||||
match percent as u64 {
|
||||
let percent = (self.calc_full_progress() * 100.0) as u64;
|
||||
match percent {
|
||||
0 => 0,
|
||||
p => ((elapsed_secs * 100) / p) - elapsed_secs,
|
||||
p => ((elapsed_secs * 100) / p).saturating_sub(elapsed_secs),
|
||||
}
|
||||
}
|
||||
|
||||
/// Format the file count string for multi-file title: "(3/12)"
|
||||
/// Uses `files_started` (not `files_completed`) so the display shows the
|
||||
/// file currently being transferred, not the last one that finished.
|
||||
/// File counter for the full-bar title: "(7/312)".
|
||||
pub fn file_count_display(&self) -> String {
|
||||
format!("({}/{})", self.files_started, self.files_total)
|
||||
format!(
|
||||
"({}/{})",
|
||||
self.files_completed.min(self.files_total),
|
||||
self.files_total
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,7 +195,7 @@ impl TransferStates {
|
||||
|
||||
/// Total bytes transferred (for notification threshold).
|
||||
pub fn full_size(&self) -> usize {
|
||||
self.progress.bytes_written
|
||||
self.progress.total_bytes_written
|
||||
}
|
||||
}
|
||||
|
||||
@@ -256,24 +227,27 @@ mod test {
|
||||
#[test]
|
||||
fn test_transfer_progress_single_file() {
|
||||
let mut progress = TransferProgress::default();
|
||||
assert_eq!(progress.calc_progress(), 0.0);
|
||||
assert_eq!(progress.calc_full_progress(), 0.0);
|
||||
assert!(progress.is_single_file());
|
||||
|
||||
progress.init(1);
|
||||
assert!(progress.is_single_file());
|
||||
assert_eq!(progress.calc_progress(), 0.0);
|
||||
assert_eq!(progress.calc_full_progress(), 0.0);
|
||||
|
||||
progress.register_file(1024);
|
||||
assert_eq!(progress.estimated_total(), 1024);
|
||||
assert_eq!(progress.calc_progress(), 0.0);
|
||||
progress.start_file(1024);
|
||||
assert_eq!(progress.calc_partial_progress(), 0.0);
|
||||
assert_eq!(progress.calc_full_progress(), 0.0);
|
||||
|
||||
// Partial and full track together for a single file.
|
||||
progress.add_bytes(256);
|
||||
assert_eq!(progress.bytes_written(), 256);
|
||||
assert_eq!(progress.calc_progress(), 0.25);
|
||||
assert!((progress.calc_partial_progress() - 0.25).abs() < 1e-9);
|
||||
assert!((progress.calc_full_progress() - 0.25).abs() < 1e-9);
|
||||
|
||||
progress.add_bytes(768);
|
||||
assert_eq!(progress.calc_progress(), 1.0);
|
||||
progress.increment();
|
||||
assert!((progress.calc_partial_progress() - 1.0).abs() < 1e-9);
|
||||
|
||||
progress.finish_file();
|
||||
assert!((progress.calc_full_progress() - 1.0).abs() < 1e-9);
|
||||
assert_eq!(progress.files_completed(), 1);
|
||||
}
|
||||
|
||||
@@ -283,80 +257,52 @@ mod test {
|
||||
progress.init(4);
|
||||
assert!(!progress.is_single_file());
|
||||
|
||||
progress.register_file(1000);
|
||||
assert_eq!(progress.estimated_total(), 4000);
|
||||
|
||||
// File 1 fully transferred => full ≈ 0.25
|
||||
progress.start_file(1000);
|
||||
progress.add_bytes(1000);
|
||||
progress.increment();
|
||||
assert!((progress.calc_progress() - 0.25).abs() < 0.001);
|
||||
|
||||
progress.register_file(500);
|
||||
assert_eq!(progress.estimated_total(), 3000);
|
||||
progress.finish_file();
|
||||
assert!((progress.calc_full_progress() - 0.25).abs() < 1e-9);
|
||||
|
||||
// File 2 half transferred => partial ≈ 0.5, full ≈ 0.375
|
||||
progress.start_file(1000);
|
||||
progress.add_bytes(500);
|
||||
progress.increment();
|
||||
assert!((progress.calc_progress() - 0.5).abs() < 0.001);
|
||||
|
||||
progress.register_file(500);
|
||||
assert_eq!(progress.estimated_total(), 2666);
|
||||
|
||||
progress.add_bytes(500);
|
||||
progress.increment();
|
||||
|
||||
progress.register_file(2000);
|
||||
assert_eq!(progress.estimated_total(), 4000);
|
||||
|
||||
progress.add_bytes(2000);
|
||||
progress.increment();
|
||||
assert_eq!(progress.calc_progress(), 1.0);
|
||||
assert!((progress.calc_partial_progress() - 0.5).abs() < 1e-9);
|
||||
assert!((progress.calc_full_progress() - 0.375).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_transfer_progress_skipped_file() {
|
||||
let mut progress = TransferProgress::default();
|
||||
progress.init(3);
|
||||
|
||||
progress.register_file(100);
|
||||
progress.add_bytes(100);
|
||||
progress.increment();
|
||||
|
||||
progress.register_skipped_file(200);
|
||||
assert_eq!(progress.bytes_written(), 300);
|
||||
assert_eq!(progress.files_completed(), 2);
|
||||
assert_eq!(progress.files_started(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_transfer_progress_zero_size_files() {
|
||||
let mut progress = TransferProgress::default();
|
||||
progress.init(3);
|
||||
|
||||
progress.register_file(0);
|
||||
progress.add_bytes(0);
|
||||
progress.increment();
|
||||
|
||||
progress.register_file(1000);
|
||||
assert_eq!(progress.estimated_total(), 2000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_transfer_progress_set_files_total() {
|
||||
let mut progress = TransferProgress::default();
|
||||
progress.init(2);
|
||||
progress.register_file(500);
|
||||
progress.add_bytes(500);
|
||||
progress.increment();
|
||||
|
||||
progress.set_files_total(3);
|
||||
assert_eq!(progress.bytes_written(), 500);
|
||||
assert_eq!(progress.files_started(), 1);
|
||||
// One file actually transferred.
|
||||
progress.start_file(100);
|
||||
progress.add_bytes(100);
|
||||
progress.finish_file();
|
||||
|
||||
// One file skipped (unchanged): counts toward completion, no bytes.
|
||||
progress.skip_file();
|
||||
|
||||
assert_eq!(progress.files_completed(), 2);
|
||||
assert!((progress.calc_full_progress() - 1.0).abs() < 1e-9);
|
||||
// Only the transferred file contributes bytes.
|
||||
assert_eq!(progress.total_bytes_written(), 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_transfer_progress_zero_size_file() {
|
||||
let mut progress = TransferProgress::default();
|
||||
progress.init(1);
|
||||
|
||||
progress.start_file(0);
|
||||
assert!((progress.calc_partial_progress() - 1.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_transfer_progress_timing() {
|
||||
let mut progress = TransferProgress::default();
|
||||
progress.init(1);
|
||||
progress.register_file(1024);
|
||||
progress.start_file(1024);
|
||||
|
||||
progress.started = progress
|
||||
.started
|
||||
@@ -364,42 +310,12 @@ mod test {
|
||||
.unwrap();
|
||||
progress.add_bytes(256);
|
||||
|
||||
// 256 bytes over 4 seconds => 64 bytes/s
|
||||
assert_eq!(progress.calc_bytes_per_second(), 64);
|
||||
// 25% done after 4s => total 16s => 12s remaining
|
||||
assert_eq!(progress.calc_eta(), 12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_transfer_progress_display_single() {
|
||||
let mut progress = TransferProgress::default();
|
||||
progress.init(1);
|
||||
progress.register_file(1024);
|
||||
progress.started = progress
|
||||
.started
|
||||
.checked_sub(Duration::from_secs(4))
|
||||
.unwrap();
|
||||
progress.add_bytes(256);
|
||||
|
||||
let display = progress.to_string();
|
||||
assert!(display.contains("/ 1.0 KiB"));
|
||||
assert!(!display.contains('~'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_transfer_progress_display_multi() {
|
||||
let mut progress = TransferProgress::default();
|
||||
progress.init(4);
|
||||
progress.register_file(1024);
|
||||
progress.started = progress
|
||||
.started
|
||||
.checked_sub(Duration::from_secs(4))
|
||||
.unwrap();
|
||||
progress.add_bytes(256);
|
||||
|
||||
let display = progress.to_string();
|
||||
assert!(display.contains("transferred"));
|
||||
assert!(display.contains('~'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_transfer_states() {
|
||||
let mut states = TransferStates::default();
|
||||
|
||||
Reference in New Issue
Block a user