summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
author魏曹先生 <1992414357@qq.com>2026-02-28 18:01:33 +0800
committer魏曹先生 <1992414357@qq.com>2026-02-28 18:49:31 +0800
commit05b7b483056902a07f83bc14f443ab59ce1471d8 (patch)
treee46c9e5e672a6d0e9baf4bca09ea159ad669f881 /src
First version0.1.1
Diffstat (limited to 'src')
-rw-r--r--src/lib.rs223
-rw-r--r--src/progress.rs200
-rw-r--r--src/renderer.rs854
3 files changed, 1277 insertions, 0 deletions
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<ProgressCenter> = OnceLock::new();
+
+const SPECIAL_FLAG_CLEAR: &str = "_clear";
+const SPECIAL_FLAG_CLOSE: &str = "_close";
+
+#[derive(Debug)]
+pub struct ProgressCenter {
+ tx: watch::Sender<HashMap<String, ProgressState>>,
+ rx: watch::Receiver<HashMap<String, ProgressState>>,
+}
+
+/// 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<String, ProgressState>>(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<F>(
+ center: &'static ProgressCenter,
+ mut callback: F,
+) -> impl std::future::Future<Output = ()> + 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<Mutex<HashMap<String, ProgressState>>>,
+
+ /// Number of lines rendered last time
+ last_line_count: Arc<Mutex<usize>>,
+}
+
+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<ProgressItem> = 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<ProgressItem>) {
+ // Find parent nodes
+ let mut parent_items: Vec<String> = 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<f32> = 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::<f32>() / 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<String> = 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<ProgressItem>) {
+ 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"));
+ }
+ }
+}