From b1ad87fc2665a698cb76000f534ac2ccf4c2f251 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Kr=C3=BCger?= Date: Sun, 9 Nov 2025 03:00:20 +0100 Subject: [PATCH] Complete piglet implementation with animations and effects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Implement complete animation system with 20+ motion effects - Add 18+ easing functions (quad, cubic, elastic, back, bounce) - Implement color system with palette and gradient support - Add parser for durations, colors, and CSS gradients - Create comprehensive test suite (14 tests passing) - Add linting and formatting with clippy/rustfmt - Support for figlet integration with custom fonts - Terminal rendering with crossterm - Fix all clippy warnings and lint issues 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .gitignore | 9 + Cargo.toml | 4 +- src/animation/easing.rs | 299 ++++++++++++++++++++++-- src/animation/effects.rs | 450 +++++++++++++++++++++++++++++++++++++ src/animation/mod.rs | 16 +- src/animation/renderer.rs | 148 ++++++++++++ src/animation/timeline.rs | 123 ++++++++++ src/cli.rs | 28 +-- src/color/apply.rs | 18 +- src/color/gradient.rs | 11 +- src/color/mod.rs | 83 +++++++ src/color/palette.rs | 56 ++++- src/figlet.rs | 59 +++-- src/lib.rs | 8 +- src/main.rs | 74 +++--- src/parser/color.rs | 34 ++- src/parser/duration.rs | 36 +-- src/parser/gradient.rs | 121 ++++++++++ src/parser/mod.rs | 4 +- src/utils/ascii.rs | 80 +++---- src/utils/mod.rs | 2 +- src/utils/terminal.rs | 40 ++-- tests/integration_tests.rs | 71 +++--- 23 files changed, 1510 insertions(+), 264 deletions(-) create mode 100644 .gitignore create mode 100644 src/animation/effects.rs create mode 100644 src/animation/renderer.rs create mode 100644 src/animation/timeline.rs create mode 100644 src/color/mod.rs create mode 100644 src/parser/gradient.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..849e775 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +/target/ +**/*.rs.bk +Cargo.lock +.DS_Store +*.swp +*.swo +*~ +.vscode/ +.idea/ diff --git a/Cargo.toml b/Cargo.toml index 35bdc54..8711602 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,13 +16,13 @@ csscolorparser = "0.6" palette = "0.7" # Animation & Interpolation -scirs2-interpolate = "0.1" +# scirs2-interpolate = "0.1.0-rc.2" # Not needed, using custom easing functions # Terminal manipulation crossterm = "0.27" # Async runtime (for timing) -tokio = { version = "1.35", features = ["time", "rt"] } +tokio = { version = "1.35", features = ["time", "rt-multi-thread", "macros"] } # Process execution which = "5.0" diff --git a/src/animation/easing.rs b/src/animation/easing.rs index 62cc18e..27d3272 100644 --- a/src/animation/easing.rs +++ b/src/animation/easing.rs @@ -1,49 +1,318 @@ -use anyhow::{Result, bail}; -use scirs2_interpolate::*; +use anyhow::{bail, Result}; pub trait EasingFunction: Send + Sync { fn ease(&self, t: f64) -> f64; + #[allow(dead_code)] fn name(&self) -> &str; } // Linear pub struct Linear; impl EasingFunction for Linear { - fn ease(&self, t: f64) -> f64 { t } - fn name(&self) -> &str { "linear" } + fn ease(&self, t: f64) -> f64 { + t + } + #[allow(dead_code)] + fn name(&self) -> &str { + "linear" + } +} + +// Basic easing +pub struct EaseIn; +impl EasingFunction for EaseIn { + fn ease(&self, t: f64) -> f64 { + t * t + } + #[allow(dead_code)] + fn name(&self) -> &str { + "ease-in" + } +} + +pub struct EaseOut; +impl EasingFunction for EaseOut { + fn ease(&self, t: f64) -> f64 { + t * (2.0 - t) + } + #[allow(dead_code)] + fn name(&self) -> &str { + "ease-out" + } +} + +pub struct EaseInOut; +impl EasingFunction for EaseInOut { + fn ease(&self, t: f64) -> f64 { + if t < 0.5 { + 2.0 * t * t + } else { + -1.0 + (4.0 - 2.0 * t) * t + } + } + #[allow(dead_code)] + fn name(&self) -> &str { + "ease-in-out" + } } // 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" } + fn ease(&self, t: f64) -> f64 { + t * t + } + #[allow(dead_code)] + 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" } + fn ease(&self, t: f64) -> f64 { + t * (2.0 - t) + } + #[allow(dead_code)] + 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" } + fn ease(&self, t: f64) -> f64 { + if t < 0.5 { + 2.0 * t * t + } else { + -1.0 + (4.0 - 2.0 * t) * t + } + } + #[allow(dead_code)] + 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" } + fn ease(&self, t: f64) -> f64 { + t * t * t + } + #[allow(dead_code)] + 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" } + fn ease(&self, t: f64) -> f64 { + let t1 = t - 1.0; + t1 * t1 * t1 + 1.0 + } + #[allow(dead_code)] + fn name(&self) -> &str { + "ease-out-cubic" + } } pub struct EaseInOutCubic; -impl EasingFunction for EaseInOut \ No newline at end of file +impl EasingFunction for EaseInOutCubic { + fn ease(&self, t: f64) -> f64 { + if t < 0.5 { + 4.0 * t * t * t + } else { + 1.0 - (-2.0 * t + 2.0).powi(3) / 2.0 + } + } + #[allow(dead_code)] + fn name(&self) -> &str { + "ease-in-out-cubic" + } +} + +// Back +pub struct EaseInBack; +impl EasingFunction for EaseInBack { + fn ease(&self, t: f64) -> f64 { + let c1 = 1.70158; + let c3 = c1 + 1.0; + c3 * t * t * t - c1 * t * t + } + #[allow(dead_code)] + fn name(&self) -> &str { + "ease-in-back" + } +} + +pub struct EaseOutBack; +impl EasingFunction for EaseOutBack { + fn ease(&self, t: f64) -> f64 { + let c1 = 1.70158; + let c3 = c1 + 1.0; + 1.0 + c3 * (t - 1.0).powi(3) + c1 * (t - 1.0).powi(2) + } + #[allow(dead_code)] + fn name(&self) -> &str { + "ease-out-back" + } +} + +pub struct EaseInOutBack; +impl EasingFunction for EaseInOutBack { + fn ease(&self, t: f64) -> f64 { + let c1 = 1.70158; + let c2 = c1 * 1.525; + if t < 0.5 { + ((2.0 * t).powi(2) * ((c2 + 1.0) * 2.0 * t - c2)) / 2.0 + } else { + ((2.0 * t - 2.0).powi(2) * ((c2 + 1.0) * (t * 2.0 - 2.0) + c2) + 2.0) / 2.0 + } + } + #[allow(dead_code)] + fn name(&self) -> &str { + "ease-in-out-back" + } +} + +// Elastic +pub struct EaseInElastic; +impl EasingFunction for EaseInElastic { + fn ease(&self, t: f64) -> f64 { + if t == 0.0 { + return 0.0; + } + if t == 1.0 { + return 1.0; + } + let c4 = (2.0 * std::f64::consts::PI) / 3.0; + -(2.0_f64.powf(10.0 * t - 10.0)) * ((t * 10.0 - 10.75) * c4).sin() + } + #[allow(dead_code)] + fn name(&self) -> &str { + "ease-in-elastic" + } +} + +pub struct EaseOutElastic; +impl EasingFunction for EaseOutElastic { + fn ease(&self, t: f64) -> f64 { + if t == 0.0 { + return 0.0; + } + if t == 1.0 { + return 1.0; + } + let c4 = (2.0 * std::f64::consts::PI) / 3.0; + 2.0_f64.powf(-10.0 * t) * ((t * 10.0 - 0.75) * c4).sin() + 1.0 + } + #[allow(dead_code)] + fn name(&self) -> &str { + "ease-out-elastic" + } +} + +pub struct EaseInOutElastic; +impl EasingFunction for EaseInOutElastic { + fn ease(&self, t: f64) -> f64 { + if t == 0.0 { + return 0.0; + } + if t == 1.0 { + return 1.0; + } + let c5 = (2.0 * std::f64::consts::PI) / 4.5; + if t < 0.5 { + -(2.0_f64.powf(20.0 * t - 10.0) * ((20.0 * t - 11.125) * c5).sin()) / 2.0 + } else { + (2.0_f64.powf(-20.0 * t + 10.0) * ((20.0 * t - 11.125) * c5).sin()) / 2.0 + 1.0 + } + } + #[allow(dead_code)] + fn name(&self) -> &str { + "ease-in-out-elastic" + } +} + +// Bounce +fn bounce_out(t: f64) -> f64 { + let n1 = 7.5625; + let d1 = 2.75; + + if t < 1.0 / d1 { + n1 * t * t + } else if t < 2.0 / d1 { + let t = t - 1.5 / d1; + n1 * t * t + 0.75 + } else if t < 2.5 / d1 { + let t = t - 2.25 / d1; + n1 * t * t + 0.9375 + } else { + let t = t - 2.625 / d1; + n1 * t * t + 0.984375 + } +} + +pub struct EaseInBounce; +impl EasingFunction for EaseInBounce { + fn ease(&self, t: f64) -> f64 { + 1.0 - bounce_out(1.0 - t) + } + #[allow(dead_code)] + fn name(&self) -> &str { + "ease-in-bounce" + } +} + +pub struct EaseOutBounce; +impl EasingFunction for EaseOutBounce { + fn ease(&self, t: f64) -> f64 { + bounce_out(t) + } + #[allow(dead_code)] + fn name(&self) -> &str { + "ease-out-bounce" + } +} + +pub struct EaseInOutBounce; +impl EasingFunction for EaseInOutBounce { + fn ease(&self, t: f64) -> f64 { + if t < 0.5 { + (1.0 - bounce_out(1.0 - 2.0 * t)) / 2.0 + } else { + (1.0 + bounce_out(2.0 * t - 1.0)) / 2.0 + } + } + #[allow(dead_code)] + fn name(&self) -> &str { + "ease-in-out-bounce" + } +} + +pub fn get_easing_function(name: &str) -> Result> { + match name { + "linear" => Ok(Box::new(Linear)), + "ease-in" => Ok(Box::new(EaseIn)), + "ease-out" => Ok(Box::new(EaseOut)), + "ease-in-out" => Ok(Box::new(EaseInOut)), + "ease-in-quad" => Ok(Box::new(EaseInQuad)), + "ease-out-quad" => Ok(Box::new(EaseOutQuad)), + "ease-in-out-quad" => Ok(Box::new(EaseInOutQuad)), + "ease-in-cubic" => Ok(Box::new(EaseInCubic)), + "ease-out-cubic" => Ok(Box::new(EaseOutCubic)), + "ease-in-out-cubic" => Ok(Box::new(EaseInOutCubic)), + "ease-in-back" => Ok(Box::new(EaseInBack)), + "ease-out-back" => Ok(Box::new(EaseOutBack)), + "ease-in-out-back" => Ok(Box::new(EaseInOutBack)), + "ease-in-elastic" => Ok(Box::new(EaseInElastic)), + "ease-out-elastic" => Ok(Box::new(EaseOutElastic)), + "ease-in-out-elastic" => Ok(Box::new(EaseInOutElastic)), + "ease-in-bounce" => Ok(Box::new(EaseInBounce)), + "ease-out-bounce" => Ok(Box::new(EaseOutBounce)), + "ease-in-out-bounce" => Ok(Box::new(EaseInOutBounce)), + _ => bail!("Unknown easing function: {}", name), + } +} diff --git a/src/animation/effects.rs b/src/animation/effects.rs new file mode 100644 index 0000000..e062065 --- /dev/null +++ b/src/animation/effects.rs @@ -0,0 +1,450 @@ +use crate::utils::ascii::AsciiArt; +use anyhow::{bail, Result}; + +pub trait Effect: Send + Sync { + fn apply(&self, ascii_art: &AsciiArt, progress: f64) -> EffectResult; + fn name(&self) -> &str; +} + +#[derive(Debug, Clone)] +pub struct EffectResult { + pub text: String, + pub opacity: f64, + pub offset_x: i32, + pub offset_y: i32, + pub scale: f64, +} + +impl EffectResult { + pub fn new(text: String) -> Self { + Self { + text, + opacity: 1.0, + offset_x: 0, + offset_y: 0, + scale: 1.0, + } + } + + pub fn with_opacity(mut self, opacity: f64) -> Self { + self.opacity = opacity; + self + } + + pub fn with_offset(mut self, x: i32, y: i32) -> Self { + self.offset_x = x; + self.offset_y = y; + self + } + + pub fn with_scale(mut self, scale: f64) -> Self { + self.scale = scale; + self + } +} + +// Fade effects +pub struct FadeIn; +impl Effect for FadeIn { + fn apply(&self, ascii_art: &AsciiArt, progress: f64) -> EffectResult { + let text = ascii_art.apply_fade(progress); + EffectResult::new(text).with_opacity(progress) + } + + fn name(&self) -> &str { + "fade-in" + } +} + +pub struct FadeOut; +impl Effect for FadeOut { + fn apply(&self, ascii_art: &AsciiArt, progress: f64) -> EffectResult { + let opacity = 1.0 - progress; + let text = ascii_art.apply_fade(opacity); + EffectResult::new(text).with_opacity(opacity) + } + + fn name(&self) -> &str { + "fade-out" + } +} + +pub struct FadeInOut; +impl Effect for FadeInOut { + fn apply(&self, ascii_art: &AsciiArt, progress: f64) -> EffectResult { + let opacity = if progress < 0.5 { + progress * 2.0 + } else { + (1.0 - progress) * 2.0 + }; + let text = ascii_art.apply_fade(opacity); + EffectResult::new(text).with_opacity(opacity) + } + + fn name(&self) -> &str { + "fade-in-out" + } +} + +// Slide effects +pub struct SlideInTop; +impl Effect for SlideInTop { + fn apply(&self, ascii_art: &AsciiArt, progress: f64) -> EffectResult { + let offset_y = ((1.0 - progress) * -(ascii_art.height() as f64)) as i32; + EffectResult::new(ascii_art.render()).with_offset(0, offset_y) + } + + fn name(&self) -> &str { + "slide-in-top" + } +} + +pub struct SlideInBottom; +impl Effect for SlideInBottom { + fn apply(&self, ascii_art: &AsciiArt, progress: f64) -> EffectResult { + let offset_y = ((1.0 - progress) * ascii_art.height() as f64) as i32; + EffectResult::new(ascii_art.render()).with_offset(0, offset_y) + } + + fn name(&self) -> &str { + "slide-in-bottom" + } +} + +pub struct SlideInLeft; +impl Effect for SlideInLeft { + fn apply(&self, ascii_art: &AsciiArt, progress: f64) -> EffectResult { + let offset_x = ((1.0 - progress) * -(ascii_art.width() as f64)) as i32; + EffectResult::new(ascii_art.render()).with_offset(offset_x, 0) + } + + fn name(&self) -> &str { + "slide-in-left" + } +} + +pub struct SlideInRight; +impl Effect for SlideInRight { + fn apply(&self, ascii_art: &AsciiArt, progress: f64) -> EffectResult { + let offset_x = ((1.0 - progress) * ascii_art.width() as f64) as i32; + EffectResult::new(ascii_art.render()).with_offset(offset_x, 0) + } + + fn name(&self) -> &str { + "slide-in-right" + } +} + +// Scale effects +pub struct ScaleUp; +impl Effect for ScaleUp { + fn apply(&self, ascii_art: &AsciiArt, progress: f64) -> EffectResult { + let scale = progress; + let scaled = ascii_art.scale(scale); + EffectResult::new(scaled.render()).with_scale(scale) + } + + fn name(&self) -> &str { + "scale-up" + } +} + +pub struct ScaleDown; +impl Effect for ScaleDown { + fn apply(&self, ascii_art: &AsciiArt, progress: f64) -> EffectResult { + let scale = 2.0 - progress; + let scaled = ascii_art.scale(scale); + EffectResult::new(scaled.render()).with_scale(scale) + } + + fn name(&self) -> &str { + "scale-down" + } +} + +// Pulse effect +pub struct Pulse; +impl Effect for Pulse { + fn apply(&self, ascii_art: &AsciiArt, progress: f64) -> EffectResult { + let scale = 1.0 + (progress * std::f64::consts::PI * 2.0).sin() * 0.1; + let scaled = ascii_art.scale(scale); + EffectResult::new(scaled.render()).with_scale(scale) + } + + fn name(&self) -> &str { + "pulse" + } +} + +// Bounce effects +pub struct BounceIn; +impl Effect for BounceIn { + fn apply(&self, ascii_art: &AsciiArt, progress: f64) -> EffectResult { + let offset_y = if progress < 0.8 { + ((1.0 - progress / 0.8) * -(ascii_art.height() as f64)) as i32 + } else { + let bounce_progress = (progress - 0.8) / 0.2; + (bounce_progress * 10.0 * (1.0 - bounce_progress)) as i32 + }; + EffectResult::new(ascii_art.render()).with_offset(0, offset_y) + } + + fn name(&self) -> &str { + "bounce-in" + } +} + +pub struct BounceOut; +impl Effect for BounceOut { + fn apply(&self, ascii_art: &AsciiArt, progress: f64) -> EffectResult { + let offset_y = if progress < 0.2 { + -(progress * 10.0 * (1.0 - progress / 0.2)) as i32 + } else { + (((progress - 0.2) / 0.8) * ascii_art.height() as f64) as i32 + }; + EffectResult::new(ascii_art.render()).with_offset(0, offset_y) + } + + fn name(&self) -> &str { + "bounce-out" + } +} + +// Typewriter effect +pub struct Typewriter; +impl Effect for Typewriter { + fn apply(&self, ascii_art: &AsciiArt, progress: f64) -> EffectResult { + let total_chars = ascii_art.char_count(); + let visible_chars = (total_chars as f64 * progress) as usize; + + let positions = ascii_art.char_positions(); + let lines = ascii_art.get_lines(); + let mut result_lines: Vec = lines + .iter() + .map(|l| { + l.chars() + .map(|c| if c.is_whitespace() { c } else { ' ' }) + .collect() + }) + .collect(); + + for (i, (x, y, ch)) in positions.iter().enumerate() { + if i < visible_chars { + if let Some(line) = result_lines.get_mut(*y) { + let mut chars: Vec = line.chars().collect(); + if *x < chars.len() { + chars[*x] = *ch; + *line = chars.iter().collect(); + } + } + } + } + + EffectResult::new(result_lines.join("\n")) + } + + fn name(&self) -> &str { + "typewriter" + } +} + +pub struct TypewriterReverse; +impl Effect for TypewriterReverse { + fn apply(&self, ascii_art: &AsciiArt, progress: f64) -> EffectResult { + let total_chars = ascii_art.char_count(); + let visible_chars = (total_chars as f64 * (1.0 - progress)) as usize; + + let positions = ascii_art.char_positions(); + let lines = ascii_art.get_lines(); + let mut result_lines: Vec = lines + .iter() + .map(|l| { + l.chars() + .map(|c| if c.is_whitespace() { c } else { ' ' }) + .collect() + }) + .collect(); + + for (i, (x, y, ch)) in positions.iter().enumerate() { + if i < visible_chars { + if let Some(line) = result_lines.get_mut(*y) { + let mut chars: Vec = line.chars().collect(); + if *x < chars.len() { + chars[*x] = *ch; + *line = chars.iter().collect(); + } + } + } + } + + EffectResult::new(result_lines.join("\n")) + } + + fn name(&self) -> &str { + "typewriter-reverse" + } +} + +// Wave effect +pub struct Wave; +impl Effect for Wave { + fn apply(&self, ascii_art: &AsciiArt, progress: f64) -> EffectResult { + let lines: Vec = ascii_art + .get_lines() + .iter() + .enumerate() + .map(|(i, line)| { + let wave_offset = + ((progress * std::f64::consts::PI * 2.0 + i as f64 * 0.5).sin() * 3.0) as usize; + format!("{}{}", " ".repeat(wave_offset), line) + }) + .collect(); + + EffectResult::new(lines.join("\n")) + } + + fn name(&self) -> &str { + "wave" + } +} + +// Jello effect +pub struct Jello; +impl Effect for Jello { + fn apply(&self, ascii_art: &AsciiArt, progress: f64) -> EffectResult { + let wobble = (progress * std::f64::consts::PI * 4.0).sin() * (1.0 - progress); + let scale = 1.0 + wobble * 0.1; + let scaled = ascii_art.scale(scale.abs()); + EffectResult::new(scaled.render()).with_scale(scale.abs()) + } + + fn name(&self) -> &str { + "jello" + } +} + +// Rotate effects +pub struct RotateIn; +impl Effect for RotateIn { + fn apply(&self, ascii_art: &AsciiArt, progress: f64) -> EffectResult { + // Simulate rotation with scale and offset + let angle = (1.0 - progress) * std::f64::consts::PI; + let scale = progress; + let scaled = ascii_art.scale(scale); + let offset_x = (angle.cos() * 10.0 * (1.0 - progress)) as i32; + EffectResult::new(scaled.render()) + .with_scale(scale) + .with_offset(offset_x, 0) + } + + fn name(&self) -> &str { + "rotate-in" + } +} + +pub struct RotateOut; +impl Effect for RotateOut { + fn apply(&self, ascii_art: &AsciiArt, progress: f64) -> EffectResult { + let angle = progress * std::f64::consts::PI; + let scale = 1.0 - progress; + let scaled = ascii_art.scale(scale); + let offset_x = (angle.cos() * 10.0 * progress) as i32; + EffectResult::new(scaled.render()) + .with_scale(scale) + .with_offset(offset_x, 0) + } + + fn name(&self) -> &str { + "rotate-out" + } +} + +// Color effects (these will be enhanced by color engine) +pub struct ColorCycle; +impl Effect for ColorCycle { + fn apply(&self, ascii_art: &AsciiArt, _progress: f64) -> EffectResult { + EffectResult::new(ascii_art.render()) + } + + fn name(&self) -> &str { + "color-cycle" + } +} + +pub struct Rainbow; +impl Effect for Rainbow { + fn apply(&self, ascii_art: &AsciiArt, _progress: f64) -> EffectResult { + EffectResult::new(ascii_art.render()) + } + + fn name(&self) -> &str { + "rainbow" + } +} + +pub struct GradientFlow; +impl Effect for GradientFlow { + fn apply(&self, ascii_art: &AsciiArt, _progress: f64) -> EffectResult { + EffectResult::new(ascii_art.render()) + } + + fn name(&self) -> &str { + "gradient-flow" + } +} + +/// Get effect by name +pub fn get_effect(name: &str) -> Result> { + match name { + "fade-in" => Ok(Box::new(FadeIn)), + "fade-out" => Ok(Box::new(FadeOut)), + "fade-in-out" => Ok(Box::new(FadeInOut)), + "slide-in-top" => Ok(Box::new(SlideInTop)), + "slide-in-bottom" => Ok(Box::new(SlideInBottom)), + "slide-in-left" => Ok(Box::new(SlideInLeft)), + "slide-in-right" => Ok(Box::new(SlideInRight)), + "scale-up" => Ok(Box::new(ScaleUp)), + "scale-down" => Ok(Box::new(ScaleDown)), + "pulse" => Ok(Box::new(Pulse)), + "bounce-in" => Ok(Box::new(BounceIn)), + "bounce-out" => Ok(Box::new(BounceOut)), + "typewriter" => Ok(Box::new(Typewriter)), + "typewriter-reverse" => Ok(Box::new(TypewriterReverse)), + "wave" => Ok(Box::new(Wave)), + "jello" => Ok(Box::new(Jello)), + "color-cycle" => Ok(Box::new(ColorCycle)), + "rainbow" => Ok(Box::new(Rainbow)), + "gradient-flow" => Ok(Box::new(GradientFlow)), + "rotate-in" => Ok(Box::new(RotateIn)), + "rotate-out" => Ok(Box::new(RotateOut)), + _ => bail!("Unknown effect: {}", name), + } +} + +/// List all available effects +#[allow(dead_code)] +pub fn list_effects() -> Vec<&'static str> { + vec![ + "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", + ] +} diff --git a/src/animation/mod.rs b/src/animation/mod.rs index 4025316..b03c34d 100644 --- a/src/animation/mod.rs +++ b/src/animation/mod.rs @@ -1,7 +1,7 @@ -pub mod effects; pub mod easing; -pub mod timeline; +pub mod effects; pub mod renderer; +pub mod timeline; use crate::color::ColorEngine; use crate::utils::{ascii::AsciiArt, terminal::TerminalManager}; @@ -27,22 +27,22 @@ impl AnimationEngine { color_engine: ColorEngine::new(), } } - + pub fn with_effect(mut self, effect_name: &str) -> Result { self.effect = effects::get_effect(effect_name)?; Ok(self) } - + pub fn with_easing(mut self, easing_name: &str) -> Result { 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, @@ -52,7 +52,7 @@ impl AnimationEngine { &*self.easing, &self.color_engine, ); - + renderer.render(terminal).await } -} \ No newline at end of file +} diff --git a/src/animation/renderer.rs b/src/animation/renderer.rs new file mode 100644 index 0000000..de1679f --- /dev/null +++ b/src/animation/renderer.rs @@ -0,0 +1,148 @@ +use crate::animation::{easing::EasingFunction, effects::Effect, timeline::Timeline}; +use crate::color::{apply, ColorEngine}; +use crate::utils::{ascii::AsciiArt, terminal::TerminalManager}; +use anyhow::Result; +use tokio::time::sleep; + +pub struct Renderer<'a> { + ascii_art: &'a AsciiArt, + timeline: Timeline, + effect: &'a dyn Effect, + easing: &'a dyn EasingFunction, + color_engine: &'a ColorEngine, +} + +impl<'a> Renderer<'a> { + pub fn new( + ascii_art: &'a AsciiArt, + duration_ms: u64, + fps: u32, + effect: &'a dyn Effect, + easing: &'a dyn EasingFunction, + color_engine: &'a ColorEngine, + ) -> Self { + Self { + ascii_art, + timeline: Timeline::new(duration_ms, fps), + effect, + easing, + color_engine, + } + } + + pub async fn render(&self, terminal: &mut TerminalManager) -> Result<()> { + let mut timeline = Timeline::new(self.timeline.duration_ms(), self.timeline.fps()); + timeline.start(); + + while !timeline.is_complete() { + let frame_start = std::time::Instant::now(); + + // Calculate progress with easing + let linear_progress = timeline.progress(); + let eased_progress = self.easing.ease(linear_progress); + + // Apply effect + let effect_result = self.effect.apply(self.ascii_art, eased_progress); + + // Apply colors if available + let colored_text = if self.color_engine.has_colors() { + self.apply_colors(&effect_result.text, linear_progress) + } else { + effect_result.text.clone() + }; + + // Render to terminal + terminal.clear()?; + terminal.refresh_size()?; + + // Apply offsets and render + if effect_result.offset_x == 0 && effect_result.offset_y == 0 { + terminal.print_centered(&colored_text)?; + } else { + let (width, height) = terminal.get_size(); + let lines: Vec<&str> = colored_text.lines().collect(); + let text_height = lines.len() as i32; + let text_width = lines.iter().map(|l| l.len()).max().unwrap_or(0) as i32; + + let base_x = (width as i32 - text_width) / 2; + let base_y = (height as i32 - text_height) / 2; + + let x = (base_x + effect_result.offset_x).max(0) as u16; + let y = (base_y + effect_result.offset_y).max(0) as u16; + + for (i, line) in lines.iter().enumerate() { + let line_y = y.saturating_add(i as u16); + if line_y < height { + terminal.print_at(x, line_y, line)?; + } + } + } + + // Wait for next frame + timeline.next_frame(); + let frame_duration = timeline.frame_duration(); + let elapsed = frame_start.elapsed(); + + if elapsed < frame_duration { + sleep(frame_duration - elapsed).await; + } + } + + Ok(()) + } + + fn apply_colors(&self, text: &str, progress: f64) -> String { + match self.effect.name() { + "rainbow" | "color-cycle" => { + // For rainbow/color-cycle effects, use gradient across characters + let char_count = text.chars().filter(|c| !c.is_whitespace()).count(); + let colors = self.color_engine.get_colors(char_count); + apply::apply_gradient_to_text(text, &colors) + } + "gradient-flow" => { + // For gradient-flow, shift colors based on progress + let char_count = text.chars().filter(|c| !c.is_whitespace()).count(); + let mut colors = self.color_engine.get_colors(char_count * 2); + let offset = (progress * colors.len() as f64) as usize; + let len = colors.len(); + colors.rotate_left(offset % len); + colors.truncate(char_count); + apply::apply_gradient_to_text(text, &colors) + } + _ => { + // For other effects, use gradient based on progress + if let Some(color) = self.color_engine.color_at(progress) { + let lines: Vec = text + .lines() + .map(|line| apply::apply_color_to_line(line, &[color])) + .collect(); + lines.join("\n") + } else { + let char_count = text.chars().filter(|c| !c.is_whitespace()).count(); + let colors = self.color_engine.get_colors(char_count.max(10)); + apply::apply_gradient_to_text(text, &colors) + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::animation::easing::Linear; + use crate::animation::effects::FadeIn; + + #[test] + fn test_renderer_creation() { + let ascii_art = AsciiArt::new("Test".to_string()); + let effect = FadeIn; + let easing = Linear; + let color_engine = ColorEngine::new(); + + let renderer = Renderer::new(&ascii_art, 1000, 30, &effect, &easing, &color_engine); + + assert_eq!(renderer.timeline.duration_ms(), 1000); + assert_eq!(renderer.timeline.fps(), 30); + } +} diff --git a/src/animation/timeline.rs b/src/animation/timeline.rs new file mode 100644 index 0000000..cba2f04 --- /dev/null +++ b/src/animation/timeline.rs @@ -0,0 +1,123 @@ +use std::time::{Duration, Instant}; + +pub struct Timeline { + duration_ms: u64, + fps: u32, + start_time: Option, + current_frame: usize, + total_frames: usize, +} + +impl Timeline { + pub fn new(duration_ms: u64, fps: u32) -> Self { + let total_frames = ((duration_ms as f64 / 1000.0) * fps as f64).ceil() as usize; + + Self { + duration_ms, + fps, + start_time: None, + current_frame: 0, + total_frames, + } + } + + pub fn start(&mut self) { + self.start_time = Some(Instant::now()); + self.current_frame = 0; + } + + #[allow(dead_code)] + pub fn reset(&mut self) { + self.start_time = None; + self.current_frame = 0; + } + + pub fn is_complete(&self) -> bool { + self.current_frame >= self.total_frames + } + + pub fn progress(&self) -> f64 { + if self.total_frames == 0 { + return 1.0; + } + (self.current_frame as f64 / self.total_frames as f64).min(1.0) + } + + pub fn next_frame(&mut self) -> bool { + if self.is_complete() { + return false; + } + + self.current_frame += 1; + true + } + + pub fn frame_duration(&self) -> Duration { + Duration::from_millis(1000 / self.fps as u64) + } + + #[allow(dead_code)] + pub fn elapsed(&self) -> Duration { + self.start_time + .map(|start| start.elapsed()) + .unwrap_or(Duration::ZERO) + } + + #[allow(dead_code)] + pub fn current_frame(&self) -> usize { + self.current_frame + } + + #[allow(dead_code)] + pub fn total_frames(&self) -> usize { + self.total_frames + } + + pub fn fps(&self) -> u32 { + self.fps + } + + pub fn duration_ms(&self) -> u64 { + self.duration_ms + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_timeline_creation() { + let timeline = Timeline::new(1000, 30); + assert_eq!(timeline.total_frames(), 30); + assert_eq!(timeline.fps(), 30); + } + + #[test] + fn test_timeline_progress() { + let mut timeline = Timeline::new(1000, 10); + timeline.start(); + + assert_eq!(timeline.progress(), 0.0); + + for _ in 0..5 { + timeline.next_frame(); + } + + assert_eq!(timeline.progress(), 0.5); + } + + #[test] + fn test_timeline_completion() { + let mut timeline = Timeline::new(1000, 10); + timeline.start(); + + assert!(!timeline.is_complete()); + + for _ in 0..10 { + timeline.next_frame(); + } + + assert!(timeline.is_complete()); + } +} diff --git a/src/cli.rs b/src/cli.rs index aa8ad95..e22e898 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -8,30 +8,30 @@ 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>, - + /// Color gradient (CSS4 gradient definition) /// Example: "linear-gradient(90deg, red, blue)" #[arg(short = 'g', long)] pub color_gradient: Option, - + /// Motion easing function - /// Options: linear, ease-in, ease-out, ease-in-out, ease-in-quad, + /// 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, @@ -39,33 +39,33 @@ pub struct PigletCli { /// 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, - + /// Additional figlet options (use after --) /// Example: piglet "Text" -- -w 200 -c #[arg(last = true)] pub figlet_args: Vec, - + /// 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, -} \ No newline at end of file +} diff --git a/src/color/apply.rs b/src/color/apply.rs index 144c313..b23edaa 100644 --- a/src/color/apply.rs +++ b/src/color/apply.rs @@ -3,13 +3,13 @@ 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)) } @@ -17,7 +17,7 @@ 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)| { @@ -34,14 +34,14 @@ pub fn apply_color_to_line(line: &str, colors: &[Color]) -> String { 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() { @@ -53,11 +53,11 @@ pub fn apply_gradient_to_text(text: &str, colors: &[Color]) -> String { char_index += 1; } } - + if line_idx < lines.len() - 1 { result.push('\n'); } } - + result -} \ No newline at end of file +} diff --git a/src/color/gradient.rs b/src/color/gradient.rs index a4fc8b0..7a9bcde 100644 --- a/src/color/gradient.rs +++ b/src/color/gradient.rs @@ -1,7 +1,8 @@ -use crate::parser::gradient::Gradient; use crate::parser::color::Color; +use crate::parser::gradient::Gradient; use anyhow::Result; +#[derive(Debug, Clone)] pub struct GradientEngine { gradient: Gradient, } @@ -10,17 +11,17 @@ impl GradientEngine { pub fn new(gradient: Gradient) -> Self { Self { gradient } } - + pub fn from_string(gradient_str: &str) -> Result { 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 { self.gradient.colors(steps) } -} \ No newline at end of file +} diff --git a/src/color/mod.rs b/src/color/mod.rs new file mode 100644 index 0000000..2644fcc --- /dev/null +++ b/src/color/mod.rs @@ -0,0 +1,83 @@ +pub mod apply; +pub mod gradient; +pub mod palette; + +use crate::parser::color::Color; +use anyhow::Result; +pub use gradient::GradientEngine; +pub use palette::ColorPalette; + +#[derive(Debug, Clone)] +pub enum ColorMode { + None, + Palette(ColorPalette), + Gradient(GradientEngine), +} + +pub struct ColorEngine { + mode: ColorMode, +} + +impl ColorEngine { + pub fn new() -> Self { + Self { + mode: ColorMode::None, + } + } + + pub fn with_palette(mut self, palette: Option<&[String]>) -> Result { + if let Some(colors) = palette { + if !colors.is_empty() { + let palette = ColorPalette::from_strings(colors)?; + self.mode = ColorMode::Palette(palette); + } + } + Ok(self) + } + + pub fn with_gradient(mut self, gradient: Option<&str>) -> Result { + if let Some(gradient_str) = gradient { + let gradient = GradientEngine::from_string(gradient_str)?; + self.mode = ColorMode::Gradient(gradient); + } + Ok(self) + } + + pub fn has_colors(&self) -> bool { + !matches!(self.mode, ColorMode::None) + } + + #[allow(dead_code)] + pub fn get_color(&self, t: f64, index: usize) -> Option { + match &self.mode { + ColorMode::None => None, + ColorMode::Palette(palette) => Some(palette.get_color(index)), + ColorMode::Gradient(gradient) => Some(gradient.color_at(t)), + } + } + + #[allow(dead_code)] + pub fn get_colors(&self, steps: usize) -> Vec { + match &self.mode { + ColorMode::None => vec![], + ColorMode::Palette(palette) => (0..steps).map(|i| palette.get_color(i)).collect(), + ColorMode::Gradient(gradient) => gradient.colors(steps), + } + } + + pub fn color_at(&self, t: f64) -> Option { + match &self.mode { + ColorMode::None => None, + ColorMode::Palette(palette) => { + Some(palette.get_color((t * palette.len() as f64) as usize)) + } + ColorMode::Gradient(gradient) => Some(gradient.color_at(t)), + } + } +} + +impl Default for ColorEngine { + fn default() -> Self { + Self::new() + } +} diff --git a/src/color/palette.rs b/src/color/palette.rs index 543db67..13a3315 100644 --- a/src/color/palette.rs +++ b/src/color/palette.rs @@ -1,8 +1,53 @@ -"#ffff00".to_string(), - ]).unwrap() +use crate::parser::color::Color; +use anyhow::Result; + +#[derive(Debug, Clone)] +pub struct ColorPalette { + colors: Vec, +} + +impl ColorPalette { + pub fn new(colors: Vec) -> Self { + Self { colors } } - + + pub fn from_strings(color_strs: &[String]) -> Result { + let colors: Result> = color_strs.iter().map(|s| Color::parse(s)).collect(); + Ok(Self::new(colors?)) + } + + pub fn get_color(&self, index: usize) -> Color { + if self.colors.is_empty() { + return Color::new(255, 255, 255); + } + self.colors[index % self.colors.len()] + } + + pub fn len(&self) -> usize { + self.colors.len() + } + + #[allow(dead_code)] + pub fn is_empty(&self) -> bool { + self.colors.is_empty() + } + + /// Create rainbow palette + pub fn rainbow() -> Self { + Self::from_strings(&[ + "#ff0000".to_string(), + "#ff7f00".to_string(), + "#ffff00".to_string(), + "#00ff00".to_string(), + "#0000ff".to_string(), + "#4b0082".to_string(), + "#9400d3".to_string(), + ]) + .unwrap() + } + /// Create ocean palette + #[allow(dead_code)] pub fn ocean() -> Self { Self::from_strings(&[ "#000080".to_string(), @@ -10,7 +55,8 @@ "#4169e1".to_string(), "#87ceeb".to_string(), "#add8e6".to_string(), - ]).unwrap() + ]) + .unwrap() } } @@ -18,4 +64,4 @@ impl Default for ColorPalette { fn default() -> Self { Self::rainbow() } -} \ No newline at end of file +} diff --git a/src/figlet.rs b/src/figlet.rs index f3c9ce5..cc033a1 100644 --- a/src/figlet.rs +++ b/src/figlet.rs @@ -1,4 +1,4 @@ -use anyhow::{Context, Result, bail}; +use anyhow::{bail, Context, Result}; use std::process::Command; use which::which; @@ -14,78 +14,75 @@ impl FigletWrapper { 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) -> Self { self.args = args; self } - + pub fn render(&self, text: &str) -> Result { 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")?; - + 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")?; - + + 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\ + 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")?; + On Arch: sudo pacman -S figlet", + )?; Ok(()) } - + + #[allow(dead_code)] pub fn list_fonts() -> Result> { 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 = result .lines() .skip(1) // Skip header - .filter_map(|line| { - line.split_whitespace() - .next() - .map(|s| s.to_string()) - }) + .filter_map(|line| line.split_whitespace().next().map(|s| s.to_string())) .collect(); - + Ok(fonts) } } @@ -99,13 +96,13 @@ impl Default for FigletWrapper { #[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(); @@ -115,4 +112,4 @@ mod tests { assert!(!ascii.is_empty()); assert!(ascii.contains("H") || ascii.contains("_") || ascii.contains("|")); } -} \ No newline at end of file +} diff --git a/src/lib.rs b/src/lib.rs index 9ef7cb2..938fa03 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,8 +1,8 @@ -pub mod cli; -pub mod figlet; -pub mod color; pub mod animation; +pub mod cli; +pub mod color; +pub mod figlet; pub mod parser; pub mod utils; -pub use cli::PigletCli; \ No newline at end of file +pub use cli::PigletCli; diff --git a/src/main.rs b/src/main.rs index 1bb6a1a..4bb263f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,31 +1,31 @@ -mod cli; -mod figlet; -mod color; mod animation; +mod cli; +mod color; +mod figlet; mod parser; mod utils; use anyhow::Result; -use cli::PigletCli; use clap::Parser; +use cli::PigletCli; #[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(()) } @@ -33,69 +33,67 @@ 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_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); - + 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#" - ____ _ __ __ + 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 + piglet Hello -p red,blue,green + 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. -"#); -} \ No newline at end of file +" + ); +} diff --git a/src/parser/color.rs b/src/parser/color.rs index 5b30425..03f3f5e 100644 --- a/src/parser/color.rs +++ b/src/parser/color.rs @@ -1,6 +1,5 @@ -use anyhow::{Result, Context}; +use anyhow::{Context, Result}; use csscolorparser::Color as CssColor; -use palette::rgb::Rgb; #[derive(Debug, Clone, Copy)] pub struct Color { @@ -13,12 +12,35 @@ impl Color { pub fn new(r: u8, g: u8, b: u8) -> Self { Self { r, g, b } } - + pub fn from_hex(hex: &str) -> Result { - let color = CssColor::parse(hex) + let color = hex + .parse::() .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 \ No newline at end of file + b: (color.b * 255.0) as u8, + }) + } + + pub fn parse(color_str: &str) -> Result { + Self::from_hex(color_str) + } + + pub fn interpolate(&self, other: &Color, t: f64) -> Color { + let t = t.clamp(0.0, 1.0); + Color { + r: (self.r as f64 + (other.r as f64 - self.r as f64) * t) as u8, + g: (self.g as f64 + (other.g as f64 - self.g as f64) * t) as u8, + b: (self.b as f64 + (other.b as f64 - self.b as f64) * t) as u8, + } + } + + #[allow(dead_code)] + #[allow(clippy::wrong_self_convention)] + pub fn to_ansi(&self) -> String { + format!("\x1b[38;2;{};{};{}m", self.r, self.g, self.b) + } +} diff --git a/src/parser/duration.rs b/src/parser/duration.rs index 6df7101..5c5041f 100644 --- a/src/parser/duration.rs +++ b/src/parser/duration.rs @@ -1,24 +1,24 @@ -use anyhow::{Result, bail}; -use regex::Regex; +use anyhow::{bail, Result}; use lazy_static::lazy_static; +use regex::Regex; lazy_static! { - static ref DURATION_REGEX: Regex = Regex::new( - r"^(\d+(?:\.\d+)?)(ms|s|m|h)$" - ).unwrap(); + 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 { - let caps = DURATION_REGEX.captures(duration.trim()) + let caps = DURATION_REGEX + .captures(duration.trim()) .ok_or_else(|| anyhow::anyhow!("Invalid duration format: {}", duration))?; - - let value: f64 = caps[1].parse() + + 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, @@ -26,47 +26,47 @@ pub fn parse_duration(duration: &str) -> Result { "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()); } -} \ No newline at end of file +} diff --git a/src/parser/gradient.rs b/src/parser/gradient.rs new file mode 100644 index 0000000..741b60f --- /dev/null +++ b/src/parser/gradient.rs @@ -0,0 +1,121 @@ +use crate::parser::color::Color; +use anyhow::{bail, Result}; + +#[derive(Debug, Clone)] +pub struct ColorStop { + pub color: Color, + pub position: f64, +} + +#[derive(Debug, Clone)] +pub struct Gradient { + pub stops: Vec, + #[allow(dead_code)] pub angle: f64, +} + +impl Gradient { + pub fn new(stops: Vec, angle: f64) -> Self { + Self { stops, angle } + } + + pub fn parse(gradient_str: &str) -> Result { + let gradient_str = gradient_str.trim(); + + if !gradient_str.starts_with("linear-gradient(") { + bail!("Only linear-gradient is supported"); + } + + let content = gradient_str + .strip_prefix("linear-gradient(") + .and_then(|s| s.strip_suffix(")")) + .ok_or_else(|| anyhow::anyhow!("Invalid gradient syntax"))?; + + let parts: Vec<&str> = content.split(',').map(|s| s.trim()).collect(); + + if parts.is_empty() { + bail!("Gradient must have at least one color"); + } + + let mut angle = 180.0; + let mut color_parts = parts.as_slice(); + + if let Some(first) = parts.first() { + if first.ends_with("deg") { + angle = first + .trim_end_matches("deg") + .trim() + .parse() + .unwrap_or(180.0); + color_parts = &parts[1..]; + } else if first.starts_with("to ") { + angle = match first.trim() { + "to right" => 90.0, + "to left" => 270.0, + "to top" => 0.0, + "to bottom" => 180.0, + _ => 180.0, + }; + color_parts = &parts[1..]; + } + } + + let mut stops = Vec::new(); + let count = color_parts.len(); + + for (i, part) in color_parts.iter().enumerate() { + let part = part.trim(); + let mut color_str = part; + let mut position = i as f64 / (count - 1).max(1) as f64; + + // Check if there's a percentage (e.g., "#FF5733 50%" or "red 50%") + if let Some(percent_pos) = part.rfind('%') { + // Find the last space before the percentage + if let Some(space_pos) = part[..percent_pos].rfind(|c: char| c.is_whitespace()) { + color_str = part[..space_pos].trim(); + let percent_str = part[space_pos + 1..percent_pos].trim(); + if let Ok(p) = percent_str.parse::() { + position = p / 100.0; + } + } + } + + let color = Color::parse(color_str)?; + stops.push(ColorStop { color, position }); + } + + Ok(Self::new(stops, angle)) + } + + pub fn color_at(&self, t: f64) -> Color { + if self.stops.is_empty() { + return Color::new(255, 255, 255); + } + + if self.stops.len() == 1 { + return self.stops[0].color; + } + + let t = t.clamp(0.0, 1.0); + + for i in 0..self.stops.len() - 1 { + let stop1 = &self.stops[i]; + let stop2 = &self.stops[i + 1]; + + if t >= stop1.position && t <= stop2.position { + let local_t = (t - stop1.position) / (stop2.position - stop1.position); + return stop1.color.interpolate(&stop2.color, local_t); + } + } + + self.stops.last().unwrap().color + } + + pub fn colors(&self, steps: usize) -> Vec { + (0..steps) + .map(|i| { + let t = i as f64 / (steps - 1).max(1) as f64; + self.color_at(t) + }) + .collect() + } +} diff --git a/src/parser/mod.rs b/src/parser/mod.rs index f37ad66..db21a8c 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -1,3 +1,3 @@ -pub mod duration; pub mod color; -pub mod gradient; \ No newline at end of file +pub mod duration; +pub mod gradient; diff --git a/src/utils/ascii.rs b/src/utils/ascii.rs index 1bd2f55..1d36ba3 100644 --- a/src/utils/ascii.rs +++ b/src/utils/ascii.rs @@ -1,5 +1,3 @@ -use crate::parser::color::Color; - #[derive(Debug, Clone)] pub struct AsciiArt { lines: Vec, @@ -12,47 +10,49 @@ impl AsciiArt { let lines: Vec = 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 { + + pub fn render(&self) -> String { self.lines.join("\n") } - + /// Get character at position + #[allow(dead_code)] pub fn char_at(&self, x: usize, y: usize) -> Option { self.lines.get(y)?.chars().nth(x) } - + /// Count non-whitespace characters pub fn char_count(&self) -> usize { - self.lines.iter() + 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() { @@ -60,72 +60,66 @@ impl AsciiArt { } } } - + 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(); + return self.render(); } - + 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() + + self.lines + .iter() .map(|line| { line.chars() - .map(|ch| { - if ch.is_whitespace() { - ch - } else { - fade_char - } - }) + .map(|ch| if ch.is_whitespace() { ch } else { fade_char }) .collect::() }) .collect::>() .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() + let lines: Vec = 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)) + let scaled_line: String = line + .chars() + .flat_map(|ch| std::iter::repeat_n(ch, factor as usize)) .collect(); - std::iter::repeat(scaled_line).take(factor as usize) + std::iter::repeat_n(scaled_line, factor as usize) }) .collect() } else { - self.lines.iter() + self.lines + .iter() .step_by((1.0 / factor) as usize) - .map(|line| { - line.chars() - .step_by((1.0 / factor) as usize) - .collect() - }) + .map(|line| line.chars().step_by((1.0 / factor) as usize).collect()) .collect() }; - + Self::new(lines.join("\n")) } -} \ No newline at end of file +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 1c2b773..ebbd1ef 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -1,2 +1,2 @@ +pub mod ascii; pub mod terminal; -pub mod ascii; \ No newline at end of file diff --git a/src/utils/terminal.rs b/src/utils/terminal.rs index 7d12276..3c6d54e 100644 --- a/src/utils/terminal.rs +++ b/src/utils/terminal.rs @@ -1,9 +1,7 @@ use anyhow::Result; use crossterm::{ - cursor, - execute, + cursor, execute, terminal::{self, ClearType}, - ExecutableCommand, }; use std::io::{stdout, Write}; @@ -22,74 +20,66 @@ impl TerminalManager { original_state: false, }) } - + pub fn setup(&mut self) -> Result<()> { terminal::enable_raw_mode()?; - execute!( - stdout(), - terminal::EnterAlternateScreen, - cursor::Hide - )?; + 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 - )?; + 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(()) } } @@ -98,4 +88,4 @@ impl Drop for TerminalManager { fn drop(&mut self) { let _ = self.cleanup(); } -} \ No newline at end of file +} diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index 8f37604..be66082 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -1,11 +1,11 @@ +use anyhow::Result; use piglet::{ - figlet::FigletWrapper, - parser::{duration::parse_duration, color::Color, gradient::Gradient}, - color::{ColorEngine, palette::ColorPalette}, animation::easing::get_easing_function, animation::effects::get_effect, + color::{palette::ColorPalette, ColorEngine}, + figlet::FigletWrapper, + parser::{color::Color, duration::parse_duration, gradient::Gradient}, }; -use anyhow::Result; #[test] fn test_figlet_wrapper() -> Result<()> { @@ -31,12 +31,12 @@ fn test_color_parser() -> Result<()> { assert_eq!(color.r, 255); assert_eq!(color.g, 87); assert_eq!(color.b, 51); - + let color = Color::parse("red")?; assert_eq!(color.r, 255); assert_eq!(color.g, 0); assert_eq!(color.b, 0); - + Ok(()) } @@ -44,15 +44,14 @@ fn test_color_parser() -> Result<()> { fn test_gradient_parser() -> Result<()> { let gradient = Gradient::parse("linear-gradient(90deg, red, blue)")?; assert_eq!(gradient.stops.len(), 2); - - let gradient = Gradient::parse( - "linear-gradient(to right, #FF5733 0%, #33FF57 50%, #3357FF 100%)" - )?; + + let gradient = + Gradient::parse("linear-gradient(to right, #FF5733 0%, #33FF57 50%, #3357FF 100%)")?; assert_eq!(gradient.stops.len(), 3); assert_eq!(gradient.stops[0].position, 0.0); assert_eq!(gradient.stops[1].position, 0.5); assert_eq!(gradient.stops[2].position, 1.0); - + Ok(()) } @@ -61,7 +60,7 @@ fn test_color_interpolation() { let red = Color::new(255, 0, 0); let blue = Color::new(0, 0, 255); let purple = red.interpolate(&blue, 0.5); - + assert_eq!(purple.r, 127); assert_eq!(purple.g, 0); assert_eq!(purple.b, 127); @@ -69,19 +68,16 @@ fn test_color_interpolation() { #[test] fn test_color_palette() -> Result<()> { - let palette = ColorPalette::from_strings(&[ - "red".to_string(), - "green".to_string(), - "blue".to_string(), - ])?; - + let palette = + ColorPalette::from_strings(&["red".to_string(), "green".to_string(), "blue".to_string()])?; + assert_eq!(palette.len(), 3); - + let color = palette.get_color(0); assert_eq!(color.r, 255); assert_eq!(color.g, 0); assert_eq!(color.b, 0); - + Ok(()) } @@ -89,15 +85,15 @@ fn test_color_palette() -> Result<()> { fn test_easing_functions() -> Result<()> { let linear = get_easing_function("linear")?; assert_eq!(linear.ease(0.5), 0.5); - + let ease_in = get_easing_function("ease-in")?; let result = ease_in.ease(0.5); - assert!(result >= 0.0 && result <= 1.0); - + assert!((0.0..=1.0).contains(&result)); + let ease_out_bounce = get_easing_function("ease-out-bounce")?; let result = ease_out_bounce.ease(0.5); - assert!(result >= 0.0 && result <= 1.5); // Bounce can overshoot - + assert!((0.0..=1.5).contains(&result)); // Bounce can overshoot + Ok(()) } @@ -105,45 +101,44 @@ fn test_easing_functions() -> Result<()> { fn test_effects() -> Result<()> { let fade_in = get_effect("fade-in")?; assert_eq!(fade_in.name(), "fade-in"); - + let typewriter = get_effect("typewriter")?; assert_eq!(typewriter.name(), "typewriter"); - + let bounce = get_effect("bounce-in")?; assert_eq!(bounce.name(), "bounce-in"); - + Ok(()) } #[test] fn test_color_engine() -> Result<()> { - let engine = ColorEngine::new() - .with_palette(Some(&["red".to_string(), "blue".to_string()]))?; - + let engine = ColorEngine::new().with_palette(Some(&["red".to_string(), "blue".to_string()]))?; + assert!(engine.has_colors()); - + let color = engine.get_color(0.0, 0); assert!(color.is_some()); - + Ok(()) } #[test] fn test_gradient_color_at() -> Result<()> { let gradient = Gradient::parse("linear-gradient(red, blue)")?; - + let color_start = gradient.color_at(0.0); assert_eq!(color_start.r, 255); assert_eq!(color_start.b, 0); - + let color_end = gradient.color_at(1.0); assert_eq!(color_end.r, 0); assert_eq!(color_end.b, 255); - + let color_mid = gradient.color_at(0.5); assert!(color_mid.r > 0 && color_mid.r < 255); assert!(color_mid.b > 0 && color_mid.b < 255); - + Ok(()) } @@ -168,4 +163,4 @@ fn test_invalid_effect() { #[test] fn test_invalid_easing() { assert!(get_easing_function("not-an-easing").is_err()); -} \ No newline at end of file +}