tempo-cli 0.4.0

Automatic project time tracking CLI tool with beautiful terminal interface
Documentation
use ratatui::style::Color;
use std::time::{Duration, Instant};

/// Reusable animation utility functions for smooth TUI effects
/// Implements "Claude Code feel" - subtle, smooth, non-flashy animations

// ============================================================================
// EASING FUNCTIONS (Simple implementations)
// ============================================================================

/// Linear interpolation between two values
pub fn lerp(start: f64, end: f64, t: f64) -> f64 {
    start + (end - start) * t.clamp(0.0, 1.0)
}

/// Ease-in-out cubic easing (smooth acceleration and deceleration)
fn ease_in_out_cubic(t: f64) -> f64 {
    let t = t.clamp(0.0, 1.0);
    if t < 0.5 {
        4.0 * t * t * t
    } else {
        1.0 - (-2.0 * t + 2.0).powi(3) / 2.0
    }
}

/// Ease-out cubic easing (deceleration)
fn ease_out_cubic(t: f64) -> f64 {
    let t = t.clamp(0.0, 1.0);
    1.0 - (1.0 - t).powi(3)
}

/// Ease a value from start to end using a given progress (0.0 to 1.0)
/// Uses ease-in-out for smooth acceleration and deceleration
pub fn ease_value(start: f64, end: f64, progress: f64) -> f64 {
    let eased_progress = ease_in_out_cubic(progress);
    lerp(start, end, eased_progress)
}

/// Calculate progress from elapsed time and duration
pub fn calc_progress(elapsed: Duration, total_duration: Duration) -> f64 {
    if total_duration.as_millis() == 0 {
        return 1.0;
    }
    (elapsed.as_millis() as f64 / total_duration.as_millis() as f64).clamp(0.0, 1.0)
}

// ============================================================================
// PULSING / BREATHING EFFECTS
// ============================================================================

/// Generate a pulsing opacity value (0.3 to 1.0) based on time
/// Creates a breathing effect perfect for "thinking" indicators
pub fn pulse_opacity(elapsed: Duration, cycle_duration: Duration) -> f64 {
    let t = (elapsed.as_millis() % cycle_duration.as_millis()) as f64
        / cycle_duration.as_millis() as f64;

    // Use sine wave for smooth pulsing
    let sine_value = (t * 2.0 * std::f64::consts::PI).sin();
    // Map from [-1, 1] to [0.3, 1.0]
    0.3 + (sine_value + 1.0) / 2.0 * 0.7
}

/// Generate a pulsing value between min and max
pub fn pulse_value(elapsed: Duration, cycle_duration: Duration, min: f64, max: f64) -> f64 {
    let t = (elapsed.as_millis() % cycle_duration.as_millis()) as f64
        / cycle_duration.as_millis() as f64;

    let sine_value = (t * 2.0 * std::f64::consts::PI).sin();
    min + (sine_value + 1.0) / 2.0 * (max - min)
}

/// Generate a slow breathing effect (slower than pulse)
pub fn breathe_opacity(elapsed: Duration) -> f64 {
    pulse_opacity(elapsed, Duration::from_millis(2000))
}

// ============================================================================
// PANEL TRANSITIONS
// ============================================================================

/// Animate a panel sliding from one position to another
pub fn slide_panel(start_pos: f64, target_pos: f64, progress: f64) -> f64 {
    let eased_progress = ease_out_cubic(progress);
    lerp(start_pos, target_pos, eased_progress)
}

/// Animate panel width/height with smooth easing
pub fn animate_size(current: u16, target: u16, progress: f64) -> u16 {
    ease_value(current as f64, target as f64, progress) as u16
}

// ============================================================================
// COLOR ANIMATIONS
// ============================================================================

/// Interpolate between two RGB colors
pub fn lerp_color(start: Color, end: Color, progress: f64) -> Color {
    match (start, end) {
        (Color::Rgb(r1, g1, b1), Color::Rgb(r2, g2, b2)) => {
            let r = lerp(r1 as f64, r2 as f64, progress) as u8;
            let g = lerp(g1 as f64, g2 as f64, progress) as u8;
            let b = lerp(b1 as f64, b2 as f64, progress) as u8;
            Color::Rgb(r, g, b)
        }
        _ => end, // Fallback for non-RGB colors
    }
}

/// Create a pulsing color effect between two colors
pub fn pulse_color(
    color1: Color,
    color2: Color,
    elapsed: Duration,
    cycle_duration: Duration,
) -> Color {
    let progress = pulse_value(elapsed, cycle_duration, 0.0, 1.0);
    lerp_color(color1, color2, progress)
}

// ============================================================================
// ANIMATION STATE MANAGEMENT
// ============================================================================

/// Tracks a single animation's progress
#[derive(Clone)]
pub struct Animation {
    pub start_time: Instant,
    pub duration: Duration,
    pub start_value: f64,
    pub end_value: f64,
}

impl Animation {
    pub fn new(start_value: f64, end_value: f64, duration: Duration) -> Self {
        Self {
            start_time: Instant::now(),
            duration,
            start_value,
            end_value,
        }
    }

    /// Get current value with easing
    pub fn current_value(&self) -> f64 {
        let elapsed = self.start_time.elapsed();
        let progress = calc_progress(elapsed, self.duration);
        ease_value(self.start_value, self.end_value, progress)
    }

    /// Check if animation is complete
    pub fn is_complete(&self) -> bool {
        self.start_time.elapsed() >= self.duration
    }

    /// Get progress (0.0 to 1.0)
    pub fn progress(&self) -> f64 {
        calc_progress(self.start_time.elapsed(), self.duration)
    }

    /// Restart animation with new target
    pub fn restart(&mut self, new_end_value: f64) {
        self.start_value = self.current_value();
        self.end_value = new_end_value;
        self.start_time = Instant::now();
    }
}

/// Manages view transition animations
#[derive(Clone)]
pub struct ViewTransition {
    pub animation: Animation,
    pub direction: TransitionDirection,
}

#[derive(Clone, Copy, PartialEq)]
pub enum TransitionDirection {
    SlideLeft,
    SlideRight,
    FadeIn,
    FadeOut,
}

impl ViewTransition {
    pub fn new(direction: TransitionDirection, duration: Duration) -> Self {
        let (start, end) = match direction {
            TransitionDirection::SlideLeft => (100.0, 0.0),
            TransitionDirection::SlideRight => (-100.0, 0.0),
            TransitionDirection::FadeIn => (0.0, 1.0),
            TransitionDirection::FadeOut => (1.0, 0.0),
        };

        Self {
            animation: Animation::new(start, end, duration),
            direction,
        }
    }

    pub fn current_offset(&self) -> f64 {
        self.animation.current_value()
    }

    pub fn is_complete(&self) -> bool {
        self.animation.is_complete()
    }
}

/// Manages a pulsing indicator (like a thinking/loading state)
pub struct PulsingIndicator {
    pub start_time: Instant,
    pub cycle_duration: Duration,
    pub min_opacity: f64,
    pub max_opacity: f64,
}

impl PulsingIndicator {
    pub fn new() -> Self {
        Self {
            start_time: Instant::now(),
            cycle_duration: Duration::from_millis(1500),
            min_opacity: 0.3,
            max_opacity: 1.0,
        }
    }

    pub fn with_speed(mut self, cycle_duration: Duration) -> Self {
        self.cycle_duration = cycle_duration;
        self
    }

    pub fn current_opacity(&self) -> f64 {
        pulse_value(
            self.start_time.elapsed(),
            self.cycle_duration,
            self.min_opacity,
            self.max_opacity,
        )
    }

    pub fn reset(&mut self) {
        self.start_time = Instant::now();
    }
}

/// Token streaming state for character-by-character rendering
pub struct TokenStream {
    pub full_text: String,
    pub start_time: Instant,
    pub chars_per_second: usize,
}

impl TokenStream {
    pub fn new(text: String, chars_per_second: usize) -> Self {
        Self {
            full_text: text,
            start_time: Instant::now(),
            chars_per_second,
        }
    }

    /// Get the currently visible portion of the text
    pub fn visible_text(&self) -> &str {
        let elapsed = self.start_time.elapsed().as_millis() as usize;
        let chars_to_show = (elapsed * self.chars_per_second / 1000).min(self.full_text.len());
        &self.full_text[..chars_to_show]
    }

    /// Check if streaming is complete
    pub fn is_complete(&self) -> bool {
        let elapsed = self.start_time.elapsed().as_millis() as usize;
        let chars_shown = elapsed * self.chars_per_second / 1000;
        chars_shown >= self.full_text.len()
    }

    /// Get progress (0.0 to 1.0)
    pub fn progress(&self) -> f64 {
        let elapsed = self.start_time.elapsed().as_millis() as usize;
        let chars_shown = elapsed * self.chars_per_second / 1000;
        (chars_shown as f64 / self.full_text.len() as f64).min(1.0)
    }
}

// ============================================================================
// SPINNER ANIMATIONS (Enhanced)
// ============================================================================

/// Frame-based spinner with multiple styles
pub struct AnimatedSpinner {
    frames: Vec<&'static str>,
    current_frame: usize,
    last_update: Instant,
    frame_duration: Duration,
}

impl AnimatedSpinner {
    /// Braille spinner (default)
    pub fn braille() -> Self {
        Self {
            frames: vec!["", "", "", "", "", "", "", "", "", ""],
            current_frame: 0,
            last_update: Instant::now(),
            frame_duration: Duration::from_millis(80),
        }
    }

    /// Dots spinner
    pub fn dots() -> Self {
        Self {
            frames: vec!["", "", "", "", "", "", "", "", "", ""],
            current_frame: 0,
            last_update: Instant::now(),
            frame_duration: Duration::from_millis(80),
        }
    }

    /// Circle spinner
    pub fn circle() -> Self {
        Self {
            frames: vec!["", "", "", ""],
            current_frame: 0,
            last_update: Instant::now(),
            frame_duration: Duration::from_millis(100),
        }
    }

    /// Arrow spinner
    pub fn arrow() -> Self {
        Self {
            frames: vec!["", "", "", "", "", "", "", ""],
            current_frame: 0,
            last_update: Instant::now(),
            frame_duration: Duration::from_millis(100),
        }
    }

    pub fn tick(&mut self) {
        if self.last_update.elapsed() >= self.frame_duration {
            self.current_frame = (self.current_frame + 1) % self.frames.len();
            self.last_update = Instant::now();
        }
    }

    pub fn current(&self) -> &str {
        self.frames[self.current_frame]
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_lerp() {
        assert_eq!(lerp(0.0, 100.0, 0.0), 0.0);
        assert_eq!(lerp(0.0, 100.0, 0.5), 50.0);
        assert_eq!(lerp(0.0, 100.0, 1.0), 100.0);
    }

    #[test]
    fn test_calc_progress() {
        let total = Duration::from_secs(1);
        assert_eq!(calc_progress(Duration::from_millis(0), total), 0.0);
        assert_eq!(calc_progress(Duration::from_millis(500), total), 0.5);
        assert_eq!(calc_progress(Duration::from_millis(1000), total), 1.0);
    }

    #[test]
    fn test_animation() {
        let mut anim = Animation::new(0.0, 100.0, Duration::from_millis(100));
        assert!(!anim.is_complete());
        assert!(anim.current_value() >= 0.0 && anim.current_value() <= 100.0);
    }
}