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.
This commit is contained in:
xxanqw committed 2025-11-26 13:52:24 +02:00
1 parent 5e2888348e
commit 175a320d84
5 files changed
+1229 -126

No files matched your search

+549
View File
@@ -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<Self, Self::Err> {
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::<u8>() {
return Ok(Color::Ansi(code));
}
Err(ColorParseError::InvalidFormat)
}
}
impl FromStr for NamedColor {
type Err = ColorParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::from_str(s, false)
}
}
impl NamedColor {
fn from_str(s: &str, ignore_case: bool) -> Result<Self, ColorParseError> {
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<Color, ColorParseError> {
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<Color, ColorParseError> {
let parts: Vec<&str> = s.split(',').map(|p| p.trim()).collect();
if parts.len() != 3 {
return Err(ColorParseError::InvalidRgb);
}
let r = parts[0]
.parse::<u8>()
.map_err(|_| ColorParseError::InvalidRgb)?;
let g = parts[1]
.parse::<u8>()
.map_err(|_| ColorParseError::InvalidRgb)?;
let b = parts[2]
.parse::<u8>()
.map_err(|_| ColorParseError::InvalidRgb)?;
Ok(Color::Rgb(r, g, b))
}
fn parse_hsl_color(s: &str) -> Result<Color, ColorParseError> {
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::<u16>()
.map_err(|_| ColorParseError::InvalidHsl)?;
let s = parts[1]
.trim_end_matches('%')
.parse::<u8>()
.map_err(|_| ColorParseError::InvalidHsl)?;
let l = parts[2]
.trim_end_matches('%')
.parse::<u8>()
.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,
)
}
+612 -113
View File
@@ -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<String>,
pub text: Option<String>,
/// Style of the Miku art. A random one is chosen if not specified
#[arg(short, long)]
style: Option<i32>,
pub style: Option<i32>,
/// 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<String>,
/// Define a custom two-color gradient (e.g., --gradient red:blue)
#[arg(long, value_name = "START:END")]
pub gradient: Option<String>,
/// Disable all coloring (respects NO_COLOR environment variable)
#[arg(long)]
pub no_color: bool,
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
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<Color>,
pub gradient: Option<(Color, Color)>,
pub color_cache: Vec<Color>,
}
fn list_all_art_styles() {
println!("Available Miku art styles:");
println!("==========================");
pub fn parse_color_args(args: &Args) -> Result<ColorConfig, Box<dyn Error>> {
// 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 <number> to select a specific style, or omit for random selection.");
}
fn draw_miku_says(text: &str, style: i32) -> Result<(), Box<dyn std::error::Error>> {
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<String> {
pub fn get_speech_bubble_lines(text: &str) -> Vec<String> {
const MAX_BUBBLE_WIDTH: usize = 50;
const MIN_BUBBLE_WIDTH: usize = 20;
@@ -199,7 +228,7 @@ fn get_speech_bubble_lines(text: &str) -> Vec<String> {
bubble_lines
}
fn wrap_text(text: &str, max_width: usize) -> Vec<String> {
pub fn wrap_text(text: &str, max_width: usize) -> Vec<String> {
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<String> {
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 <number> 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 <COLOR> Set a single solid color (supports named colors, hex, rgb, hsl, ansi)"
);
println!(" --gradient <START:END> 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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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::<String>::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);
}
}