summaryrefslogtreecommitdiff
path: root/src/renderer.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/renderer.rs')
-rw-r--r--src/renderer.rs854
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"));
+ }
+ }
+}