16 Commits

Author SHA1 Message Date
dependabot[bot]
f6b77756fd Update nom requirement from 7.1 to 8.0
Updates the requirements on [nom](https://github.com/rust-bakery/nom) to permit the latest version.
- [Changelog](https://github.com/rust-bakery/nom/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rust-bakery/nom/compare/7.1.0...8.0.0)

---
updated-dependencies:
- dependency-name: nom
  dependency-version: 8.0.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-10 02:35:00 +00:00
b2ebba865d Add Phase 3 slide-out, blink, focus, and shadow effects
Implements 10 new visual effects:
- slide-out-top: Slide out towards the top
- slide-out-bottom: Slide out towards the bottom
- slide-out-left: Slide out towards the left
- slide-out-right: Slide out towards the right
- blink: Rapid on/off blinking (6 blinks during animation)
- focus-in: Come into focus with scale and opacity
- blur-out: Go out of focus with reduced scale and opacity
- shadow-drop: Drop down from above with shadow simulation
- shadow-pop: Pop forward with scale bounce effect
- rotate-center: Rotate around center point with line offsets

All effects registered in get_effect() and list_effects().
Fixed clippy warning: use unsigned_abs() instead of abs().

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-09 12:01:02 +01:00
59cd854f55 Add Phase 2 specialty & combination animation effects
Implements 10 new advanced animation effects:
- puff-in: Scale up from tiny with fade in
- puff-out: Scale down to tiny with fade out
- slide-rotate-hor: Slide from left with rotation
- slide-rotate-ver: Slide from top with rotation
- flicker: Fast flickering that stabilizes over time
- tracking-in: Letter spacing contracts from wide to normal
- tracking-out: Letter spacing expands from normal to wide
- bounce-top: Bounce down from top with easing
- bounce-bottom: Bounce up from bottom with easing
- tilt-in: Tilt in with perspective simulation

All effects combine multiple transformations (scale, offset,
opacity) for rich visual experiences. Registered in get_effect()
and list_effects() for CLI usage.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-09 11:51:48 +01:00
4cc8d5c489 Add Phase 1 high-impact animation effects from Animista
Implements 10 new animation effects:
- shake: horizontal vibration with decreasing amplitude
- wobble: rotation wobble effect with offset variations
- vibrate: rapid small movements in multiple directions
- heartbeat: pulsing scale with two-beat rhythm pattern
- flip-horizontal: flip text horizontally with character reversal
- flip-vertical: flip text vertically with line reversal
- swing: pendulum motion with decreasing amplitude
- sway: gentle continuous swaying motion
- roll-in: roll in from left with rotation effect
- roll-out: roll out to right with rotation effect

All effects implement the Effect trait and are registered in
get_effect() and list_effects() for CLI usage.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-09 11:42:45 +01:00
51c2c5a14a Fix formatting: reformat imports and thread closure
Apply rustfmt to fix formatting issues:
- Reformat nested use statement for std::sync imports
- Reformat thread::spawn closure to use inline loop syntax

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-09 07:11:26 +01:00
12793893c3 Fix: Propagate user exit signal to break animation loop
**CRITICAL BUG FIX**: When using -l (loop), the outer loop in main.rs
was restarting the animation even after user pressed exit keys.

Changes:
- render() now returns Result<bool> instead of Result<()>
- Returns true when user presses exit key (q/ESC/Ctrl+C)
- Returns false when animation completes naturally
- main.rs checks return value and breaks loop on user exit

This fixes the infinite loop issue where pressing q/ESC/Ctrl+C
had no effect when using the -l flag.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-09 07:08:24 +01:00
946289544d Add frequent exit checks throughout render loop
The issue was that sleep() was blocking for 16-33ms without checking
the exit flag. Now:
- Check should_exit at 5 points in the render loop
- Break sleep into 5ms chunks, checking between each chunk
- This gives <5ms response time to exit commands

Exit responds within 5ms: Press 'q', ESC, or Ctrl+C

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-09 07:03:57 +01:00
4e88ea5677 Use background thread for reliable keyboard event handling
Previous attempts to poll keyboard events in the render loop were
failing. Now using a dedicated background thread that continuously
monitors for exit keys and communicates with the render loop via
an atomic boolean flag.

This ensures keyboard events are never missed, even during heavy
rendering operations.

Exit: Press 'q', ESC, or Ctrl+C

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-09 07:00:04 +01:00
b2a9b6df36 Simplify keyboard handling and increase poll timeout
Removed complex signal handling that wasn't working reliably.
Now using simpler event polling with 10ms timeout instead of 0ms,
which allows keyboard events to be properly detected.

Exit methods:
- Press 'q' (most reliable)
- Press ESC
- Ctrl+C (with 10ms poll window)
- Ctrl+D (alternative)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-09 06:53:19 +01:00
5a707df725 Improve Ctrl+C handling with dual detection method
Implemented both keyboard event and signal detection for Ctrl+C:
1. Check for Ctrl+C as keyboard event (KeyModifiers::CONTROL + 'c')
2. Check for SIGINT signal via tokio::signal::unix

This ensures Ctrl+C works reliably in both raw terminal mode and
through signal handling.

Exit options: Ctrl+C, 'q', or ESC

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-09 06:41:07 +01:00
097fca6ed3 Fix Ctrl+C handling with proper tokio signal handler
Previous keyboard event polling didn't work for Ctrl+C in raw mode.
Now using tokio::signal::ctrl_c() with a background task that signals
the render loop through a channel when Ctrl+C is pressed.

Changes:
- Added "signal" and "sync" features to tokio dependency
- Spawn background task to listen for Ctrl+C signal
- Use mpsc channel to communicate signal to render loop
- Keep 'q' and ESC keyboard shortcuts for convenience

Ctrl+C now properly exits looping animations.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-09 06:38:15 +01:00
5a0ff3f5cc Add keyboard input handling to allow exiting animations
Fixed issue where Ctrl+C did not work to exit looping animations.
In raw terminal mode, signals are not automatically handled, so we
need to manually poll for keyboard events.

Changes:
- Added crossterm event polling in render loop
- Check for Ctrl+C, 'q', or ESC key to exit animation
- Polls with 0ms timeout to avoid blocking animation frames

Users can now exit with:
- Ctrl+C
- Press 'q'
- Press ESC

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-09 06:29:02 +01:00
4035f2dc23 Fix release workflow to upload platform-specific binary names
Previously all binaries were uploaded with the same name 'piglet',
causing them to overwrite each other. Now each binary is renamed
to include the target platform (e.g., piglet-x86_64-unknown-linux-gnu)
before uploading.

This ensures all 4 platform binaries are available in the release.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-09 04:46:37 +01:00
000823457d Fix release workflow permissions for creating releases
Added 'contents: write' permission to allow GITHUB_TOKEN to create
releases. This fixes the 403 error when the workflow tries to create
a GitHub release.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-09 04:42:34 +01:00
d51fada172 Add comprehensive automated release workflow
Creates a complete release workflow that:
- Triggers on version tags (v*.*.*)
- Creates GitHub release with installation instructions
- Builds binaries for 4 platforms (Linux x86_64/musl, macOS Intel/ARM)
- Strips binaries to reduce size
- Uploads all binaries as release assets
- Optionally publishes to crates.io

Usage: git tag v0.1.0 && git push origin v0.1.0

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-09 04:32:43 +01:00
0a75003451 Fix formatting: break long lines for rustfmt compliance
Rustfmt requires long chained method calls to be broken across
multiple lines when they exceed line length limits.

Changes:
- Break visual_width() iterator chains in renderer.rs
- Break visual_width() iterator chains in terminal.rs
- Fix comment alignment in ansi.rs

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-09 04:24:40 +01:00
8 changed files with 887 additions and 29 deletions

View File

@@ -5,37 +5,174 @@ on:
tags:
- 'v*.*.*'
permissions:
contents: write
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1
jobs:
create-release:
name: Create Release
runs-on: ubuntu-latest
outputs:
upload_url: ${{ steps.create_release.outputs.upload_url }}
version: ${{ steps.get_version.outputs.version }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Get version from tag
id: get_version
run: echo "version=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
- name: Create Release
id: create_release
uses: actions/create-release@v1
uses: softprops/action-gh-release@v1
with:
draft: false
prerelease: false
generate_release_notes: false
body: |
# Piglet v${{ steps.get_version.outputs.version }}
🐷 Animated and colorful figlet wrapper written in Rust
## Features
- 20+ motion effects (fade, slide, scale, typewriter, wave, rainbow, etc.)
- 18+ easing functions (linear, ease-in/out, quad, cubic, elastic, bounce, etc.)
- Full color support with gradients and palettes
- CSS gradient syntax support
- Cross-platform (Linux, macOS)
## Installation
### Linux (x86_64)
```bash
curl -L https://github.com/${{ github.repository }}/releases/download/v${{ steps.get_version.outputs.version }}/piglet-x86_64-unknown-linux-gnu -o piglet
chmod +x piglet
sudo mv piglet /usr/local/bin/
```
### Linux (musl)
```bash
curl -L https://github.com/${{ github.repository }}/releases/download/v${{ steps.get_version.outputs.version }}/piglet-x86_64-unknown-linux-musl -o piglet
chmod +x piglet
sudo mv piglet /usr/local/bin/
```
### macOS (Intel)
```bash
curl -L https://github.com/${{ github.repository }}/releases/download/v${{ steps.get_version.outputs.version }}/piglet-x86_64-apple-darwin -o piglet
chmod +x piglet
sudo mv piglet /usr/local/bin/
```
### macOS (Apple Silicon)
```bash
curl -L https://github.com/${{ github.repository }}/releases/download/v${{ steps.get_version.outputs.version }}/piglet-aarch64-apple-darwin -o piglet
chmod +x piglet
sudo mv piglet /usr/local/bin/
```
### Via Cargo
```bash
cargo install --git https://github.com/${{ github.repository }} --tag v${{ steps.get_version.outputs.version }}
```
## Usage Examples
```bash
# Simple gradient
piglet "Hello" -g "linear-gradient(90deg, red, blue)"
# Typewriter effect
piglet "World" -m typewriter -i ease-out
# Wave with rainbow colors
piglet "Cool!" -p "hotpink,cyan,gold" -m wave
```
See the [README](https://github.com/${{ github.repository }}/blob/v${{ steps.get_version.outputs.version }}/README.md) for full documentation.
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
build-release:
name: Build Release (${{ matrix.target }})
needs: create-release
runs-on: ${{ matrix.os }}
strategy:
matrix:
include:
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
- os: ubuntu-latest
target: x86_64-unknown-linux-musl
- os: macos-latest
target: x86_64-apple-darwin
- os: macos-latest
target: aarch64-apple-darwin
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
tag_name: ${{ github.ref }}
release_name: Release ${{ github.ref }}
body: |
## Changes in this Release
See [CHANGELOG.md](https://github.com/${{ github.repository }}/blob/main/CHANGELOG.md) for details.
## Installation
### Linux (x86_64)
targets: ${{ matrix.target }}
- name: Install musl tools (Linux musl)
if: matrix.target == 'x86_64-unknown-linux-musl'
run: sudo apt-get update && sudo apt-get install -y musl-tools
- name: Cache cargo registry
uses: actions/cache@v3
with:
path: ~/.cargo/registry
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
- name: Cache cargo index
uses: actions/cache@v3
with:
path: ~/.cargo/git
key: ${{ runner.os }}-cargo-git-${{ hashFiles('**/Cargo.lock') }}
- name: Cache cargo build
uses: actions/cache@v3
with:
path: target
key: ${{ runner.os }}-${{ matrix.target }}-cargo-build-release-${{ hashFiles('**/Cargo.lock') }}
- name: Build release binary
run: cargo build --release --target ${{ matrix.target }} --verbose
- name: Strip binary (Linux)
if: matrix.os == 'ubuntu-latest'
run: strip target/${{ matrix.target }}/release/piglet
- name: Strip binary (macOS)
if: matrix.os == 'macos-latest'
run: strip target/${{ matrix.target }}/release/piglet
- name: Rename binary
run: |
cp target/${{ matrix.target }}/release/piglet piglet-${{ matrix.target }}
- name: Upload release binary
uses: softprops/action-gh-release@v1
with:
files: piglet-${{ matrix.target }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
publish-crate:
name: Publish to crates.io
needs: build-release
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Publish to crates.io
run: cargo publish --token ${{ secrets.CARGO_TOKEN }}
continue-on-error: true

View File

@@ -21,14 +21,14 @@ palette = "0.7"
# Terminal manipulation
crossterm = "0.27"
# Async runtime (for timing)
tokio = { version = "1.35", features = ["time", "rt-multi-thread", "macros"] }
# Async runtime (for timing and signal handling)
tokio = { version = "1.35", features = ["time", "rt-multi-thread", "macros", "signal", "sync"] }
# Process execution
which = "5.0"
# Parsing
nom = "7.1"
nom = "8.0"
regex = "1.10"
# Error handling

View File

@@ -393,6 +393,592 @@ impl Effect for GradientFlow {
}
}
// Phase 1: High-Impact Effects from Animista
// Shake effect - horizontal vibration
pub struct Shake;
impl Effect for Shake {
fn apply(&self, ascii_art: &AsciiArt, progress: f64) -> EffectResult {
// Fast oscillation that decreases over time
let frequency = 20.0;
let amplitude = 10.0 * (1.0 - progress);
let offset_x = (progress * frequency * std::f64::consts::PI * 2.0).sin() * amplitude;
EffectResult::new(ascii_art.render()).with_offset(offset_x as i32, 0)
}
fn name(&self) -> &str {
"shake"
}
}
// Wobble effect - rotation wobble (simulated with offset variations)
pub struct Wobble;
impl Effect for Wobble {
fn apply(&self, ascii_art: &AsciiArt, progress: f64) -> EffectResult {
// Wobble with decreasing amplitude
let angle = progress * std::f64::consts::PI * 4.0;
let amplitude = 15.0 * (1.0 - progress);
let offset_x = (angle.sin() * amplitude) as i32;
let offset_y = (angle.cos() * amplitude * 0.3) as i32;
EffectResult::new(ascii_art.render()).with_offset(offset_x, offset_y)
}
fn name(&self) -> &str {
"wobble"
}
}
// Vibrate effect - rapid small movements
pub struct Vibrate;
impl Effect for Vibrate {
fn apply(&self, ascii_art: &AsciiArt, progress: f64) -> EffectResult {
// Very fast, small vibrations
let frequency = 50.0;
let amplitude = 3.0;
let offset_x = (progress * frequency * std::f64::consts::PI).sin() * amplitude;
let offset_y = (progress * frequency * std::f64::consts::PI * 1.3).cos() * amplitude;
EffectResult::new(ascii_art.render()).with_offset(offset_x as i32, offset_y as i32)
}
fn name(&self) -> &str {
"vibrate"
}
}
// Heartbeat effect - pulsing scale with heartbeat rhythm
pub struct Heartbeat;
impl Effect for Heartbeat {
fn apply(&self, ascii_art: &AsciiArt, progress: f64) -> EffectResult {
// Two-beat pulse pattern like a heartbeat
let beat_progress = (progress * 2.0) % 1.0;
let scale = if beat_progress < 0.3 {
1.0 + (beat_progress / 0.3) * 0.15
} else if beat_progress < 0.4 {
1.15 - ((beat_progress - 0.3) / 0.1) * 0.15
} else if beat_progress < 0.6 {
1.0 + ((beat_progress - 0.4) / 0.2) * 0.1
} else if beat_progress < 0.7 {
1.1 - ((beat_progress - 0.6) / 0.1) * 0.1
} else {
1.0
};
let scaled = ascii_art.scale(scale);
EffectResult::new(scaled.render()).with_scale(scale)
}
fn name(&self) -> &str {
"heartbeat"
}
}
// Flip horizontal - flip text horizontally
pub struct FlipHorizontal;
impl Effect for FlipHorizontal {
fn apply(&self, ascii_art: &AsciiArt, progress: f64) -> EffectResult {
// Scale horizontally from 1 to -1 (flip)
let scale = 1.0 - (progress * 2.0);
if scale <= 0.0 {
// Show reversed text when flipped
let lines: Vec<String> = ascii_art
.get_lines()
.iter()
.map(|line| line.chars().rev().collect())
.collect();
EffectResult::new(lines.join("\n"))
} else {
let scaled = ascii_art.scale(scale);
EffectResult::new(scaled.render()).with_scale(scale)
}
}
fn name(&self) -> &str {
"flip-horizontal"
}
}
// Flip vertical - flip text vertically
pub struct FlipVertical;
impl Effect for FlipVertical {
fn apply(&self, ascii_art: &AsciiArt, progress: f64) -> EffectResult {
// Scale vertically with midpoint flip
let scale = 1.0 - (progress * 2.0).min(1.0);
if progress > 0.5 {
// Show reversed lines when flipped
let mut lines: Vec<String> = ascii_art
.get_lines()
.iter()
.map(|s| s.to_string())
.collect();
lines.reverse();
let result_scale = (progress - 0.5) * 2.0;
let scaled = AsciiArt::new(lines.join("\n")).scale(result_scale);
EffectResult::new(scaled.render()).with_scale(result_scale)
} else {
let scaled = ascii_art.scale(scale.max(0.1));
EffectResult::new(scaled.render()).with_scale(scale.max(0.1))
}
}
fn name(&self) -> &str {
"flip-vertical"
}
}
// Swing effect - pendulum motion
pub struct Swing;
impl Effect for Swing {
fn apply(&self, ascii_art: &AsciiArt, progress: f64) -> EffectResult {
// Pendulum swing with decreasing amplitude
let swings = 2.0;
let angle = (progress * swings * std::f64::consts::PI * 2.0).sin() * (1.0 - progress);
let offset_x = (angle * 20.0) as i32;
let offset_y = (angle.abs() * 5.0) as i32;
EffectResult::new(ascii_art.render()).with_offset(offset_x, -offset_y)
}
fn name(&self) -> &str {
"swing"
}
}
// Sway effect - gentle swaying motion
pub struct Sway;
impl Effect for Sway {
fn apply(&self, ascii_art: &AsciiArt, progress: f64) -> EffectResult {
// Smooth, gentle sway
let angle = (progress * std::f64::consts::PI * 2.0).sin();
let offset_x = (angle * 8.0) as i32;
let offset_y = (angle.abs() * 2.0) as i32;
EffectResult::new(ascii_art.render()).with_offset(offset_x, offset_y)
}
fn name(&self) -> &str {
"sway"
}
}
// Roll-in effect - roll in from left with rotation
pub struct RollIn;
impl Effect for RollIn {
fn apply(&self, ascii_art: &AsciiArt, progress: f64) -> EffectResult {
// Slide in from left while appearing to roll
let offset_x = ((1.0 - progress) * -(ascii_art.width() as f64 + 20.0)) as i32;
let rotation_effect = ((1.0 - progress) * 5.0) as i32;
let offset_y = (rotation_effect as f64 * (progress * std::f64::consts::PI).sin()) as i32;
EffectResult::new(ascii_art.render()).with_offset(offset_x, offset_y)
}
fn name(&self) -> &str {
"roll-in"
}
}
// Roll-out effect - roll out to right with rotation
pub struct RollOut;
impl Effect for RollOut {
fn apply(&self, ascii_art: &AsciiArt, progress: f64) -> EffectResult {
// Slide out to right while appearing to roll
let offset_x = (progress * (ascii_art.width() as f64 + 20.0)) as i32;
let rotation_effect = (progress * 5.0) as i32;
let offset_y = (rotation_effect as f64 * (progress * std::f64::consts::PI).sin()) as i32;
EffectResult::new(ascii_art.render()).with_offset(offset_x, offset_y)
}
fn name(&self) -> &str {
"roll-out"
}
}
// Phase 2: Specialty & Combination Effects
// Puff-in effect - scale up from tiny with fade in
pub struct PuffIn;
impl Effect for PuffIn {
fn apply(&self, ascii_art: &AsciiArt, progress: f64) -> EffectResult {
// Start very small and expand while fading in
let scale = 0.1 + (progress * 0.9);
let opacity = progress;
let scaled = ascii_art.scale(scale);
EffectResult::new(scaled.render())
.with_scale(scale)
.with_opacity(opacity)
}
fn name(&self) -> &str {
"puff-in"
}
}
// Puff-out effect - scale down to tiny with fade out
pub struct PuffOut;
impl Effect for PuffOut {
fn apply(&self, ascii_art: &AsciiArt, progress: f64) -> EffectResult {
// Shrink down while fading out
let scale = 1.0 - (progress * 0.9);
let opacity = 1.0 - progress;
let scaled = ascii_art.scale(scale.max(0.1));
EffectResult::new(scaled.render())
.with_scale(scale)
.with_opacity(opacity)
}
fn name(&self) -> &str {
"puff-out"
}
}
// Slide-rotate horizontal - slide from left with rotation
pub struct SlideRotateHor;
impl Effect for SlideRotateHor {
fn apply(&self, ascii_art: &AsciiArt, progress: f64) -> EffectResult {
// Slide in from left while rotating
let offset_x = ((1.0 - progress) * -(ascii_art.width() as f64 + 10.0)) as i32;
let rotation_progress = 1.0 - progress;
let offset_y =
(rotation_progress * 10.0 * (rotation_progress * std::f64::consts::PI).sin()) as i32;
EffectResult::new(ascii_art.render()).with_offset(offset_x, offset_y)
}
fn name(&self) -> &str {
"slide-rotate-hor"
}
}
// Slide-rotate vertical - slide from top with rotation
pub struct SlideRotateVer;
impl Effect for SlideRotateVer {
fn apply(&self, ascii_art: &AsciiArt, progress: f64) -> EffectResult {
// Slide in from top while rotating
let offset_y = ((1.0 - progress) * -(ascii_art.height() as f64 + 5.0)) as i32;
let rotation_progress = 1.0 - progress;
let offset_x =
(rotation_progress * 15.0 * (rotation_progress * std::f64::consts::PI).cos()) as i32;
EffectResult::new(ascii_art.render()).with_offset(offset_x, offset_y)
}
fn name(&self) -> &str {
"slide-rotate-ver"
}
}
// Flicker effect - random flickering opacity
pub struct Flicker;
impl Effect for Flicker {
fn apply(&self, ascii_art: &AsciiArt, progress: f64) -> EffectResult {
// Fast flickering that stabilizes
let flicker_speed = 30.0;
let stability = progress; // Gets more stable over time
let flicker = ((progress * flicker_speed).sin() + 1.0) / 2.0;
let opacity = stability + (1.0 - stability) * flicker;
EffectResult::new(ascii_art.render()).with_opacity(opacity)
}
fn name(&self) -> &str {
"flicker"
}
}
// Tracking-in effect - letters expand from center
pub struct TrackingIn;
impl Effect for TrackingIn {
fn apply(&self, ascii_art: &AsciiArt, progress: f64) -> EffectResult {
// Simulate letter spacing by adding spaces between characters
let spacing = ((1.0 - progress) * 3.0) as usize;
if spacing == 0 {
EffectResult::new(ascii_art.render())
} else {
let lines: Vec<String> = ascii_art
.get_lines()
.iter()
.map(|line| {
line.chars()
.map(|c| {
if c == ' ' {
" ".repeat(spacing + 1)
} else {
format!("{}{}", c, " ".repeat(spacing))
}
})
.collect::<String>()
})
.collect();
EffectResult::new(lines.join("\n"))
}
}
fn name(&self) -> &str {
"tracking-in"
}
}
// Tracking-out effect - letters contract to center
pub struct TrackingOut;
impl Effect for TrackingOut {
fn apply(&self, ascii_art: &AsciiArt, progress: f64) -> EffectResult {
// Simulate letter spacing by adding spaces between characters
let spacing = (progress * 3.0) as usize;
if spacing == 0 {
EffectResult::new(ascii_art.render())
} else {
let lines: Vec<String> = ascii_art
.get_lines()
.iter()
.map(|line| {
line.chars()
.map(|c| {
if c == ' ' {
" ".repeat(spacing + 1)
} else {
format!("{}{}", c, " ".repeat(spacing))
}
})
.collect::<String>()
})
.collect();
EffectResult::new(lines.join("\n"))
}
}
fn name(&self) -> &str {
"tracking-out"
}
}
// Bounce-top effect - bounce down from top
pub struct BounceTop;
impl Effect for BounceTop {
fn apply(&self, ascii_art: &AsciiArt, progress: f64) -> EffectResult {
// Bounce from top with easing
let bounces = 2.0;
let bounce_height = ascii_art.height() as f64 + 10.0;
let base_offset = (1.0 - progress) * bounce_height;
let bounce_factor =
(progress * bounces * std::f64::consts::PI).sin().abs() * (1.0 - progress);
let offset_y = -(base_offset + bounce_factor * 5.0) as i32;
EffectResult::new(ascii_art.render()).with_offset(0, offset_y)
}
fn name(&self) -> &str {
"bounce-top"
}
}
// Bounce-bottom effect - bounce up from bottom
pub struct BounceBottom;
impl Effect for BounceBottom {
fn apply(&self, ascii_art: &AsciiArt, progress: f64) -> EffectResult {
// Bounce from bottom with easing
let bounces = 2.0;
let bounce_height = ascii_art.height() as f64 + 10.0;
let base_offset = (1.0 - progress) * bounce_height;
let bounce_factor =
(progress * bounces * std::f64::consts::PI).sin().abs() * (1.0 - progress);
let offset_y = (base_offset + bounce_factor * 5.0) as i32;
EffectResult::new(ascii_art.render()).with_offset(0, offset_y)
}
fn name(&self) -> &str {
"bounce-bottom"
}
}
// Tilt-in effect - tilt in with perspective simulation
pub struct TiltIn;
impl Effect for TiltIn {
fn apply(&self, ascii_art: &AsciiArt, progress: f64) -> EffectResult {
// Simulate tilting in with combined scale and offset
let tilt_progress = 1.0 - progress;
let scale = 0.5 + (progress * 0.5);
let offset_x = (tilt_progress * 20.0 * (tilt_progress * std::f64::consts::PI).sin()) as i32;
let offset_y = -(tilt_progress * 15.0) as i32;
let scaled = ascii_art.scale(scale);
EffectResult::new(scaled.render())
.with_scale(scale)
.with_offset(offset_x, offset_y)
}
fn name(&self) -> &str {
"tilt-in"
}
}
// Phase 3: Additional Slide, Blink, Focus, and Shadow Effects
// Slide-out-top effect - slide out to top
pub struct SlideOutTop;
impl Effect for SlideOutTop {
fn apply(&self, ascii_art: &AsciiArt, progress: f64) -> EffectResult {
let offset_y = -(progress * (ascii_art.height() as f64 + 10.0)) as i32;
EffectResult::new(ascii_art.render()).with_offset(0, offset_y)
}
fn name(&self) -> &str {
"slide-out-top"
}
}
// Slide-out-bottom effect - slide out to bottom
pub struct SlideOutBottom;
impl Effect for SlideOutBottom {
fn apply(&self, ascii_art: &AsciiArt, progress: f64) -> EffectResult {
let offset_y = (progress * (ascii_art.height() as f64 + 10.0)) as i32;
EffectResult::new(ascii_art.render()).with_offset(0, offset_y)
}
fn name(&self) -> &str {
"slide-out-bottom"
}
}
// Slide-out-left effect - slide out to left
pub struct SlideOutLeft;
impl Effect for SlideOutLeft {
fn apply(&self, ascii_art: &AsciiArt, progress: f64) -> EffectResult {
let offset_x = -(progress * (ascii_art.width() as f64 + 10.0)) as i32;
EffectResult::new(ascii_art.render()).with_offset(offset_x, 0)
}
fn name(&self) -> &str {
"slide-out-left"
}
}
// Slide-out-right effect - slide out to right
pub struct SlideOutRight;
impl Effect for SlideOutRight {
fn apply(&self, ascii_art: &AsciiArt, progress: f64) -> EffectResult {
let offset_x = (progress * (ascii_art.width() as f64 + 10.0)) as i32;
EffectResult::new(ascii_art.render()).with_offset(offset_x, 0)
}
fn name(&self) -> &str {
"slide-out-right"
}
}
// Blink effect - rapid on/off blinking
pub struct Blink;
impl Effect for Blink {
fn apply(&self, ascii_art: &AsciiArt, progress: f64) -> EffectResult {
// Blink 3 times during animation
let blinks = 6.0;
let blink_state = ((progress * blinks).floor() % 2.0) as i32;
let opacity = if blink_state == 0 { 1.0 } else { 0.0 };
EffectResult::new(ascii_art.render()).with_opacity(opacity)
}
fn name(&self) -> &str {
"blink"
}
}
// Focus-in effect - simulate coming into focus with scale and opacity
pub struct FocusIn;
impl Effect for FocusIn {
fn apply(&self, ascii_art: &AsciiArt, progress: f64) -> EffectResult {
// Start blurry (small scale, low opacity) and come into focus
let scale = 0.7 + (progress * 0.3);
let opacity = progress.powf(0.5);
let scaled = ascii_art.scale(scale);
EffectResult::new(scaled.render())
.with_scale(scale)
.with_opacity(opacity)
}
fn name(&self) -> &str {
"focus-in"
}
}
// Blur-out effect - simulate going out of focus
pub struct BlurOut;
impl Effect for BlurOut {
fn apply(&self, ascii_art: &AsciiArt, progress: f64) -> EffectResult {
// Go out of focus (reduce scale, reduce opacity)
let scale = 1.0 - (progress * 0.3);
let opacity = (1.0 - progress).powf(0.5);
let scaled = ascii_art.scale(scale);
EffectResult::new(scaled.render())
.with_scale(scale)
.with_opacity(opacity)
}
fn name(&self) -> &str {
"blur-out"
}
}
// Shadow-drop effect - drop down with shadow simulation
pub struct ShadowDrop;
impl Effect for ShadowDrop {
fn apply(&self, ascii_art: &AsciiArt, progress: f64) -> EffectResult {
// Drop down from above with increasing shadow (opacity)
let drop_distance = 20.0;
let offset_y = -((1.0 - progress) * drop_distance) as i32;
let opacity = 0.3 + (progress * 0.7); // Start semi-transparent
EffectResult::new(ascii_art.render())
.with_offset(0, offset_y)
.with_opacity(opacity)
}
fn name(&self) -> &str {
"shadow-drop"
}
}
// Shadow-pop effect - pop forward with shadow simulation
pub struct ShadowPop;
impl Effect for ShadowPop {
fn apply(&self, ascii_art: &AsciiArt, progress: f64) -> EffectResult {
// Scale up quickly then settle, simulating popping forward
let pop_scale = if progress < 0.5 {
1.0 + (progress * 2.0) * 0.3
} else {
1.3 - ((progress - 0.5) * 2.0) * 0.3
};
let scaled = ascii_art.scale(pop_scale);
EffectResult::new(scaled.render()).with_scale(pop_scale)
}
fn name(&self) -> &str {
"shadow-pop"
}
}
// Rotate-center effect - rotate around center point
pub struct RotateCenter;
impl Effect for RotateCenter {
fn apply(&self, ascii_art: &AsciiArt, progress: f64) -> EffectResult {
// Simulate rotation with alternating line offsets
let rotations = 1.0;
let angle = progress * rotations * std::f64::consts::PI * 2.0;
let max_offset = 5.0;
let lines: Vec<String> = ascii_art
.get_lines()
.iter()
.enumerate()
.map(|(i, line)| {
let line_factor = (i as f64 / ascii_art.get_lines().len().max(1) as f64) - 0.5;
let offset = (angle.sin() * line_factor * max_offset) as i32;
if offset > 0 {
format!("{}{}", " ".repeat(offset as usize), line)
} else if offset < 0 {
line.chars().skip(offset.unsigned_abs() as usize).collect()
} else {
line.to_string()
}
})
.collect();
EffectResult::new(lines.join("\n"))
}
fn name(&self) -> &str {
"rotate-center"
}
}
/// Get effect by name
pub fn get_effect(name: &str) -> Result<Box<dyn Effect>> {
match name {
@@ -417,6 +1003,36 @@ pub fn get_effect(name: &str) -> Result<Box<dyn Effect>> {
"gradient-flow" => Ok(Box::new(GradientFlow)),
"rotate-in" => Ok(Box::new(RotateIn)),
"rotate-out" => Ok(Box::new(RotateOut)),
"shake" => Ok(Box::new(Shake)),
"wobble" => Ok(Box::new(Wobble)),
"vibrate" => Ok(Box::new(Vibrate)),
"heartbeat" => Ok(Box::new(Heartbeat)),
"flip-horizontal" => Ok(Box::new(FlipHorizontal)),
"flip-vertical" => Ok(Box::new(FlipVertical)),
"swing" => Ok(Box::new(Swing)),
"sway" => Ok(Box::new(Sway)),
"roll-in" => Ok(Box::new(RollIn)),
"roll-out" => Ok(Box::new(RollOut)),
"puff-in" => Ok(Box::new(PuffIn)),
"puff-out" => Ok(Box::new(PuffOut)),
"slide-rotate-hor" => Ok(Box::new(SlideRotateHor)),
"slide-rotate-ver" => Ok(Box::new(SlideRotateVer)),
"flicker" => Ok(Box::new(Flicker)),
"tracking-in" => Ok(Box::new(TrackingIn)),
"tracking-out" => Ok(Box::new(TrackingOut)),
"bounce-top" => Ok(Box::new(BounceTop)),
"bounce-bottom" => Ok(Box::new(BounceBottom)),
"tilt-in" => Ok(Box::new(TiltIn)),
"slide-out-top" => Ok(Box::new(SlideOutTop)),
"slide-out-bottom" => Ok(Box::new(SlideOutBottom)),
"slide-out-left" => Ok(Box::new(SlideOutLeft)),
"slide-out-right" => Ok(Box::new(SlideOutRight)),
"blink" => Ok(Box::new(Blink)),
"focus-in" => Ok(Box::new(FocusIn)),
"blur-out" => Ok(Box::new(BlurOut)),
"shadow-drop" => Ok(Box::new(ShadowDrop)),
"shadow-pop" => Ok(Box::new(ShadowPop)),
"rotate-center" => Ok(Box::new(RotateCenter)),
_ => bail!("Unknown effect: {}", name),
}
}
@@ -446,5 +1062,35 @@ pub fn list_effects() -> Vec<&'static str> {
"gradient-flow",
"rotate-in",
"rotate-out",
"shake",
"wobble",
"vibrate",
"heartbeat",
"flip-horizontal",
"flip-vertical",
"swing",
"sway",
"roll-in",
"roll-out",
"puff-in",
"puff-out",
"slide-rotate-hor",
"slide-rotate-ver",
"flicker",
"tracking-in",
"tracking-out",
"bounce-top",
"bounce-bottom",
"tilt-in",
"slide-out-top",
"slide-out-bottom",
"slide-out-left",
"slide-out-right",
"blink",
"focus-in",
"blur-out",
"shadow-drop",
"shadow-pop",
"rotate-center",
]
}

View File

@@ -43,7 +43,7 @@ impl AnimationEngine {
self
}
pub async fn run(&self, terminal: &mut TerminalManager) -> Result<()> {
pub async fn run(&self, terminal: &mut TerminalManager) -> Result<bool> {
let renderer = renderer::Renderer::new(
&self.ascii_art,
self.duration_ms,

View File

@@ -2,6 +2,12 @@ use crate::animation::{easing::EasingFunction, effects::Effect, timeline::Timeli
use crate::color::{apply, ColorEngine};
use crate::utils::{ansi, ascii::AsciiArt, terminal::TerminalManager};
use anyhow::Result;
use crossterm::event::{self, Event, KeyCode, KeyModifiers};
use std::sync::{
atomic::{AtomicBool, Ordering},
Arc,
};
use std::time::Duration;
use tokio::time::sleep;
pub struct Renderer<'a> {
@@ -30,17 +36,52 @@ impl<'a> Renderer<'a> {
}
}
pub async fn render(&self, terminal: &mut TerminalManager) -> Result<()> {
pub async fn render(&self, terminal: &mut TerminalManager) -> Result<bool> {
let mut timeline = Timeline::new(self.timeline.duration_ms(), self.timeline.fps());
timeline.start();
// Spawn background thread to listen for exit keys
let should_exit = Arc::new(AtomicBool::new(false));
let should_exit_clone = should_exit.clone();
std::thread::spawn(move || loop {
if let Ok(true) = event::poll(Duration::from_millis(100)) {
if let Ok(Event::Key(key)) = event::read() {
match key.code {
KeyCode::Char('q') | KeyCode::Esc => {
should_exit_clone.store(true, Ordering::Relaxed);
break;
}
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
should_exit_clone.store(true, Ordering::Relaxed);
break;
}
_ => {}
}
}
}
if should_exit_clone.load(Ordering::Relaxed) {
break;
}
});
loop {
// Check for exit FIRST
if should_exit.load(Ordering::Relaxed) {
return Ok(true); // User requested exit
}
let frame_start = std::time::Instant::now();
// Calculate progress with easing
let linear_progress = timeline.progress();
let eased_progress = self.easing.ease(linear_progress);
// Check again before rendering
if should_exit.load(Ordering::Relaxed) {
return Ok(true); // User requested exit
}
// Apply effect
let effect_result = self.effect.apply(self.ascii_art, eased_progress);
@@ -51,6 +92,11 @@ impl<'a> Renderer<'a> {
effect_result.text.clone()
};
// Check before terminal operations
if should_exit.load(Ordering::Relaxed) {
return Ok(true); // User requested exit
}
// Render to terminal
terminal.clear()?;
terminal.refresh_size()?;
@@ -62,7 +108,11 @@ impl<'a> Renderer<'a> {
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| ansi::visual_width(l)).max().unwrap_or(0) as i32;
let text_width = lines
.iter()
.map(|l| ansi::visual_width(l))
.max()
.unwrap_or(0) as i32;
let base_x = (width as i32 - text_width) / 2;
let base_y = (height as i32 - text_height) / 2;
@@ -78,9 +128,14 @@ impl<'a> Renderer<'a> {
}
}
// Check if user wants to exit
if should_exit.load(Ordering::Relaxed) {
return Ok(true); // User requested exit
}
// Check if animation is complete before advancing
if timeline.is_complete() {
break;
return Ok(false); // Animation completed naturally
}
// Advance to next frame and wait
@@ -89,11 +144,21 @@ impl<'a> Renderer<'a> {
let elapsed = frame_start.elapsed();
if elapsed < frame_duration {
sleep(frame_duration - elapsed).await;
let sleep_duration = frame_duration - elapsed;
// Break sleep into small chunks to check should_exit frequently
let chunk_duration = Duration::from_millis(5);
let mut remaining = sleep_duration;
while remaining > Duration::ZERO {
if should_exit.load(Ordering::Relaxed) {
return Ok(true); // User requested exit during sleep
}
let sleep_time = remaining.min(chunk_duration);
sleep(sleep_time).await;
remaining = remaining.saturating_sub(sleep_time);
}
}
}
Ok(())
}
fn apply_colors(&self, text: &str, progress: f64) -> String {

View File

@@ -61,8 +61,14 @@ async fn run_piglet(args: PigletCli) -> Result<()> {
// Run animation
loop {
animation_engine.run(&mut terminal).await?;
let user_exited = animation_engine.run(&mut terminal).await?;
// If user pressed exit key, stop looping
if user_exited {
break;
}
// If not looping, stop after one animation
if !args.loop_animation {
break;
}

View File

@@ -8,7 +8,7 @@ pub fn strip_ansi(text: &str) -> String {
// Skip ANSI escape sequence
if chars.peek() == Some(&'[') {
chars.next(); // consume '['
// Skip until we hit a letter (the command character)
// Skip until we hit a letter (the command character)
while let Some(&c) = chars.peek() {
chars.next();
if c.is_ascii_alphabetic() {

View File

@@ -69,7 +69,11 @@ impl TerminalManager {
pub fn print_centered(&self, text: &str) -> Result<()> {
let lines: Vec<&str> = text.lines().collect();
let max_width = lines.iter().map(|l| ansi::visual_width(l)).max().unwrap_or(0) as u16;
let max_width = lines
.iter()
.map(|l| ansi::visual_width(l))
.max()
.unwrap_or(0) as u16;
let height = lines.len() as u16;
let start_x = (self.width.saturating_sub(max_width)) / 2;