use clap::error::ErrorKind; use clap::{CommandFactory, Parser}; use crossterm::QueueableCommand; use crossterm::style::{Print, ResetColor}; use crossterm::terminal::size; use std::error::Error; use std::fmt; use std::io::{Write, stdout}; use std::str::FromStr; use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; mod color; mod mikuart; use color::{Color, ColorMode, detect_color_mode, lerp_color_hsv}; use mikuart::get_miku_art; #[derive(Parser)] #[command(version, about, long_about = None)] pub struct Args { /// Text to display in the speech bubble pub text: Option, /// Style of the Miku art. A random one is chosen if not specified #[arg(short, long, value_parser = clap::value_parser!(i32).range(0..))] pub style: Option, /// List all available art styles with their indices #[arg(short, long)] pub list: bool, /// Apply a smooth rainbow gradient across the ASCII art #[arg(long, conflicts_with_all = ["color", "gradient"])] pub rainbow: bool, /// Saturation level for rainbow gradient (0-100) #[arg(long, default_value_t = 100, value_parser = clap::value_parser!(u8).range(0..=100))] pub saturation: u8, /// Brightness level for rainbow gradient (0-100) #[arg(long, default_value_t = 50, value_parser = clap::value_parser!(u8).range(0..=100))] pub brightness: u8, /// Override with a single solid color #[arg(long, value_name = "COLOR", conflicts_with = "gradient")] 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, /// Print color detection/debug info and exit #[arg(long)] pub color_debug: bool, } #[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, pub ansi_enabled: bool, pub debug: bool, } #[derive(Debug, PartialEq)] pub struct ColorConfigError(String); impl fmt::Display for ColorConfigError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(&self.0) } } impl Error for ColorConfigError {} const COLOR_FORMAT_HELP: &str = "expected a named color, #RRGGBB, #RGB, R,G,B, hsl(H,S%,L%), or ANSI value 0-255"; 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; } // Parse single color if provided let single_color = args .color .as_deref() .map(|color_str| { Color::from_str(color_str).map_err(|error| { ColorConfigError(format!( "invalid color '{color_str}': {error}; {COLOR_FORMAT_HELP}" )) }) }) .transpose()?; // Parse gradient if provided let gradient = if let Some(gradient_str) = args.gradient.as_deref() { let (start_str, end_str) = gradient_str.split_once(':').ok_or_else(|| { ColorConfigError( "invalid gradient: expected START:END (for example, red:blue)".to_string(), ) })?; if start_str.is_empty() || end_str.is_empty() || end_str.contains(':') { return Err(ColorConfigError( "invalid gradient: expected exactly two colors as START:END".to_string(), )); } let start = Color::from_str(start_str).map_err(|error| { ColorConfigError(format!( "invalid gradient start '{start_str}': {error}; {COLOR_FORMAT_HELP}" )) })?; let end = Color::from_str(end_str).map_err(|error| { ColorConfigError(format!( "invalid gradient end '{end_str}': {error}; {COLOR_FORMAT_HELP}" )) })?; Some((start, end)) } else { None }; // Keep this validation for direct callers as well as Clap-parsed CLI input. if args.rainbow && single_color.is_some() { return Err(ColorConfigError( "--rainbow cannot be used with --color".to_string(), )); } if args.rainbow && gradient.is_some() { return Err(ColorConfigError( "--rainbow cannot be used with --gradient".to_string(), )); } if single_color.is_some() && gradient.is_some() { return Err(ColorConfigError( "--color cannot be used with --gradient".to_string(), )); } // Determine if ANSI is enabled (Windows toggle helper) — don't enable if --no-color #[cfg(windows)] let ansi_enabled = !args.no_color && color::enable_windows_ansi(); #[cfg(not(windows))] let ansi_enabled = false; Ok(ColorConfig { mode: color_mode, rainbow: args.rainbow, saturation: args.saturation, brightness: args.brightness, single_color, gradient, color_cache: Vec::new(), ansi_enabled, debug: args.color_debug, }) } 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 { if max_width == 0 { return if text.is_empty() { Vec::new() } else { vec![text.to_string()] }; } let mut lines = Vec::new(); let mut current_line = String::new(); for word in text.split_whitespace() { let word_width = word.width(); let current_width = current_line.width(); let separator_width = usize::from(!current_line.is_empty()); if word_width <= max_width && current_width + separator_width + word_width <= max_width { if !current_line.is_empty() { current_line.push(' '); } current_line.push_str(word); continue; } if !current_line.is_empty() { lines.push(std::mem::take(&mut current_line)); } if word_width <= max_width { current_line.push_str(word); continue; } let chunks = split_word_by_width(word, max_width); let last_index = chunks.len().saturating_sub(1); for (index, chunk) in chunks.into_iter().enumerate() { if index == last_index && chunk.width() < max_width { current_line = chunk; } else { lines.push(chunk); } } } if !current_line.is_empty() { lines.push(current_line); } lines } fn split_word_by_width(word: &str, max_width: usize) -> Vec { let mut chunks = Vec::new(); let mut chunk = String::new(); let mut chunk_width = 0; for character in word.chars() { let character_width = character.width().unwrap_or(0); if !chunk.is_empty() && chunk_width + character_width > max_width { chunks.push(std::mem::take(&mut chunk)); chunk_width = 0; } chunk.push(character); chunk_width += character_width; } if !chunk.is_empty() { chunks.push(chunk); } chunks } 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 = if total_chars == 1 { 0.0 } else { i as f32 / (total_chars - 1) as f32 }; let color = lerp_color_hsv(start, end, t); color_config.color_cache.push(color); } } fn list_all_art_styles() { println!("Available Miku art styles:"); println!("=========================="); // Get all art styles to determine the count let all_art = get_miku_art(None, Some(true)); let styles_count = all_art.len(); println!("Total styles: {}", styles_count); for i in 0..styles_count { println!("\n--- Style {} ---", i); let art = get_miku_art(Some(i as i32), Some(false)); for line in art { println!("{}", line); } } 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, color_config: &mut ColorConfig, ) -> Result<(), Box> { let speech_bubble_lines = get_speech_bubble_lines(text); let miku_art = get_miku_art(Some(style), None); // Distribute colors across characters that are actually colored. let total_chars: usize = miku_art .iter() .map(|line| { line.chars() .filter(|character| !character.is_whitespace()) .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; let mut all_content_lines = Vec::new(); all_content_lines.extend(speech_bubble_lines.clone()); all_content_lines.extend(miku_art.iter().map(|s| s.to_string())); let max_content_width = all_content_lines .iter() .map(|l| l.width()) .max() .unwrap_or(0); let overall_left_padding = if available_width > max_content_width { (available_width - max_content_width) / 2 } else { 0 }; let bubble_width = speech_bubble_lines .first() .map(|line| line.width()) .unwrap_or(0); let first_miku_line = miku_art.first().unwrap_or(&""); let tail_pos = first_miku_line .chars() .position(|c| !c.is_whitespace()) .unwrap_or(0); let bubble_center = bubble_width / 2; let miku_tail_absolute_pos = overall_left_padding + tail_pos; let bubble_left_padding = miku_tail_absolute_pos.saturating_sub(bubble_center); let bubble_line_count = speech_bubble_lines.len(); let content_height = all_content_lines.len(); let available_height = window_height as usize; let top_padding = if content_height < available_height { (available_height - content_height) / 2 } else { 0 }; for _ in 0..top_padding { println!(); } // Track color index for non-whitespace characters let mut color_index = 0; for (i, line) in all_content_lines.iter().enumerate() { let is_bubble_line = i < bubble_line_count; let is_tail_line = is_bubble_line && (i == bubble_line_count - 2 || i == bubble_line_count - 1); if is_bubble_line { if is_tail_line { let tail_padding = overall_left_padding + tail_pos; let horizontal_padding = " ".repeat(tail_padding); println!("{horizontal_padding}{line}"); } else { let horizontal_padding = " ".repeat(bubble_left_padding); println!("{horizontal_padding}{line}"); } } else { let horizontal_padding = " ".repeat(overall_left_padding); // Apply coloring to Miku art lines if color_config.mode != ColorMode::NoColor && !color_config.color_cache.is_empty() { // Use crossterm queueing to color characters so it's platform-agnostic let mut stdout = stdout(); stdout.queue(Print(horizontal_padding.clone()))?; for c in line.chars() { if c.is_whitespace() { stdout.queue(Print(c))?; } else if color_index < color_config.color_cache.len() { let color = &color_config.color_cache[color_index]; stdout.queue(Print(color.to_ansi_fg(color_config.mode)))?; stdout.queue(Print(c))?; stdout.queue(ResetColor)?; color_index += 1; } else { stdout.queue(Print(c))?; } } stdout.queue(Print("\n"))?; stdout.flush()?; continue; } else if color_config.mode != ColorMode::NoColor && let Some(single_color) = &color_config.single_color { // Apply single solid color using crossterm let mut stdout = stdout(); stdout.queue(Print(horizontal_padding.clone()))?; stdout.queue(Print(single_color.to_ansi_fg(color_config.mode)))?; stdout.queue(Print(line))?; stdout.queue(ResetColor)?; stdout.queue(Print("\n"))?; stdout.flush()?; } else { // No coloring println!("{}{}", horizontal_padding, line); } } } for _ in 0..top_padding { println!(); } Ok(()) } fn main() -> Result<(), Box> { let args = Args::parse(); if args.list { list_all_art_styles(); return Ok(()); } let text = args.text.clone().unwrap_or_else(|| { eprintln!("Error: Text is required when not using --list"); std::process::exit(1); }); let styles_count = get_miku_art(None, Some(true)).len(); let style = match args.style { Some(style) if style as usize >= styles_count => Args::command() .error( ErrorKind::InvalidValue, format!( "style {style} does not exist; choose a value between 0 and {}", styles_count - 1 ), ) .exit(), Some(style) => style, None => -1, }; // Parse color-related arguments let mut color_config = parse_color_args(&args).unwrap_or_else(|error| { Args::command() .error(ErrorKind::ValueValidation, error.to_string()) .exit() }); if color_config.debug { println!("Detected color mode: {:?}", color_config.mode); println!("ANSI enabled: {}", color_config.ansi_enabled); println!("NO_COLOR: {:?}", std::env::var("NO_COLOR").ok()); println!("COLORTERM: {:?}", std::env::var("COLORTERM").ok()); println!("TERM: {:?}", std::env::var("TERM").ok()); #[cfg(windows)] { println!("Windows shell hint: {:?}", color::detect_windows_shell()); } return Ok(()); } draw_miku_says(&text, style, &mut color_config)?; Ok(()) } /// 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"]); } #[test] fn test_wrap_text_empty() { let text = ""; let wrapped = wrap_text(text, 10); assert_eq!(wrapped, Vec::::new()); } #[test] fn test_wrap_text_long_word() { let text = "ThisIsAVeryLongWordThatExceedsMaxWidth"; let wrapped = wrap_text(text, 10); assert_eq!(wrapped.concat(), text); assert!(wrapped.iter().all(|line| line.width() <= 10)); } #[test] fn test_wrap_text_exact_width() { let text = "Hello world"; let wrapped = wrap_text(text, 11); assert_eq!(wrapped, vec!["Hello world"]); } #[test] fn test_get_speech_bubble_lines_basic() { let text = "Hello"; let bubble = get_speech_bubble_lines(text); // Should have: top border, text line, bottom border, 2 tail lines assert_eq!(bubble.len(), 5); // Check top border assert!(bubble[0].starts_with('╭')); assert!(bubble[0].ends_with('╮')); // 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], " \\"); } #[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('╯')); } #[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, color_debug: 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, color_debug: false, }; 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.concat(), text); assert!(wrapped.iter().all(|line| line.width() <= 10)); // 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); let border_width = bubble[0].width(); assert!( bubble[..bubble.len() - 2] .iter() .all(|line| line.width() == border_width) ); } #[test] fn test_wrap_text_splits_long_unicode_word_by_display_width() { let wrapped = wrap_text("初音ミク初音ミク初音ミク", 8); assert!(wrapped.len() > 1); assert!(wrapped.iter().all(|line| line.width() <= 8)); } #[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 ansi_enabled: false, debug: false, }; // 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 ansi_enabled: false, debug: false, }; 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(), ansi_enabled: false, debug: false, }; 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_rgb_to_ansi16_mapping() { // Validate mapping for basic colors assert_eq!(crate::color::rgb_to_ansi16(255, 0, 0), 31); assert_eq!(crate::color::rgb_to_ansi16(255, 255, 255), 97); assert_eq!(crate::color::rgb_to_ansi16(0, 0, 0), 30); } #[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(), ansi_enabled: false, debug: false, }; 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); let first_color = &config.color_cache[0]; let last_color = &config.color_cache[9]; assert_eq!(first_color.to_rgb(), start.to_rgb()); assert_eq!(last_color.to_rgb(), end.to_rgb()); } #[test] fn test_parse_color_args_reports_conflicts_without_exiting() { let args = Args { text: Some("test".to_string()), style: None, list: false, rainbow: true, saturation: 100, brightness: 50, color: Some("red".to_string()), gradient: None, no_color: false, color_debug: false, }; let error = parse_color_args(&args).unwrap_err(); assert_eq!(error.to_string(), "--rainbow cannot be used with --color"); } #[test] fn test_color_conversion_respects_terminal_mode() { let red = Color::Rgb(255, 0, 0); assert_eq!(red.to_ansi_fg(ColorMode::Truecolor), "\x1b[38;2;255;0;0m"); assert_eq!(red.to_ansi_fg(ColorMode::Ansi256), "\x1b[38;5;196m"); assert_eq!(red.to_ansi_fg(ColorMode::Ansi16), "\x1b[31m"); assert_eq!(red.to_ansi_fg(ColorMode::NoColor), ""); } #[test] fn test_parse_color_debug_flag() { 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, color_debug: true, }; let config = parse_color_args(&args).unwrap(); assert!(config.debug); } }