From 05b7b483056902a07f83bc14f443ab59ce1471d8 Mon Sep 17 00:00:00 2001 From: 魏曹先生 <1992414357@qq.com> Date: Sat, 28 Feb 2026 18:01:33 +0800 Subject: First version --- src/lib.rs | 223 +++++++++++++++ src/progress.rs | 200 +++++++++++++ src/renderer.rs | 854 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 1277 insertions(+) create mode 100644 src/lib.rs create mode 100644 src/progress.rs create mode 100644 src/renderer.rs (limited to 'src') diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..2875c41 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,223 @@ +//! Progress Management Library +//! +//! This library provides flexible progress management functionality, supporting multi-level progress display and custom renderers. +//! +//! # Quick Start +//! +//! The following is a simple usage example demonstrating how to create a progress center, bind a renderer, and update progress for multiple concurrent tasks: +//! +//! ```rust +//! use just_progress::progress; +//! use just_progress::renderer::ProgressSimpleRenderer; +//! use tokio::time::{sleep, Duration}; +//! use tokio::join; +//! +//! #[tokio::main] +//! async fn main() { +//! // Initialize the progress center +//! let center = progress::init(); +//! // Create a renderer and enable subprogress display +//! let renderer = ProgressSimpleRenderer::new().with_subprogress(true); +//! // Bind the renderer callback function +//! let bind = progress::bind(center, move |name, state| renderer.update(name, state)); +//! +//! // Concurrently execute progress binding and business logic +//! join!(bind, proc()); +//! } +//! +//! async fn proc() { +//! println!("Starting package download!"); +//! sleep(Duration::from_millis(500)).await; +//! +//! // Define multiple concurrent download tasks +//! let download_task = async { +//! for i in 1..21 { +//! sleep(Duration::from_millis(50)).await; +//! progress::update_progress("Download/Data", i as f32 * 0.05); +//! } +//! }; +//! +//! let extract_task = async { +//! for i in 1..11 { +//! sleep(Duration::from_millis(100)).await; +//! progress::update_progress("Download/Extract", i as f32 * 0.1); +//! } +//! }; +//! +//! let verify_sha256_task = async { +//! for i in 1..6 { +//! sleep(Duration::from_millis(300)).await; +//! progress::update_progress("Download/Verify/SHA256", i as f32 * 0.2); +//! } +//! }; +//! +//! let verify_md5_task = async { +//! for i in 1..6 { +//! sleep(Duration::from_millis(100)).await; +//! progress::update_progress("Download/Verify/MD5", i as f32 * 0.2); +//! } +//! }; +//! +//! let verify_blake3_task = async { +//! for i in 1..6 { +//! sleep(Duration::from_millis(500)).await; +//! progress::update_progress("Download/Verify/Blake3", i as f32 * 0.2); +//! } +//! }; +//! +//! // Concurrently execute all download tasks +//! join!( +//! download_task, +//! extract_task, +//! verify_sha256_task, +//! verify_blake3_task, +//! verify_md5_task +//! ); +//! +//! sleep(Duration::from_millis(500)).await; +//! progress::clear_all(); // Clear all progress after download completes +//! println!("Package download complete!"); +//! +//! progress::close(); // Close the progress channel, ending the program +//! } +//! ``` +//! + +/// Progress Module +/// +/// Provides core functionality for progress management, including progress center initialization, +/// status updates, and callback binding. +/// +/// # Usage +/// +/// 1. Initialize the progress center: +/// ```rust +/// # use just_progress::progress; +/// let progress_center = progress::init(); +/// ``` +/// +/// 2. Bind a renderer callback: +/// ```rust +/// # use just_progress::progress; +/// # use just_progress::renderer::ProgressSimpleRenderer; +/// let center = progress::init(); +/// let renderer = ProgressSimpleRenderer::new().with_subprogress(true); +/// let bind = progress::bind(center, move |name, state| renderer.update(name, state)); +/// // Run `bind` and business logic in an async context +/// ``` +/// +/// 3. Update progress status: +/// ```rust +/// # use just_progress::progress::{self, ProgressInfo}; +/// // Update progress value +/// progress::update_progress("download/file1", 0.5); +/// +/// // Update status information +/// progress::update_info("download/file1", ProgressInfo::Info("Downloading...")); +/// +/// // Update both progress and status simultaneously +/// progress::update("download/file1", 0.75, ProgressInfo::Warning("Slow network")); +/// +/// // Mark as complete +/// // Equivalent to progress::update_progress("download/file1", 1.0); +/// progress::complete("download/file1"); +/// +/// // Clear all progress items +/// progress::clear_all(); +/// +/// // Close the progress channel +/// progress::close(); +/// ``` +/// +/// # Special Flags +/// +/// The module uses two special flags internally to control the flow: +/// - `_clear`: Clears all progress items +/// - `_close`: Closes the binding channel, stopping callbacks +/// +/// # Asynchronous Binding +/// +/// The `bind` function creates an asynchronous Future that listens for progress changes and invokes the callback. +/// The callback receives the progress name and state, making it suitable for use with renderers. +/// +/// # Hierarchical Progress +/// +/// Progress names can use slashes (`/`) to represent hierarchical relationships, e.g., `"parent/child"`. +/// Renderers can leverage this structure to display indentation or calculate the average progress of parent tasks. +/// +/// # Notes +/// +/// - `init()` must be called before using any other functions. +/// - The Future returned by `bind()` needs to be polled or awaited for callbacks to execute. +/// - `clear_all()` sends a clear signal but does not immediately clear messages in the channel. +/// - `close()` stops all `bind()` calls; progress updates are no longer possible afterward. +/// +pub mod progress; + +/// Renderer Module +/// +/// Provides the `ProgressSimpleRenderer` for displaying progress bars and status information. +/// +/// # Usage +/// +/// 1. Create a renderer instance: +/// ```rust +/// # use just_progress::renderer::ProgressSimpleRenderer; +/// let renderer = ProgressSimpleRenderer::new(); +/// ``` +/// +/// 2. Configure the renderer (optional): +/// ```rust +/// # use just_progress::renderer::ProgressSimpleRenderer; +/// # use just_progress::renderer::RendererSortingRule; +/// let renderer = ProgressSimpleRenderer::new() +/// .with_subprogress(true) // Enable subprogress +/// .with_auto_remove_completed(true) // Automatically remove completed items +/// .with_sorting(RendererSortingRule::AlphabeticalAsc); // Sort alphabetically ascending +/// ``` +/// +/// 3. Trigger rendering: +/// ```rust +/// # use just_progress::renderer::ProgressSimpleRenderer; +/// # use just_progress::renderer::RendererSortingRule; +/// # use just_progress::progress::{ProgressState, ProgressInfo}; +/// # let renderer = ProgressSimpleRenderer::new(); +/// renderer.render_all(); +/// ``` +/// +/// # Subprogress +/// +/// When `subprogress` is enabled, progress names can be separated by slashes (`/`) to represent hierarchical relationships: +/// - `"parent"` - Top-level progress +/// - `"parent/child"` - Subprogress, automatically indented when displayed +/// - `"parent/child/grandchild"` - Deeper level progress +/// +/// If a parent node does not have a directly corresponding progress state, the renderer will automatically calculate the average progress of its child nodes as the display value. +/// +/// # Theme +/// +/// Customize the rendering style via the `RendererTheme` struct: +/// ```rust +/// use just_progress::renderer::{RendererTheme, ProgressSimpleRenderer}; +/// let theme = RendererTheme { +/// layout: "{name},{progress},{percent}", +/// show_state: false, +/// progress_content_len: 30, +/// progress_filled_char: "█", +/// progress_empty_char: "░", +/// ..RendererTheme::default() +/// }; +/// let renderer = ProgressSimpleRenderer::new().with_theme(theme); +/// ``` +/// +/// # Layout +/// +/// The `layout` field uses a comma-separated template string, supporting the following placeholders: +/// - `{name}`: Progress name (supports indentation) +/// - `{state}`: Status information (INFO/WARN/ERROR) +/// - `{progress}`: Progress bar +/// - `{percent}`: Percentage +/// +/// Example: `"{name},{state},{progress},{percent}"` +/// +pub mod renderer; diff --git a/src/progress.rs b/src/progress.rs new file mode 100644 index 0000000..a5509ef --- /dev/null +++ b/src/progress.rs @@ -0,0 +1,200 @@ +use std::{collections::HashMap, fmt::Display, sync::OnceLock}; +use tokio::sync::watch; + +pub static PROGRESS_CENTER: OnceLock = OnceLock::new(); + +const SPECIAL_FLAG_CLEAR: &str = "_clear"; +const SPECIAL_FLAG_CLOSE: &str = "_close"; + +#[derive(Debug)] +pub struct ProgressCenter { + tx: watch::Sender>, + rx: watch::Receiver>, +} + +/// Initialize `ProgressCenter` early in the program +/// +/// ``` rust +/// use just_progress::progress; +/// +/// fn main() { +/// // Initialize +/// let progress_center = progress::init(); +/// } +/// ``` +pub fn init() -> &'static ProgressCenter { + let (tx, rx) = watch::channel::>(HashMap::new()); + PROGRESS_CENTER.get_or_init(|| ProgressCenter { tx, rx }) +} + +/// Bind a callback to ProgressCenter and create a Future for receiving and rendering messages +/// +/// +/// ```rust +/// use just_progress::progress; +/// +/// #[tokio::main] +/// async fn main() { +/// // Initialize +/// let progress_center = progress::init(); +/// let bind_future = progress::bind(progress_center, |name, state| { +/// println!("Update progress `{}` state to `{}`", name, state); +/// }); +/// } +/// ``` +pub fn bind( + center: &'static ProgressCenter, + mut callback: F, +) -> impl std::future::Future + Send +where + F: FnMut(String, ProgressState) + Send + 'static, +{ + let mut rx = center.rx.clone(); + async move { + while rx.changed().await.is_ok() { + let state = rx.borrow().clone(); + + // If it's a close flag, break + if let Some(_) = state.get(SPECIAL_FLAG_CLOSE) { + break; + } + // Clear flag + if let Some(_) = state.get(SPECIAL_FLAG_CLEAR) { + // Send all known names + progress 0 + EmptyState + for (name, _) in state.iter().filter(|(k, _)| *k != SPECIAL_FLAG_CLEAR) { + callback( + name.clone(), + ProgressState { + progress: 0.0, + info: ProgressInfo::Empty, + }, + ); + } + // Clear the entire HashMap + let _ = center.tx.send(HashMap::new()); + } else { + for (name, progress_state) in state { + callback(name, progress_state); + } + } + } + } +} + +#[derive(Debug, Default, Clone, Copy)] +pub struct ProgressState { + pub(crate) progress: f32, + pub(crate) info: ProgressInfo, +} + +impl ProgressState { + /// Get the progress value (0.0 to 1.0) + pub fn progress(&self) -> f32 { + self.progress + } + + /// Get the progress info + pub fn info(&self) -> ProgressInfo { + self.info + } +} + +impl Display for ProgressState { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let space = if !matches!(self.info, ProgressInfo::Empty) { + " " + } else { + "" + }; + write!(f, "{}{}({:.1}%)", self.info, space, self.progress * 100.0) + } +} + +#[derive(Debug, Default, Clone, Copy)] +pub enum ProgressInfo { + #[default] + Empty, + Info(&'static str), + Warning(&'static str), + Error(&'static str), +} + +impl Display for ProgressInfo { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ProgressInfo::Empty => write!(f, ""), + ProgressInfo::Info(msg) => write!(f, "Info: `{}`", msg), + ProgressInfo::Warning(msg) => write!(f, "Warning: `{}`", msg), + ProgressInfo::Error(msg) => write!(f, "Error: `{}`", msg), + } + } +} + +/// Complete an item +/// +/// Set an item's progress to 100% so it disappears from progress rendering +pub fn complete(name: &str) { + update_progress(name, 1.); +} + +/// Clear all +/// +/// Send a message to clear all items, ensuring no progress remains +pub fn clear_all() { + update(SPECIAL_FLAG_CLEAR, 0., ProgressInfo::Empty); +} + +/// Close the channel +/// +/// Send a close message to stop bind() from running +pub fn close() { + update(SPECIAL_FLAG_CLOSE, 0., ProgressInfo::Empty); +} + +/// Update +/// +/// Update a progress item's information +pub fn update(name: &str, progress: f32, info: ProgressInfo) { + let Some(center) = PROGRESS_CENTER.get() else { + return; + }; + let mut state = center.tx.borrow().clone(); + state.insert(name.to_string(), ProgressState { progress, info }); + let _ = center.tx.send(state); +} + +/// Update progress +/// +/// Modify a progress item's value +pub fn update_progress(name: &str, progress: f32) { + let Some(center) = PROGRESS_CENTER.get() else { + return; + }; + let mut state = center.tx.borrow().clone(); + let entry = state + .entry(name.to_string()) + .or_insert_with(|| ProgressState { + progress: 0.0, + info: ProgressInfo::default(), + }); + entry.progress = progress; + let _ = center.tx.send(state); +} + +/// Update info +/// +/// Modify a progress item's status information +pub fn update_info(name: &str, info: ProgressInfo) { + let Some(center) = PROGRESS_CENTER.get() else { + return; + }; + let mut state = center.tx.borrow().clone(); + let entry = state + .entry(name.to_string()) + .or_insert_with(|| ProgressState { + progress: 0.0, + info: ProgressInfo::default(), + }); + entry.info = info; + let _ = center.tx.send(state); +} diff --git a/src/renderer.rs b/src/renderer.rs new file mode 100644 index 0000000..ccd083b --- /dev/null +++ b/src/renderer.rs @@ -0,0 +1,854 @@ +use crate::progress::ProgressInfo; +use crate::progress::ProgressState; +use std::collections::HashMap; +use std::io::{self, Write}; +use std::sync::{Arc, Mutex}; + +pub struct ProgressSimpleRenderer<'a> { + /// Use sub-progress (split by "/", indent by level) + /// If no relevant parent node exists, show parent progress as overall percentage of child progress + pub subprogress: bool, + + /// Sorting rule + pub sorting: RendererSortingRule, + + /// Theme + pub theme: RendererTheme<'a>, + + /// Whether to automatically remove completed (100%) progress items + pub auto_remove_completed: bool, + + /// Internal state storage + states: Arc>>, + + /// Number of lines rendered last time + last_line_count: Arc>, +} + +impl<'a> ProgressSimpleRenderer<'a> { + /// Create a new renderer + pub fn new() -> Self { + Self { + subprogress: false, + sorting: RendererSortingRule::Append, + theme: RendererTheme::default(), + auto_remove_completed: true, + states: Arc::new(Mutex::new(HashMap::new())), + last_line_count: Arc::new(Mutex::new(0)), + } + } + + /// Set whether to use sub-progress + pub fn with_subprogress(mut self, subprogress: bool) -> Self { + self.subprogress = subprogress; + self + } + + /// Set sorting rule + pub fn with_sorting(mut self, sorting: RendererSortingRule) -> Self { + self.sorting = sorting; + self + } + + /// Set theme + pub fn with_theme(mut self, theme: RendererTheme<'a>) -> Self { + self.theme = theme; + self + } + + /// Set whether to automatically remove completed (100%) progress items + pub fn with_auto_remove_completed(mut self, auto_remove: bool) -> Self { + self.auto_remove_completed = auto_remove; + self + } + + /// Update progress state and render + pub fn update(&self, name: String, state: ProgressState) { + { + let mut states = self.states.lock().unwrap(); + + if self.auto_remove_completed && state.progress() >= 1.0 { + states.remove(&name); + } else { + states.insert(name.clone(), state); + } + } + + // Render all states + self.render_all(); + } + + /// Clear the previously rendered content in the terminal + fn clear_previous_output(&self) { + let line_count = *self.last_line_count.lock().unwrap(); + if line_count > 0 { + print!("\x1B[{}A\x1B[0J", line_count); + } + } + + /// Save the number of lines rendered this time + fn save_line_count(&self, count: usize) { + let mut last_line_count = self.last_line_count.lock().unwrap(); + *last_line_count = count; + } + + /// Render all progress states + pub fn render_all(&self) { + let states = self.states.lock().unwrap(); + + if states.is_empty() { + self.clear_previous_output(); + self.save_line_count(0); + return; + } + + // Collect all progress items + let mut items: Vec = Vec::new(); + + for (name, state) in states.iter() { + // If `auto_remove_completed` is enabled, skip 100% progress items + if self.auto_remove_completed && state.progress() >= 1.0 { + continue; + } + + items.push(ProgressItem { + name: name.clone(), + progress: state.progress(), + info: state.info(), + depth: 0, + is_parent: false, + }); + } + + // If `subprogress` is enabled, compute hierarchy + if self.subprogress { + self.process_subprogress(&mut items); + } + + // Sort + self.sort_items(&mut items); + + // Render and save line count + let line_count = self.render_items(&items); + self.save_line_count(line_count); + } + + /// Clear all progress states + pub fn clear(&self) { + // Clear previous output + self.clear_previous_output(); + + let mut states = self.states.lock().unwrap(); + states.clear(); + + // Reset line count + self.save_line_count(0); + } + + /// Process sub-progress, compute levels and indentation + fn process_subprogress(&self, items: &mut Vec) { + // Find parent nodes + let mut parent_items: Vec = Vec::new(); + + for item in items.iter() { + let parts: Vec<&str> = item.name.split('/').collect(); + if parts.len() > 1 { + // Build parent node path + for i in 1..parts.len() { + let parent_path = parts[0..i].join("/"); + if !parent_items.contains(&parent_path) { + parent_items.push(parent_path); + } + } + } + } + + // Compute depth for each item + for item in items.iter_mut() { + item.depth = item.name.matches('/').count(); + } + + // Add missing parent nodes + for parent_path in parent_items { + if !items.iter().any(|item| item.name == parent_path) { + // Compute parent node progress + let child_progress: Vec = items + .iter() + .filter(|item| item.name.starts_with(&format!("{}/", parent_path))) + .map(|item| item.progress) + .collect(); + + let avg_progress = if !child_progress.is_empty() { + child_progress.iter().sum::() / child_progress.len() as f32 + } else { + 0.0 + }; + + items.push(ProgressItem { + name: parent_path.clone(), + progress: avg_progress, + info: ProgressInfo::Empty, + depth: parent_path.matches('/').count(), + is_parent: true, + }); + } + } + + // Mark parent nodes + let item_names: Vec = items.iter().map(|item| item.name.clone()).collect(); + for item in items.iter_mut() { + if item_names.iter().any(|other_name| { + other_name != &item.name && other_name.starts_with(&format!("{}/", item.name)) + }) { + item.is_parent = true; + } + } + } + + /// Sort items according to sorting rule + fn sort_items(&self, items: &mut Vec) { + if self.subprogress { + items.sort_by(|a, b| match self.sorting { + RendererSortingRule::Append => a.name.cmp(&b.name), + RendererSortingRule::AlphabeticalAsc => a.name.cmp(&b.name), + RendererSortingRule::AlphabeticalDesc => b.name.cmp(&a.name), + }); + } else { + match self.sorting { + RendererSortingRule::AlphabeticalAsc => { + items.sort_by(|a, b| a.name.cmp(&b.name)); + } + RendererSortingRule::AlphabeticalDesc => { + items.sort_by(|a, b| b.name.cmp(&a.name)); + } + RendererSortingRule::Append => {} + } + } + } + + /// Render all items, return the number of lines rendered + fn render_items(&self, items: &[ProgressItem]) -> usize { + // Compute maximum width for alignment + let max_name_width = items + .iter() + .map(|item| { + let indent = if self.subprogress { + item.depth * self.theme.indent_spaces + } else { + 0 + }; + let display_name_len = if self.subprogress { + // For sub-progress, only show the last part + item.name.split('/').last().unwrap_or(&item.name).len() + } else { + item.name.len() + }; + display_name_len + indent + }) + .max() + .unwrap_or(0); + + let max_state_width = if self.theme.show_state { + items + .iter() + .map(|item| self.format_state(item.info).len()) + .max() + .unwrap_or(0) + } else { + 0 + }; + + // Build complete output string + let mut output = String::new(); + + // Add clear command + let line_count = *self.last_line_count.lock().unwrap(); + if line_count > 0 { + output.push_str(&format!("\x1B[{}A\x1B[0J", line_count)); + } + + // Add all lines + for item in items { + let line = self.format_item(item, max_name_width, max_state_width); + output.push_str(&line); + output.push('\n'); + } + + // Disable buffering, output immediately + let _ = io::stdout().write_all(output.as_bytes()); + let _ = io::stdout().flush(); + + items.len() + } + + /// Format a single item + fn format_item( + &self, + item: &ProgressItem, + max_name_width: usize, + max_state_width: usize, + ) -> String { + let mut result = String::new(); + let layout = self.theme.layout; + + // Parse layout template + for part in layout.split(',') { + match part.trim() { + "{name}" => { + let name = self.format_name(item, max_name_width); + result.push_str(&name); + } + "{state}" => { + if self.theme.show_state { + let state = self.format_state(item.info); + if !state.is_empty() { + let state = if self.theme.align { + self.align_component(&state, max_state_width, false) + } else { + state + }; + result.push_str(&state); + } + } + } + "{progress}" => { + if self.theme.show_progress { + let progress = self.format_progress(item.progress); + if !progress.is_empty() { + let progress = if self.theme.align { + self.align_component(&progress, 0, false) + } else { + progress + }; + result.push_str(&progress); + } + } + } + "{percent}" => { + if self.theme.show_percent { + let percent = self.format_percent(item.progress); + if !percent.is_empty() { + let percent = if self.theme.align { + self.align_component(&percent, 0, false) + } else { + percent + }; + result.push_str(&percent); + } + } + } + _ => { + // Output as-is + result.push_str(part); + } + } + } + + result + } + + /// Format name (including indentation) + fn format_name(&self, item: &ProgressItem, max_width: usize) -> String { + let indent = if self.subprogress { + " ".repeat(item.depth * self.theme.indent_spaces) + } else { + String::new() + }; + + // Only show the last part of the path + let display_name = if self.subprogress { + item.name + .split('/') + .last() + .unwrap_or(&item.name) + .to_string() + } else { + item.name.clone() + }; + + let name = format!("{}{}", indent, display_name); + + if self.theme.align { + self.align_component(&name, max_width, true) + } else { + name + } + } + + /// Format state + fn format_state(&self, info: ProgressInfo) -> String { + match info { + ProgressInfo::Empty => self.theme.state_empty.to_string(), + ProgressInfo::Info(msg) => self.theme.state_info.replace("{}", msg), + ProgressInfo::Warning(msg) => self.theme.state_warning.replace("{}", msg), + ProgressInfo::Error(msg) => self.theme.state_error.replace("{}", msg), + } + } + + /// Format progress bar + fn format_progress(&self, progress: f32) -> String { + let filled_len = (progress * self.theme.progress_content_len as f32).round() as usize; + let empty_len = self.theme.progress_content_len - filled_len; + + let filled = self.theme.progress_filled_char.repeat(filled_len); + let empty = self.theme.progress_empty_char.repeat(empty_len); + + self.theme + .progress + .replace("{}", &format!("{}{}", filled, empty)) + } + + /// Format percentage + fn format_percent(&self, progress: f32) -> String { + let percent = progress * 100.0; + let formatted = format!("{:.1$}", percent, self.theme.percent_decimal_places); + self.theme.percent.replace("{}", &formatted) + } + + /// Align component + fn align_component( + &self, + component: &str, + max_width: usize, + is_first_component: bool, + ) -> String { + let trimmed = if is_first_component { + // For the first component, only trim the end, not the beginning + component.trim_end() + } else { + component.trim() + }; + + if trimmed.is_empty() { + // Empty component is not aligned, return empty string + String::new() + } else { + let padding = max_width.saturating_sub(trimmed.len()); + let before_padding = " ".repeat(self.theme.align_padding_before); + let after_padding = " ".repeat(self.theme.align_padding_after); + format!( + "{}{}{}{}", + before_padding, + trimmed, + " ".repeat(padding), + after_padding + ) + } + } +} + +struct ProgressItem { + name: String, + progress: f32, + info: ProgressInfo, + depth: usize, + is_parent: bool, +} + +pub enum RendererSortingRule { + /// Append, newly added progress items are always at the end + Append, + + /// Sort by name in ascending alphabetical order + AlphabeticalAsc, + + /// Sort by name in descending alphabetical order + AlphabeticalDesc, +} + +pub struct RendererTheme<'a> { + /// Alignment + /// When enabled, ensures the first character of each component of each item is aligned + /// At the same time, each component will be trimmed before and after + pub align: bool, + + /// Number of spaces before a component when aligning + pub align_padding_before: usize, + + /// Number of spaces after a component when aligning + pub align_padding_after: usize, + + /// Overall layout + pub layout: &'a str, + + /// Show state (different from setting in `layout`, it affects whether this component is rendered) + pub show_state: bool, + + /// Warning state style + pub state_warning: &'a str, + + /// Error state style + pub state_error: &'a str, + + /// Info state style + pub state_info: &'a str, + + /// Empty state style + pub state_empty: &'a str, + + /// Show progress bar (different from setting in `layout`, it affects whether this component is rendered) + pub show_progress: bool, + + /// Progress bar style + pub progress: &'a str, + + /// Number of content characters in the progress bar + pub progress_content_len: usize, + + /// Character for filled part + pub progress_filled_char: &'a str, + + /// Character for unfilled part + pub progress_empty_char: &'a str, + + /// Show percentage (different from setting in `layout`, it affects whether this component is rendered) + pub show_percent: bool, + + /// Percentage style + pub percent: &'a str, + + /// Number of decimal places for percentage + pub percent_decimal_places: usize, + + /// Number of spaces per indentation level + pub indent_spaces: usize, +} + +impl<'a> Default for RendererTheme<'a> { + fn default() -> Self { + Self { + align: true, + layout: "{name},{state},{progress},{percent}", + show_state: true, + state_warning: " WARN: {}", + state_error: " ERR: {}", + state_info: " INFO: {}", + state_empty: "", + show_progress: true, + progress: " [{}]", + progress_content_len: 20, + progress_filled_char: "#", + progress_empty_char: " ", + show_percent: true, + percent: " ({}%)", + percent_decimal_places: 1, + indent_spaces: 2, + align_padding_before: 0, + align_padding_after: 1, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::progress::{ProgressInfo, ProgressState}; + + #[test] + fn test_renderer_update() { + let renderer = ProgressSimpleRenderer::new(); + + // Test update and rendering + renderer.update( + "task1".to_string(), + ProgressState { + progress: 0.5, + info: ProgressInfo::Info("Processing"), + }, + ); + + renderer.update( + "task2".to_string(), + ProgressState { + progress: 0.75, + info: ProgressInfo::Warning("Almost done"), + }, + ); + + // Call render_all to render + renderer.render_all(); + } + + #[test] + fn test_renderer_with_subprogress() { + let renderer = ProgressSimpleRenderer::new().with_subprogress(true); + + renderer.update( + "download/file1".to_string(), + ProgressState { + progress: 0.5, + info: ProgressInfo::Info("Downloading file1"), + }, + ); + + renderer.update( + "download/file2".to_string(), + ProgressState { + progress: 0.75, + info: ProgressInfo::Info("Downloading file2"), + }, + ); + + renderer.render_all(); + } + + #[test] + fn test_auto_remove_completed() { + let renderer = ProgressSimpleRenderer::new().with_auto_remove_completed(true); + + // Add an unfinished task + renderer.update( + "task1".to_string(), + ProgressState { + progress: 0.5, + info: ProgressInfo::Info("Processing"), + }, + ); + + // Add a completed task + renderer.update( + "task2".to_string(), + ProgressState { + progress: 1.0, + info: ProgressInfo::Info("Completed"), + }, + ); + + // Render, should only show task1 + renderer.render_all(); + + // Check internal state, task2 should be removed + { + let states = renderer.states.lock().unwrap(); + assert!(states.contains_key("task1")); + assert!(!states.contains_key("task2")); + } + } + + #[test] + fn test_auto_remove_completed_disabled() { + let renderer = ProgressSimpleRenderer::new().with_auto_remove_completed(false); + + // Add a completed task + renderer.update( + "task1".to_string(), + ProgressState { + progress: 1.0, + info: ProgressInfo::Info("Completed"), + }, + ); + + // Check internal state, task1 should still exist + { + let states = renderer.states.lock().unwrap(); + assert!(states.contains_key("task1")); + } + } + #[test] + fn test_indent_spaces_config() { + // Create custom theme, set indent spaces to 4 + let mut theme = RendererTheme::default(); + theme.indent_spaces = 4; + + let renderer = ProgressSimpleRenderer::new() + .with_subprogress(true) + .with_theme(theme); + + // Add multi-level progress + renderer.update( + "parent".to_string(), + ProgressState { + progress: 0.5, + info: ProgressInfo::Info("Parent task"), + }, + ); + + renderer.update( + "parent/child".to_string(), + ProgressState { + progress: 0.75, + info: ProgressInfo::Info("Child task"), + }, + ); + + renderer.update( + "parent/child/grandchild".to_string(), + ProgressState { + progress: 0.9, + info: ProgressInfo::Info("Grandchild task"), + }, + ); + + // Render and check + renderer.render_all(); + + // Check internal state + { + let states = renderer.states.lock().unwrap(); + assert!(states.contains_key("parent")); + assert!(states.contains_key("parent/child")); + assert!(states.contains_key("parent/child/grandchild")); + } + } + + #[test] + fn test_align_padding_config() { + // Create custom theme, set alignment padding before and after + let mut theme = RendererTheme::default(); + theme.align = true; + theme.align_padding_before = 2; + theme.align_padding_after = 3; + + let renderer = ProgressSimpleRenderer::new().with_theme(theme); + + // Add test data + renderer.update( + "task1".to_string(), + ProgressState { + progress: 0.5, + info: ProgressInfo::Info("Processing"), + }, + ); + + renderer.update( + "task2".to_string(), + ProgressState { + progress: 0.75, + info: ProgressInfo::Warning("Almost done"), + }, + ); + + // Render and check + renderer.render_all(); + + // Check internal state + { + let states = renderer.states.lock().unwrap(); + assert!(states.contains_key("task1")); + assert!(states.contains_key("task2")); + } + } + + #[test] + fn test_align_padding_with_empty_state() { + // Create custom theme, set alignment padding before and after + let mut theme = RendererTheme::default(); + theme.align = true; + theme.align_padding_before = 1; + theme.align_padding_after = 2; + theme.show_state = true; + + let renderer = ProgressSimpleRenderer::new().with_theme(theme); + + // Add test data - empty state + renderer.update( + "task1".to_string(), + ProgressState { + progress: 0.5, + info: ProgressInfo::Empty, + }, + ); + + // Add test data - with state + renderer.update( + "task2".to_string(), + ProgressState { + progress: 0.75, + info: ProgressInfo::Info("Processing"), + }, + ); + + // Render and check + renderer.render_all(); + + // Check internal state + { + let states = renderer.states.lock().unwrap(); + assert!(states.contains_key("task1")); + assert!(states.contains_key("task2")); + } + } + + #[test] + fn test_align_first_component_no_trim_start() { + let renderer = ProgressSimpleRenderer::new(); + + // Test that the first component does not trim the start when aligning + let component = " test "; + let aligned = renderer.align_component(component, 10, true); + + // The first component should only trim the end, not the start + assert!(aligned.starts_with(" test")); + assert!(!aligned.starts_with("test")); + } + + #[test] + fn test_align_non_first_component_trim_both() { + let renderer = ProgressSimpleRenderer::new(); + + // Test that non-first components trim both sides when aligning + let component = " test "; + let aligned = renderer.align_component(component, 10, false); + + // Non-first components should trim both sides + assert!(aligned.starts_with("test")); + assert!(!aligned.starts_with(" test")); + } + + #[test] + fn test_format_progress() { + let renderer = ProgressSimpleRenderer::new(); + let progress = renderer.format_progress(0.5); + assert!(progress.contains("[")); + assert!(progress.contains("]")); + } + + #[test] + fn test_format_percent() { + let renderer = ProgressSimpleRenderer::new(); + let percent = renderer.format_percent(0.5); + assert!(percent.contains("50")); + assert!(percent.contains("%")); + } + + #[test] + fn test_subprogress_indentation() { + let renderer = ProgressSimpleRenderer::new().with_subprogress(true); + + // Add parent and child nodes + renderer.update( + "progress".to_string(), + ProgressState { + progress: 0.95, + info: ProgressInfo::Info("Processing"), + }, + ); + + renderer.update( + "progress/2".to_string(), + ProgressState { + progress: 0.95, + info: ProgressInfo::Info("Step 2"), + }, + ); + + renderer.update( + "progress/3".to_string(), + ProgressState { + progress: 0.8, + info: ProgressInfo::Info("Step 3"), + }, + ); + + // Render and check output format + renderer.render_all(); + + // Check internal state + { + let states = renderer.states.lock().unwrap(); + assert!(states.contains_key("progress")); + assert!(states.contains_key("progress/2")); + assert!(states.contains_key("progress/3")); + } + } +} -- cgit