diff options
| author | 魏曹先生 <1992414357@qq.com> | 2026-02-28 18:01:33 +0800 |
|---|---|---|
| committer | 魏曹先生 <1992414357@qq.com> | 2026-02-28 18:49:31 +0800 |
| commit | 05b7b483056902a07f83bc14f443ab59ce1471d8 (patch) | |
| tree | e46c9e5e672a6d0e9baf4bca09ea159ad669f881 /src/renderer.rs | |
First version0.1.1
Diffstat (limited to 'src/renderer.rs')
| -rw-r--r-- | src/renderer.rs | 854 |
1 files changed, 854 insertions, 0 deletions
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")); + } + } +} |
