// 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, /// Optional human-readable note. pub note: Option, } #[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>, } 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(), } } /// Preferred constructor using Config. Respects config.no_progress and TTY. pub fn from_config(config: &crate::Config) -> Self { // Prefer Config.no_progress over manual flag; still honor NO_PROGRESS env var. let force_disable = config.no_progress; Self::new(force_disable) } } #[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), Multi(Arc), } #[derive(Debug)] struct SingleBars { header: ProgressBar, info: ProgressBar, current: ProgressBar, // keep MultiProgress alive for suspend/println behavior _mp: Arc, } #[derive(Debug)] struct MultiBars { // Header row shown above bars header: ProgressBar, // Single info/status row shown under header and above bars info: ProgressBar, // Bars: current file and total current: ProgressBar, total: ProgressBar, // Optional per-file bars and aggregated total percent bar (unused in new UX) files: Mutex>>, // each length 100 total_pct: Mutex>, // length 100 // Metadata for aggregation sizes: Mutex>>>, fractions: Mutex>>, // 0..=1 per file last_total_draw_ms: Mutex, // keep MultiProgress alive _mp: Arc, } #[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) -> Self { // Order: header, info row, then current file bar let header = mp.add(ProgressBar::new(0)); header.set_style(info_style()); let info = mp.add(ProgressBar::new(0)); info.set_style(info_style()); let current = mp.add(ProgressBar::new(100)); current.set_style(current_style()); Self { inner: ProgressInner::Single(Arc::new(SingleBars { header, info, current, _mp: mp })), } } fn with_multi(mp: Arc, total_inputs: u64) -> Self { // Order: header, info row, then current file bar, then total bar at the bottom let header = mp.add(ProgressBar::new(0)); header.set_style(info_style()); let info = mp.add(ProgressBar::new(0)); info.set_style(info_style()); let current = mp.add(ProgressBar::new(100)); current.set_style(current_style()); let total = mp.add(ProgressBar::new(total_inputs)); total.set_style(total_style()); Self { inner: ProgressInner::Multi(Arc::new(MultiBars { header, info, current, total, 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 " 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 current_style() -> ProgressStyle { // Per-item determinate progress: show 0..100 as pos/len with a simple bar ProgressStyle::with_template("{spinner:.green} [{elapsed_precise}] {pos}/{len} {bar:40.cyan/blue} {msg}") .expect("invalid progress template in current_style()") } fn info_style() -> ProgressStyle { ProgressStyle::with_template("{msg}").unwrap() } fn total_style() -> ProgressStyle { // Bottom total bar with elapsed time ProgressStyle::with_template("Total [{bar:28}] {pos}/{len} [{elapsed_precise}]") .unwrap() .progress_chars("=> ") } #[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, /// 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(&self, labels_and_sizes: I) where I: IntoIterator)>, S: Into, { 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 = Vec::new(); let mut sizes: Vec> = Vec::new(); let mut fractions: Vec = 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(current_style()); let short = truncate_label(&label, NAME_WIDTH); pb.set_message(format!("{: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::()) / (fractions.len() as f32) } } } else { if fractions.is_empty() { 0.0 } else { (fractions.iter().sum::()) / (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::(); 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 + ... } }