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:
1 parent
5e2888348e
commit
175a320d84
5 files changed
+1229
-126
No files matched your search
+549
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user