From 175a320d84197896c5b6e038c0d67bbaa84baff0 Mon Sep 17 00:00:00 2001 From: xxanqw Date: Wed, 26 Nov 2025 13:52:24 +0200 Subject: Add color handling and CLI integration tests - Implemented a comprehensive color module in `src/color.rs` that supports named colors, hex, RGB, HSL, ANSI, and truecolor formats. - Added parsing functions for different color formats with error handling. - Introduced conversion methods for colors to RGB and ANSI escape codes. - Created a `ColorMode` enum to detect terminal color capabilities. - Added linear interpolation for colors in HSV space. - Developed integration tests in `tests/integration_tests.rs` to verify CLI functionality, including listing styles and error handling for missing or conflicting arguments. --- src/main.rs | 671 ++++++++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 585 insertions(+), 86 deletions(-) (limited to 'src/main.rs') diff --git a/src/main.rs b/src/main.rs index 39fc9fe..023f7f3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,41 +1,302 @@ -use clap::Parser; +use clap::{Parser, ValueEnum}; use crossterm::terminal::size; +use std::error::Error; +use std::str::FromStr; use unicode_width::UnicodeWidthStr; +mod color; mod mikuart; +use color::{Color, ColorMode, detect_color_mode, lerp_color_hsv}; use mikuart::get_miku_art; +#[derive(Debug, Clone, ValueEnum)] +enum ColorFormat { + Hex, + Rgb, + Hsl, + Ansi, + Named, +} + #[derive(Parser)] #[command(version, about, long_about = None)] -struct Args { +pub struct Args { /// Text to display in the speech bubble - text: Option, + pub text: Option, /// Style of the Miku art. A random one is chosen if not specified #[arg(short, long)] - style: Option, + pub style: Option, /// List all available art styles with their indices #[arg(short, long)] - list: bool, + pub list: bool, + + /// Apply a smooth rainbow gradient across the ASCII art + #[arg(long)] + pub rainbow: bool, + + /// Saturation level for rainbow gradient (0-100) + #[arg(long, default_value_t = 100)] + pub saturation: u8, + + /// Brightness level for rainbow gradient (0-100) + #[arg(long, default_value_t = 50)] + pub brightness: u8, + + /// Override with a single solid color + #[arg(long, value_name = "COLOR")] + pub color: Option, + + /// Define a custom two-color gradient (e.g., --gradient red:blue) + #[arg(long, value_name = "START:END")] + pub gradient: Option, + + /// Disable all coloring (respects NO_COLOR environment variable) + #[arg(long)] + pub no_color: bool, } -fn main() -> Result<(), Box> { - let args = Args::parse(); +#[derive(Debug)] +pub struct ColorConfig { + pub mode: ColorMode, + pub rainbow: bool, + pub saturation: u8, + pub brightness: u8, + pub single_color: Option, + pub gradient: Option<(Color, Color)>, + pub color_cache: Vec, +} - if args.list { - list_all_art_styles(); - return Ok(()); +pub fn parse_color_args(args: &Args) -> Result> { + // Detect color mode + let mut color_mode = detect_color_mode(); + + // Override with --no-color if specified + if args.no_color { + color_mode = ColorMode::NoColor; } - let text = args.text.unwrap_or_else(|| { - eprintln!("Error: Text is required when not using --list"); + // Parse single color if provided + let single_color = if let Some(color_str) = args.color.as_deref() { + match Color::from_str(color_str) { + Ok(color) => Some(color), + Err(e) => { + eprintln!("Error parsing color '{}': {}", color_str, e); + eprintln!("Valid color formats:"); + eprintln!(" - Named colors: red, blue, green, etc."); + eprintln!(" - Hex: #RRGGBB or #RGB (e.g., #FF0000 or #F00)"); + eprintln!(" - RGB: R,G,B (e.g., 255,0,128)"); + eprintln!(" - HSL: hsl(H,S%,L%) (e.g., hsl(180,100%,50%))"); + eprintln!(" - ANSI: 0-255 (e.g., 93 for bright yellow)"); + std::process::exit(1); + } + } + } else { + None + }; + + // Parse gradient if provided + let gradient = if let Some(gradient_str) = args.gradient.as_deref() { + let parts: Vec<&str> = gradient_str.split(':').collect(); + if parts.len() != 2 { + eprintln!("Error: Gradient format should be START:END (e.g., --gradient red:blue)"); + std::process::exit(1); + } + + let start = match Color::from_str(parts[0]) { + Ok(color) => color, + Err(e) => { + eprintln!("Error parsing gradient start color '{}': {}", parts[0], e); + eprintln!("Valid color formats:"); + eprintln!(" - Named colors: red, blue, green, etc."); + eprintln!(" - Hex: #RRGGBB or #RGB (e.g., #FF0000 or #F00)"); + eprintln!(" - RGB: R,G,B (e.g., 255,0,128)"); + eprintln!(" - HSL: hsl(H,S%,L%) (e.g., hsl(180,100%,50%))"); + eprintln!(" - ANSI: 0-255 (e.g., 93 for bright yellow)"); + std::process::exit(1); + } + }; + + let end = match Color::from_str(parts[1]) { + Ok(color) => color, + Err(e) => { + eprintln!("Error parsing gradient end color '{}': {}", parts[1], e); + eprintln!("Valid color formats:"); + eprintln!(" - Named colors: red, blue, green, etc."); + eprintln!(" - Hex: #RRGGBB or #RGB (e.g., #FF0000 or #F00)"); + eprintln!(" - RGB: R,G,B (e.g., 255,0,128)"); + eprintln!(" - HSL: hsl(H,S%,L%) (e.g., hsl(180,100%,50%))"); + eprintln!(" - ANSI: 0-255 (e.g., 93 for bright yellow)"); + std::process::exit(1); + } + }; + + Some((start, end)) + } else { + None + }; + + // Validate saturation and brightness + let saturation = args.saturation.clamp(0, 100); + let brightness = args.brightness.clamp(0, 100); + + // Check for conflicting options + if args.rainbow && single_color.is_some() { + eprintln!("Error: Cannot use --rainbow and --color together"); std::process::exit(1); - }); - let style = args.style.unwrap_or(-1); // Default to -1 to select a random style - draw_miku_says(&text, style)?; + } - Ok(()) + if args.rainbow && gradient.is_some() { + eprintln!("Error: Cannot use --rainbow and --gradient together"); + std::process::exit(1); + } + + if single_color.is_some() && gradient.is_some() { + eprintln!("Error: Cannot use --color and --gradient together"); + std::process::exit(1); + } + + Ok(ColorConfig { + mode: color_mode, + rainbow: args.rainbow, + saturation, + brightness, + single_color, + gradient, + color_cache: Vec::new(), + }) +} + +pub fn get_speech_bubble_lines(text: &str) -> Vec { + const MAX_BUBBLE_WIDTH: usize = 50; + const MIN_BUBBLE_WIDTH: usize = 20; + + let temp_lines = wrap_text(text, MAX_BUBBLE_WIDTH - 4); + + let content_width = temp_lines + .iter() + .map(|line| line.width()) + .max() + .unwrap_or(0); + + let bubble_width = (content_width + 4).clamp(MIN_BUBBLE_WIDTH, MAX_BUBBLE_WIDTH); + + let lines = wrap_text(text, bubble_width - 4); + let mut bubble_lines = Vec::new(); + + let top_left = '╭'; + let top_right = '╮'; + let bottom_left = '╰'; + let bottom_right = '╯'; + let horizontal = '─'; + let vertical = '│'; + + bubble_lines.push(format!( + "{}{}{}", + top_left, + horizontal.to_string().repeat(bubble_width), + top_right + )); + + for line in &lines { + let line_display_width = line.width(); + let padding_needed = (bubble_width - 2).saturating_sub(line_display_width); + let left_padding = padding_needed / 2; + let right_padding = padding_needed - left_padding; + + bubble_lines.push(format!( + "{} {}{}{} {}", + vertical, + " ".repeat(left_padding), + line, + " ".repeat(right_padding), + vertical + )); + } + + bubble_lines.push(format!( + "{}{}{}", + bottom_left, + horizontal.to_string().repeat(bubble_width), + bottom_right + )); + + bubble_lines.push("\\".to_string()); + bubble_lines.push(" \\".to_string()); + + bubble_lines +} + +pub fn wrap_text(text: &str, max_width: usize) -> Vec { + let mut lines = Vec::new(); + let words: Vec<&str> = text.split_whitespace().collect(); + let mut current_line = String::new(); + + for word in words { + let word_width = word.width(); + let current_width = current_line.width(); + + if current_width + word_width < max_width { + if !current_line.is_empty() { + current_line.push(' '); + } + current_line.push_str(word); + } else { + if !current_line.is_empty() { + lines.push(current_line); + } + current_line = word.to_string(); + } + } + + if !current_line.is_empty() { + lines.push(current_line); + } + + lines +} + +pub fn generate_rainbow_cache(color_config: &mut ColorConfig, total_chars: usize) { + color_config.color_cache.clear(); + + if total_chars == 0 { + return; + } + + // Generate smooth rainbow gradient across all characters + for i in 0..total_chars { + let t = i as f32 / total_chars as f32; + let hue = t * 360.0; // Full hue spectrum from 0 to 360 + + // Create color with specified saturation and brightness + let color = Color::Hsl( + hue.round() as u16, + color_config.saturation, + color_config.brightness, + ); + color_config.color_cache.push(color); + } +} + +pub fn generate_gradient_cache( + color_config: &mut ColorConfig, + start: &Color, + end: &Color, + total_chars: usize, +) { + color_config.color_cache.clear(); + + if total_chars == 0 { + return; + } + + // Generate smooth gradient across all characters + for i in 0..total_chars { + let t = i as f32 / total_chars as f32; + let color = lerp_color_hsv(start, end, t); + color_config.color_cache.push(color); + } } fn list_all_art_styles() { @@ -56,12 +317,42 @@ fn list_all_art_styles() { } println!("\nUse --style to select a specific style, or omit for random selection."); + println!("\nColor Options:"); + println!(" --rainbow Apply a smooth rainbow gradient"); + println!(" --saturation <0-100> Set rainbow saturation level (default: 100)"); + println!(" --brightness <0-100> Set rainbow brightness level (default: 50)"); + println!( + " --color Set a single solid color (supports named colors, hex, rgb, hsl, ansi)" + ); + println!(" --gradient Define a custom two-color gradient"); + println!(" --no-color Disable all coloring"); + println!("\nColor Format Examples:"); + println!(" --color red Named color"); + println!(" --color #FF0000 Hex color"); + println!(" --color 255,0,128 RGB tuple"); + println!(" --color hsl(180,100%,50%) HSL value"); + println!(" --color 93 ANSI color code"); + println!(" --gradient red:blue Custom gradient"); } -fn draw_miku_says(text: &str, style: i32) -> Result<(), Box> { +fn draw_miku_says( + text: &str, + style: i32, + color_config: &mut ColorConfig, +) -> Result<(), Box> { let speech_bubble_lines = get_speech_bubble_lines(text); let miku_art = get_miku_art(Some(style), None); + // Calculate total character count for color distribution + let total_chars: usize = miku_art.iter().map(|line| line.chars().count()).sum(); + + // Generate color cache if needed + if color_config.rainbow { + generate_rainbow_cache(color_config, total_chars); + } else if let Some((start, end)) = color_config.gradient.clone() { + generate_gradient_cache(color_config, &start, &end, total_chars); + } + let (window_width, window_height) = size()?; let available_width = window_width as usize; @@ -112,6 +403,9 @@ fn draw_miku_says(text: &str, style: i32) -> Result<(), Box Result<(), Box Result<(), Box Vec { - const MAX_BUBBLE_WIDTH: usize = 50; - const MIN_BUBBLE_WIDTH: usize = 20; +fn main() -> Result<(), Box> { + let args = Args::parse(); - let temp_lines = wrap_text(text, MAX_BUBBLE_WIDTH - 4); + if args.list { + list_all_art_styles(); + return Ok(()); + } - let content_width = temp_lines - .iter() - .map(|line| line.width()) - .max() - .unwrap_or(0); + let text = args.text.clone().unwrap_or_else(|| { + eprintln!("Error: Text is required when not using --list"); + std::process::exit(1); + }); - let bubble_width = (content_width + 4).clamp(MIN_BUBBLE_WIDTH, MAX_BUBBLE_WIDTH); + let style = args.style.unwrap_or(-1); // Default to -1 to select a random style - let lines = wrap_text(text, bubble_width - 4); - let mut bubble_lines = Vec::new(); + // Parse color-related arguments + let mut color_config = parse_color_args(&args)?; - let top_left = '╭'; - let top_right = '╮'; - let bottom_left = '╰'; - let bottom_right = '╯'; - let horizontal = '─'; - let vertical = '│'; + draw_miku_says(&text, style, &mut color_config)?; - bubble_lines.push(format!( - "{}{}{}", - top_left, - horizontal.to_string().repeat(bubble_width), - top_right - )); + Ok(()) +} - for line in &lines { - let line_display_width = line.width(); - let padding_needed = (bubble_width - 2).saturating_sub(line_display_width); - let left_padding = padding_needed / 2; - let right_padding = padding_needed - left_padding; +/// MARK: Tests +#[cfg(test)] +mod tests { + use super::*; + use crate::color::{Color, ColorMode}; + + #[test] + fn test_wrap_text_basic() { + let text = "Hello world this is a test"; + let wrapped = wrap_text(text, 15); + assert_eq!(wrapped, vec!["Hello world", "this is a test"]); + } - bubble_lines.push(format!( - "{} {}{}{} {}", - vertical, - " ".repeat(left_padding), - line, - " ".repeat(right_padding), - vertical - )); + #[test] + fn test_wrap_text_empty() { + let text = ""; + let wrapped = wrap_text(text, 10); + assert_eq!(wrapped, Vec::::new()); } - bubble_lines.push(format!( - "{}{}{}", - bottom_left, - horizontal.to_string().repeat(bubble_width), - bottom_right - )); + #[test] + fn test_wrap_text_long_word() { + let text = "ThisIsAVeryLongWordThatExceedsMaxWidth"; + let wrapped = wrap_text(text, 10); + assert_eq!(wrapped, vec!["ThisIsAVeryLongWordThatExceedsMaxWidth"]); + } - bubble_lines.push("\\".to_string()); - bubble_lines.push(" \\".to_string()); + #[test] + fn test_wrap_text_exact_width() { + let text = "Hello world"; + let wrapped = wrap_text(text, 11); + assert_eq!(wrapped, vec!["Hello world"]); + } - bubble_lines -} + #[test] + fn test_get_speech_bubble_lines_basic() { + let text = "Hello"; + let bubble = get_speech_bubble_lines(text); -fn wrap_text(text: &str, max_width: usize) -> Vec { - let mut lines = Vec::new(); - let words: Vec<&str> = text.split_whitespace().collect(); - let mut current_line = String::new(); + // Should have: top border, text line, bottom border, 2 tail lines + assert_eq!(bubble.len(), 5); - for word in words { - let word_width = word.width(); - let current_width = current_line.width(); + // Check top border + assert!(bubble[0].starts_with('╭')); + assert!(bubble[0].ends_with('╮')); - if current_width + word_width < max_width { - if !current_line.is_empty() { - current_line.push(' '); - } - current_line.push_str(word); - } else { - if !current_line.is_empty() { - lines.push(current_line); - } - current_line = word.to_string(); - } + // Check text line + assert!(bubble[1].contains("Hello")); + + // Check bottom border + assert!(bubble[2].starts_with('╰')); + assert!(bubble[2].ends_with('╯')); + + // Check tail + assert_eq!(bubble[3], "\\"); + assert_eq!(bubble[4], " \\"); } - if !current_line.is_empty() { - lines.push(current_line); + #[test] + fn test_get_speech_bubble_lines_long_text() { + let text = "This is a very long text that should be wrapped into multiple lines"; + let bubble = get_speech_bubble_lines(text); + + // Should have: top border, multiple text lines, bottom border, 2 tail lines + assert!(bubble.len() > 5); + + // Check top border + assert!(bubble[0].starts_with('╭')); + assert!(bubble[0].ends_with('╮')); + + // Check bottom border + let bottom_idx = bubble.len() - 3; + assert!(bubble[bottom_idx].starts_with('╰')); + assert!(bubble[bottom_idx].ends_with('╯')); } - lines + #[test] + fn test_parse_color_args_no_color() { + let args = Args { + text: Some("test".to_string()), + style: None, + list: false, + rainbow: false, + saturation: 100, + brightness: 50, + color: None, + gradient: None, + no_color: false, + }; + + let config = parse_color_args(&args).unwrap(); + // Don't assert exact color mode since it depends on environment + assert!(!config.rainbow); + assert_eq!(config.saturation, 100); + assert_eq!(config.brightness, 50); + assert!(config.single_color.is_none()); + assert!(config.gradient.is_none()); + assert!(config.color_cache.is_empty()); + } + + #[test] + fn test_parse_color_args_no_color_explicit() { + let args = Args { + text: Some("test".to_string()), + style: None, + list: false, + rainbow: false, + saturation: 100, + brightness: 50, + color: None, + gradient: None, + no_color: true, + }; + + let config = parse_color_args(&args).unwrap(); + assert_eq!(config.mode, ColorMode::NoColor); + } + + #[test] + fn test_wrap_text_edge_cases() { + // Test with single very long word + let text = "Supercalifragilisticexpialidocious"; + let wrapped = wrap_text(text, 10); + assert_eq!(wrapped, vec!["Supercalifragilisticexpialidocious"]); + + // Test with exact width match + let text = "Hello world"; + let wrapped = wrap_text(text, 11); + assert_eq!(wrapped, vec!["Hello world"]); + + // Test with multiple spaces + let text = "Hello world"; + let wrapped = wrap_text(text, 10); + assert_eq!(wrapped, vec!["Hello", "world"]); + } + + #[test] + fn test_get_speech_bubble_lines_edge_cases() { + // Test with empty string + let bubble = get_speech_bubble_lines(""); + assert_eq!(bubble.len(), 4); // Should have: top, empty line, bottom, tail (no extra tail line) + + // Test with very long word + let text = "Supercalifragilisticexpialidocious"; + let bubble = get_speech_bubble_lines(text); + assert!(bubble.len() >= 5); + assert!(bubble[0].len() >= 24); // Should accommodate the long word + } + + #[test] + fn test_generate_rainbow_cache_edge_cases() { + let mut config = ColorConfig { + mode: ColorMode::Truecolor, + rainbow: true, + saturation: 100, + brightness: 50, + single_color: None, + gradient: None, + color_cache: vec![Color::Hex("#000000".to_string())], // Pre-filled + }; + + // Test with 0 characters + generate_rainbow_cache(&mut config, 0); + assert_eq!(config.color_cache.len(), 0); + + // Test with 1 character + generate_rainbow_cache(&mut config, 1); + assert_eq!(config.color_cache.len(), 1); + } + + #[test] + fn test_generate_gradient_cache_edge_cases() { + let mut config = ColorConfig { + mode: ColorMode::Truecolor, + rainbow: false, + saturation: 100, + brightness: 50, + single_color: None, + gradient: None, + color_cache: vec![Color::Hex("#000000".to_string())], // Pre-filled + }; + + let start = Color::Hex("#FF0000".to_string()); + let end = Color::Hex("#0000FF".to_string()); + + // Test with 0 characters + generate_gradient_cache(&mut config, &start, &end, 0); + assert_eq!(config.color_cache.len(), 0); + + // Test with 1 character + generate_gradient_cache(&mut config, &start, &end, 1); + assert_eq!(config.color_cache.len(), 1); + } + + #[test] + fn test_generate_rainbow_cache() { + let mut config = ColorConfig { + mode: ColorMode::Truecolor, + rainbow: true, + saturation: 100, + brightness: 50, + single_color: None, + gradient: None, + color_cache: Vec::new(), + }; + + generate_rainbow_cache(&mut config, 10); + assert_eq!(config.color_cache.len(), 10); + + // Check that colors are different (rainbow gradient) + let first_color = &config.color_cache[0]; + let last_color = &config.color_cache[9]; + assert_ne!(first_color, last_color); + } + + #[test] + fn test_generate_gradient_cache() { + let mut config = ColorConfig { + mode: ColorMode::Truecolor, + rainbow: false, + saturation: 100, + brightness: 50, + single_color: None, + gradient: None, + color_cache: Vec::new(), + }; + + let start = Color::Hex("#FF0000".to_string()); // Red + let end = Color::Hex("#0000FF".to_string()); // Blue + + generate_gradient_cache(&mut config, &start, &end, 10); + assert_eq!(config.color_cache.len(), 10); + + // Check that first color is close to start + let first_color = &config.color_cache[0]; + // Check that last color is close to end + let last_color = &config.color_cache[9]; + assert_ne!(first_color, last_color); + } } -- cgit v1.3-2-g0d8e