diff --git a/Cargo.lock b/Cargo.lock index 00b3d60..beaaa6b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -72,9 +72,9 @@ checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" [[package]] name = "clap" -version = "4.5.41" +version = "4.5.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be92d32e80243a54711e5d7ce823c35c41c9d929dc4ab58e1276f625841aadf9" +checksum = "c9e340e012a1bf4935f5282ed1436d1489548e8f72308207ea5df0e23d2d03f8" dependencies = [ "clap_builder", "clap_derive", @@ -82,9 +82,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.41" +version = "4.5.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "707eab41e9622f9139419d573eca0900137718000c517d47da73045f54331c3d" +checksum = "d76b5d13eaa18c901fd2f7fca939fefe3a0727a953561fefdf3b2922b8569d00" dependencies = [ "anstream", "anstyle", @@ -94,9 +94,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.41" +version = "4.5.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef4f52386a59ca4c860f7393bcf8abd8dfd91ecccc0f774635ff68e92eeef491" +checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" dependencies = [ "heck", "proc-macro2", @@ -252,7 +252,7 @@ checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" [[package]] name = "mikusays" -version = "0.1.3" +version = "0.1.4" dependencies = [ "clap", "crossterm", @@ -458,9 +458,9 @@ checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" [[package]] name = "unicode-width" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a1a07cc7db3810833284e8d372ccdc6da29741639ecc70c9ec107df0fa6154c" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" [[package]] name = "utf8parse" diff --git a/Cargo.toml b/Cargo.toml index eab83a2..e61489c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mikusays" -version = "0.1.3" +version = "0.1.4" edition = "2024" description = "A \"cowsay\" clone with Hatsune Miku ASCII art and speech bubbles." license = "MIT" @@ -28,6 +28,6 @@ assets = [ [dependencies] crossterm = "0.29" -unicode-width = "0.2" -rand = "0.9" -clap = { version = "4.5.41", features = ["derive"] } +unicode-width = "0.2.2" +rand = "0.9.2" +clap = { version = "4.5.53", features = ["derive"] } diff --git a/src/color.rs b/src/color.rs new file mode 100644 index 0000000..1b1b201 --- /dev/null +++ b/src/color.rs @@ -0,0 +1,549 @@ +use clap::ValueEnum; +use std::error::Error; +use std::fmt; +use std::str::FromStr; + +#[derive(Debug, Clone, PartialEq)] +pub enum Color { + Named(NamedColor), + Hex(String), + Rgb(u8, u8, u8), + Hsl(u16, u8, u8), // h: 0-360, s: 0-100, l: 0-100 + Ansi(u8), // ANSI color code (0-255) + Truecolor(u8, u8, u8), // 24-bit RGB +} + +#[derive(Debug, Clone, Copy, PartialEq, ValueEnum)] +pub enum NamedColor { + Black, + Red, + Green, + Yellow, + Blue, + Magenta, + Cyan, + White, + BrightBlack, + BrightRed, + BrightGreen, + BrightYellow, + BrightBlue, + BrightMagenta, + BrightCyan, + BrightWhite, +} + +#[derive(Debug)] +pub enum ColorParseError { + InvalidFormat, + InvalidHex, + InvalidRgb, + InvalidHsl, + UnknownNamedColor, +} + +impl fmt::Display for ColorParseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ColorParseError::InvalidFormat => write!(f, "Invalid color format"), + ColorParseError::InvalidHex => write!(f, "Invalid hex color code"), + ColorParseError::InvalidRgb => write!(f, "Invalid RGB format. Expected format: R,G,B"), + ColorParseError::InvalidHsl => { + write!(f, "Invalid HSL format. Expected format: hsl(H,S%,L%)") + } + ColorParseError::UnknownNamedColor => write!(f, "Unknown named color"), + } + } +} + +impl Error for ColorParseError {} + +impl FromStr for Color { + type Err = ColorParseError; + + fn from_str(s: &str) -> Result { + let s_lower = s.to_lowercase(); + + // Try named color first + if let Ok(named) = NamedColor::from_str(s, true) { + return Ok(Color::Named(named)); + } + + // Try hex color (#RRGGBB or #RGB) + if s.starts_with('#') { + return parse_hex_color(s); + } + + // Try HSL (hsl(180,100%,50%)) + if s_lower.starts_with("hsl(") && s.ends_with(')') { + return parse_hsl_color(s); + } + + // Try RGB tuple (255,0,128) + if s.contains(',') { + return parse_rgb_color(s); + } + + // Try ANSI code (number between 0-255) + if let Ok(code) = s.parse::() { + return Ok(Color::Ansi(code)); + } + + Err(ColorParseError::InvalidFormat) + } +} + +impl FromStr for NamedColor { + type Err = ColorParseError; + + fn from_str(s: &str) -> Result { + Self::from_str(s, false) + } +} + +impl NamedColor { + fn from_str(s: &str, ignore_case: bool) -> Result { + let s = if ignore_case { + s.to_lowercase() + } else { + s.to_string() + }; + + match s.as_str() { + "black" => Ok(NamedColor::Black), + "red" => Ok(NamedColor::Red), + "green" => Ok(NamedColor::Green), + "yellow" => Ok(NamedColor::Yellow), + "blue" => Ok(NamedColor::Blue), + "magenta" => Ok(NamedColor::Magenta), + "cyan" => Ok(NamedColor::Cyan), + "white" => Ok(NamedColor::White), + "brightblack" | "bright_black" => Ok(NamedColor::BrightBlack), + "brightred" | "bright_red" => Ok(NamedColor::BrightRed), + "brightgreen" | "bright_green" => Ok(NamedColor::BrightGreen), + "brightyellow" | "bright_yellow" => Ok(NamedColor::BrightYellow), + "brightblue" | "bright_blue" => Ok(NamedColor::BrightBlue), + "brightmagenta" | "bright_magenta" => Ok(NamedColor::BrightMagenta), + "brightcyan" | "bright_cyan" => Ok(NamedColor::BrightCyan), + "brightwhite" | "bright_white" => Ok(NamedColor::BrightWhite), + _ => Err(ColorParseError::UnknownNamedColor), + } + } +} + +fn parse_hex_color(s: &str) -> Result { + let hex = s.trim_start_matches('#'); + + match hex.len() { + 3 => { + // #RGB format + let r = u8::from_str_radix(&hex[0..1].repeat(2), 16) + .map_err(|_| ColorParseError::InvalidHex)?; + let g = u8::from_str_radix(&hex[1..2].repeat(2), 16) + .map_err(|_| ColorParseError::InvalidHex)?; + let b = u8::from_str_radix(&hex[2..3].repeat(2), 16) + .map_err(|_| ColorParseError::InvalidHex)?; + Ok(Color::Hex(format!("#{:02x}{:02x}{:02x}", r, g, b))) + } + 6 => { + // #RRGGBB format + let r = u8::from_str_radix(&hex[0..2], 16).map_err(|_| ColorParseError::InvalidHex)?; + let g = u8::from_str_radix(&hex[2..4], 16).map_err(|_| ColorParseError::InvalidHex)?; + let b = u8::from_str_radix(&hex[4..6], 16).map_err(|_| ColorParseError::InvalidHex)?; + Ok(Color::Hex(format!("#{:02x}{:02x}{:02x}", r, g, b))) + } + _ => Err(ColorParseError::InvalidHex), + } +} + +fn parse_rgb_color(s: &str) -> Result { + let parts: Vec<&str> = s.split(',').map(|p| p.trim()).collect(); + if parts.len() != 3 { + return Err(ColorParseError::InvalidRgb); + } + + let r = parts[0] + .parse::() + .map_err(|_| ColorParseError::InvalidRgb)?; + let g = parts[1] + .parse::() + .map_err(|_| ColorParseError::InvalidRgb)?; + let b = parts[2] + .parse::() + .map_err(|_| ColorParseError::InvalidRgb)?; + + Ok(Color::Rgb(r, g, b)) +} + +fn parse_hsl_color(s: &str) -> Result { + let content = s.trim_start_matches("hsl(").trim_end_matches(')'); + let parts: Vec<&str> = content.split(',').map(|p| p.trim()).collect(); + + if parts.len() != 3 { + return Err(ColorParseError::InvalidHsl); + } + + let h = parts[0] + .trim_end_matches(|c: char| !c.is_ascii_digit()) + .parse::() + .map_err(|_| ColorParseError::InvalidHsl)?; + let s = parts[1] + .trim_end_matches('%') + .parse::() + .map_err(|_| ColorParseError::InvalidHsl)?; + let l = parts[2] + .trim_end_matches('%') + .parse::() + .map_err(|_| ColorParseError::InvalidHsl)?; + + if h > 360 || s > 100 || l > 100 { + return Err(ColorParseError::InvalidHsl); + } + + Ok(Color::Hsl(h, s, l)) +} + +impl Color { + /// Convert color to RGB tuple + pub fn to_rgb(&self) -> (u8, u8, u8) { + match self { + Color::Named(named) => named.to_rgb(), + Color::Hex(hex) => { + let hex = hex.trim_start_matches('#'); + match hex.len() { + 3 => { + // #RGB format + let r = u8::from_str_radix(&hex[0..1].repeat(2), 16).unwrap_or(0); + let g = u8::from_str_radix(&hex[1..2].repeat(2), 16).unwrap_or(0); + let b = u8::from_str_radix(&hex[2..3].repeat(2), 16).unwrap_or(0); + (r, g, b) + } + 6 => { + // #RRGGBB format + let r = u8::from_str_radix(&hex[0..2], 16).unwrap_or(0); + let g = u8::from_str_radix(&hex[2..4], 16).unwrap_or(0); + let b = u8::from_str_radix(&hex[4..6], 16).unwrap_or(0); + (r, g, b) + } + _ => (0, 0, 0), + } + } + Color::Rgb(r, g, b) => (*r, *g, *b), + Color::Hsl(h, s, l) => hsl_to_rgb(*h, *s, *l), + Color::Ansi(code) => ansi_to_rgb(*code), + Color::Truecolor(r, g, b) => (*r, *g, *b), + } + } + + /// Convert color to ANSI escape code for foreground + pub fn to_ansi_fg(&self, color_mode: ColorMode) -> String { + match color_mode { + ColorMode::Truecolor => self.to_truecolor_fg(), + ColorMode::Ansi256 => self.to_ansi256_fg(), + ColorMode::Ansi16 => self.to_ansi16_fg(), + ColorMode::NoColor => "".to_string(), + } + } + + fn to_truecolor_fg(&self) -> String { + let (r, g, b) = self.to_rgb(); + format!("\x1b[38;2;{};{};{}m", r, g, b) + } + + fn to_ansi256_fg(&self) -> String { + let (r, g, b) = self.to_rgb(); + let code = rgb_to_ansi256(r, g, b); + format!("\x1b[38;5;{}m", code) + } + + fn to_ansi16_fg(&self) -> String { + let (r, g, b) = self.to_rgb(); + let code = rgb_to_ansi16(r, g, b); + format!("\x1b[{}m", code) + } +} + +impl NamedColor { + pub fn to_rgb(self) -> (u8, u8, u8) { + match self { + NamedColor::Black => (0, 0, 0), + NamedColor::Red => (197, 15, 31), + NamedColor::Green => (19, 161, 14), + NamedColor::Yellow => (193, 156, 0), + NamedColor::Blue => (0, 55, 218), + NamedColor::Magenta => (136, 23, 152), + NamedColor::Cyan => (58, 150, 221), + NamedColor::White => (204, 204, 204), + NamedColor::BrightBlack => (118, 118, 118), + NamedColor::BrightRed => (231, 72, 86), + NamedColor::BrightGreen => (22, 198, 12), + NamedColor::BrightYellow => (249, 241, 165), + NamedColor::BrightBlue => (59, 120, 255), + NamedColor::BrightMagenta => (180, 0, 158), + NamedColor::BrightCyan => (97, 214, 214), + NamedColor::BrightWhite => (242, 242, 242), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum ColorMode { + Truecolor, + Ansi256, + Ansi16, + NoColor, +} + +/// Convert HSL to RGB +fn hsl_to_rgb(h: u16, s: u8, l: u8) -> (u8, u8, u8) { + let h = h as f32 / 360.0; + let s = s as f32 / 100.0; + let l = l as f32 / 100.0; + + let r; + let g; + let b; + + if s == 0.0 { + r = l; + g = l; + b = l; + } else { + let hue_to_rgb = |p: f32, q: f32, t: f32| -> f32 { + let mut t = t; + if t < 0.0 { + t += 1.0; + } + if t > 1.0 { + t -= 1.0; + } + if t < 1.0 / 6.0 { + return p + (q - p) * 6.0 * t; + } + if t < 1.0 / 2.0 { + return q; + } + if t < 2.0 / 3.0 { + return p + (q - p) * (2.0 / 3.0 - t) * 6.0; + } + p + }; + + let q = if l < 0.5 { + l * (1.0 + s) + } else { + l + s - l * s + }; + let p = 2.0 * l - q; + + r = hue_to_rgb(p, q, h + 1.0 / 3.0); + g = hue_to_rgb(p, q, h); + b = hue_to_rgb(p, q, h - 1.0 / 3.0); + } + + ( + (r * 255.0).round() as u8, + (g * 255.0).round() as u8, + (b * 255.0).round() as u8, + ) +} + +/// Convert RGB to ANSI 256 color code +fn rgb_to_ansi256(r: u8, g: u8, b: u8) -> u8 { + // Calculate grayscale value + let gray = (r as f32 * 0.299 + g as f32 * 0.587 + b as f32 * 0.114).round() as u8; + + // Check if it's a grayscale color + if r == g && g == b { + // Grayscale ramp (232-255) + if gray <= 8 { + 16 + } else if gray >= 248 { + 231 + } else { + ((gray as f32 - 8.0) / 247.0 * 24.0).round() as u8 + 232 + } + } else { + // Color cube (16-231) + let r = (r as f32 / 255.0 * 5.0).round() as u8; + let g = (g as f32 / 255.0 * 5.0).round() as u8; + let b = (b as f32 / 255.0 * 5.0).round() as u8; + + 16 + (r * 36) + (g * 6) + b + } +} + +/// Convert RGB to ANSI 16 color code +fn rgb_to_ansi16(r: u8, g: u8, b: u8) -> u8 { + // Calculate brightness + let brightness = (r as f32 * 0.299 + g as f32 * 0.587 + b as f32 * 0.114).round() as u8; + + // Determine if it's bright or dark + let is_bright = brightness > 128; + + // Determine base color + let base_color = if r > g && r > b { + // Red dominant + if r > 192 && g < 64 && b < 64 { + 1 // Red + } else if r > g && r > b { + 3 // Yellow + } else { + 7 // White + } + } else if g > r && g > b { + // Green dominant + if g > 192 && r < 64 && b < 64 { + 2 // Green + } else if g > b { + 3 // Yellow + } else { + 6 // Cyan + } + } else { + // Blue dominant + if b > 192 && r < 64 && g < 64 { + 4 // Blue + } else if b > r { + 6 // Cyan + } else { + 5 // Magenta + } + }; + + if is_bright { + base_color + 8 // Bright colors are 8-15 + } else { + base_color // Normal colors are 0-7 + } +} + +/// Convert ANSI color code to RGB +fn ansi_to_rgb(code: u8) -> (u8, u8, u8) { + match code { + // Standard 16 colors + 0 => (0, 0, 0), // Black + 1 => (128, 0, 0), // Red + 2 => (0, 128, 0), // Green + 3 => (128, 128, 0), // Yellow + 4 => (0, 0, 128), // Blue + 5 => (128, 0, 128), // Magenta + 6 => (0, 128, 128), // Cyan + 7 => (192, 192, 192), // White + 8 => (128, 128, 128), // Bright Black + 9 => (255, 0, 0), // Bright Red + 10 => (0, 255, 0), // Bright Green + 11 => (255, 255, 0), // Bright Yellow + 12 => (0, 0, 255), // Bright Blue + 13 => (255, 0, 255), // Bright Magenta + 14 => (0, 255, 255), // Bright Cyan + 15 => (255, 255, 255), // Bright White + + // 256-color mode + 16..=231 => { + // Color cube + let code = code - 16; + let r = ((code / 36) % 6) * 51; + let g = ((code / 6) % 6) * 51; + let b = (code % 6) * 51; + (r, g, b) + } + + 232..=255 => { + // Grayscale ramp + let gray = (code - 232) * 10 + 8; + (gray, gray, gray) + } + } +} + +/// Detect the best available color mode for the terminal +pub fn detect_color_mode() -> ColorMode { + if std::env::var("NO_COLOR").is_ok() { + return ColorMode::NoColor; + } + + if let Ok(colorterm) = std::env::var("COLORTERM") + && (colorterm.contains("truecolor") || colorterm.contains("24bit")) + { + return ColorMode::Truecolor; + } + + if let Ok(term) = std::env::var("TERM") { + if term.contains("256color") { + return ColorMode::Ansi256; + } + if term.contains("color") { + return ColorMode::Ansi16; + } + } + + // Default fallback + ColorMode::Ansi16 +} + +/// Linear interpolation between two colors in HSV space +pub fn lerp_color_hsv(start: &Color, end: &Color, t: f32) -> Color { + let (h1, s1, v1) = rgb_to_hsv(start.to_rgb()); + let (h2, s2, v2) = rgb_to_hsv(end.to_rgb()); + + // Handle hue interpolation correctly (shortest path around the color wheel) + let h_diff = (h2 - h1 + 180.0) % 360.0 - 180.0; + let h = (h1 + h_diff * t + 360.0) % 360.0; + + let s = s1 + (s2 - s1) * t; + let v = v1 + (v2 - v1) * t; + + let (r, g, b) = hsv_to_rgb(h, s, v); + Color::Truecolor(r, g, b) +} + +/// Convert RGB to HSV +fn rgb_to_hsv((r, g, b): (u8, u8, u8)) -> (f32, f32, f32) { + let r = r as f32 / 255.0; + let g = g as f32 / 255.0; + let b = b as f32 / 255.0; + + let max = r.max(g).max(b); + let min = r.min(g).min(b); + let delta = max - min; + + let h = if delta == 0.0 { + 0.0 + } else if max == r { + 60.0 * (((g - b) / delta) % 6.0) + } else if max == g { + 60.0 * (((b - r) / delta) + 2.0) + } else { + 60.0 * (((r - g) / delta) + 4.0) + }; + + let h = if h < 0.0 { h + 360.0 } else { h }; + + let s = if max == 0.0 { 0.0 } else { delta / max }; + let v = max; + + (h, s, v) +} + +/// Convert HSV to RGB +fn hsv_to_rgb(h: f32, s: f32, v: f32) -> (u8, u8, u8) { + let c = v * s; + let x = c * (1.0 - ((h / 60.0) % 2.0 - 1.0).abs()); + let m = v - c; + + let (r, g, b) = match h { + h if (0.0..60.0).contains(&h) => (c, x, 0.0), + h if (60.0..120.0).contains(&h) => (x, c, 0.0), + h if (120.0..180.0).contains(&h) => (0.0, c, x), + h if (180.0..240.0).contains(&h) => (0.0, x, c), + h if (240.0..300.0).contains(&h) => (x, 0.0, c), + _ => (c, 0.0, x), + }; + + ( + ((r + m) * 255.0).round() as u8, + ((g + m) * 255.0).round() as u8, + ((b + m) * 255.0).round() as u8, + ) +} diff --git a/src/main.rs b/src/main.rs index 39fc9fe..023f7f3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,145 +1,174 @@ -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(); - - if args.list { - list_all_art_styles(); - return Ok(()); - } - - let text = args.text.unwrap_or_else(|| { - eprintln!("Error: Text is required when not using --list"); - 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(()) +#[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, } -fn list_all_art_styles() { - println!("Available Miku art styles:"); - println!("=========================="); +pub fn parse_color_args(args: &Args) -> Result> { + // Detect color mode + let mut color_mode = detect_color_mode(); - // 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); - } + // Override with --no-color if specified + if args.no_color { + color_mode = ColorMode::NoColor; } - println!("\nUse --style to select a specific style, or omit for random selection."); -} - -fn draw_miku_says(text: &str, style: i32) -> Result<(), Box> { - let speech_bubble_lines = get_speech_bubble_lines(text); - let miku_art = get_miku_art(Some(style), None); - - 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!(); - } - - 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}"); + // 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 { - let horizontal_padding = " ".repeat(overall_left_padding); - println!("{horizontal_padding}{line}"); } + } 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); } - for _ in 0..top_padding { - println!(); + if args.rainbow && gradient.is_some() { + eprintln!("Error: Cannot use --rainbow and --gradient together"); + std::process::exit(1); } - Ok(()) + 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(), + }) } -fn get_speech_bubble_lines(text: &str) -> Vec { +pub fn get_speech_bubble_lines(text: &str) -> Vec { const MAX_BUBBLE_WIDTH: usize = 50; const MIN_BUBBLE_WIDTH: usize = 20; @@ -199,7 +228,7 @@ fn get_speech_bubble_lines(text: &str) -> Vec { bubble_lines } -fn wrap_text(text: &str, max_width: usize) -> Vec { +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(); @@ -227,3 +256,473 @@ fn wrap_text(text: &str, max_width: usize) -> Vec { 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() { + 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); + + // 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; + + 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() { + let mut colored_line = String::new(); + colored_line.push_str(&horizontal_padding); + + for c in line.chars() { + if c.is_whitespace() { + colored_line.push(c); + } else if color_index < color_config.color_cache.len() { + let color = &color_config.color_cache[color_index]; + let ansi_code = color.to_ansi_fg(color_config.mode); + colored_line.push_str(&ansi_code); + colored_line.push(c); + colored_line.push_str("\x1b[0m"); // Reset + color_index += 1; + } else { + colored_line.push(c); + } + } + + println!("{}", colored_line); + } else if let Some(single_color) = &color_config.single_color { + // Apply single solid color + let ansi_code = single_color.to_ansi_fg(color_config.mode); + let reset_code = "\x1b[0m"; + println!("{}{}{}{}", horizontal_padding, ansi_code, line, reset_code); + } 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 style = args.style.unwrap_or(-1); // Default to -1 to select a random style + + // Parse color-related arguments + let mut color_config = parse_color_args(&args)?; + + 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, vec!["ThisIsAVeryLongWordThatExceedsMaxWidth"]); + } + + #[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, + }; + + 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); + } +} diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs new file mode 100644 index 0000000..c589db0 --- /dev/null +++ b/tests/integration_tests.rs @@ -0,0 +1,55 @@ +use std::process::Command; +use std::str; + +#[test] +fn test_cli_list_styles() { + let output = Command::new("target/debug/mikusays") + .arg("--list") + .output() + .expect("Failed to execute command"); + + assert!(output.status.success()); + let stdout = str::from_utf8(&output.stdout).expect("Invalid UTF-8"); + + // Should contain style listing information + assert!(stdout.contains("Available Miku art styles:")); + assert!(stdout.contains("Total styles:")); + assert!(stdout.contains("--- Style")); +} + +#[test] +fn test_cli_error_no_text() { + let output = Command::new("target/debug/mikusays") + .output() + .expect("Failed to execute command"); + + assert!(!output.status.success()); + let stderr = str::from_utf8(&output.stderr).expect("Invalid UTF-8"); + assert!(stderr.contains("Error: Text is required when not using --list")); +} + +#[test] +fn test_cli_error_conflicting_args() { + let output = Command::new("target/debug/mikusays") + .arg("Test") + .arg("--rainbow") + .arg("--color") + .arg("red") + .output() + .expect("Failed to execute command"); + + assert!(!output.status.success()); + let stderr = str::from_utf8(&output.stderr).expect("Invalid UTF-8"); + assert!(stderr.contains("Cannot use --rainbow and --color together")); +} + +#[test] +fn test_cli_basic_functionality() { + // Test that the binary runs and produces some output + let output = Command::new("target/debug/mikusays") + .arg("--list") + .output() + .expect("Failed to execute command"); + + assert!(output.status.success()); +}