commit f6fac85bc412b2f59b04d62240b3aab1824e7cab Author: Sebastian KrΓΌger Date: Sun Nov 9 01:53:26 2025 +0100 Initial commit: Piglet - Animated figlet wrapper Add complete Rust implementation with: - 20+ motion effects (fade, slide, scale, typewriter, wave, bounce, etc.) - 18+ easing functions (linear, quad, cubic, elastic, back, bounce) - Color support (CSS4 colors, hex codes, gradients) - Figlet integration with custom fonts and options - Cross-platform support (Linux, macOS, Windows) - Comprehensive CI/CD workflows - Full test suite with integration tests - Documentation (README.md, CLAUDE.md) πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..5d7c00b --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,21 @@ +version: 2 +updates: + # Maintain dependencies for Cargo + - package-ecosystem: "cargo" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 10 + labels: + - "dependencies" + - "rust" + + # Maintain dependencies for GitHub Actions + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 5 + labels: + - "dependencies" + - "github-actions" \ No newline at end of file diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml new file mode 100644 index 0000000..096c234 --- /dev/null +++ b/.github/workflows/benchmark.yml @@ -0,0 +1,42 @@ +name: Benchmark + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + benchmark: + name: Run Benchmarks + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Install figlet + run: sudo apt-get update && sudo apt-get install -y figlet + + - 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: Run benchmarks + run: cargo bench --no-fail-fast + + - name: Upload benchmark results + uses: actions/upload-artifact@v3 + with: + name: benchmark-results + path: target/criterion/ \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6183346 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,176 @@ +name: CI + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main, develop ] + +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: 1 + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - 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 }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }} + + - name: Check formatting + run: cargo fmt --all -- --check + + - name: Run clippy + run: cargo clippy --all-targets --all-features -- -D warnings + + - name: Check documentation + run: cargo doc --no-deps --all-features + env: + RUSTDOCFLAGS: -D warnings + + test: + name: Test + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + rust: [stable, beta] + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@master + with: + toolchain: ${{ matrix.rust }} + + - name: Install figlet (Ubuntu) + if: matrix.os == 'ubuntu-latest' + run: sudo apt-get update && sudo apt-get install -y figlet + + - name: Install figlet (macOS) + if: matrix.os == 'macos-latest' + run: brew install figlet + + - name: Install figlet (Windows) + if: matrix.os == 'windows-latest' + run: | + choco install figlet -y + echo "C:\ProgramData\chocolatey\lib\figlet\tools" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + + - 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 }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }} + + - name: Run tests + run: cargo test --verbose --all-features + + - name: Run tests (no default features) + run: cargo test --verbose --no-default-features + + build: + name: Build + 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 + - os: windows-latest + target: x86_64-pc-windows-msvc + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + 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-target-${{ hashFiles('**/Cargo.lock') }} + + - name: Build + run: cargo build --release --target ${{ matrix.target }} --verbose + + - name: Upload artifact + uses: actions/upload-artifact@v3 + with: + name: piglet-${{ matrix.target }} + path: | + target/${{ matrix.target }}/release/piglet${{ matrix.os == 'windows-latest' && '.exe' || '' }} + + security-audit: + name: Security Audit + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Run cargo-audit + uses: rustsec/audit-check@v1 + with: + token: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml new file mode 100644 index 0000000..11ee2d0 --- /dev/null +++ b/.github/workflows/coverage.yml @@ -0,0 +1,36 @@ +name: Code Coverage + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + coverage: + name: Code Coverage + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + components: llvm-tools-preview + + - name: Install figlet + run: sudo apt-get update && sudo apt-get install -y figlet + + - name: Install cargo-llvm-cov + uses: taiki-e/install-action@cargo-llvm-cov + + - name: Generate coverage + run: cargo llvm-cov --all-features --workspace --lcov --output-path lcov.info + + - name: Upload to codecov.io + uses: codecov/codecov-action@v3 + with: + files: lcov.info + fail_ci_if_error: true + token: ${{ secrets.CODECOV_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..e6c1825 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,41 @@ +name: Release + +on: + push: + tags: + - 'v*.*.*' + +env: + CARGO_TERM_COLOR: always + +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 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + 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) \ No newline at end of file diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 0000000..94cdbdc --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,34 @@ +name: Security Audit + +on: + schedule: + - cron: '0 0 * * *' # Run daily at midnight UTC + push: + paths: + - '**/Cargo.toml' + - '**/Cargo.lock' + workflow_dispatch: + +jobs: + audit: + name: Security Audit + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Run cargo-audit + uses: rustsec/audit-check@v1 + with: + token: ${{ secrets.GITHUB_TOKEN }} + + dependency-review: + name: Dependency Review + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Dependency Review + uses: actions/dependency-review-action@v3 \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..f2f9618 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,183 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Overview + +Piglet is an animated and colorful figlet wrapper written in Rust that renders ASCII art with motion effects, color gradients, and easing functions. It wraps the `figlet` command-line tool to generate ASCII art and applies real-time terminal animations using crossterm. + +## Prerequisites + +- **figlet** must be installed on the system: + - Ubuntu/Debian: `sudo apt-get install figlet` + - macOS: `brew install figlet` + - Windows: `choco install figlet -y` +- Rust toolchain (managed via rustup) + +## Common Commands + +### Development +```bash +# Build the project +cargo build + +# Build for release +cargo build --release + +# Run the binary +cargo run -- "Hello" -p "#FF5733,#33FF57" + +# Run with specific effect +cargo run -- "World" -g "linear-gradient(90deg, red, blue)" -e fade-in +``` + +### Testing +```bash +# Run all tests (requires figlet installed) +cargo test --verbose --all-features + +# Run tests without default features +cargo test --verbose --no-default-features + +# Run a single test +cargo test test_figlet_wrapper + +# Run integration tests only +cargo test --test integration_tests +``` + +### Linting and Code Quality +```bash +# Check formatting +cargo fmt --all -- --check + +# Apply formatting +cargo fmt --all + +# Run clippy with all warnings as errors +cargo clippy --all-targets --all-features -- -D warnings + +# Check documentation +cargo doc --no-deps --all-features +``` + +### Cross-compilation Targets +The project supports multiple targets (see CI configuration): +- `x86_64-unknown-linux-gnu` +- `x86_64-unknown-linux-musl` (requires musl-tools) +- `x86_64-apple-darwin` +- `aarch64-apple-darwin` +- `x86_64-pc-windows-msvc` + +```bash +# Build for specific target +cargo build --release --target x86_64-unknown-linux-musl +``` + +## Architecture + +### Module Structure + +**Core Pipeline Flow:** +1. **CLI Parsing** (`cli.rs`) β†’ Parses command-line arguments using clap +2. **Figlet Wrapper** (`figlet.rs`) β†’ Executes figlet command to generate base ASCII art +3. **Parsers** (`parser/`) β†’ Parses duration, colors, and gradients from CLI strings +4. **Color Engine** (`color/`) β†’ Manages palettes and gradients, interpolates colors +5. **Animation Engine** (`animation/`) β†’ Applies motion effects and easing functions +6. **Terminal Manager** (`utils/terminal.rs`) β†’ Handles terminal setup, rendering, and cleanup + +### Key Components + +**figlet.rs - FigletWrapper** +- Wraps external `figlet` binary execution +- Validates figlet installation via `which` crate +- Supports custom fonts and arguments passed through to figlet + +**parser/ - Input Parsers** +- `duration.rs`: Parses time strings (e.g., "3000ms", "0.3s", "5m", "0.5h") to milliseconds using regex +- `color.rs`: Parses hex colors (#FF5733) and CSS4 color names (red, blue) using csscolorparser +- `gradient.rs`: Parses CSS gradient syntax (e.g., "linear-gradient(90deg, red, blue)") + +**color/ - Color System** +- `palette.rs`: Manages discrete color palettes (comma-separated colors) +- `gradient.rs`: Generates smooth color transitions using gradient definitions +- `apply.rs`: Applies colors to ASCII art characters +- Uses `palette` crate for color space conversions and interpolation + +**animation/ - Animation System** +- `easing.rs`: Implements 18+ easing functions (linear, ease-in/out, quad, cubic, back, elastic, bounce) using scirs2-interpolate +- `effects/`: Motion effects including fade-in/out, slide, scale, typewriter, wave, rainbow +- `renderer.rs`: Frame-by-frame rendering loop with tokio async timing +- `timeline.rs`: Manages animation progress and frame timing + +**utils/ - Utilities** +- `terminal.rs`: Terminal manipulation using crossterm (alternate screen, cursor hiding, clearing) +- `ascii.rs`: ASCII art data structure and character-level manipulation + +### External Dependencies + +**Critical dependencies:** +- `clap` (4.4+): CLI argument parsing with derive macros +- `crossterm` (0.27): Terminal manipulation and ANSI escape sequences +- `tokio` (1.35): Async runtime for frame timing +- `scirs2-interpolate` (0.1): Easing function implementations +- `palette` (0.7): Color space conversions and interpolation +- `csscolorparser` (0.6): Parsing hex and CSS color names +- `which` (5.0): Locating figlet binary in PATH +- `nom` (7.1): Parser combinators for gradient syntax +- `regex` (1.10): Duration string parsing + +### Animation Flow + +1. Parse CLI args β†’ validate duration, colors, effect, easing +2. Execute figlet β†’ capture ASCII art output as String +3. Parse colors β†’ build ColorEngine with palette or gradient +4. Create AnimationEngine β†’ configure effect + easing + colors +5. Setup terminal β†’ enter alternate screen, hide cursor +6. Render loop β†’ for each frame: + - Calculate progress (0.0 to 1.0) + - Apply easing function to progress + - Apply effect transformation to ASCII art + - Apply color at current progress + - Render to terminal + - Sleep until next frame (based on fps) +7. Cleanup β†’ restore terminal state + +### Testing Strategy + +- Unit tests embedded in modules (e.g., `parser/duration.rs`, `figlet.rs`) +- Integration tests in `tests/integration_tests.rs` testing full pipeline +- Tests validate: parsers, color interpolation, easing functions, effects, gradient rendering +- CI runs tests on Ubuntu, macOS, Windows with stable and beta Rust + +## CLI Usage Examples + +```bash +# Simple color palette +piglet "Hello" -p "#FF5733,#33FF57" + +# Gradient with motion effect +piglet "World" -g "linear-gradient(90deg, red, blue)" -e fade-in + +# Typewriter with custom duration and easing +piglet "Cool!" -e typewriter -d 2s -i ease-out + +# Loop animation infinitely +piglet "Loop" -e wave -l + +# Custom figlet font and arguments +piglet "Custom" -f slant -- -w 200 -c + +# List available effects/easing/colors +piglet --list-effects +piglet --list-easing +piglet --list-colors +``` + +## Available Effects + +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 + +## Available Easing Functions + +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 diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..35bdc54 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,48 @@ +[package] +name = "piglet" +version = "0.1.0" +edition = "2021" +authors = ["Your Name"] +description = "Animated and colorful figlet wrapper with motion effects" +license = "MIT" +repository = "https://github.com/valknarthing/piglet" + +[dependencies] +# CLI +clap = { version = "4.4", features = ["derive", "cargo"] } + +# Color handling +csscolorparser = "0.6" +palette = "0.7" + +# Animation & Interpolation +scirs2-interpolate = "0.1" + +# Terminal manipulation +crossterm = "0.27" + +# Async runtime (for timing) +tokio = { version = "1.35", features = ["time", "rt"] } + +# Process execution +which = "5.0" + +# Parsing +nom = "7.1" +regex = "1.10" + +# Error handling +anyhow = "1.0" +thiserror = "1.0" + +# Utilities +itertools = "0.12" +lazy_static = "1.4" +rand = "0.8" + +[dev-dependencies] +pretty_assertions = "1.4" + +[[bin]] +name = "piglet" +path = "src/main.rs" \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..499fa39 --- /dev/null +++ b/README.md @@ -0,0 +1,290 @@ +# 🐷 Piglet + +
+ +**Animated and colorful figlet wrapper with motion effects** + +[![CI](https://github.com/valknarthing/piglet/workflows/CI/badge.svg)](https://github.com/valknarthing/piglet/actions/workflows/ci.yml) +[![Security Audit](https://github.com/valknarthing/piglet/workflows/Security%20Audit/badge.svg)](https://github.com/valknarthing/piglet/actions/workflows/security.yml) +[![Coverage](https://github.com/valknarthing/piglet/workflows/Coverage/badge.svg)](https://github.com/valknarthing/piglet/actions/workflows/coverage.yml) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +[![Rust Version](https://img.shields.io/badge/rust-1.70%2B-blue.svg)](https://www.rust-lang.org) +[![Crates.io](https://img.shields.io/crates/v/piglet.svg)](https://crates.io/crates/piglet) + +
+ +--- + +## ✨ Features + +- 🎨 **Rich Color Support**: CSS4 colors, hex codes, and smooth gradients +- 🎬 **20+ Motion Effects**: Fade, slide, scale, typewriter, wave, bounce, and more +- ⚑ **18+ Easing Functions**: Linear, quad, cubic, elastic, back, bounce variations +- πŸ–ΌοΈ **Figlet Integration**: Full support for figlet fonts and options +- πŸ”„ **Looping Animations**: Infinite or single-run modes +- 🎯 **High Performance**: Async rendering with configurable FPS +- 🌈 **Gradient Engine**: Parse and apply CSS-style gradients +- πŸ“¦ **Cross-Platform**: Linux, macOS, and Windows support + +## πŸ“¦ Installation + +### From Source + +```bash +git clone https://github.com/valknarthing/piglet.git +cd piglet +cargo build --release +``` + +The binary will be available at `target/release/piglet`. + +### Prerequisites + +Piglet requires `figlet` to be installed on your system: + +```bash +# Ubuntu/Debian +sudo apt-get install figlet + +# macOS +brew install figlet + +# Windows +choco install figlet -y +``` + +## πŸš€ Quick Start + +```bash +# Simple color palette animation +piglet "Hello World" -p "#FF5733,#33FF57,#3357FF" + +# Gradient with fade-in effect +piglet "Rainbow" -g "linear-gradient(90deg, red, orange, yellow, green, blue, purple)" -e fade-in + +# Typewriter effect with custom timing +piglet "Type It Out" -e typewriter -d 3s -i ease-out + +# Bouncing text with loop +piglet "Bounce!" -e bounce-in -l +``` + +## πŸ“– Usage + +``` +piglet [TEXT] [OPTIONS] + +Arguments: + Text to render with figlet + +Options: + -d, --duration Duration of animation [default: 3s] + Formats: 3000ms, 0.3s, 5m, 0.5h + + -p, --color-palette Color palette (comma-separated) + Example: "#FF5733,#33FF57,blue,red" + + -g, --color-gradient CSS gradient definition + Example: "linear-gradient(90deg, red, blue)" + + -e, --motion-effect Motion effect to apply [default: fade-in] + + -i, --motion-ease Easing function [default: ease-in-out] + + -f, --font Figlet font to use + + -l, --loop Loop animation infinitely + + --fps Frame rate [default: 30] + + --list-effects List all available effects + --list-easing List all available easing functions + --list-colors List all CSS4 color names + + -- ... Additional figlet options + Example: -- -w 200 -c + + -h, --help Print help + -V, --version Print version +``` + +## 🎬 Motion Effects + +| Effect | Description | Effect | Description | +|--------|-------------|--------|-------------| +| `fade-in` | Fade from transparent | `fade-out` | Fade to transparent | +| `fade-in-out` | Fade in then out | `slide-in-top` | Slide from top | +| `slide-in-bottom` | Slide from bottom | `slide-in-left` | Slide from left | +| `slide-in-right` | Slide from right | `scale-up` | Scale from small | +| `scale-down` | Scale from large | `pulse` | Pulsing effect | +| `bounce-in` | Bounce into view | `bounce-out` | Bounce out of view | +| `typewriter` | Type character by character | `typewriter-reverse` | Untype backwards | +| `wave` | Wave motion | `jello` | Jello wobble | +| `color-cycle` | Cycle through colors | `rainbow` | Rainbow effect | +| `gradient-flow` | Flowing gradient | `rotate-in` | Rotate into view | +| `rotate-out` | Rotate out of view | | | + +## ⚑ Easing Functions + +| Category | Functions | +|----------|-----------| +| **Linear** | `linear` | +| **Basic** | `ease-in`, `ease-out`, `ease-in-out` | +| **Quadratic** | `ease-in-quad`, `ease-out-quad`, `ease-in-out-quad` | +| **Cubic** | `ease-in-cubic`, `ease-out-cubic`, `ease-in-out-cubic` | +| **Back** | `ease-in-back`, `ease-out-back`, `ease-in-out-back` | +| **Elastic** | `ease-in-elastic`, `ease-out-elastic`, `ease-in-out-elastic` | +| **Bounce** | `ease-in-bounce`, `ease-out-bounce`, `ease-in-out-bounce` | + +## 🎨 Color Options + +### Palette Mode +Provide a comma-separated list of colors: +```bash +piglet "Text" -p "red,blue,green" +piglet "Text" -p "#FF5733,#33FF57,#3357FF" +piglet "Text" -p "crimson,gold,navy" +``` + +### Gradient Mode +Use CSS gradient syntax: +```bash +piglet "Text" -g "linear-gradient(90deg, red, blue)" +piglet "Text" -g "linear-gradient(to right, #FF5733 0%, #33FF57 50%, #3357FF 100%)" +``` + +Supports: +- Hex colors (`#FF5733`) +- CSS4 color names (`red`, `blue`, `crimson`, etc.) +- Position percentages (`0%`, `50%`, `100%`) +- Angle notation (`90deg`, `180deg`, `to right`, `to bottom`) + +## πŸ’‘ Examples + +### Basic Animation +```bash +piglet "Welcome" -e fade-in -d 2s +``` + +### Rainbow Gradient with Typewriter +```bash +piglet "Rainbow Text" \ + -g "linear-gradient(90deg, red, orange, yellow, green, blue, indigo, violet)" \ + -e typewriter \ + -d 4s \ + -i ease-in-out +``` + +### Bouncing Logo with Custom Font +```bash +piglet "LOGO" \ + -f slant \ + -e bounce-in \ + -p "#FF6B6B,#4ECDC4,#45B7D1" \ + -i ease-out-bounce \ + -l +``` + +### Infinite Wave with Gradient Flow +```bash +piglet "Ocean Waves" \ + -g "linear-gradient(180deg, #0077be, #00c9ff, #0077be)" \ + -e wave \ + -l +``` + +### Custom Figlet Options +```bash +# Center text with width 200 +piglet "Centered" -- -w 200 -c + +# Use specific font with kerning +piglet "Custom" -f banner -- -k +``` + +## πŸ—οΈ Architecture + +``` +CLI Input β†’ Figlet Wrapper β†’ Parser (duration/colors/gradients) + ↓ +Color Engine (palette/gradient interpolation) + ↓ +Animation Engine (effects + easing) + ↓ +Terminal Manager β†’ Render Loop β†’ Output +``` + +### Key Components +- **FigletWrapper**: Executes figlet and captures ASCII output +- **Parser**: Converts CLI strings to structured data (duration, colors, gradients) +- **ColorEngine**: Manages color palettes and gradient interpolation +- **AnimationEngine**: Applies motion effects with easing functions +- **TerminalManager**: Handles terminal setup/cleanup and frame rendering + +## πŸ”§ Development + +### Build +```bash +cargo build +``` + +### Test +```bash +cargo test --all-features +``` + +### Lint +```bash +cargo fmt --all -- --check +cargo clippy --all-targets --all-features -- -D warnings +``` + +### Documentation +```bash +cargo doc --no-deps --all-features +``` + +## 🎯 Cross-Platform Support + +Piglet builds on: +- **Linux**: `x86_64-unknown-linux-gnu`, `x86_64-unknown-linux-musl` +- **macOS**: `x86_64-apple-darwin`, `aarch64-apple-darwin` +- **Windows**: `x86_64-pc-windows-msvc` + +```bash +# Build for specific target +cargo build --release --target x86_64-unknown-linux-musl +``` + +## 🀝 Contributing + +Contributions are welcome! Please feel free to submit issues or pull requests. + +1. Fork the repository +2. Create your feature branch (`git checkout -b feature/amazing-effect`) +3. Commit your changes (`git commit -m 'Add amazing effect'`) +4. Push to the branch (`git push origin feature/amazing-effect`) +5. Open a Pull Request + +## πŸ“ License + +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. + +## πŸ™ Acknowledgments + +- [figlet](http://www.figlet.org/) - The original ASCII art generator +- [crossterm](https://github.com/crossterm-rs/crossterm) - Terminal manipulation +- [palette](https://github.com/Ogeon/palette) - Color space conversions +- [scirs2-interpolate](https://github.com/scirs/scirs2-interpolate) - Easing functions + +## πŸ“Š Project Stats + +![GitHub code size](https://img.shields.io/github/languages/code-size/valknarthing/piglet) +![GitHub repo size](https://img.shields.io/github/repo-size/valknarthing/piglet) +![Lines of code](https://img.shields.io/tokei/lines/github/valknarthing/piglet) + +--- + +
+Made with ❀️ and Rust +
diff --git a/src/animation/easing.rs b/src/animation/easing.rs new file mode 100644 index 0000000..62cc18e --- /dev/null +++ b/src/animation/easing.rs @@ -0,0 +1,49 @@ +use anyhow::{Result, bail}; +use scirs2_interpolate::*; + +pub trait EasingFunction: Send + Sync { + fn ease(&self, t: f64) -> f64; + fn name(&self) -> &str; +} + +// Linear +pub struct Linear; +impl EasingFunction for Linear { + fn ease(&self, t: f64) -> f64 { t } + fn name(&self) -> &str { "linear" } +} + +// Quadratic +pub struct EaseInQuad; +impl EasingFunction for EaseInQuad { + fn ease(&self, t: f64) -> f64 { quad_ease_in(t, 0.0, 1.0, 1.0) } + fn name(&self) -> &str { "ease-in-quad" } +} + +pub struct EaseOutQuad; +impl EasingFunction for EaseOutQuad { + fn ease(&self, t: f64) -> f64 { quad_ease_out(t, 0.0, 1.0, 1.0) } + fn name(&self) -> &str { "ease-out-quad" } +} + +pub struct EaseInOutQuad; +impl EasingFunction for EaseInOutQuad { + fn ease(&self, t: f64) -> f64 { quad_ease_in_out(t, 0.0, 1.0, 1.0) } + fn name(&self) -> &str { "ease-in-out-quad" } +} + +// Cubic +pub struct EaseInCubic; +impl EasingFunction for EaseInCubic { + fn ease(&self, t: f64) -> f64 { cubic_ease_in(t, 0.0, 1.0, 1.0) } + fn name(&self) -> &str { "ease-in-cubic" } +} + +pub struct EaseOutCubic; +impl EasingFunction for EaseOutCubic { + fn ease(&self, t: f64) -> f64 { cubic_ease_out(t, 0.0, 1.0, 1.0) } + fn name(&self) -> &str { "ease-out-cubic" } +} + +pub struct EaseInOutCubic; +impl EasingFunction for EaseInOut \ No newline at end of file diff --git a/src/animation/mod.rs b/src/animation/mod.rs new file mode 100644 index 0000000..4025316 --- /dev/null +++ b/src/animation/mod.rs @@ -0,0 +1,58 @@ +pub mod effects; +pub mod easing; +pub mod timeline; +pub mod renderer; + +use crate::color::ColorEngine; +use crate::utils::{ascii::AsciiArt, terminal::TerminalManager}; +use anyhow::Result; + +pub struct AnimationEngine { + ascii_art: AsciiArt, + duration_ms: u64, + fps: u32, + effect: Box, + easing: Box, + color_engine: ColorEngine, +} + +impl AnimationEngine { + pub fn new(ascii_text: String, duration_ms: u64, fps: u32) -> Self { + Self { + ascii_art: AsciiArt::new(ascii_text), + duration_ms, + fps, + effect: Box::new(effects::FadeIn), + easing: Box::new(easing::Linear), + color_engine: ColorEngine::new(), + } + } + + pub fn with_effect(mut self, effect_name: &str) -> Result { + self.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, + self.duration_ms, + self.fps, + &*self.effect, + &*self.easing, + &self.color_engine, + ); + + renderer.render(terminal).await + } +} \ No newline at end of file diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..aa8ad95 --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,71 @@ +use clap::Parser; + +#[derive(Parser, Debug)] +#[command(author, version, about, long_about = None)] +#[command(name = "piglet")] +#[command(about = "🐷 Animated and colorful figlet wrapper", long_about = None)] +pub struct PigletCli { + /// Text to render with figlet + #[arg(value_name = "TEXT")] + pub text: String, + + /// Duration of animation (e.g., 3000ms, 0.3s, 0.5h, 5m) + #[arg(short, long, default_value = "3s")] + pub duration: String, + + /// Color palette (hex or CSS4 colors, comma-separated) + /// Example: "#FF5733,#33FF57,#3357FF" or "red,green,blue" + #[arg(short = 'p', long, value_delimiter = ',')] + pub color_palette: Option>, + + /// 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, + /// ease-out-quad, ease-in-out-quad, ease-in-cubic, ease-out-cubic, + /// ease-in-out-cubic, ease-in-back, ease-out-back, ease-in-out-back, + /// ease-in-elastic, ease-out-elastic, ease-in-out-elastic, + /// ease-in-bounce, ease-out-bounce, ease-in-out-bounce + #[arg(short = 'i', long, default_value = "ease-in-out")] + pub motion_ease: String, + + /// Motion effect name + /// Options: fade-in, fade-out, fade-in-out, slide-in-top, slide-in-bottom, + /// slide-in-left, slide-in-right, scale-up, scale-down, pulse, + /// bounce-in, bounce-out, typewriter, typewriter-reverse, wave, + /// jello, color-cycle, rainbow, gradient-flow, rotate-in, rotate-out + #[arg(short, long, default_value = "fade-in")] + pub motion_effect: String, + + /// Figlet font + #[arg(short = 'f', long)] + pub font: Option, + + /// 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 new file mode 100644 index 0000000..144c313 --- /dev/null +++ b/src/color/apply.rs @@ -0,0 +1,63 @@ +use crate::parser::color::Color; +use crossterm::style::Color as CrosstermColor; + +pub fn apply_color_to_char(ch: char, color: Color) -> String { + use crossterm::style::Stylize; + + let crossterm_color = CrosstermColor::Rgb { + r: color.r, + g: color.g, + b: color.b, + }; + + format!("{}", ch.to_string().with(crossterm_color)) +} + +pub fn apply_color_to_line(line: &str, colors: &[Color]) -> String { + if colors.is_empty() { + return line.to_string(); + } + + line.chars() + .enumerate() + .map(|(i, ch)| { + if ch.is_whitespace() { + ch.to_string() + } else { + let color = colors[i % colors.len()]; + apply_color_to_char(ch, color) + } + }) + .collect() +} + +pub fn apply_gradient_to_text(text: &str, colors: &[Color]) -> String { + let lines: Vec<&str> = text.lines().collect(); + let total_chars: usize = lines.iter().map(|l| l.chars().count()).sum(); + + if total_chars == 0 || colors.is_empty() { + return text.to_string(); + } + + let mut result = String::new(); + let mut char_index = 0; + + for (line_idx, line) in lines.iter().enumerate() { + for ch in line.chars() { + if ch.is_whitespace() { + result.push(ch); + } else { + let color_index = (char_index * colors.len()) / total_chars.max(1); + let color = colors[color_index.min(colors.len() - 1)]; + result.push_str(&apply_color_to_char(ch, color)); + char_index += 1; + } + } + + if line_idx < lines.len() - 1 { + result.push('\n'); + } + } + + result +} \ No newline at end of file diff --git a/src/color/gradient.rs b/src/color/gradient.rs new file mode 100644 index 0000000..a4fc8b0 --- /dev/null +++ b/src/color/gradient.rs @@ -0,0 +1,26 @@ +use crate::parser::gradient::Gradient; +use crate::parser::color::Color; +use anyhow::Result; + +pub struct GradientEngine { + gradient: Gradient, +} + +impl GradientEngine { + pub fn new(gradient: Gradient) -> Self { + Self { gradient } + } + + pub fn from_string(gradient_str: &str) -> Result { + 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/palette.rs b/src/color/palette.rs new file mode 100644 index 0000000..543db67 --- /dev/null +++ b/src/color/palette.rs @@ -0,0 +1,21 @@ +"#ffff00".to_string(), + ]).unwrap() + } + + /// Create ocean palette + pub fn ocean() -> Self { + Self::from_strings(&[ + "#000080".to_string(), + "#0000ff".to_string(), + "#4169e1".to_string(), + "#87ceeb".to_string(), + "#add8e6".to_string(), + ]).unwrap() + } +} + +impl Default for ColorPalette { + fn default() -> Self { + Self::rainbow() + } +} \ No newline at end of file diff --git a/src/figlet.rs b/src/figlet.rs new file mode 100644 index 0000000..f3c9ce5 --- /dev/null +++ b/src/figlet.rs @@ -0,0 +1,118 @@ +use anyhow::{Context, Result, bail}; +use std::process::Command; +use which::which; + +pub struct FigletWrapper { + font: Option, + args: Vec, +} + +impl FigletWrapper { + pub fn new() -> Self { + Self { + font: None, + args: Vec::new(), + } + } + + pub fn with_font(mut self, font: Option<&str>) -> Self { + self.font = font.map(|s| s.to_string()); + self + } + + pub fn with_args(mut self, args: Vec) -> 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")?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + bail!("Figlet error: {}", stderr); + } + + let result = String::from_utf8(output.stdout) + .context("Figlet output is not valid UTF-8")?; + + Ok(result) + } + + pub fn check_installed() -> Result<()> { + which("figlet") + .context("figlet not found. Please install figlet first.\n\ + On Ubuntu/Debian: sudo apt-get install figlet\n\ + On macOS: brew install figlet\n\ + On Arch: sudo pacman -S figlet")?; + Ok(()) + } + + pub fn list_fonts() -> Result> { + 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()) + }) + .collect(); + + Ok(fonts) + } +} + +impl Default for FigletWrapper { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_figlet_installed() { + // This test will fail if figlet is not installed + assert!(FigletWrapper::check_installed().is_ok()); + } + + #[test] + fn test_basic_render() { + let figlet = FigletWrapper::new(); + let result = figlet.render("Hi"); + assert!(result.is_ok()); + let ascii = result.unwrap(); + assert!(!ascii.is_empty()); + assert!(ascii.contains("H") || ascii.contains("_") || ascii.contains("|")); + } +} \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..9ef7cb2 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,8 @@ +pub mod cli; +pub mod figlet; +pub mod color; +pub mod animation; +pub mod parser; +pub mod utils; + +pub use cli::PigletCli; \ No newline at end of file diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..1bb6a1a --- /dev/null +++ b/src/main.rs @@ -0,0 +1,101 @@ +mod cli; +mod figlet; +mod color; +mod animation; +mod parser; +mod utils; + +use anyhow::Result; +use cli::PigletCli; +use clap::Parser; + +#[tokio::main] +async fn main() -> Result<()> { + // Parse CLI arguments + let args = PigletCli::parse(); + + // Show banner on first run + if std::env::args().len() == 1 { + show_welcome(); + return Ok(()); + } + + // Verify figlet is installed + figlet::FigletWrapper::check_installed()?; + + // Run the piglet magic + run_piglet(args).await?; + + Ok(()) +} + +async fn run_piglet(args: PigletCli) -> Result<()> { + use crate::animation::AnimationEngine; + use crate::color::ColorEngine; + use crate::utils::terminal::TerminalManager; + + // Parse duration + let duration_ms = parser::duration::parse_duration(&args.duration)?; + + // Create figlet wrapper and render base ASCII art + let figlet = figlet::FigletWrapper::new() + .with_font(args.font.as_deref()) + .with_args(args.figlet_args); + + let ascii_art = figlet.render(&args.text)?; + + // Setup color engine + let color_engine = ColorEngine::new() + .with_palette(args.color_palette.as_deref()) + .with_gradient(args.color_gradient.as_deref())?; + + // Setup animation engine + let animation_engine = AnimationEngine::new( + ascii_art, + duration_ms, + args.fps, + ) + .with_effect(&args.motion_effect)? + .with_easing(&args.motion_ease)? + .with_color_engine(color_engine); + + // Setup terminal + let mut terminal = TerminalManager::new()?; + terminal.setup()?; + + // Run animation + loop { + animation_engine.run(&mut terminal).await?; + + if !args.loop_animation { + break; + } + } + + // Cleanup + terminal.cleanup()?; + + Ok(()) +} + +fn show_welcome() { + println!(r#" + ____ _ __ __ + / __ \(_)___ _/ /__ / /_ + / /_/ / / __ `/ / _ \/ __/ + / ____/ / /_/ / / __/ /_ +/_/ /_/\__, /_/\___/\__/ + /____/ + +🐷 Piglet - Animated Figlet Wrapper + +Usage: piglet [TEXT] [OPTIONS] + +Examples: + piglet "Hello" -p "#FF5733,#33FF57" + piglet "World" -g "linear-gradient(90deg, red, blue)" -e fade-in + piglet "Cool!" -e typewriter -d 2s -i ease-out + +Run 'piglet --help' for more information. +"#); +} \ No newline at end of file diff --git a/src/parser/color.rs b/src/parser/color.rs new file mode 100644 index 0000000..5b30425 --- /dev/null +++ b/src/parser/color.rs @@ -0,0 +1,24 @@ +use anyhow::{Result, Context}; +use csscolorparser::Color as CssColor; +use palette::rgb::Rgb; + +#[derive(Debug, Clone, Copy)] +pub struct Color { + pub r: u8, + pub g: u8, + pub b: u8, +} + +impl Color { + pub fn new(r: u8, g: u8, b: u8) -> Self { + Self { r, g, b } + } + + pub fn from_hex(hex: &str) -> Result { + let color = CssColor::parse(hex) + .context(format!("Failed to parse hex color: {}", hex))?; + + Ok(Self { + r: (color.r * 255.0) as u8, + g: (color.g * 255.0) as u8, + b \ No newline at end of file diff --git a/src/parser/duration.rs b/src/parser/duration.rs new file mode 100644 index 0000000..6df7101 --- /dev/null +++ b/src/parser/duration.rs @@ -0,0 +1,72 @@ +use anyhow::{Result, bail}; +use regex::Regex; +use lazy_static::lazy_static; + +lazy_static! { + static ref DURATION_REGEX: Regex = Regex::new( + r"^(\d+(?:\.\d+)?)(ms|s|m|h)$" + ).unwrap(); +} + +/// Parse duration string to milliseconds +/// Supports: 3000ms, 0.3s, 5m, 0.5h +pub fn parse_duration(duration: &str) -> Result { + let caps = DURATION_REGEX.captures(duration.trim()) + .ok_or_else(|| anyhow::anyhow!("Invalid duration format: {}", duration))?; + + let value: f64 = caps[1].parse() + .map_err(|_| anyhow::anyhow!("Invalid numeric value in duration"))?; + + let unit = &caps[2]; + + let milliseconds = match unit { + "ms" => value, + "s" => value * 1000.0, + "m" => value * 60.0 * 1000.0, + "h" => value * 60.0 * 60.0 * 1000.0, + _ => bail!("Unknown time unit: {}", unit), + }; + + if milliseconds < 0.0 { + bail!("Duration cannot be negative"); + } + + Ok(milliseconds as u64) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_milliseconds() { + assert_eq!(parse_duration("3000ms").unwrap(), 3000); + assert_eq!(parse_duration("500ms").unwrap(), 500); + } + + #[test] + fn test_parse_seconds() { + assert_eq!(parse_duration("3s").unwrap(), 3000); + assert_eq!(parse_duration("0.5s").unwrap(), 500); + assert_eq!(parse_duration("1.5s").unwrap(), 1500); + } + + #[test] + fn test_parse_minutes() { + assert_eq!(parse_duration("1m").unwrap(), 60000); + assert_eq!(parse_duration("0.5m").unwrap(), 30000); + } + + #[test] + fn test_parse_hours() { + assert_eq!(parse_duration("1h").unwrap(), 3600000); + assert_eq!(parse_duration("0.5h").unwrap(), 1800000); + } + + #[test] + fn test_invalid_format() { + assert!(parse_duration("invalid").is_err()); + assert!(parse_duration("10").is_err()); + assert!(parse_duration("10x").is_err()); + } +} \ No newline at end of file diff --git a/src/parser/mod.rs b/src/parser/mod.rs new file mode 100644 index 0000000..f37ad66 --- /dev/null +++ b/src/parser/mod.rs @@ -0,0 +1,3 @@ +pub mod duration; +pub mod color; +pub mod gradient; \ No newline at end of file diff --git a/src/utils/ascii.rs b/src/utils/ascii.rs new file mode 100644 index 0000000..1bd2f55 --- /dev/null +++ b/src/utils/ascii.rs @@ -0,0 +1,131 @@ +use crate::parser::color::Color; + +#[derive(Debug, Clone)] +pub struct AsciiArt { + lines: Vec, + width: usize, + height: usize, +} + +impl AsciiArt { + pub fn new(text: String) -> Self { + 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 { + self.lines.join("\n") + } + + /// Get character at position + 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() + .flat_map(|line| line.chars()) + .filter(|c| !c.is_whitespace()) + .count() + } + + /// Get all character positions + pub fn char_positions(&self) -> Vec<(usize, usize, char)> { + let mut positions = Vec::new(); + + for (y, line) in self.lines.iter().enumerate() { + for (x, ch) in line.chars().enumerate() { + if !ch.is_whitespace() { + positions.push((x, y, ch)); + } + } + } + + positions + } + + /// Apply fade effect (0.0 = invisible, 1.0 = visible) + pub fn apply_fade(&self, opacity: f64) -> String { + if opacity >= 1.0 { + return self.to_string(); + } + + if opacity <= 0.0 { + return " ".repeat(self.width).repeat(self.height); + } + + // For ASCII, we can simulate fade by replacing chars with lighter ones + let fade_chars = [' ', '.', '·', '-', '~', '=', '+', '*', '#', '@']; + let index = (opacity * (fade_chars.len() - 1) as f64) as usize; + let fade_char = fade_chars[index]; + + self.lines.iter() + .map(|line| { + line.chars() + .map(|ch| { + if ch.is_whitespace() { + ch + } else { + fade_char + } + }) + .collect::() + }) + .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() + .flat_map(|line| { + let scaled_line: String = line.chars() + .flat_map(|ch| std::iter::repeat(ch).take(factor as usize)) + .collect(); + std::iter::repeat(scaled_line).take(factor as usize) + }) + .collect() + } else { + self.lines.iter() + .step_by((1.0 / factor) as usize) + .map(|line| { + line.chars() + .step_by((1.0 / factor) as usize) + .collect() + }) + .collect() + }; + + Self::new(lines.join("\n")) + } +} \ No newline at end of file diff --git a/src/utils/mod.rs b/src/utils/mod.rs new file mode 100644 index 0000000..1c2b773 --- /dev/null +++ b/src/utils/mod.rs @@ -0,0 +1,2 @@ +pub mod terminal; +pub mod ascii; \ No newline at end of file diff --git a/src/utils/terminal.rs b/src/utils/terminal.rs new file mode 100644 index 0000000..7d12276 --- /dev/null +++ b/src/utils/terminal.rs @@ -0,0 +1,101 @@ +use anyhow::Result; +use crossterm::{ + cursor, + execute, + terminal::{self, ClearType}, + ExecutableCommand, +}; +use std::io::{stdout, Write}; + +pub struct TerminalManager { + width: u16, + height: u16, + original_state: bool, +} + +impl TerminalManager { + pub fn new() -> Result { + let (width, height) = terminal::size()?; + Ok(Self { + width, + height, + original_state: false, + }) + } + + pub fn setup(&mut self) -> Result<()> { + terminal::enable_raw_mode()?; + execute!( + stdout(), + terminal::EnterAlternateScreen, + cursor::Hide + )?; + self.original_state = true; + Ok(()) + } + + pub fn cleanup(&mut self) -> Result<()> { + if self.original_state { + execute!( + stdout(), + cursor::Show, + terminal::LeaveAlternateScreen + )?; + terminal::disable_raw_mode()?; + self.original_state = false; + } + Ok(()) + } + + pub fn clear(&self) -> Result<()> { + execute!(stdout(), terminal::Clear(ClearType::All))?; + Ok(()) + } + + pub fn move_to(&self, x: u16, y: u16) -> Result<()> { + execute!(stdout(), cursor::MoveTo(x, y))?; + Ok(()) + } + + pub fn get_size(&self) -> (u16, u16) { + (self.width, self.height) + } + + pub fn refresh_size(&mut self) -> Result<()> { + let (width, height) = terminal::size()?; + self.width = width; + self.height = height; + Ok(()) + } + + pub fn print_at(&self, x: u16, y: u16, text: &str) -> Result<()> { + self.move_to(x, y)?; + print!("{}", text); + stdout().flush()?; + Ok(()) + } + + pub fn print_centered(&self, text: &str) -> Result<()> { + let lines: Vec<&str> = text.lines().collect(); + let max_width = lines.iter().map(|l| l.len()).max().unwrap_or(0) as u16; + let height = lines.len() as u16; + + let start_x = (self.width.saturating_sub(max_width)) / 2; + let start_y = (self.height.saturating_sub(height)) / 2; + + for (i, line) in lines.iter().enumerate() { + let line_width = line.len() as u16; + let x = start_x + (max_width.saturating_sub(line_width)) / 2; + let y = start_y + i as u16; + self.print_at(x, y, line)?; + } + + Ok(()) + } +} + +impl Drop for TerminalManager { + fn drop(&mut self) { + let _ = self.cleanup(); + } +} \ No newline at end of file diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs new file mode 100644 index 0000000..8f37604 --- /dev/null +++ b/tests/integration_tests.rs @@ -0,0 +1,171 @@ +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, +}; +use anyhow::Result; + +#[test] +fn test_figlet_wrapper() -> Result<()> { + let figlet = FigletWrapper::new(); + let result = figlet.render("Test")?; + assert!(!result.is_empty()); + Ok(()) +} + +#[test] +fn test_duration_parser() -> Result<()> { + assert_eq!(parse_duration("1000ms")?, 1000); + assert_eq!(parse_duration("1s")?, 1000); + assert_eq!(parse_duration("1m")?, 60000); + assert_eq!(parse_duration("1h")?, 3600000); + assert_eq!(parse_duration("0.5s")?, 500); + Ok(()) +} + +#[test] +fn test_color_parser() -> Result<()> { + let color = Color::parse("#FF5733")?; + 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(()) +} + +#[test] +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%)" + )?; + 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(()) +} + +#[test] +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); +} + +#[test] +fn test_color_palette() -> Result<()> { + 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(()) +} + +#[test] +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); + + 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 + + Ok(()) +} + +#[test] +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()]))?; + + 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(()) +} + +#[test] +fn test_invalid_duration() { + assert!(parse_duration("invalid").is_err()); + assert!(parse_duration("10").is_err()); + assert!(parse_duration("10x").is_err()); +} + +#[test] +fn test_invalid_color() { + assert!(Color::parse("notacolor").is_err()); + assert!(Color::parse("#GGGGGG").is_err()); +} + +#[test] +fn test_invalid_effect() { + assert!(get_effect("not-an-effect").is_err()); +} + +#[test] +fn test_invalid_easing() { + assert!(get_easing_function("not-an-easing").is_err()); +} \ No newline at end of file