tempo_cli/ui/
animations.rs

1use ratatui::style::Color;
2use std::time::{Duration, Instant};
3
4/// Reusable animation utility functions for smooth TUI effects
5/// Implements "Claude Code feel" - subtle, smooth, non-flashy animations
6
7// ============================================================================
8// EASING FUNCTIONS (Simple implementations)
9// ============================================================================
10
11/// Linear interpolation between two values
12pub fn lerp(start: f64, end: f64, t: f64) -> f64 {
13    start + (end - start) * t.clamp(0.0, 1.0)
14}
15
16/// Ease-in-out cubic easing (smooth acceleration and deceleration)
17fn ease_in_out_cubic(t: f64) -> f64 {
18    let t = t.clamp(0.0, 1.0);
19    if t < 0.5 {
20        4.0 * t * t * t
21    } else {
22        1.0 - (-2.0 * t + 2.0).powi(3) / 2.0
23    }
24}
25
26/// Ease-out cubic easing (deceleration)
27fn ease_out_cubic(t: f64) -> f64 {
28    let t = t.clamp(0.0, 1.0);
29    1.0 - (1.0 - t).powi(3)
30}
31
32/// Ease a value from start to end using a given progress (0.0 to 1.0)
33/// Uses ease-in-out for smooth acceleration and deceleration
34pub fn ease_value(start: f64, end: f64, progress: f64) -> f64 {
35    let eased_progress = ease_in_out_cubic(progress);
36    lerp(start, end, eased_progress)
37}
38
39/// Calculate progress from elapsed time and duration
40pub fn calc_progress(elapsed: Duration, total_duration: Duration) -> f64 {
41    if total_duration.as_millis() == 0 {
42        return 1.0;
43    }
44    (elapsed.as_millis() as f64 / total_duration.as_millis() as f64).clamp(0.0, 1.0)
45}
46
47// ============================================================================
48// PULSING / BREATHING EFFECTS
49// ============================================================================
50
51/// Generate a pulsing opacity value (0.3 to 1.0) based on time
52/// Creates a breathing effect perfect for "thinking" indicators
53pub fn pulse_opacity(elapsed: Duration, cycle_duration: Duration) -> f64 {
54    let t = (elapsed.as_millis() % cycle_duration.as_millis()) as f64
55        / cycle_duration.as_millis() as f64;
56
57    // Use sine wave for smooth pulsing
58    let sine_value = (t * 2.0 * std::f64::consts::PI).sin();
59    // Map from [-1, 1] to [0.3, 1.0]
60    0.3 + (sine_value + 1.0) / 2.0 * 0.7
61}
62
63/// Generate a pulsing value between min and max
64pub fn pulse_value(elapsed: Duration, cycle_duration: Duration, min: f64, max: f64) -> f64 {
65    let t = (elapsed.as_millis() % cycle_duration.as_millis()) as f64
66        / cycle_duration.as_millis() as f64;
67
68    let sine_value = (t * 2.0 * std::f64::consts::PI).sin();
69    min + (sine_value + 1.0) / 2.0 * (max - min)
70}
71
72/// Generate a slow breathing effect (slower than pulse)
73pub fn breathe_opacity(elapsed: Duration) -> f64 {
74    pulse_opacity(elapsed, Duration::from_millis(2000))
75}
76
77// ============================================================================
78// PANEL TRANSITIONS
79// ============================================================================
80
81/// Animate a panel sliding from one position to another
82pub fn slide_panel(start_pos: f64, target_pos: f64, progress: f64) -> f64 {
83    let eased_progress = ease_out_cubic(progress);
84    lerp(start_pos, target_pos, eased_progress)
85}
86
87/// Animate panel width/height with smooth easing
88pub fn animate_size(current: u16, target: u16, progress: f64) -> u16 {
89    ease_value(current as f64, target as f64, progress) as u16
90}
91
92// ============================================================================
93// COLOR ANIMATIONS
94// ============================================================================
95
96/// Interpolate between two RGB colors
97pub fn lerp_color(start: Color, end: Color, progress: f64) -> Color {
98    match (start, end) {
99        (Color::Rgb(r1, g1, b1), Color::Rgb(r2, g2, b2)) => {
100            let r = lerp(r1 as f64, r2 as f64, progress) as u8;
101            let g = lerp(g1 as f64, g2 as f64, progress) as u8;
102            let b = lerp(b1 as f64, b2 as f64, progress) as u8;
103            Color::Rgb(r, g, b)
104        }
105        _ => end, // Fallback for non-RGB colors
106    }
107}
108
109/// Create a pulsing color effect between two colors
110pub fn pulse_color(
111    color1: Color,
112    color2: Color,
113    elapsed: Duration,
114    cycle_duration: Duration,
115) -> Color {
116    let progress = pulse_value(elapsed, cycle_duration, 0.0, 1.0);
117    lerp_color(color1, color2, progress)
118}
119
120// ============================================================================
121// ANIMATION STATE MANAGEMENT
122// ============================================================================
123
124/// Tracks a single animation's progress
125#[derive(Clone)]
126pub struct Animation {
127    pub start_time: Instant,
128    pub duration: Duration,
129    pub start_value: f64,
130    pub end_value: f64,
131}
132
133impl Animation {
134    pub fn new(start_value: f64, end_value: f64, duration: Duration) -> Self {
135        Self {
136            start_time: Instant::now(),
137            duration,
138            start_value,
139            end_value,
140        }
141    }
142
143    /// Get current value with easing
144    pub fn current_value(&self) -> f64 {
145        let elapsed = self.start_time.elapsed();
146        let progress = calc_progress(elapsed, self.duration);
147        ease_value(self.start_value, self.end_value, progress)
148    }
149
150    /// Check if animation is complete
151    pub fn is_complete(&self) -> bool {
152        self.start_time.elapsed() >= self.duration
153    }
154
155    /// Get progress (0.0 to 1.0)
156    pub fn progress(&self) -> f64 {
157        calc_progress(self.start_time.elapsed(), self.duration)
158    }
159
160    /// Restart animation with new target
161    pub fn restart(&mut self, new_end_value: f64) {
162        self.start_value = self.current_value();
163        self.end_value = new_end_value;
164        self.start_time = Instant::now();
165    }
166}
167
168/// Manages view transition animations
169#[derive(Clone)]
170pub struct ViewTransition {
171    pub animation: Animation,
172    pub direction: TransitionDirection,
173}
174
175#[derive(Clone, Copy, PartialEq)]
176pub enum TransitionDirection {
177    SlideLeft,
178    SlideRight,
179    FadeIn,
180    FadeOut,
181}
182
183impl ViewTransition {
184    pub fn new(direction: TransitionDirection, duration: Duration) -> Self {
185        let (start, end) = match direction {
186            TransitionDirection::SlideLeft => (100.0, 0.0),
187            TransitionDirection::SlideRight => (-100.0, 0.0),
188            TransitionDirection::FadeIn => (0.0, 1.0),
189            TransitionDirection::FadeOut => (1.0, 0.0),
190        };
191
192        Self {
193            animation: Animation::new(start, end, duration),
194            direction,
195        }
196    }
197
198    pub fn current_offset(&self) -> f64 {
199        self.animation.current_value()
200    }
201
202    pub fn is_complete(&self) -> bool {
203        self.animation.is_complete()
204    }
205}
206
207/// Manages a pulsing indicator (like a thinking/loading state)
208pub struct PulsingIndicator {
209    pub start_time: Instant,
210    pub cycle_duration: Duration,
211    pub min_opacity: f64,
212    pub max_opacity: f64,
213}
214
215impl PulsingIndicator {
216    pub fn new() -> Self {
217        Self {
218            start_time: Instant::now(),
219            cycle_duration: Duration::from_millis(1500),
220            min_opacity: 0.3,
221            max_opacity: 1.0,
222        }
223    }
224
225    pub fn with_speed(mut self, cycle_duration: Duration) -> Self {
226        self.cycle_duration = cycle_duration;
227        self
228    }
229
230    pub fn current_opacity(&self) -> f64 {
231        pulse_value(
232            self.start_time.elapsed(),
233            self.cycle_duration,
234            self.min_opacity,
235            self.max_opacity,
236        )
237    }
238
239    pub fn reset(&mut self) {
240        self.start_time = Instant::now();
241    }
242}
243
244/// Token streaming state for character-by-character rendering
245pub struct TokenStream {
246    pub full_text: String,
247    pub start_time: Instant,
248    pub chars_per_second: usize,
249}
250
251impl TokenStream {
252    pub fn new(text: String, chars_per_second: usize) -> Self {
253        Self {
254            full_text: text,
255            start_time: Instant::now(),
256            chars_per_second,
257        }
258    }
259
260    /// Get the currently visible portion of the text
261    pub fn visible_text(&self) -> &str {
262        let elapsed = self.start_time.elapsed().as_millis() as usize;
263        let chars_to_show = (elapsed * self.chars_per_second / 1000).min(self.full_text.len());
264        &self.full_text[..chars_to_show]
265    }
266
267    /// Check if streaming is complete
268    pub fn is_complete(&self) -> bool {
269        let elapsed = self.start_time.elapsed().as_millis() as usize;
270        let chars_shown = elapsed * self.chars_per_second / 1000;
271        chars_shown >= self.full_text.len()
272    }
273
274    /// Get progress (0.0 to 1.0)
275    pub fn progress(&self) -> f64 {
276        let elapsed = self.start_time.elapsed().as_millis() as usize;
277        let chars_shown = elapsed * self.chars_per_second / 1000;
278        (chars_shown as f64 / self.full_text.len() as f64).min(1.0)
279    }
280}
281
282// ============================================================================
283// SPINNER ANIMATIONS (Enhanced)
284// ============================================================================
285
286/// Frame-based spinner with multiple styles
287pub struct AnimatedSpinner {
288    frames: Vec<&'static str>,
289    current_frame: usize,
290    last_update: Instant,
291    frame_duration: Duration,
292}
293
294impl AnimatedSpinner {
295    /// Braille spinner (default)
296    pub fn braille() -> Self {
297        Self {
298            frames: vec!["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
299            current_frame: 0,
300            last_update: Instant::now(),
301            frame_duration: Duration::from_millis(80),
302        }
303    }
304
305    /// Dots spinner
306    pub fn dots() -> Self {
307        Self {
308            frames: vec!["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
309            current_frame: 0,
310            last_update: Instant::now(),
311            frame_duration: Duration::from_millis(80),
312        }
313    }
314
315    /// Circle spinner
316    pub fn circle() -> Self {
317        Self {
318            frames: vec!["◐", "◓", "◑", "◒"],
319            current_frame: 0,
320            last_update: Instant::now(),
321            frame_duration: Duration::from_millis(100),
322        }
323    }
324
325    /// Arrow spinner
326    pub fn arrow() -> Self {
327        Self {
328            frames: vec!["←", "↖", "↑", "↗", "→", "↘", "↓", "↙"],
329            current_frame: 0,
330            last_update: Instant::now(),
331            frame_duration: Duration::from_millis(100),
332        }
333    }
334
335    pub fn tick(&mut self) {
336        if self.last_update.elapsed() >= self.frame_duration {
337            self.current_frame = (self.current_frame + 1) % self.frames.len();
338            self.last_update = Instant::now();
339        }
340    }
341
342    pub fn current(&self) -> &str {
343        self.frames[self.current_frame]
344    }
345}
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350
351    #[test]
352    fn test_lerp() {
353        assert_eq!(lerp(0.0, 100.0, 0.0), 0.0);
354        assert_eq!(lerp(0.0, 100.0, 0.5), 50.0);
355        assert_eq!(lerp(0.0, 100.0, 1.0), 100.0);
356    }
357
358    #[test]
359    fn test_calc_progress() {
360        let total = Duration::from_secs(1);
361        assert_eq!(calc_progress(Duration::from_millis(0), total), 0.0);
362        assert_eq!(calc_progress(Duration::from_millis(500), total), 0.5);
363        assert_eq!(calc_progress(Duration::from_millis(1000), total), 1.0);
364    }
365
366    #[test]
367    fn test_animation() {
368        let mut anim = Animation::new(0.0, 100.0, Duration::from_millis(100));
369        assert!(!anim.is_complete());
370        assert!(anim.current_value() >= 0.0 && anim.current_value() <= 100.0);
371    }
372}