From 9841550dcc2ec97fe4834b99464c90dc7a39f804 Mon Sep 17 00:00:00 2001 From: vikingowl Date: Thu, 14 Aug 2025 03:31:00 +0200 Subject: [PATCH] [refactor] replace `indicatif` with `cliclack` for progress and logging, updating affected modules and dependencies --- Cargo.lock | 54 ++++- Cargo.toml | 1 - crates/polyscribe-cli/Cargo.toml | 1 - crates/polyscribe-cli/src/main.rs | 16 +- crates/polyscribe-core/Cargo.toml | 3 +- crates/polyscribe-core/src/models.rs | 248 ++++++++-------------- crates/polyscribe-core/src/ui.rs | 122 ++++++++--- crates/polyscribe-core/src/ui/progress.rs | 99 ++++----- 8 files changed, 289 insertions(+), 255 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4a256f7..460a342 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -298,6 +298,20 @@ dependencies = [ "roff", ] +[[package]] +name = "cliclack" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c420bdc04c123a2df04d9c5a07289195f00007af6e45ab18f55e56dc7e04b8" +dependencies = [ + "console", + "indicatif", + "once_cell", + "strsim", + "textwrap", + "zeroize", +] + [[package]] name = "cmake" version = "0.1.54" @@ -1103,7 +1117,6 @@ dependencies = [ "clap_complete", "clap_mangen", "directories", - "indicatif", "polyscribe-core", "polyscribe-host", "polyscribe-protocol", @@ -1121,9 +1134,9 @@ version = "0.1.0" dependencies = [ "anyhow", "chrono", + "cliclack", "directories", "hex", - "indicatif", "libc", "reqwest", "serde", @@ -1598,6 +1611,12 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "smawk" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" + [[package]] name = "socket2" version = "0.5.10" @@ -1680,6 +1699,17 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "textwrap" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" +dependencies = [ + "smawk", + "unicode-linebreak", + "unicode-width", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -1972,6 +2002,12 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + [[package]] name = "unicode-width" version = "0.2.1" @@ -2579,6 +2615,20 @@ name = "zeroize" version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] name = "zerotrie" diff --git a/Cargo.toml b/Cargo.toml index 4c27502..4a07b7c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,7 +20,6 @@ sha2 = "0.10.9" which = "6.0.3" tokio = { version = "1.47.1", features = ["rt-multi-thread", "macros"] } clap = { version = "4.5.44", features = ["derive"] } -indicatif = "0.17.11" directories = "5.0.1" whisper-rs = "0.14.3" cliclack = "0.3.6" diff --git a/crates/polyscribe-cli/Cargo.toml b/crates/polyscribe-cli/Cargo.toml index d0b044e..b783297 100644 --- a/crates/polyscribe-cli/Cargo.toml +++ b/crates/polyscribe-cli/Cargo.toml @@ -9,7 +9,6 @@ clap = { version = "4.5.44", features = ["derive"] } clap_complete = "4.5.57" clap_mangen = "0.2.29" directories = "5.0.1" -indicatif = "0.17.11" serde = { version = "1.0.219", features = ["derive"] } serde_json = "1.0.142" tokio = { version = "1.47.1", features = ["rt-multi-thread", "macros", "process", "fs"] } diff --git a/crates/polyscribe-cli/src/main.rs b/crates/polyscribe-cli/src/main.rs index 43d784c..f71a29a 100644 --- a/crates/polyscribe-cli/src/main.rs +++ b/crates/polyscribe-cli/src/main.rs @@ -7,7 +7,6 @@ use polyscribe_core::{config::ConfigService, ui::progress::ProgressReporter}; use polyscribe_core::models; // Added: call into core models use polyscribe_host::PluginManager; use tokio::io::AsyncWriteExt; -use tracing::{error, info}; use tracing_subscriber::EnvFilter; fn init_tracing(quiet: bool, verbose: u8) { @@ -54,7 +53,7 @@ async fn main() -> Result<()> { gpu_layers, inputs, } => { - info!("starting transcription workflow"); + polyscribe_core::ui::info("starting transcription workflow"); let mut progress = ProgressReporter::new(args.no_interaction); progress.step("Validating inputs"); @@ -80,19 +79,19 @@ async fn main() -> Result<()> { Commands::Models { cmd } => { match cmd { ModelsCmd::Update => { - info!("verifying/updating local models"); + polyscribe_core::ui::info("verifying/updating local models"); tokio::task::spawn_blocking(|| models::update_local_models()) .await .map_err(|e| anyhow!("blocking task join error: {e}"))? .context("updating models")?; } ModelsCmd::Download => { - info!("interactive model selection and download"); + polyscribe_core::ui::info("interactive model selection and download"); tokio::task::spawn_blocking(|| models::run_interactive_model_downloader()) .await .map_err(|e| anyhow!("blocking task join error: {e}"))? .context("running downloader")?; - println!("Model download complete."); + polyscribe_core::ui::success("Model download complete."); } } @@ -106,13 +105,14 @@ async fn main() -> Result<()> { PluginsCmd::List => { let list = pm.list().context("discovering plugins")?; for item in list { - println!("{}", item.name); + polyscribe_core::ui::info(item.name); } Ok(()) } PluginsCmd::Info { name } => { let info = pm.info(&name).with_context(|| format!("getting info for {}", name))?; - println!("{}", serde_json::to_string_pretty(&info)?); + let s = serde_json::to_string_pretty(&info)?; + polyscribe_core::ui::info(s); Ok(()) } PluginsCmd::Run { name, command, json } => { @@ -130,7 +130,7 @@ async fn main() -> Result<()> { let status = pm.forward_stdio(&mut child).await?; if !status.success() { - error!("plugin returned non-zero exit code: {}", status); + polyscribe_core::ui::error(format!("plugin returned non-zero exit code: {}", status)); return Err(anyhow!("plugin failed")); } Ok(()) diff --git a/crates/polyscribe-core/Cargo.toml b/crates/polyscribe-core/Cargo.toml index bbeb518..c50aad4 100644 --- a/crates/polyscribe-core/Cargo.toml +++ b/crates/polyscribe-core/Cargo.toml @@ -13,7 +13,8 @@ directories = "5.0.1" chrono = "0.4.41" libc = "0.2.175" whisper-rs = "0.14.3" -indicatif = "0.17.11" +# UI and progress +cliclack = { workspace = true } # New: HTTP downloads + hashing reqwest = { version = "0.12.7", default-features = false, features = ["blocking", "rustls-tls", "gzip", "json"] } sha2 = "0.10.8" diff --git a/crates/polyscribe-core/src/models.rs b/crates/polyscribe-core/src/models.rs index 9e2a46d..5573176 100644 --- a/crates/polyscribe-core/src/models.rs +++ b/crates/polyscribe-core/src/models.rs @@ -7,7 +7,6 @@ use anyhow::{anyhow, Context, Result}; use chrono::{DateTime, Utc}; use hex::ToHex; -use indicatif::{ProgressBar, ProgressStyle}; use reqwest::blocking::Client; use reqwest::header::{ ACCEPT_RANGES, CONTENT_LENGTH, CONTENT_RANGE, ETAG, IF_RANGE, LAST_MODIFIED, RANGE, @@ -80,45 +79,6 @@ fn mirror_label(url: &str) -> &'static str { } } -// Helper: build a single progress bar with desired format -fn new_progress_bar(total: Option) -> ProgressBar { - let zero_or_none = matches!(total, None | Some(0)); - - let pb = if zero_or_none { - ProgressBar::new_spinner() - } else { - ProgressBar::new(total.unwrap()) - }; - pb.enable_steady_tick(Duration::from_millis(120)); - - // Use built-in byte and time placeholders - let style = if zero_or_none { - // No total known: show spinner, bytes so far, speed, and elapsed - ProgressStyle::with_template( - // Panel-ish spinner - "{spinner} {bytes:>9} @ {bytes_per_sec} | {elapsed} | {msg}" - ).unwrap_or_else(|_| ProgressStyle::default_spinner()) - } else { - // Total known: show bar, percent, bytes progress, speed, and ETA - ProgressStyle::with_template( - // Railcar + numeric focus - "{spinner:.green} [{elapsed_precise}] [{wide_bar:.cyan/blue}] {bytes}/{total_bytes} ({bytes_per_sec}, {eta})" - ) - .unwrap_or_else(|_| ProgressStyle::default_bar()).tick_strings(&[ - "▹▹▹▹▹", - "▸▹▹▹▹", - "▹▸▹▹▹", - "▹▹▸▹▹", - "▹▹▹▸▹", - "▹▹▹▹▸", - "▪▪▪▪▪", - ]) - .progress_chars("=>-") - }; - - pb.set_style(style); - pb -} // Perform a HEAD to get size/etag/last-modified and fill what we can fn head_entry(client: &Client, url: &str) -> Result<(Option, Option, Option, bool)> { @@ -603,7 +563,7 @@ pub fn ensure_model_available_noninteractive(name: &str) -> Result { // If already matches, early return if file_matches(&dest, entry.size, entry.sha256.as_deref())? { - println!("Already up to date: {}", dest.display()); + crate::ui::info(format!("Already up to date: {}", dest.display())); return Ok(dest); } @@ -611,12 +571,12 @@ pub fn ensure_model_available_noninteractive(name: &str) -> Result { let base = &entry.base; let variant = &entry.variant; let size_str = format_size_mb(entry.size); - println!("Base: {base} • Type: {variant}"); - println!( + crate::ui::println_above_bars(format!("Base: {base} • Type: {variant}")); + crate::ui::println_above_bars(format!( "Source: {} • Size: {}", mirror_label(&entry.url), size_str - ); + )); download_with_progress(&dest, &entry)?; Ok(dest) @@ -693,7 +653,7 @@ fn download_with_progress(dest_path: &Path, entry: &ModelEntry) -> Result<()> { .user_agent("polyscribe-model-downloader/1") .build()?; - println!("Resolving source: {} ({})", mirror_label(url), url); + crate::ui::info(format!("Resolving source: {} ({})", mirror_label(url), url)); // HEAD for size/etag/ranges let (mut total_len, remote_etag, _remote_last_mod, ranges_ok) = head_entry(&client, url) @@ -717,7 +677,7 @@ fn download_with_progress(dest_path: &Path, entry: &ModelEntry) -> Result<()> { if dest_path.exists() { if file_matches(dest_path, total_len, entry.sha256.as_deref())? { - println!("Already up to date: {}", dest_path.display()); + crate::ui::info(format!("Already up to date: {}", dest_path.display())); return Ok(()); } } @@ -749,16 +709,19 @@ fn download_with_progress(dest_path: &Path, entry: &ModelEntry) -> Result<()> { } } - println!("Download: {}", part_path.display()); + crate::ui::info(format!("Download: {}", part_path.display())); let pb_total = total_len.unwrap_or(0); - let pb = if pb_total > 0 { - let pb = new_progress_bar(Some(pb_total)); - pb.set_position(resume_from); - pb + let mut bar = None; + let mut sp: Option = None; + if pb_total > 0 { + let mut b = cliclack::progress_bar(pb_total); + b.start("Downloading"); + if resume_from > 0 { b.inc(resume_from); } + bar = Some(b); } else { - new_progress_bar(None) - }; + sp = Some(crate::ui::Spinner::start("Downloading")); + } let start = Instant::now(); let mut resp = req.send()?.error_for_status()?; @@ -781,7 +744,12 @@ fn download_with_progress(dest_path: &Path, entry: &ModelEntry) -> Result<()> { // Plain GET without conditional headers let mut req2 = client.get(url); resp = req2.send()?.error_for_status()?; - pb.set_position(0); + if let Some(b) = bar.as_mut() { + b.stop("restarting"); + } + let mut b2 = cliclack::progress_bar(pb_total); + b2.start("Downloading"); + bar = Some(b2); // Reopen the part file since we dropped it part_file = OpenOptions::new() @@ -803,20 +771,25 @@ fn download_with_progress(dest_path: &Path, entry: &ModelEntry) -> Result<()> { } part_file.write_all(&buf[..read])?; if pb_total > 0 { - let pos = part_file.metadata()?.len(); - pb.set_position(pos); + if let Some(b) = bar.as_mut() { + b.inc(read as u64); + } } else { - pb.inc(read as u64); + // spinner: nothing to update per chunk beyond the animation } } part_file.flush()?; part_file.sync_all()?; } - pb.finish_and_clear(); + if pb_total > 0 { + if let Some(b) = bar.as_mut() { b.stop("done"); } + } else { + if let Some(s) = sp.take() { s.success("done"); } + } if let Some(expected_hex) = entry.sha256.as_deref() { - println!("Verify: SHA-256"); + crate::ui::info("Verify: SHA-256"); let mut f = File::open(&part_path)?; let mut hasher = Sha256::new(); let mut buf = vec![0u8; 1024 * 1024]; @@ -836,7 +809,7 @@ fn download_with_progress(dest_path: &Path, entry: &ModelEntry) -> Result<()> { )); } } else { - println!("Verify: checksum not provided by source (skipped)"); + crate::ui::info("Verify: checksum not provided by source (skipped)"); } if let Some(parent) = dest_path.parent() { @@ -853,26 +826,26 @@ fn download_with_progress(dest_path: &Path, entry: &ModelEntry) -> Result<()> { if elapsed > 0.0 { let mib = sz as f64 / 1024.0 / 1024.0; let rate = mib / elapsed; - println!( - "✔ Saved: {} ({}) in {:.1}s, {:.1} MiB/s", + crate::ui::success(format!( + "Saved: {} ({}) in {:.1}s, {:.1} MiB/s", dest_path.display(), format_size_mb(Some(sz)), elapsed, rate - ); + )); } else { - println!( - "✔ Saved: {} ({})", + crate::ui::success(format!( + "Saved: {} ({})", dest_path.display(), format_size_mb(Some(sz)) - ); + )); } } else { - println!( - "✔ Saved: {} ({})", + crate::ui::success(format!( + "Saved: {} ({})", dest_path.display(), format_size_mb(None) - ); + )); } Ok(()) @@ -912,8 +885,10 @@ pub fn run_interactive_model_downloader() -> Result<()> { } ui::intro("PolyScribe model downloader"); - ui::println_above_bars("Select a model base:"); - for (i, base) in ordered_bases.iter().enumerate() { + + // Build Select items for bases with counts and size ranges + let mut base_labels: Vec = Vec::new(); + for base in &ordered_bases { let variants = &by_base[base]; let (min_sz, max_sz) = variants.iter().fold((None, None), |acc, m| { let (mut lo, mut hi) = acc; @@ -930,51 +905,24 @@ pub fn run_interactive_model_downloader() -> Result<()> { hi as f64 / 1_000_000.0 ), (Some(sz), _) => format!(" ~{:.2} MB", sz as f64 / 1_000_000.0), - _ => "".to_string(), + _ => String::new(), }; - ui::println_above_bars(format!( - " {}. {} ({:>2} types){}", - i + 1, - base, - variants.len(), - size_info - )); + base_labels.push(format!("{} ({} types){}", base, variants.len(), size_info)); } + let base_refs: Vec<&str> = base_labels.iter().map(|s| s.as_str()).collect(); + let base_idx = ui::prompt_select("Choose a model base", &base_refs)?; + let chosen_base = ordered_bases[base_idx].clone(); - let base_ans = ui::prompt_input("Base [1]", Some("1"))?; - // Robust: accept either index (1-based) or base name - let base_idx = base_ans.trim().parse::().ok(); - let chosen_base = if let Some(idx) = base_idx { - if idx == 0 || idx > ordered_bases.len() { - return Err(anyhow!("invalid base selection")); - } - ordered_bases[idx - 1].clone() - } else { - // Match by name, case-insensitive - let ans = base_ans.trim().to_ascii_lowercase(); - let pos = ordered_bases - .iter() - .position(|b| b.eq_ignore_ascii_case(&ans)) - .ok_or_else(|| anyhow!("invalid base selection"))?; - ordered_bases[pos].clone() - }; - + // Prepare variant list for chosen base let mut variants = by_base.remove(&chosen_base).unwrap_or_default(); - - // Sort variants by a friendly order: default, en, then others alphabetically variants.sort_by(|a, b| { - let rank = |v: &str| match v { - "default" => 0, - "en" => 1, - _ => 2, - }; - rank(&a.variant) - .cmp(&rank(&b.variant)) - .then_with(|| a.variant.cmp(&b.variant)) + let rank = |v: &str| match v { "default" => 0, "en" => 1, _ => 2 }; + rank(&a.variant).cmp(&rank(&b.variant)).then_with(|| a.variant.cmp(&b.variant)) }); - ui::println_above_bars(format!("Select a type for '{}':", chosen_base)); - for (i, m) in variants.iter().enumerate() { + // Build Multi-Select items for variants + let mut variant_labels: Vec = Vec::new(); + for m in &variants { let size = format_size_mb(m.size.as_ref().copied()); let updated = m .last_modified @@ -982,62 +930,46 @@ pub fn run_interactive_model_downloader() -> Result<()> { .map(short_date) .map(|d| format!(" • updated {}", d)) .unwrap_or_default(); - let variant_label = if m.variant == "default" { - "default" - } else { - &m.variant - }; - ui::println_above_bars(format!( - " {}. {} ({}{})", - i + 1, - variant_label, - size, - updated - )); + let variant_label = if m.variant == "default" { "default" } else { &m.variant }; + variant_labels.push(format!("{} ({}{})", variant_label, size, updated)); + } + let variant_refs: Vec<&str> = variant_labels.iter().map(|s| s.as_str()).collect(); + let mut defaults = vec![false; variant_refs.len()]; + if !defaults.is_empty() { defaults[0] = true; } + let picks = ui::prompt_multi_select( + &format!("Select types for '{}'", chosen_base), + &variant_refs, + Some(&defaults), + )?; + + if picks.is_empty() { + ui::warn("No types selected; aborting."); + ui::outro("No changes made."); + return Ok(()); } - let type_ans = ui::prompt_input("Type [1]", Some("1"))?; - let type_idx = type_ans - .trim() - .parse::() - .ok() - .filter(|n| *n >= 1 && *n <= variants.len()) - .or_else(|| { - // Optional: allow typing the variant name - let ans = type_ans.trim().to_ascii_lowercase(); - variants - .iter() - .position(|m| { - let v = if m.variant == "default" { - "default" - } else { - &m.variant - }; - v.eq_ignore_ascii_case(&ans) - }) - .map(|i| i + 1) + ui::println_above_bars("Downloading selected models..."); + + // Setup multi-progress when multiple items are selected + let labels: Vec = picks + .iter() + .map(|&i| { + let m = &variants[i]; + format!("{} ({})", m.name, format_size_mb(m.size)) }) - .ok_or_else(|| anyhow!("invalid type selection"))?; - - let picked = variants[type_idx - 1].clone(); - - fn entry_label(entry: &ModelEntry) -> String { - format!("{} ({})", entry.name, format_size_mb(entry.size)) - } - - let labels = vec![entry_label(&picked)]; + .collect(); let mut pm = ui::progress::ProgressManager::default_for_files(labels.len()); pm.init_files(&labels); - if let Some(pb) = pm.per_bar(0) { - pb.set_message("downloading"); + + for (bar_idx, idx) in picks.into_iter().enumerate() { + let picked = variants[idx].clone(); + pm.set_per_message(bar_idx, "downloading"); + let _path = ensure_model_available_noninteractive(&picked.name)?; + pm.mark_file_done(bar_idx); + ui::success(format!("Ready: {}", picked.name)); } - let path = ensure_model_available_noninteractive(&picked.name)?; - ui::println_above_bars(format!("Ready: {}", path.display())); - pm.mark_file_done(0); - if let Some(total) = pm.total_bar() { - total.finish_with_message("all done"); - } + pm.finish_total("all done"); ui::outro("Model selection complete."); Ok(()) } diff --git a/crates/polyscribe-core/src/ui.rs b/crates/polyscribe-core/src/ui.rs index 6cda105..bb3da7a 100644 --- a/crates/polyscribe-core/src/ui.rs +++ b/crates/polyscribe-core/src/ui.rs @@ -1,64 +1,124 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2025 . All rights reserved. -//! Minimal UI helpers used across the core crate. -//! This keeps interactive bits centralized and easy to stub in tests. +//! UI helpers powered by cliclack for interactive console experiences. +//! Centralizes prompts, logging, and progress primitives. /// Progress indicators and reporting tools for displaying task completion. pub mod progress; -use std::io::{self, Write}; +use std::io; -/// Print an informational line to stderr (suppressed when quiet mode is enabled by callers). +/// Log an informational message. pub fn info(msg: impl AsRef) { - eprintln!("{}", msg.as_ref()); + let m = msg.as_ref(); + let _ = cliclack::log::info(m); } -/// Print a warning line to stderr. +/// Log a warning message. pub fn warn(msg: impl AsRef) { - eprintln!("WARNING: {}", msg.as_ref()); + let m = msg.as_ref(); + let _ = cliclack::log::warning(m); } -/// Print an error line to stderr. +/// Log an error message. pub fn error(msg: impl AsRef) { - eprintln!("ERROR: {}", msg.as_ref()); + let m = msg.as_ref(); + let _ = cliclack::log::error(m); } -/// Print a short intro header (non-fancy). +/// Log a success message. +pub fn success(msg: impl AsRef) { + let m = msg.as_ref(); + let _ = cliclack::log::success(m); +} + +/// Log a note message with a prompt and a message. +pub fn note(prompt: impl AsRef, message: impl AsRef) { + let _ = cliclack::note(prompt.as_ref(), message.as_ref()); +} + +/// Print a short intro header. pub fn intro(title: impl AsRef) { - eprintln!("== {} ==", title.as_ref()); + let _ = cliclack::intro(title.as_ref()); } -/// Print a short outro footer (non-fancy). +/// Print a short outro footer. pub fn outro(msg: impl AsRef) { - eprintln!("{}", msg.as_ref()); + let _ = cliclack::outro(msg.as_ref()); } -/// Print a line that should appear above any progress indicators (plain for now). +/// Print a line that should appear above any progress indicators. pub fn println_above_bars(line: impl AsRef) { - eprintln!("{}", line.as_ref()); + let _ = cliclack::log::info(line.as_ref()); } -/// Prompt for input on stdin. Returns default if provided and user enters empty string. +/// Prompt for input on stdin using cliclack's input component. +/// Returns default if provided and user enters empty string. /// In non-interactive workflows, callers should skip prompt based on their flags. pub fn prompt_input(prompt: &str, default: Option<&str>) -> io::Result { - let mut stdout = io::stdout(); - match default { - Some(def) => { - write!(stdout, "{} [{}]: ", prompt, def)?; - } - None => { - write!(stdout, "{}: ", prompt)?; + let mut q = cliclack::input(prompt); + if let Some(def) = default { q = q.default_input(def); } + q.interact().map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string())) +} + +/// Present a single-choice selector and return the selected index. +pub fn prompt_select<'a>(prompt: &str, items: &[&'a str]) -> io::Result { + let mut sel = cliclack::select::(prompt); + for (idx, label) in items.iter().enumerate() { + sel = sel.item(idx, *label, ""); + } + sel.interact() + .map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string())) +} + +/// Present a multi-choice selector and return indices of selected items. +pub fn prompt_multi_select<'a>(prompt: &str, items: &[&'a str], defaults: Option<&[bool]>) -> io::Result> { + let mut ms = cliclack::multiselect::(prompt); + for (idx, label) in items.iter().enumerate() { + ms = ms.item(idx, *label, ""); + } + if let Some(def) = defaults { + let selected: Vec = def + .iter() + .enumerate() + .filter_map(|(i, &on)| if on { Some(i) } else { None }) + .collect(); + if !selected.is_empty() { + ms = ms.initial_values(selected); } } - stdout.flush()?; + ms.interact() + .map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string())) +} - let mut buf = String::new(); - io::stdin().read_line(&mut buf)?; - let trimmed = buf.trim(); - if trimmed.is_empty() { - Ok(default.unwrap_or_default().to_string()) - } else { - Ok(trimmed.to_string()) +/// A simple spinner wrapper built on top of `cliclack::spinner()`. +/// +/// This wrapper provides a minimal API with start/stop/success/error methods +/// to standardize spinner usage across the project. +pub struct Spinner(cliclack::ProgressBar); + +impl Spinner { + /// Creates and starts a new spinner with the provided status text. + pub fn start(text: impl AsRef) -> Self { + let s = cliclack::spinner(); + s.start(text.as_ref()); + Self(s) + } + /// Stops the spinner with a submitted/completed style and message. + pub fn stop(self, text: impl AsRef) { + let s = self.0; + s.stop(text.as_ref()); + } + /// Marks the spinner as successfully finished (alias for `stop`). + pub fn success(self, text: impl AsRef) { + let s = self.0; + // cliclack progress bar uses `stop` for successful completion styling + s.stop(text.as_ref()); + } + /// Marks the spinner as failed with an error style and message. + pub fn error(self, text: impl AsRef) { + let s = self.0; + s.error(text.as_ref()); } } diff --git a/crates/polyscribe-core/src/ui/progress.rs b/crates/polyscribe-core/src/ui/progress.rs index 79f6671..4e9819d 100644 --- a/crates/polyscribe-core/src/ui/progress.rs +++ b/crates/polyscribe-core/src/ui/progress.rs @@ -1,22 +1,21 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2025 . All rights reserved. -use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; use std::io::IsTerminal as _; -/// Manages a set of per-file progress bars plus a top aggregate bar. +/// Manages a set of per-file progress bars plus a top aggregate bar using cliclack. pub struct ProgressManager { enabled: bool, - mp: Option, - per: Vec, - total: Option, + per: Vec, + total: Option, completed: usize, + total_len: usize, } impl ProgressManager { /// Create a new manager with the given enabled flag. pub fn new(enabled: bool) -> Self { - Self { enabled, mp: None, per: Vec::new(), total: None, completed: 0 } + Self { enabled, per: Vec::new(), total: None, completed: 0, total_len: 0 } } /// Create a manager that enables bars when `n > 1`, stderr is a TTY, and not quiet. @@ -27,61 +26,69 @@ impl ProgressManager { /// Initialize bars for the given file labels. If disabled or single file, no-op. pub fn init_files(&mut self, labels: &[String]) { + self.total_len = labels.len(); if !self.enabled || labels.len() <= 1 { // No bars in single-file mode or when disabled self.enabled = false; return; } - let mp = MultiProgress::new(); // Aggregate bar at the top - let total = mp.add(ProgressBar::new(labels.len() as u64)); - total.set_style(ProgressStyle::with_template("{prefix} [{bar:40.cyan/blue}] {pos}/{len}") - .unwrap() - .progress_chars("=>-")); - total.set_prefix("Total"); + let mut total = cliclack::progress_bar(labels.len() as u64); + total.start("Total"); self.total = Some(total); - // Per-file bars + // Per-file bars (100% scale for each) for label in labels { - let pb = mp.add(ProgressBar::new(100)); - pb.set_style(ProgressStyle::with_template("{prefix} [{bar:40.green/black}] {pos}% {msg}") - .unwrap() - .progress_chars("=>-")); - pb.set_position(0); - pb.set_prefix(label.clone()); + let mut pb = cliclack::progress_bar(100); + pb.start(label); self.per.push(pb); } - self.mp = Some(mp); } /// Returns true when bars are enabled (multi-file TTY mode). pub fn is_enabled(&self) -> bool { self.enabled } - /// Get a clone of the per-file progress bar at index, if enabled. - pub fn per_bar(&self, idx: usize) -> Option { - if !self.enabled { return None; } - self.per.get(idx).cloned() + /// Update a per-file bar message. + pub fn set_per_message(&mut self, idx: usize, message: &str) { + if !self.enabled { return; } + if let Some(pb) = self.per.get_mut(idx) { + pb.set_message(message); + } } - /// Get a clone of the aggregate (total) progress bar, if enabled. - pub fn total_bar(&self) -> Option { - if !self.enabled { return None; } - self.total.as_ref().cloned() + /// Update a per-file bar percent (0..=100). + pub fn set_per_percent(&mut self, idx: usize, percent: u64) { + if !self.enabled { return; } + if let Some(pb) = self.per.get_mut(idx) { + let p = percent.min(100); + pb.set_message(&format!("{p}%")); + } } /// Mark a file as finished (set to 100% and update total counter). pub fn mark_file_done(&mut self, idx: usize) { if !self.enabled { return; } - if let Some(pb) = self.per.get(idx) { - pb.set_position(100); - pb.finish_with_message("done"); + if let Some(pb) = self.per.get_mut(idx) { + pb.stop("done"); } self.completed += 1; - if let Some(total) = &self.total { total.set_position(self.completed as u64); } + if let Some(total) = &mut self.total { + total.inc(1); + if self.completed >= self.total_len { + total.stop("all done"); + } + } + } + + /// Finish the aggregate bar with a custom message. + pub fn finish_total(&mut self, message: &str) { + if !self.enabled { return; } + if let Some(total) = &mut self.total { + total.stop(message); + } } } -/// A simple reporter for displaying progress messages in the terminal. -/// Provides different output formatting based on whether the environment is interactive or not. +/// A simple reporter for displaying progress messages using cliclack logging. #[derive(Debug)] pub struct ProgressReporter { non_interactive: bool, @@ -89,37 +96,23 @@ pub struct ProgressReporter { impl ProgressReporter { /// Creates a new progress reporter. - /// - /// # Arguments - /// - /// * `non_interactive` - Whether the output should be formatted for non-interactive environments. - pub fn new(non_interactive: bool) -> Self { - Self { non_interactive } - } + pub fn new(non_interactive: bool) -> Self { Self { non_interactive } } /// Displays a progress step message. - /// - /// # Arguments - /// - /// * `message` - The message to display for this progress step. pub fn step(&mut self, message: &str) { if self.non_interactive { - eprintln!("[..] {message}"); + let _ = cliclack::log::info(format!("[..] {message}")); } else { - eprintln!("• {message}"); + let _ = cliclack::log::info(format!("• {message}")); } } /// Displays a completion message. - /// - /// # Arguments - /// - /// * `message` - The message to display when a task is completed. pub fn finish_with_message(&mut self, message: &str) { if self.non_interactive { - eprintln!("[ok] {message}"); + let _ = cliclack::log::info(format!("[ok] {message}")); } else { - eprintln!("✓ {message}"); + let _ = cliclack::log::info(format!("✓ {message}")); } } }