[feat] add robust progress management utilities and new tests
This commit is contained in:
690
src/progress.rs
Normal file
690
src/progress.rs
Normal file
@@ -0,0 +1,690 @@
|
||||
// Progress abstraction for STDERR-only, TTY-aware progress bars.
|
||||
// Centralizes progress logic so it can be swapped or disabled easily.
|
||||
|
||||
use std::env;
|
||||
use std::io::IsTerminal;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Instant;
|
||||
|
||||
use indicatif::{MultiProgress, ProgressBar, ProgressDrawTarget, ProgressStyle};
|
||||
|
||||
const NAME_WIDTH: usize = 28;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
/// Progress message sent from worker threads to the UI/main thread.
|
||||
/// fraction: 0.0..1.0 progress value; stage/message are optional labels.
|
||||
pub struct ProgressMessage {
|
||||
/// Fractional progress in range 0.0..=1.0.
|
||||
pub fraction: f32,
|
||||
/// Optional stage label (e.g., "load_model", "encode", "decode", "done").
|
||||
pub stage: Option<String>,
|
||||
/// Optional human-readable note.
|
||||
pub note: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
/// Mode describing how progress should be displayed.
|
||||
///
|
||||
/// - None: progress is disabled or not supported.
|
||||
/// - Single: one spinner for the current item only.
|
||||
/// - Multi: a total progress bar plus a current-item spinner.
|
||||
pub enum ProgressMode {
|
||||
/// No progress output.
|
||||
None,
|
||||
/// Single spinner for the currently processed item.
|
||||
Single,
|
||||
/// Multi-bar progress including a total counter of all inputs.
|
||||
Multi {
|
||||
/// Total number of inputs to process when using multi-bar mode.
|
||||
total_inputs: u64,
|
||||
},
|
||||
}
|
||||
|
||||
fn stderr_is_tty() -> bool {
|
||||
// Prefer std IsTerminal when available
|
||||
std::io::stderr().is_terminal()
|
||||
}
|
||||
|
||||
fn progress_disabled_by_env() -> bool {
|
||||
matches!(env::var("NO_PROGRESS"), Ok(ref v) if v == "1" || v.eq_ignore_ascii_case("true"))
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
/// Factory that decides progress mode and produces a ProgressManager bound to stderr.
|
||||
pub struct ProgressFactory {
|
||||
enabled: bool,
|
||||
mp: Option<Arc<MultiProgress>>,
|
||||
}
|
||||
|
||||
impl ProgressFactory {
|
||||
/// Create a factory that enables progress when stderr is a TTY and neither
|
||||
/// the NO_PROGRESS env var nor the force_disable flag are set.
|
||||
pub fn new(force_disable: bool) -> Self {
|
||||
let tty = stderr_is_tty();
|
||||
let env_off = progress_disabled_by_env();
|
||||
let enabled = !(force_disable || env_off) && tty;
|
||||
if enabled {
|
||||
let mp = MultiProgress::with_draw_target(ProgressDrawTarget::stderr_with_hz(20));
|
||||
// Render tick even if nothing changes periodically for spinner feel
|
||||
mp.set_move_cursor(true);
|
||||
Self {
|
||||
enabled,
|
||||
mp: Some(Arc::new(mp)),
|
||||
}
|
||||
} else {
|
||||
Self {
|
||||
enabled: false,
|
||||
mp: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Decide a suitable ProgressMode for the given number of inputs,
|
||||
/// respecting whether progress is globally enabled.
|
||||
pub fn decide_mode(&self, inputs_len: usize) -> ProgressMode {
|
||||
if !self.enabled {
|
||||
return ProgressMode::None;
|
||||
}
|
||||
if inputs_len == 0 {
|
||||
ProgressMode::None
|
||||
} else if inputs_len == 1 {
|
||||
ProgressMode::Single
|
||||
} else {
|
||||
ProgressMode::Multi {
|
||||
total_inputs: inputs_len as u64,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Construct a ProgressManager for the previously decided mode. Returns
|
||||
/// a no-op manager when progress is disabled.
|
||||
pub fn make_manager(&self, mode: ProgressMode) -> ProgressManager {
|
||||
match (self.enabled, &self.mp, mode) {
|
||||
(true, Some(mp), ProgressMode::Single) => ProgressManager::with_single(mp.clone()),
|
||||
(true, Some(mp), ProgressMode::Multi { total_inputs }) => {
|
||||
ProgressManager::with_multi(mp.clone(), total_inputs)
|
||||
}
|
||||
_ => ProgressManager::noop(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
/// Handle for updating and finishing progress bars or a no-op when disabled.
|
||||
pub struct ProgressManager {
|
||||
inner: ProgressInner,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
enum ProgressInner {
|
||||
Noop,
|
||||
Single(Arc<SingleBars>),
|
||||
Multi(Arc<MultiBars>),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct SingleBars {
|
||||
current: ProgressBar,
|
||||
// keep MultiProgress alive for suspend/println behavior
|
||||
_mp: Arc<MultiProgress>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct MultiBars {
|
||||
// Legacy bars for compatibility (used when not using per-file init)
|
||||
total: ProgressBar,
|
||||
current: ProgressBar,
|
||||
// Optional per-file bars and aggregated total percent bar
|
||||
files: Mutex<Option<Vec<ProgressBar>>>, // each length 100
|
||||
total_pct: Mutex<Option<ProgressBar>>, // length 100
|
||||
// Metadata for aggregation
|
||||
sizes: Mutex<Option<Vec<Option<u64>>>>,
|
||||
fractions: Mutex<Option<Vec<f32>>>, // 0..=1 per file
|
||||
last_total_draw_ms: Mutex<Instant>,
|
||||
// keep MultiProgress alive
|
||||
_mp: Arc<MultiProgress>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
/// Handle for per-item progress updates. Safe to clone and send across threads to update
|
||||
/// the currently active item's progress without affecting the global total counter.
|
||||
pub struct ItemHandle {
|
||||
pb: ProgressBar,
|
||||
}
|
||||
|
||||
impl ItemHandle {
|
||||
/// Update the determinate progress for this item using a fraction in 0.0..=1.0.
|
||||
/// Internally mapped to 0..100 units.
|
||||
pub fn set_progress(&self, fraction: f32) {
|
||||
let f = if fraction.is_nan() { 0.0 } else { fraction.clamp(0.0, 1.0) };
|
||||
let pos = (f * 100.0).round() as u64;
|
||||
if self.pb.length().unwrap_or(0) == 0 {
|
||||
self.pb.set_length(100);
|
||||
}
|
||||
if self.pb.position() != pos {
|
||||
self.pb.set_position(pos);
|
||||
}
|
||||
}
|
||||
/// Set a human-readable message for this item (e.g., current stage name).
|
||||
pub fn set_message(&self, message: &str) {
|
||||
self.pb.set_message(message.to_string());
|
||||
}
|
||||
/// Finish this item by prefixing "done " to the currently displayed message.
|
||||
/// The provided message parameter is ignored to preserve stable width and avoid flicker.
|
||||
pub fn finish_with(&self, _message: &str) {
|
||||
if !self.pb.is_finished() {
|
||||
self.pb.finish_with_message(_message.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ProgressManager {
|
||||
/// Test helper: create a Multi-mode manager with a hidden draw target, safe for tests
|
||||
/// even when not attached to a TTY.
|
||||
pub fn new_for_tests_multi_hidden(total: usize) -> Self {
|
||||
let mp = Arc::new(MultiProgress::with_draw_target(ProgressDrawTarget::hidden()));
|
||||
Self::with_multi(mp, total as u64)
|
||||
}
|
||||
|
||||
/// Backwards-compatible constructor used by older tests: same as new_for_tests_multi_hidden.
|
||||
pub fn test_new_multi(total: usize) -> Self {
|
||||
Self::new_for_tests_multi_hidden(total)
|
||||
}
|
||||
|
||||
/// Test helper: return (completed, total) for the global bar if present.
|
||||
pub fn total_state_for_tests(&self) -> Option<(u64, u64)> {
|
||||
match &self.inner {
|
||||
ProgressInner::Multi(m) => Some((m.total.position(), m.total.length().unwrap_or(0))),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn noop() -> Self {
|
||||
Self {
|
||||
inner: ProgressInner::Noop,
|
||||
}
|
||||
}
|
||||
|
||||
fn with_single(mp: Arc<MultiProgress>) -> Self {
|
||||
let current = mp.add(ProgressBar::new(100));
|
||||
current.set_style(spinner_style());
|
||||
Self {
|
||||
inner: ProgressInner::Single(Arc::new(SingleBars { current, _mp: mp })),
|
||||
}
|
||||
}
|
||||
|
||||
fn with_multi(mp: Arc<MultiProgress>, total_inputs: u64) -> Self {
|
||||
// Add current first, then total so that total stays anchored at the bottom line
|
||||
let current = mp.add(ProgressBar::new(100));
|
||||
current.set_style(spinner_style());
|
||||
let total = mp.add(ProgressBar::new(total_inputs));
|
||||
total.set_style(total_style());
|
||||
total.set_message("total");
|
||||
Self {
|
||||
inner: ProgressInner::Multi(Arc::new(MultiBars {
|
||||
total,
|
||||
current,
|
||||
files: Mutex::new(None),
|
||||
total_pct: Mutex::new(None),
|
||||
sizes: Mutex::new(None),
|
||||
fractions: Mutex::new(None),
|
||||
last_total_draw_ms: Mutex::new(Instant::now()),
|
||||
_mp: mp,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the total number of items for the global progress (multi mode).
|
||||
pub fn set_total(&self, n: usize) {
|
||||
match &self.inner {
|
||||
ProgressInner::Multi(m) => {
|
||||
m.total.set_length(n as u64);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark exactly one completed item (clamped to not exceed total).
|
||||
pub fn inc_completed(&self) {
|
||||
match &self.inner {
|
||||
ProgressInner::Multi(m) => {
|
||||
let len = m.total.length().unwrap_or(0);
|
||||
let pos = m.total.position();
|
||||
if pos < len {
|
||||
m.total.inc(1);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Start a new item handle with an optional label.
|
||||
pub fn start_item(&self, label: &str) -> ItemHandle {
|
||||
match &self.inner {
|
||||
ProgressInner::Noop => ItemHandle { pb: ProgressBar::hidden() },
|
||||
ProgressInner::Single(s) => {
|
||||
s.current.set_message(label.to_string());
|
||||
ItemHandle { pb: s.current.clone() }
|
||||
}
|
||||
ProgressInner::Multi(m) => {
|
||||
m.current.set_message(label.to_string());
|
||||
ItemHandle { pb: m.current.clone() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pause progress rendering to allow a clean prompt line to be printed.
|
||||
pub fn pause_for_prompt(&self) {
|
||||
match &self.inner {
|
||||
ProgressInner::Noop => {}
|
||||
ProgressInner::Single(s) => {
|
||||
let _ = s._mp.suspend(|| {});
|
||||
}
|
||||
ProgressInner::Multi(m) => {
|
||||
let _ = m._mp.suspend(|| {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Print a line above the bars safely (TTY-aware). Falls back to eprintln! when disabled.
|
||||
pub fn println_above_bars(&self, line: &str) {
|
||||
match &self.inner {
|
||||
ProgressInner::Noop => eprintln!("{}", line),
|
||||
ProgressInner::Single(s) => {
|
||||
let _ = s._mp.println(line);
|
||||
}
|
||||
ProgressInner::Multi(m) => {
|
||||
let _ = m._mp.println(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resume progress after a prompt (currently a no-op; redraw continues automatically).
|
||||
pub fn resume_after_prompt(&self) {}
|
||||
|
||||
/// Set the message for the current-item spinner.
|
||||
pub fn set_current_message(&self, msg: &str) {
|
||||
match &self.inner {
|
||||
ProgressInner::Noop => {}
|
||||
ProgressInner::Single(s) => s.current.set_message(msg.to_string()),
|
||||
ProgressInner::Multi(m) => m.current.set_message(msg.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set an explicit length for the current-item spinner (useful when it becomes a determinate bar).
|
||||
pub fn set_current_length(&self, len: u64) {
|
||||
match &self.inner {
|
||||
ProgressInner::Noop => {}
|
||||
ProgressInner::Single(s) => s.current.set_length(len),
|
||||
ProgressInner::Multi(m) => m.current.set_length(len),
|
||||
}
|
||||
}
|
||||
|
||||
/// Increment the current-item spinner by the given delta.
|
||||
pub fn inc_current(&self, delta: u64) {
|
||||
match &self.inner {
|
||||
ProgressInner::Noop => {}
|
||||
ProgressInner::Single(s) => s.current.inc(delta),
|
||||
ProgressInner::Multi(m) => m.current.inc(delta),
|
||||
}
|
||||
}
|
||||
|
||||
/// Finish the current-item spinner by prefixing "done " to its current message.
|
||||
pub fn finish_current_with(&self, _msg: &str) {
|
||||
match &self.inner {
|
||||
ProgressInner::Noop => {}
|
||||
ProgressInner::Single(s) => {
|
||||
let orig = s.current.message().to_string();
|
||||
s.current.finish_with_message(format!("done {}", orig));
|
||||
}
|
||||
ProgressInner::Multi(m) => {
|
||||
let orig = m.current.message().to_string();
|
||||
m.current.finish_with_message(format!("done {}", orig));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Increment the total progress bar by the given delta (multi-bar mode only).
|
||||
pub fn inc_total(&self, delta: u64) {
|
||||
match &self.inner {
|
||||
ProgressInner::Noop => {}
|
||||
ProgressInner::Single(_) => {}
|
||||
ProgressInner::Multi(m) => m.total.inc(delta),
|
||||
}
|
||||
}
|
||||
|
||||
/// Finish progress bars. Keep total bar visible with a final message and prefix "done " for items.
|
||||
pub fn finish_all(&self) {
|
||||
match &self.inner {
|
||||
ProgressInner::Noop => {}
|
||||
ProgressInner::Single(s) => {
|
||||
if !s.current.is_finished() {
|
||||
let orig = s.current.message().to_string();
|
||||
s.current.finish_with_message(format!("done {}", orig));
|
||||
}
|
||||
}
|
||||
ProgressInner::Multi(m) => {
|
||||
// If per-file bars are active, finish each with stable "done <msg>"
|
||||
let mut had_files = false;
|
||||
if let Ok(g) = m.files.lock() {
|
||||
if let Some(files) = g.as_ref() {
|
||||
had_files = true;
|
||||
for pb in files.iter() {
|
||||
if !pb.is_finished() {
|
||||
let orig = pb.message().to_string();
|
||||
pb.finish_with_message(format!("done {}", orig));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Finish the aggregated total percent bar or the legacy total
|
||||
if let Ok(gt) = m.total_pct.lock() {
|
||||
if let Some(tpb) = gt.as_ref() {
|
||||
if !tpb.is_finished() {
|
||||
tpb.finish_with_message("100% total".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
if !had_files {
|
||||
// Legacy total/current bars: keep total visible too
|
||||
let len = m.total.length().unwrap_or(0);
|
||||
if !m.current.is_finished() {
|
||||
m.current.finish_and_clear();
|
||||
}
|
||||
if !m.total.is_finished() {
|
||||
m.total.finish_with_message(format!("{}/{} total", len, len));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Set determinate progress of the current item using a fractional value 0.0..=1.0.
|
||||
pub fn set_progress(&self, fraction: f32) {
|
||||
let f = if fraction.is_nan() { 0.0 } else { fraction.clamp(0.0, 1.0) };
|
||||
let pos = (f * 100.0).round() as u64;
|
||||
match &self.inner {
|
||||
ProgressInner::Noop => {}
|
||||
ProgressInner::Single(s) => {
|
||||
if s.current.length().unwrap_or(0) == 0 {
|
||||
s.current.set_length(100);
|
||||
}
|
||||
if s.current.position() != pos {
|
||||
s.current.set_position(pos);
|
||||
}
|
||||
}
|
||||
ProgressInner::Multi(m) => {
|
||||
if m.current.length().unwrap_or(0) == 0 {
|
||||
m.current.set_length(100);
|
||||
}
|
||||
if m.current.position() != pos {
|
||||
m.current.set_position(pos);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Set a message/label for the current item (alias for set_current_message).
|
||||
pub fn set_message(&self, message: &str) {
|
||||
self.set_current_message(message);
|
||||
}
|
||||
}
|
||||
|
||||
fn spinner_style() -> ProgressStyle {
|
||||
// Style for per-item determinate progress: 0-100% with a compact bar and message
|
||||
ProgressStyle::with_template("{bar:24.green/green} {percent:>3}% {msg}")
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn total_style() -> ProgressStyle {
|
||||
// Persistent bottom bar showing total completed/total inputs
|
||||
ProgressStyle::with_template("{bar:40.cyan/blue} {pos}/{len} {msg}").unwrap()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
/// Inputs used to determine progress enablement and mode.
|
||||
pub struct SelectionInput {
|
||||
/// Number of inputs to process (used to choose single vs multi mode).
|
||||
pub inputs_len: usize,
|
||||
/// Whether progress was explicitly disabled via a CLI flag.
|
||||
pub no_progress_flag: bool,
|
||||
/// Optional override for whether stderr is a TTY; if None, auto-detect.
|
||||
pub stderr_tty_override: Option<bool>,
|
||||
/// Whether progress was disabled via the NO_PROGRESS environment variable.
|
||||
pub env_no_progress: bool,
|
||||
}
|
||||
|
||||
/// Decide whether progress is enabled and which mode to use based on SelectionInput.
|
||||
pub fn select_mode(si: SelectionInput) -> (bool, ProgressMode) {
|
||||
// Compute effective enablement
|
||||
let tty = si.stderr_tty_override.unwrap_or_else(stderr_is_tty);
|
||||
let disabled = si.no_progress_flag || si.env_no_progress;
|
||||
let enabled = tty && !disabled;
|
||||
let mode = if !enabled || si.inputs_len == 0 {
|
||||
ProgressMode::None
|
||||
} else if si.inputs_len == 1 {
|
||||
ProgressMode::Single
|
||||
} else {
|
||||
ProgressMode::Multi {
|
||||
total_inputs: si.inputs_len as u64,
|
||||
}
|
||||
};
|
||||
(enabled, mode)
|
||||
}
|
||||
|
||||
/// Optional Ctrl-C cleanup: clears progress bars and removes .last_model before exiting on SIGINT.
|
||||
pub fn install_ctrlc_cleanup(pm: ProgressManager) {
|
||||
let state = Arc::new(Mutex::new(Some(pm.clone())));
|
||||
let state_clone = state.clone();
|
||||
if let Err(e) = ctrlc::set_handler(move || {
|
||||
// Clear any visible progress bars
|
||||
if let Ok(mut guard) = state_clone.lock() {
|
||||
if let Some(pm) = guard.take() {
|
||||
pm.finish_all();
|
||||
}
|
||||
}
|
||||
// Best-effort removal of the last-model cache so it doesn't persist after Ctrl-C
|
||||
let last_path = crate::models_dir_path().join(".last_model");
|
||||
let _ = std::fs::remove_file(&last_path);
|
||||
// Exit with 130 to reflect SIGINT
|
||||
std::process::exit(130);
|
||||
}) {
|
||||
// Warn if we failed to install the handler; without it, Ctrl-C won't trigger cleanup
|
||||
crate::wlog!("Failed to install Ctrl-C handler: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// --- New: Per-file progress bars API for Multi mode ---
|
||||
impl ProgressManager {
|
||||
/// Initialize per-file bars and an aggregated total percent bar using indicatif::MultiProgress.
|
||||
/// Each bar has length 100 and shows a truncated filename as message.
|
||||
/// This replaces the legacy current/total display with fixed per-file lines.
|
||||
pub fn init_files<I, S>(&self, labels_and_sizes: I)
|
||||
where
|
||||
I: IntoIterator<Item = (S, Option<u64>)>,
|
||||
S: Into<String>,
|
||||
{
|
||||
if let ProgressInner::Multi(m) = &self.inner {
|
||||
// Clear legacy bars from display to avoid duplication
|
||||
m.current.finish_and_clear();
|
||||
m.total.finish_and_clear();
|
||||
let mut files: Vec<ProgressBar> = Vec::new();
|
||||
let mut sizes: Vec<Option<u64>> = Vec::new();
|
||||
let mut fractions: Vec<f32> = Vec::new();
|
||||
for (label_in, size_opt) in labels_and_sizes {
|
||||
let label: String = label_in.into();
|
||||
let pb = m._mp.add(ProgressBar::new(100));
|
||||
pb.set_style(spinner_style());
|
||||
let short = truncate_label(&label, NAME_WIDTH);
|
||||
pb.set_message(format!("{:<width$}", short, width = NAME_WIDTH));
|
||||
files.push(pb);
|
||||
sizes.push(size_opt);
|
||||
fractions.push(0.0);
|
||||
}
|
||||
let total_pct = m._mp.add(ProgressBar::new(100));
|
||||
total_pct
|
||||
.set_style(ProgressStyle::with_template("{bar:40.cyan/blue} {percent:>3}% total").unwrap());
|
||||
// Store
|
||||
if let Ok(mut gf) = m.files.lock() { *gf = Some(files); }
|
||||
if let Ok(mut gt) = m.total_pct.lock() { *gt = Some(total_pct); }
|
||||
if let Ok(mut gs) = m.sizes.lock() { *gs = Some(sizes); }
|
||||
if let Ok(mut gfr) = m.fractions.lock() { *gfr = Some(fractions); }
|
||||
if let Ok(mut t) = m.last_total_draw_ms.lock() { *t = Instant::now(); }
|
||||
}
|
||||
}
|
||||
|
||||
/// Return whether per-file bars are active (Multi mode only)
|
||||
pub fn has_file_bars(&self) -> bool {
|
||||
match &self.inner {
|
||||
ProgressInner::Multi(m) => m.files.lock().map(|g| g.is_some()).unwrap_or(false),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get an item handle for a specific file index (Multi mode with file bars). Falls back to legacy current.
|
||||
pub fn item_handle_at(&self, index: usize) -> ItemHandle {
|
||||
match &self.inner {
|
||||
ProgressInner::Multi(m) => {
|
||||
if let Ok(g) = m.files.lock() {
|
||||
if let Some(vec) = g.as_ref() {
|
||||
if let Some(pb) = vec.get(index) {
|
||||
return ItemHandle { pb: pb.clone() };
|
||||
}
|
||||
}
|
||||
}
|
||||
ItemHandle { pb: m.current.clone() }
|
||||
}
|
||||
ProgressInner::Single(s) => ItemHandle { pb: s.current.clone() },
|
||||
ProgressInner::Noop => ItemHandle { pb: ProgressBar::hidden() },
|
||||
}
|
||||
}
|
||||
|
||||
/// Update a specific file's progress (0.0..=1.0) and recompute the aggregated total percent.
|
||||
pub fn set_file_progress(&self, index: usize, fraction: f32) {
|
||||
let f = if fraction.is_nan() { 0.0 } else { fraction.clamp(0.0, 1.0) };
|
||||
if let ProgressInner::Multi(m) = &self.inner {
|
||||
if let Ok(gf) = m.files.lock() {
|
||||
if let Some(files) = gf.as_ref() {
|
||||
if index < files.len() {
|
||||
let pb = &files[index];
|
||||
pb.set_length(100);
|
||||
let pos = (f * 100.0).round() as u64;
|
||||
if pb.position() != pos {
|
||||
pb.set_position(pos);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Ok(mut gfr) = m.fractions.lock() {
|
||||
if let Some(fracs) = gfr.as_mut() {
|
||||
if index < fracs.len() {
|
||||
fracs[index] = f;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.recompute_total_pct();
|
||||
}
|
||||
}
|
||||
|
||||
fn recompute_total_pct(&self) {
|
||||
if let ProgressInner::Multi(m) = &self.inner {
|
||||
let has_total = m.total_pct.lock().map(|g| g.is_some()).unwrap_or(false);
|
||||
if !has_total {
|
||||
return;
|
||||
}
|
||||
let now = Instant::now();
|
||||
let do_draw = if let Ok(mut last) = m.last_total_draw_ms.lock() {
|
||||
if now.duration_since(*last).as_millis() >= 50 {
|
||||
*last = now;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
true
|
||||
};
|
||||
if !do_draw {
|
||||
return;
|
||||
}
|
||||
let fractions = match m.fractions.lock().ok().and_then(|g| g.clone()) {
|
||||
Some(v) => v,
|
||||
None => return,
|
||||
};
|
||||
let sizes_opt = m.sizes.lock().ok().and_then(|g| g.clone());
|
||||
let pct = if let Some(sizes) = sizes_opt.as_ref() {
|
||||
if !sizes.is_empty() && sizes.iter().all(|o| o.is_some()) {
|
||||
let mut num: f64 = 0.0;
|
||||
let mut den: f64 = 0.0;
|
||||
for (f, s) in fractions.iter().zip(sizes.iter()) {
|
||||
let sz = s.unwrap_or(0) as f64;
|
||||
num += (*f as f64) * sz;
|
||||
den += sz;
|
||||
}
|
||||
if den > 0.0 { (num / den) as f32 } else { 0.0 }
|
||||
} else {
|
||||
// Fallback to unweighted average
|
||||
if fractions.is_empty() { 0.0 } else { (fractions.iter().sum::<f32>()) / (fractions.len() as f32) }
|
||||
}
|
||||
} else {
|
||||
if fractions.is_empty() { 0.0 } else { (fractions.iter().sum::<f32>()) / (fractions.len() as f32) }
|
||||
};
|
||||
let pos = (pct.clamp(0.0, 1.0) * 100.0).round() as u64;
|
||||
if let Ok(gt) = m.total_pct.lock() {
|
||||
if let Some(total_pb) = gt.as_ref() {
|
||||
total_pb.set_length(100);
|
||||
if total_pb.position() != pos {
|
||||
total_pb.set_position(pos);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn truncate_label(s: &str, max: usize) -> String {
|
||||
if s.len() <= max {
|
||||
s.to_string()
|
||||
} else {
|
||||
if max <= 3 {
|
||||
return ".".repeat(max);
|
||||
}
|
||||
let keep = max - 3;
|
||||
let truncated = s.chars().take(keep).collect::<String>();
|
||||
format!("{}...", truncated)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::truncate_label;
|
||||
|
||||
#[test]
|
||||
fn truncate_keeps_short_and_exact() {
|
||||
assert_eq!(truncate_label("short", 10), "short");
|
||||
assert_eq!(truncate_label("short", 5), "short");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_long_adds_ellipsis() {
|
||||
assert_eq!(truncate_label("abcdefghij", 8), "abcde...");
|
||||
assert_eq!(truncate_label("filename_long.flac", 12), "filename_...");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_small_max_returns_dots() {
|
||||
assert_eq!(truncate_label("anything", 3), "...");
|
||||
assert_eq!(truncate_label("anything", 2), "..");
|
||||
assert_eq!(truncate_label("anything", 1), ".");
|
||||
assert_eq!(truncate_label("anything", 0), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_handles_unicode_by_char_boundary() {
|
||||
// Using chars().take(keep) prevents splitting code points; not grapheme-perfect but safe.
|
||||
// "é" is 2 bytes but 1 char; keep=2 should keep "Aé" then add dots
|
||||
let s = "AéBCD"; // chars: A, é, B, C, D
|
||||
assert_eq!(truncate_label(s, 5), "Aé..."); // keep 2 chars + ...
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user