Initial commit: Piglet - Animated figlet wrapper
Add complete Rust implementation with: - 20+ motion effects (fade, slide, scale, typewriter, wave, bounce, etc.) - 18+ easing functions (linear, quad, cubic, elastic, back, bounce) - Color support (CSS4 colors, hex codes, gradients) - Figlet integration with custom fonts and options - Cross-platform support (Linux, macOS, Windows) - Comprehensive CI/CD workflows - Full test suite with integration tests - Documentation (README.md, CLAUDE.md) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
49
src/animation/easing.rs
Normal file
49
src/animation/easing.rs
Normal file
@@ -0,0 +1,49 @@
|
||||
use anyhow::{Result, bail};
|
||||
use scirs2_interpolate::*;
|
||||
|
||||
pub trait EasingFunction: Send + Sync {
|
||||
fn ease(&self, t: f64) -> f64;
|
||||
fn name(&self) -> &str;
|
||||
}
|
||||
|
||||
// Linear
|
||||
pub struct Linear;
|
||||
impl EasingFunction for Linear {
|
||||
fn ease(&self, t: f64) -> f64 { t }
|
||||
fn name(&self) -> &str { "linear" }
|
||||
}
|
||||
|
||||
// Quadratic
|
||||
pub struct EaseInQuad;
|
||||
impl EasingFunction for EaseInQuad {
|
||||
fn ease(&self, t: f64) -> f64 { quad_ease_in(t, 0.0, 1.0, 1.0) }
|
||||
fn name(&self) -> &str { "ease-in-quad" }
|
||||
}
|
||||
|
||||
pub struct EaseOutQuad;
|
||||
impl EasingFunction for EaseOutQuad {
|
||||
fn ease(&self, t: f64) -> f64 { quad_ease_out(t, 0.0, 1.0, 1.0) }
|
||||
fn name(&self) -> &str { "ease-out-quad" }
|
||||
}
|
||||
|
||||
pub struct EaseInOutQuad;
|
||||
impl EasingFunction for EaseInOutQuad {
|
||||
fn ease(&self, t: f64) -> f64 { quad_ease_in_out(t, 0.0, 1.0, 1.0) }
|
||||
fn name(&self) -> &str { "ease-in-out-quad" }
|
||||
}
|
||||
|
||||
// Cubic
|
||||
pub struct EaseInCubic;
|
||||
impl EasingFunction for EaseInCubic {
|
||||
fn ease(&self, t: f64) -> f64 { cubic_ease_in(t, 0.0, 1.0, 1.0) }
|
||||
fn name(&self) -> &str { "ease-in-cubic" }
|
||||
}
|
||||
|
||||
pub struct EaseOutCubic;
|
||||
impl EasingFunction for EaseOutCubic {
|
||||
fn ease(&self, t: f64) -> f64 { cubic_ease_out(t, 0.0, 1.0, 1.0) }
|
||||
fn name(&self) -> &str { "ease-out-cubic" }
|
||||
}
|
||||
|
||||
pub struct EaseInOutCubic;
|
||||
impl EasingFunction for EaseInOut
|
||||
58
src/animation/mod.rs
Normal file
58
src/animation/mod.rs
Normal file
@@ -0,0 +1,58 @@
|
||||
pub mod effects;
|
||||
pub mod easing;
|
||||
pub mod timeline;
|
||||
pub mod renderer;
|
||||
|
||||
use crate::color::ColorEngine;
|
||||
use crate::utils::{ascii::AsciiArt, terminal::TerminalManager};
|
||||
use anyhow::Result;
|
||||
|
||||
pub struct AnimationEngine {
|
||||
ascii_art: AsciiArt,
|
||||
duration_ms: u64,
|
||||
fps: u32,
|
||||
effect: Box<dyn effects::Effect>,
|
||||
easing: Box<dyn easing::EasingFunction>,
|
||||
color_engine: ColorEngine,
|
||||
}
|
||||
|
||||
impl AnimationEngine {
|
||||
pub fn new(ascii_text: String, duration_ms: u64, fps: u32) -> Self {
|
||||
Self {
|
||||
ascii_art: AsciiArt::new(ascii_text),
|
||||
duration_ms,
|
||||
fps,
|
||||
effect: Box::new(effects::FadeIn),
|
||||
easing: Box::new(easing::Linear),
|
||||
color_engine: ColorEngine::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_effect(mut self, effect_name: &str) -> Result<Self> {
|
||||
self.effect = effects::get_effect(effect_name)?;
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
pub fn with_easing(mut self, easing_name: &str) -> Result<Self> {
|
||||
self.easing = easing::get_easing_function(easing_name)?;
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
pub fn with_color_engine(mut self, color_engine: ColorEngine) -> Self {
|
||||
self.color_engine = color_engine;
|
||||
self
|
||||
}
|
||||
|
||||
pub async fn run(&self, terminal: &mut TerminalManager) -> Result<()> {
|
||||
let renderer = renderer::Renderer::new(
|
||||
&self.ascii_art,
|
||||
self.duration_ms,
|
||||
self.fps,
|
||||
&*self.effect,
|
||||
&*self.easing,
|
||||
&self.color_engine,
|
||||
);
|
||||
|
||||
renderer.render(terminal).await
|
||||
}
|
||||
}
|
||||
71
src/cli.rs
Normal file
71
src/cli.rs
Normal file
@@ -0,0 +1,71 @@
|
||||
use clap::Parser;
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(author, version, about, long_about = None)]
|
||||
#[command(name = "piglet")]
|
||||
#[command(about = "🐷 Animated and colorful figlet wrapper", long_about = None)]
|
||||
pub struct PigletCli {
|
||||
/// Text to render with figlet
|
||||
#[arg(value_name = "TEXT")]
|
||||
pub text: String,
|
||||
|
||||
/// Duration of animation (e.g., 3000ms, 0.3s, 0.5h, 5m)
|
||||
#[arg(short, long, default_value = "3s")]
|
||||
pub duration: String,
|
||||
|
||||
/// Color palette (hex or CSS4 colors, comma-separated)
|
||||
/// Example: "#FF5733,#33FF57,#3357FF" or "red,green,blue"
|
||||
#[arg(short = 'p', long, value_delimiter = ',')]
|
||||
pub color_palette: Option<Vec<String>>,
|
||||
|
||||
/// Color gradient (CSS4 gradient definition)
|
||||
/// Example: "linear-gradient(90deg, red, blue)"
|
||||
#[arg(short = 'g', long)]
|
||||
pub color_gradient: Option<String>,
|
||||
|
||||
/// Motion easing function
|
||||
/// Options: linear, ease-in, ease-out, ease-in-out, ease-in-quad,
|
||||
/// ease-out-quad, ease-in-out-quad, ease-in-cubic, ease-out-cubic,
|
||||
/// ease-in-out-cubic, ease-in-back, ease-out-back, ease-in-out-back,
|
||||
/// ease-in-elastic, ease-out-elastic, ease-in-out-elastic,
|
||||
/// ease-in-bounce, ease-out-bounce, ease-in-out-bounce
|
||||
#[arg(short = 'i', long, default_value = "ease-in-out")]
|
||||
pub motion_ease: String,
|
||||
|
||||
/// Motion effect name
|
||||
/// Options: fade-in, fade-out, fade-in-out, slide-in-top, slide-in-bottom,
|
||||
/// slide-in-left, slide-in-right, scale-up, scale-down, pulse,
|
||||
/// bounce-in, bounce-out, typewriter, typewriter-reverse, wave,
|
||||
/// jello, color-cycle, rainbow, gradient-flow, rotate-in, rotate-out
|
||||
#[arg(short, long, default_value = "fade-in")]
|
||||
pub motion_effect: String,
|
||||
|
||||
/// Figlet font
|
||||
#[arg(short = 'f', long)]
|
||||
pub font: Option<String>,
|
||||
|
||||
/// Additional figlet options (use after --)
|
||||
/// Example: piglet "Text" -- -w 200 -c
|
||||
#[arg(last = true)]
|
||||
pub figlet_args: Vec<String>,
|
||||
|
||||
/// Loop animation infinitely
|
||||
#[arg(short, long)]
|
||||
pub loop_animation: bool,
|
||||
|
||||
/// Frame rate (fps)
|
||||
#[arg(long, default_value = "30")]
|
||||
pub fps: u32,
|
||||
|
||||
/// List all available effects
|
||||
#[arg(long)]
|
||||
pub list_effects: bool,
|
||||
|
||||
/// List all available easing functions
|
||||
#[arg(long)]
|
||||
pub list_easing: bool,
|
||||
|
||||
/// List all available CSS4 colors
|
||||
#[arg(long)]
|
||||
pub list_colors: bool,
|
||||
}
|
||||
63
src/color/apply.rs
Normal file
63
src/color/apply.rs
Normal file
@@ -0,0 +1,63 @@
|
||||
use crate::parser::color::Color;
|
||||
use crossterm::style::Color as CrosstermColor;
|
||||
|
||||
pub fn apply_color_to_char(ch: char, color: Color) -> String {
|
||||
use crossterm::style::Stylize;
|
||||
|
||||
let crossterm_color = CrosstermColor::Rgb {
|
||||
r: color.r,
|
||||
g: color.g,
|
||||
b: color.b,
|
||||
};
|
||||
|
||||
format!("{}", ch.to_string().with(crossterm_color))
|
||||
}
|
||||
|
||||
pub fn apply_color_to_line(line: &str, colors: &[Color]) -> String {
|
||||
if colors.is_empty() {
|
||||
return line.to_string();
|
||||
}
|
||||
|
||||
line.chars()
|
||||
.enumerate()
|
||||
.map(|(i, ch)| {
|
||||
if ch.is_whitespace() {
|
||||
ch.to_string()
|
||||
} else {
|
||||
let color = colors[i % colors.len()];
|
||||
apply_color_to_char(ch, color)
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn apply_gradient_to_text(text: &str, colors: &[Color]) -> String {
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
let total_chars: usize = lines.iter().map(|l| l.chars().count()).sum();
|
||||
|
||||
if total_chars == 0 || colors.is_empty() {
|
||||
return text.to_string();
|
||||
}
|
||||
|
||||
let mut result = String::new();
|
||||
let mut char_index = 0;
|
||||
|
||||
for (line_idx, line) in lines.iter().enumerate() {
|
||||
for ch in line.chars() {
|
||||
if ch.is_whitespace() {
|
||||
result.push(ch);
|
||||
} else {
|
||||
let color_index = (char_index * colors.len()) / total_chars.max(1);
|
||||
let color = colors[color_index.min(colors.len() - 1)];
|
||||
result.push_str(&apply_color_to_char(ch, color));
|
||||
char_index += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if line_idx < lines.len() - 1 {
|
||||
result.push('\n');
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
26
src/color/gradient.rs
Normal file
26
src/color/gradient.rs
Normal file
@@ -0,0 +1,26 @@
|
||||
use crate::parser::gradient::Gradient;
|
||||
use crate::parser::color::Color;
|
||||
use anyhow::Result;
|
||||
|
||||
pub struct GradientEngine {
|
||||
gradient: Gradient,
|
||||
}
|
||||
|
||||
impl GradientEngine {
|
||||
pub fn new(gradient: Gradient) -> Self {
|
||||
Self { gradient }
|
||||
}
|
||||
|
||||
pub fn from_string(gradient_str: &str) -> Result<Self> {
|
||||
let gradient = Gradient::parse(gradient_str)?;
|
||||
Ok(Self::new(gradient))
|
||||
}
|
||||
|
||||
pub fn color_at(&self, t: f64) -> Color {
|
||||
self.gradient.color_at(t)
|
||||
}
|
||||
|
||||
pub fn colors(&self, steps: usize) -> Vec<Color> {
|
||||
self.gradient.colors(steps)
|
||||
}
|
||||
}
|
||||
21
src/color/palette.rs
Normal file
21
src/color/palette.rs
Normal file
@@ -0,0 +1,21 @@
|
||||
"#ffff00".to_string(),
|
||||
]).unwrap()
|
||||
}
|
||||
|
||||
/// Create ocean palette
|
||||
pub fn ocean() -> Self {
|
||||
Self::from_strings(&[
|
||||
"#000080".to_string(),
|
||||
"#0000ff".to_string(),
|
||||
"#4169e1".to_string(),
|
||||
"#87ceeb".to_string(),
|
||||
"#add8e6".to_string(),
|
||||
]).unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ColorPalette {
|
||||
fn default() -> Self {
|
||||
Self::rainbow()
|
||||
}
|
||||
}
|
||||
118
src/figlet.rs
Normal file
118
src/figlet.rs
Normal file
@@ -0,0 +1,118 @@
|
||||
use anyhow::{Context, Result, bail};
|
||||
use std::process::Command;
|
||||
use which::which;
|
||||
|
||||
pub struct FigletWrapper {
|
||||
font: Option<String>,
|
||||
args: Vec<String>,
|
||||
}
|
||||
|
||||
impl FigletWrapper {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
font: None,
|
||||
args: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_font(mut self, font: Option<&str>) -> Self {
|
||||
self.font = font.map(|s| s.to_string());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_args(mut self, args: Vec<String>) -> Self {
|
||||
self.args = args;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn render(&self, text: &str) -> Result<String> {
|
||||
let mut cmd = Command::new("figlet");
|
||||
|
||||
// Add font if specified
|
||||
if let Some(font) = &self.font {
|
||||
cmd.arg("-f").arg(font);
|
||||
}
|
||||
|
||||
// Add additional arguments
|
||||
for arg in &self.args {
|
||||
cmd.arg(arg);
|
||||
}
|
||||
|
||||
// Add the text
|
||||
cmd.arg(text);
|
||||
|
||||
// Execute and capture output
|
||||
let output = cmd.output()
|
||||
.context("Failed to execute figlet")?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
bail!("Figlet error: {}", stderr);
|
||||
}
|
||||
|
||||
let result = String::from_utf8(output.stdout)
|
||||
.context("Figlet output is not valid UTF-8")?;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub fn check_installed() -> Result<()> {
|
||||
which("figlet")
|
||||
.context("figlet not found. Please install figlet first.\n\
|
||||
On Ubuntu/Debian: sudo apt-get install figlet\n\
|
||||
On macOS: brew install figlet\n\
|
||||
On Arch: sudo pacman -S figlet")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn list_fonts() -> Result<Vec<String>> {
|
||||
let output = Command::new("figlet")
|
||||
.arg("-l")
|
||||
.output()
|
||||
.context("Failed to list figlet fonts")?;
|
||||
|
||||
if !output.status.success() {
|
||||
bail!("Failed to list fonts");
|
||||
}
|
||||
|
||||
let result = String::from_utf8_lossy(&output.stdout);
|
||||
let fonts: Vec<String> = result
|
||||
.lines()
|
||||
.skip(1) // Skip header
|
||||
.filter_map(|line| {
|
||||
line.split_whitespace()
|
||||
.next()
|
||||
.map(|s| s.to_string())
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(fonts)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for FigletWrapper {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_figlet_installed() {
|
||||
// This test will fail if figlet is not installed
|
||||
assert!(FigletWrapper::check_installed().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_basic_render() {
|
||||
let figlet = FigletWrapper::new();
|
||||
let result = figlet.render("Hi");
|
||||
assert!(result.is_ok());
|
||||
let ascii = result.unwrap();
|
||||
assert!(!ascii.is_empty());
|
||||
assert!(ascii.contains("H") || ascii.contains("_") || ascii.contains("|"));
|
||||
}
|
||||
}
|
||||
8
src/lib.rs
Normal file
8
src/lib.rs
Normal file
@@ -0,0 +1,8 @@
|
||||
pub mod cli;
|
||||
pub mod figlet;
|
||||
pub mod color;
|
||||
pub mod animation;
|
||||
pub mod parser;
|
||||
pub mod utils;
|
||||
|
||||
pub use cli::PigletCli;
|
||||
101
src/main.rs
Normal file
101
src/main.rs
Normal file
@@ -0,0 +1,101 @@
|
||||
mod cli;
|
||||
mod figlet;
|
||||
mod color;
|
||||
mod animation;
|
||||
mod parser;
|
||||
mod utils;
|
||||
|
||||
use anyhow::Result;
|
||||
use cli::PigletCli;
|
||||
use clap::Parser;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
// Parse CLI arguments
|
||||
let args = PigletCli::parse();
|
||||
|
||||
// Show banner on first run
|
||||
if std::env::args().len() == 1 {
|
||||
show_welcome();
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Verify figlet is installed
|
||||
figlet::FigletWrapper::check_installed()?;
|
||||
|
||||
// Run the piglet magic
|
||||
run_piglet(args).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_piglet(args: PigletCli) -> Result<()> {
|
||||
use crate::animation::AnimationEngine;
|
||||
use crate::color::ColorEngine;
|
||||
use crate::utils::terminal::TerminalManager;
|
||||
|
||||
// Parse duration
|
||||
let duration_ms = parser::duration::parse_duration(&args.duration)?;
|
||||
|
||||
// Create figlet wrapper and render base ASCII art
|
||||
let figlet = figlet::FigletWrapper::new()
|
||||
.with_font(args.font.as_deref())
|
||||
.with_args(args.figlet_args);
|
||||
|
||||
let ascii_art = figlet.render(&args.text)?;
|
||||
|
||||
// Setup color engine
|
||||
let color_engine = ColorEngine::new()
|
||||
.with_palette(args.color_palette.as_deref())
|
||||
.with_gradient(args.color_gradient.as_deref())?;
|
||||
|
||||
// Setup animation engine
|
||||
let animation_engine = AnimationEngine::new(
|
||||
ascii_art,
|
||||
duration_ms,
|
||||
args.fps,
|
||||
)
|
||||
.with_effect(&args.motion_effect)?
|
||||
.with_easing(&args.motion_ease)?
|
||||
.with_color_engine(color_engine);
|
||||
|
||||
// Setup terminal
|
||||
let mut terminal = TerminalManager::new()?;
|
||||
terminal.setup()?;
|
||||
|
||||
// Run animation
|
||||
loop {
|
||||
animation_engine.run(&mut terminal).await?;
|
||||
|
||||
if !args.loop_animation {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
terminal.cleanup()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn show_welcome() {
|
||||
println!(r#"
|
||||
____ _ __ __
|
||||
/ __ \(_)___ _/ /__ / /_
|
||||
/ /_/ / / __ `/ / _ \/ __/
|
||||
/ ____/ / /_/ / / __/ /_
|
||||
/_/ /_/\__, /_/\___/\__/
|
||||
/____/
|
||||
|
||||
🐷 Piglet - Animated Figlet Wrapper
|
||||
|
||||
Usage: piglet [TEXT] [OPTIONS]
|
||||
|
||||
Examples:
|
||||
piglet "Hello" -p "#FF5733,#33FF57"
|
||||
piglet "World" -g "linear-gradient(90deg, red, blue)" -e fade-in
|
||||
piglet "Cool!" -e typewriter -d 2s -i ease-out
|
||||
|
||||
Run 'piglet --help' for more information.
|
||||
"#);
|
||||
}
|
||||
24
src/parser/color.rs
Normal file
24
src/parser/color.rs
Normal file
@@ -0,0 +1,24 @@
|
||||
use anyhow::{Result, Context};
|
||||
use csscolorparser::Color as CssColor;
|
||||
use palette::rgb::Rgb;
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Color {
|
||||
pub r: u8,
|
||||
pub g: u8,
|
||||
pub b: u8,
|
||||
}
|
||||
|
||||
impl Color {
|
||||
pub fn new(r: u8, g: u8, b: u8) -> Self {
|
||||
Self { r, g, b }
|
||||
}
|
||||
|
||||
pub fn from_hex(hex: &str) -> Result<Self> {
|
||||
let color = CssColor::parse(hex)
|
||||
.context(format!("Failed to parse hex color: {}", hex))?;
|
||||
|
||||
Ok(Self {
|
||||
r: (color.r * 255.0) as u8,
|
||||
g: (color.g * 255.0) as u8,
|
||||
b
|
||||
72
src/parser/duration.rs
Normal file
72
src/parser/duration.rs
Normal file
@@ -0,0 +1,72 @@
|
||||
use anyhow::{Result, bail};
|
||||
use regex::Regex;
|
||||
use lazy_static::lazy_static;
|
||||
|
||||
lazy_static! {
|
||||
static ref DURATION_REGEX: Regex = Regex::new(
|
||||
r"^(\d+(?:\.\d+)?)(ms|s|m|h)$"
|
||||
).unwrap();
|
||||
}
|
||||
|
||||
/// Parse duration string to milliseconds
|
||||
/// Supports: 3000ms, 0.3s, 5m, 0.5h
|
||||
pub fn parse_duration(duration: &str) -> Result<u64> {
|
||||
let caps = DURATION_REGEX.captures(duration.trim())
|
||||
.ok_or_else(|| anyhow::anyhow!("Invalid duration format: {}", duration))?;
|
||||
|
||||
let value: f64 = caps[1].parse()
|
||||
.map_err(|_| anyhow::anyhow!("Invalid numeric value in duration"))?;
|
||||
|
||||
let unit = &caps[2];
|
||||
|
||||
let milliseconds = match unit {
|
||||
"ms" => value,
|
||||
"s" => value * 1000.0,
|
||||
"m" => value * 60.0 * 1000.0,
|
||||
"h" => value * 60.0 * 60.0 * 1000.0,
|
||||
_ => bail!("Unknown time unit: {}", unit),
|
||||
};
|
||||
|
||||
if milliseconds < 0.0 {
|
||||
bail!("Duration cannot be negative");
|
||||
}
|
||||
|
||||
Ok(milliseconds as u64)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_milliseconds() {
|
||||
assert_eq!(parse_duration("3000ms").unwrap(), 3000);
|
||||
assert_eq!(parse_duration("500ms").unwrap(), 500);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_seconds() {
|
||||
assert_eq!(parse_duration("3s").unwrap(), 3000);
|
||||
assert_eq!(parse_duration("0.5s").unwrap(), 500);
|
||||
assert_eq!(parse_duration("1.5s").unwrap(), 1500);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_minutes() {
|
||||
assert_eq!(parse_duration("1m").unwrap(), 60000);
|
||||
assert_eq!(parse_duration("0.5m").unwrap(), 30000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_hours() {
|
||||
assert_eq!(parse_duration("1h").unwrap(), 3600000);
|
||||
assert_eq!(parse_duration("0.5h").unwrap(), 1800000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_format() {
|
||||
assert!(parse_duration("invalid").is_err());
|
||||
assert!(parse_duration("10").is_err());
|
||||
assert!(parse_duration("10x").is_err());
|
||||
}
|
||||
}
|
||||
3
src/parser/mod.rs
Normal file
3
src/parser/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod duration;
|
||||
pub mod color;
|
||||
pub mod gradient;
|
||||
131
src/utils/ascii.rs
Normal file
131
src/utils/ascii.rs
Normal file
@@ -0,0 +1,131 @@
|
||||
use crate::parser::color::Color;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AsciiArt {
|
||||
lines: Vec<String>,
|
||||
width: usize,
|
||||
height: usize,
|
||||
}
|
||||
|
||||
impl AsciiArt {
|
||||
pub fn new(text: String) -> Self {
|
||||
let lines: Vec<String> = text.lines().map(|s| s.to_string()).collect();
|
||||
let width = lines.iter().map(|l| l.len()).max().unwrap_or(0);
|
||||
let height = lines.len();
|
||||
|
||||
Self {
|
||||
lines,
|
||||
width,
|
||||
height,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_lines(&self) -> &[String] {
|
||||
&self.lines
|
||||
}
|
||||
|
||||
pub fn width(&self) -> usize {
|
||||
self.width
|
||||
}
|
||||
|
||||
pub fn height(&self) -> usize {
|
||||
self.height
|
||||
}
|
||||
|
||||
pub fn to_string(&self) -> String {
|
||||
self.lines.join("\n")
|
||||
}
|
||||
|
||||
/// Get character at position
|
||||
pub fn char_at(&self, x: usize, y: usize) -> Option<char> {
|
||||
self.lines.get(y)?.chars().nth(x)
|
||||
}
|
||||
|
||||
/// Count non-whitespace characters
|
||||
pub fn char_count(&self) -> usize {
|
||||
self.lines.iter()
|
||||
.flat_map(|line| line.chars())
|
||||
.filter(|c| !c.is_whitespace())
|
||||
.count()
|
||||
}
|
||||
|
||||
/// Get all character positions
|
||||
pub fn char_positions(&self) -> Vec<(usize, usize, char)> {
|
||||
let mut positions = Vec::new();
|
||||
|
||||
for (y, line) in self.lines.iter().enumerate() {
|
||||
for (x, ch) in line.chars().enumerate() {
|
||||
if !ch.is_whitespace() {
|
||||
positions.push((x, y, ch));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
positions
|
||||
}
|
||||
|
||||
/// Apply fade effect (0.0 = invisible, 1.0 = visible)
|
||||
pub fn apply_fade(&self, opacity: f64) -> String {
|
||||
if opacity >= 1.0 {
|
||||
return self.to_string();
|
||||
}
|
||||
|
||||
if opacity <= 0.0 {
|
||||
return " ".repeat(self.width).repeat(self.height);
|
||||
}
|
||||
|
||||
// For ASCII, we can simulate fade by replacing chars with lighter ones
|
||||
let fade_chars = [' ', '.', '·', '-', '~', '=', '+', '*', '#', '@'];
|
||||
let index = (opacity * (fade_chars.len() - 1) as f64) as usize;
|
||||
let fade_char = fade_chars[index];
|
||||
|
||||
self.lines.iter()
|
||||
.map(|line| {
|
||||
line.chars()
|
||||
.map(|ch| {
|
||||
if ch.is_whitespace() {
|
||||
ch
|
||||
} else {
|
||||
fade_char
|
||||
}
|
||||
})
|
||||
.collect::<String>()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
/// Scale the ASCII art
|
||||
pub fn scale(&self, factor: f64) -> Self {
|
||||
if factor <= 0.0 {
|
||||
return Self::new(String::new());
|
||||
}
|
||||
|
||||
if (factor - 1.0).abs() < 0.01 {
|
||||
return self.clone();
|
||||
}
|
||||
|
||||
// Simple scaling by character repetition
|
||||
let lines = if factor > 1.0 {
|
||||
self.lines.iter()
|
||||
.flat_map(|line| {
|
||||
let scaled_line: String = line.chars()
|
||||
.flat_map(|ch| std::iter::repeat(ch).take(factor as usize))
|
||||
.collect();
|
||||
std::iter::repeat(scaled_line).take(factor as usize)
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
self.lines.iter()
|
||||
.step_by((1.0 / factor) as usize)
|
||||
.map(|line| {
|
||||
line.chars()
|
||||
.step_by((1.0 / factor) as usize)
|
||||
.collect()
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
Self::new(lines.join("\n"))
|
||||
}
|
||||
}
|
||||
2
src/utils/mod.rs
Normal file
2
src/utils/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod terminal;
|
||||
pub mod ascii;
|
||||
101
src/utils/terminal.rs
Normal file
101
src/utils/terminal.rs
Normal file
@@ -0,0 +1,101 @@
|
||||
use anyhow::Result;
|
||||
use crossterm::{
|
||||
cursor,
|
||||
execute,
|
||||
terminal::{self, ClearType},
|
||||
ExecutableCommand,
|
||||
};
|
||||
use std::io::{stdout, Write};
|
||||
|
||||
pub struct TerminalManager {
|
||||
width: u16,
|
||||
height: u16,
|
||||
original_state: bool,
|
||||
}
|
||||
|
||||
impl TerminalManager {
|
||||
pub fn new() -> Result<Self> {
|
||||
let (width, height) = terminal::size()?;
|
||||
Ok(Self {
|
||||
width,
|
||||
height,
|
||||
original_state: false,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn setup(&mut self) -> Result<()> {
|
||||
terminal::enable_raw_mode()?;
|
||||
execute!(
|
||||
stdout(),
|
||||
terminal::EnterAlternateScreen,
|
||||
cursor::Hide
|
||||
)?;
|
||||
self.original_state = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn cleanup(&mut self) -> Result<()> {
|
||||
if self.original_state {
|
||||
execute!(
|
||||
stdout(),
|
||||
cursor::Show,
|
||||
terminal::LeaveAlternateScreen
|
||||
)?;
|
||||
terminal::disable_raw_mode()?;
|
||||
self.original_state = false;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn clear(&self) -> Result<()> {
|
||||
execute!(stdout(), terminal::Clear(ClearType::All))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn move_to(&self, x: u16, y: u16) -> Result<()> {
|
||||
execute!(stdout(), cursor::MoveTo(x, y))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_size(&self) -> (u16, u16) {
|
||||
(self.width, self.height)
|
||||
}
|
||||
|
||||
pub fn refresh_size(&mut self) -> Result<()> {
|
||||
let (width, height) = terminal::size()?;
|
||||
self.width = width;
|
||||
self.height = height;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn print_at(&self, x: u16, y: u16, text: &str) -> Result<()> {
|
||||
self.move_to(x, y)?;
|
||||
print!("{}", text);
|
||||
stdout().flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn print_centered(&self, text: &str) -> Result<()> {
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
let max_width = lines.iter().map(|l| l.len()).max().unwrap_or(0) as u16;
|
||||
let height = lines.len() as u16;
|
||||
|
||||
let start_x = (self.width.saturating_sub(max_width)) / 2;
|
||||
let start_y = (self.height.saturating_sub(height)) / 2;
|
||||
|
||||
for (i, line) in lines.iter().enumerate() {
|
||||
let line_width = line.len() as u16;
|
||||
let x = start_x + (max_width.saturating_sub(line_width)) / 2;
|
||||
let y = start_y + i as u16;
|
||||
self.print_at(x, y, line)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TerminalManager {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.cleanup();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user