849 lines
32 KiB
Rust
849 lines
32 KiB
Rust
// 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};
|
|
|
|
// Global hook to route logs through the active progress manager so they render within
|
|
// the same cliclack/indicatif area instead of raw stderr.
|
|
static GLOBAL_PM: std::sync::Mutex<Option<ProgressManager>> = std::sync::Mutex::new(None);
|
|
|
|
/// Install a global ProgressManager used for printing log lines above bars.
|
|
pub fn set_global_progress_manager(pm: &ProgressManager) {
|
|
if let Ok(mut g) = GLOBAL_PM.lock() {
|
|
*g = Some(pm.clone());
|
|
}
|
|
}
|
|
|
|
/// Remove the global ProgressManager hook.
|
|
pub fn clear_global_progress_manager() {
|
|
if let Ok(mut g) = GLOBAL_PM.lock() {
|
|
*g = None;
|
|
}
|
|
}
|
|
|
|
/// Try to print a line via the global ProgressManager, returning true if handled.
|
|
pub fn log_line_via_global(line: &str) -> bool {
|
|
if let Ok(g) = GLOBAL_PM.lock() {
|
|
if let Some(pm) = g.as_ref() {
|
|
pm.println_above_bars(line);
|
|
return true;
|
|
}
|
|
}
|
|
false
|
|
}
|
|
|
|
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(),
|
|
}
|
|
}
|
|
|
|
/// 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<SingleBars>),
|
|
Multi(Arc<MultiBars>),
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct SingleBars {
|
|
header: ProgressBar,
|
|
info: ProgressBar,
|
|
current: ProgressBar,
|
|
// keep MultiProgress alive for suspend/println behavior
|
|
_mp: Arc<MultiProgress>,
|
|
}
|
|
|
|
#[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<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)
|
|
}
|
|
|
|
/// Test helper: create a Single-mode manager with a hidden draw target, safe for tests
|
|
/// even when not attached to a TTY.
|
|
pub fn new_for_tests_single_hidden() -> Self {
|
|
let mp = Arc::new(MultiProgress::with_draw_target(ProgressDrawTarget::hidden()));
|
|
Self::with_single(mp)
|
|
}
|
|
|
|
/// 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,
|
|
}
|
|
}
|
|
|
|
/// Test helper: return the number of visible bars managed initially.
|
|
/// Single mode: 3 (header, info, current). Multi mode: 4 (header, info, current, total).
|
|
pub fn testing_bar_count(&self) -> usize {
|
|
match &self.inner {
|
|
ProgressInner::Noop => 0,
|
|
ProgressInner::Single(_) => 3,
|
|
ProgressInner::Multi(m) => {
|
|
// Base bars always present
|
|
let mut count = 4;
|
|
// If per-file bars were initialized, include them as well
|
|
if let Ok(files) = m.files.lock() { if let Some(v) = &*files { count += v.len(); } }
|
|
if let Ok(t) = m.total_pct.lock() { if t.is_some() { count += 1; } }
|
|
count
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Test helper: get state of the current item bar (position, length, finished, message).
|
|
pub fn current_state_for_tests(&self) -> Option<(u64, u64, bool, String)> {
|
|
match &self.inner {
|
|
ProgressInner::Single(s) => Some((
|
|
s.current.position(),
|
|
s.current.length().unwrap_or(0),
|
|
s.current.is_finished(),
|
|
s.current.message().to_string(),
|
|
)),
|
|
ProgressInner::Multi(m) => Some((
|
|
m.current.position(),
|
|
m.current.length().unwrap_or(0),
|
|
m.current.is_finished(),
|
|
m.current.message().to_string(),
|
|
)),
|
|
ProgressInner::Noop => None,
|
|
}
|
|
}
|
|
|
|
fn noop() -> Self {
|
|
Self {
|
|
inner: ProgressInner::Noop,
|
|
}
|
|
}
|
|
|
|
fn with_single(mp: Arc<MultiProgress>) -> 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<MultiProgress>, 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) {
|
|
// Try to interpret certain INFO lines as a stable title + dynamic message.
|
|
// Examples to match:
|
|
// - "INFO: Fetching online data: listing models from ggerganov/whisper.cpp..."
|
|
// -> header = "INFO: Fetching online data"; info = "listing models from ..."
|
|
// - "INFO: Downloading tiny.en-q5_1 (252 MiB | https://...)..."
|
|
// -> header = "INFO: Downloading"; info = rest
|
|
// - "INFO: Total 1/3" (defensive): header = "INFO: Total"; info = rest
|
|
let parsed: Option<(String, String)> = {
|
|
let s = line.trim();
|
|
if let Some(rest) = s.strip_prefix("INFO: ") {
|
|
// Case A: explicit title followed by colon
|
|
if let Some((title, body)) = rest.split_once(':') {
|
|
let title_clean = format!("INFO: {}", title.trim());
|
|
let body_clean = body.trim().to_string();
|
|
Some((title_clean, body_clean))
|
|
} else if let Some(rest2) = rest.strip_prefix("Downloading ") {
|
|
Some(("INFO: Downloading".to_string(), rest2.trim().to_string()))
|
|
} else if let Some(rest2) = rest.strip_prefix("Total") {
|
|
Some(("INFO: Total".to_string(), rest2.trim().to_string()))
|
|
} else {
|
|
// Fallback: use first word as title, remainder as body
|
|
let mut it = rest.splitn(2, ' ');
|
|
let first = it.next().unwrap_or("").trim();
|
|
let remainder = it.next().unwrap_or("").trim();
|
|
if !first.is_empty() {
|
|
Some((format!("INFO: {}", first), remainder.to_string()))
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
} else {
|
|
None
|
|
}
|
|
};
|
|
|
|
match &self.inner {
|
|
ProgressInner::Noop => eprintln!("{}", line),
|
|
ProgressInner::Single(s) => {
|
|
if let Some((title, body)) = parsed.as_ref() {
|
|
s.header.set_message(title.clone());
|
|
s.info.set_message(body.clone());
|
|
} else {
|
|
let _ = s._mp.println(line);
|
|
}
|
|
}
|
|
ProgressInner::Multi(m) => {
|
|
if let Some((title, body)) = parsed.as_ref() {
|
|
m.header.set_message(title.clone());
|
|
m.info.set_message(body.clone());
|
|
} else {
|
|
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 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<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 temporary files 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 models_dir = crate::models_dir_path();
|
|
let last_path = models_dir.join(".last_model");
|
|
let _ = std::fs::remove_file(&last_path);
|
|
// Also remove any unfinished model downloads ("*.part")
|
|
if let Ok(rd) = std::fs::read_dir(&models_dir) {
|
|
for entry in rd.flatten() {
|
|
let p = entry.path();
|
|
if let Some(name) = p.file_name().and_then(|s| s.to_str()) {
|
|
if name.ends_with(".part") {
|
|
let _ = std::fs::remove_file(&p);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// 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(current_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 + ...
|
|
}
|
|
}
|