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")); } } }