use artbox::{
fonts, Alignment, Color, ColorStop, Fill, Font, LinearGradient, RadialGradient, Renderer,
};
use clap::{Parser, ValueEnum};
#[derive(Parser)]
#[command(
name = "gradient",
about = "Render ASCII text with colors and gradients."
)]
struct Args {
text: String,
width: u16,
height: u16,
#[arg(short, long, value_parser = parse_rgb, conflicts_with_all = ["gradient", "from", "to"])]
color: Option<(u8, u8, u8)>,
#[arg(short, long, value_enum)]
gradient: Option<GradientType>,
#[arg(long, value_parser = parse_color_tuple, requires = "gradient")]
from: Option<(f32, f32, f32)>,
#[arg(long, value_parser = parse_color_tuple, requires = "gradient")]
to: Option<(f32, f32, f32)>,
#[arg(long, requires = "gradient")]
angle: Option<f32>,
#[arg(long)]
hsl: bool,
#[arg(short, long, default_value = "c", value_parser = parse_alignment)]
alignment: Alignment,
#[arg(short = 's', long = "spacing", default_value_t = 0)]
spacing: i16,
#[arg(short, long, value_parser = parse_font_name, conflicts_with = "family")]
font: Option<String>,
#[arg(long, value_parser = parse_family_name, conflicts_with = "font")]
family: Option<String>,
#[arg(long)]
no_border: bool,
}
#[derive(Clone, Copy, ValueEnum)]
enum GradientType {
Horizontal,
Vertical,
Diagonal,
Radial,
}
fn main() {
let args = Args::parse();
let fonts = resolve_fonts(&args);
let mut renderer = Renderer::new(fonts)
.with_plain_fallback()
.with_alignment(args.alignment)
.with_letter_spacing(args.spacing);
renderer = renderer.with_fill(resolve_fill(&args));
let result = renderer
.render(&args.text, args.width, args.height)
.map(|rendered| rendered.to_ansi_string());
match result {
Ok(output) => {
if args.no_border {
println!("{}", output);
} else {
print_with_border(&output, args.width, args.height);
}
}
Err(err) => {
eprintln!("Render error: {err}");
std::process::exit(1);
}
}
}
fn resolve_fill(args: &Args) -> Fill {
if let Some((r, g, b)) = args.color {
return Fill::solid(Color::rgb(r, g, b));
}
let gradient_type = args.gradient.unwrap_or(GradientType::Diagonal);
let from = args.from.unwrap_or((255.0, 0.0, 128.0));
let to = args.to.unwrap_or((0.0, 128.0, 255.0));
let from_color = tuple_to_color(from, args.hsl);
let to_color = tuple_to_color(to, args.hsl);
let stops = vec![
ColorStop::new(0.0, from_color),
ColorStop::new(1.0, to_color),
];
match gradient_type {
GradientType::Horizontal => {
let angle = args.angle.unwrap_or(0.0);
Fill::Linear(LinearGradient::new(angle, stops))
}
GradientType::Vertical => {
let angle = args.angle.unwrap_or(90.0);
Fill::Linear(LinearGradient::new(angle, stops))
}
GradientType::Diagonal => {
let angle = args.angle.unwrap_or(45.0);
Fill::Linear(LinearGradient::new(angle, stops))
}
GradientType::Radial => {
Fill::Radial(RadialGradient::new((0.5, 0.5), (0.5, 0.5), 1.0, stops))
}
}
}
fn tuple_to_color(tuple: (f32, f32, f32), is_hsl: bool) -> Color {
if is_hsl {
Color::hsl(tuple.0, tuple.1, tuple.2)
} else {
Color::rgb(tuple.0 as u8, tuple.1 as u8, tuple.2 as u8)
}
}
fn resolve_fonts(args: &Args) -> Vec<Font> {
if let Some(font_name) = args.font.as_deref() {
let font = fonts::font(font_name).unwrap_or_else(|| {
eprintln!("Failed to load font: {font_name}");
std::process::exit(2);
});
return vec![font];
}
if let Some(family_name) = args.family.as_deref() {
let family = fonts::family(family_name).unwrap_or_else(|| {
eprintln!("Failed to load font family: {family_name}");
std::process::exit(2);
});
return family;
}
fonts::family("blocky").unwrap_or_else(fonts::default)
}
fn parse_alignment(value: &str) -> Result<Alignment, String> {
match value.to_ascii_lowercase().as_str() {
"tl" | "top-left" => Ok(Alignment::TopLeft),
"t" | "top" => Ok(Alignment::Top),
"tr" | "top-right" => Ok(Alignment::TopRight),
"l" | "left" => Ok(Alignment::Left),
"c" | "center" | "middle" => Ok(Alignment::Center),
"r" | "right" => Ok(Alignment::Right),
"bl" | "bottom-left" => Ok(Alignment::BottomLeft),
"b" | "bottom" => Ok(Alignment::Bottom),
"br" | "bottom-right" => Ok(Alignment::BottomRight),
_ => Err(format!(
"Invalid alignment: {value}. Use tl, t, tr, l, c, r, bl, b, br."
)),
}
}
fn parse_rgb(value: &str) -> Result<(u8, u8, u8), String> {
let parts: Vec<&str> = value.split(',').collect();
if parts.len() != 3 {
return Err(format!("Expected R,G,B format, got: {value}"));
}
let r = parts[0]
.trim()
.parse::<u8>()
.map_err(|_| format!("Invalid R value: {}", parts[0]))?;
let g = parts[1]
.trim()
.parse::<u8>()
.map_err(|_| format!("Invalid G value: {}", parts[1]))?;
let b = parts[2]
.trim()
.parse::<u8>()
.map_err(|_| format!("Invalid B value: {}", parts[2]))?;
Ok((r, g, b))
}
fn parse_color_tuple(value: &str) -> Result<(f32, f32, f32), String> {
let parts: Vec<&str> = value.split(',').collect();
if parts.len() != 3 {
return Err(format!("Expected 3 comma-separated values, got: {value}"));
}
let a = parts[0]
.trim()
.parse::<f32>()
.map_err(|_| format!("Invalid value: {}", parts[0]))?;
let b = parts[1]
.trim()
.parse::<f32>()
.map_err(|_| format!("Invalid value: {}", parts[1]))?;
let c = parts[2]
.trim()
.parse::<f32>()
.map_err(|_| format!("Invalid value: {}", parts[2]))?;
Ok((a, b, c))
}
fn parse_font_name(value: &str) -> Result<String, String> {
let trimmed = value.trim();
if trimmed.is_empty() {
return Err("Font name cannot be empty.".to_string());
}
let names = fonts::names();
if !names.iter().any(|name| name.eq_ignore_ascii_case(trimmed)) {
return Err(format!(
"Unknown font: {trimmed}. Available fonts: {}",
names.join(", ")
));
}
Ok(trimmed.to_string())
}
fn parse_family_name(value: &str) -> Result<String, String> {
let trimmed = value.trim();
if trimmed.is_empty() {
return Err("Font family cannot be empty.".to_string());
}
let names = fonts::family_names();
if !names.iter().any(|name| name.eq_ignore_ascii_case(trimmed)) {
return Err(format!(
"Unknown font family: {trimmed}. Available families: {}",
names.join(", ")
));
}
Ok(trimmed.to_string())
}
fn print_with_border(rendered: &str, width: u16, height: u16) {
use unicode_width::UnicodeWidthStr;
let inner_width = width as usize;
let border = format!("+{}+", "-".repeat(inner_width));
println!("{border}");
let mut lines = rendered.lines();
for _ in 0..height {
let line = lines.next().unwrap_or("");
let visible = strip_ansi(line);
let line_width = UnicodeWidthStr::width(visible.as_str());
let pad = inner_width.saturating_sub(line_width);
if pad == 0 {
println!("|{}|", line);
} else {
println!("|{}\x1b[0m{}|", line, " ".repeat(pad));
}
}
println!("{border}");
}
fn strip_ansi(s: &str) -> String {
let mut result = String::new();
let mut in_escape = false;
for ch in s.chars() {
if ch == '\x1b' {
in_escape = true;
} else if in_escape {
if ch == 'm' {
in_escape = false;
}
} else {
result.push(ch);
}
}
result
}