Skip to main content

ratatui_interact/components/
animated_text.rs

1//! AnimatedText widget for animated text labels with color effects
2//!
3//! Provides animated text display with multiple effect modes:
4//! - Pulse: Entire text oscillates between two colors
5//! - Wave: A highlighted portion travels back and forth across the text
6//! - Rainbow: Colors cycle through a spectrum across the text
7//!
8//! # Example
9//!
10//! ```rust
11//! use ratatui_interact::components::{AnimatedText, AnimatedTextState, AnimatedTextStyle, AnimatedTextEffect};
12//! use ratatui::layout::Rect;
13//! use ratatui::buffer::Buffer;
14//! use ratatui::widgets::Widget;
15//! use ratatui::style::Color;
16//!
17//! // Create state and advance each frame
18//! let mut state = AnimatedTextState::new();
19//!
20//! // Pulse effect - entire text oscillates between colors
21//! let style = AnimatedTextStyle::pulse(Color::Cyan, Color::Blue);
22//! let text = AnimatedText::new("Loading...", &state).style(style);
23//!
24//! // Wave effect - highlight travels across the text
25//! let style = AnimatedTextStyle::wave(Color::White, Color::Yellow);
26//! let text = AnimatedText::new("Processing data", &state).style(style);
27//!
28//! // In your event loop, advance the animation
29//! state.tick();
30//! ```
31
32use std::time::{Duration, Instant};
33
34use ratatui::{
35    buffer::Buffer,
36    layout::Rect,
37    style::{Color, Modifier, Style},
38    widgets::Widget,
39};
40use unicode_width::UnicodeWidthStr;
41
42/// Animation effect types for animated text
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
44pub enum AnimatedTextEffect {
45    /// Entire text pulses/oscillates between two colors
46    #[default]
47    Pulse,
48    /// A highlighted portion travels back and forth (wave effect)
49    Wave,
50    /// Colors cycle through a rainbow spectrum across the text
51    Rainbow,
52    /// Gradient that shifts over time
53    GradientShift,
54    /// Text appears to sparkle with random highlights
55    Sparkle,
56}
57
58/// Direction for wave animation
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
60pub enum WaveDirection {
61    /// Wave travels left to right
62    #[default]
63    Forward,
64    /// Wave travels right to left
65    Backward,
66}
67
68/// State for animated text
69#[derive(Debug, Clone)]
70pub struct AnimatedTextState {
71    /// Current animation frame (0-255 for smooth transitions)
72    pub frame: u8,
73    /// Wave position (for wave effect)
74    pub wave_position: usize,
75    /// Wave direction (for bounce behavior)
76    pub wave_direction: WaveDirection,
77    /// Last tick time
78    last_tick: Option<Instant>,
79    /// Frame interval
80    interval: Duration,
81    /// Whether the animation is active
82    pub active: bool,
83    /// Random seed for sparkle effect
84    sparkle_seed: u64,
85}
86
87impl Default for AnimatedTextState {
88    fn default() -> Self {
89        Self::new()
90    }
91}
92
93impl AnimatedTextState {
94    /// Create a new animated text state
95    pub fn new() -> Self {
96        Self {
97            frame: 0,
98            wave_position: 0,
99            wave_direction: WaveDirection::Forward,
100            last_tick: None,
101            interval: Duration::from_millis(50),
102            active: true,
103            sparkle_seed: 0,
104        }
105    }
106
107    /// Create state with a specific interval
108    pub fn with_interval(interval_ms: u64) -> Self {
109        Self {
110            interval: Duration::from_millis(interval_ms),
111            ..Self::new()
112        }
113    }
114
115    /// Set the animation interval
116    pub fn set_interval(&mut self, interval_ms: u64) {
117        self.interval = Duration::from_millis(interval_ms);
118    }
119
120    /// Advance the animation by one tick
121    ///
122    /// Returns true if the frame changed
123    pub fn tick(&mut self) -> bool {
124        self.tick_with_text_width(20) // Default width assumption
125    }
126
127    /// Advance the animation with known text width (for wave calculations)
128    ///
129    /// Returns true if the frame changed
130    pub fn tick_with_text_width(&mut self, text_width: usize) -> bool {
131        if !self.active {
132            return false;
133        }
134
135        let now = Instant::now();
136
137        match self.last_tick {
138            Some(last) if now.duration_since(last) >= self.interval => {
139                // Advance frame (wraps at 256)
140                self.frame = self.frame.wrapping_add(4);
141
142                // Update wave position
143                let max_pos = text_width.saturating_sub(1);
144                match self.wave_direction {
145                    WaveDirection::Forward => {
146                        if self.wave_position >= max_pos {
147                            self.wave_direction = WaveDirection::Backward;
148                        } else {
149                            self.wave_position += 1;
150                        }
151                    }
152                    WaveDirection::Backward => {
153                        if self.wave_position == 0 {
154                            self.wave_direction = WaveDirection::Forward;
155                        } else {
156                            self.wave_position -= 1;
157                        }
158                    }
159                }
160
161                // Update sparkle seed
162                self.sparkle_seed = self.sparkle_seed.wrapping_add(1);
163
164                self.last_tick = Some(now);
165                true
166            }
167            None => {
168                self.last_tick = Some(now);
169                false
170            }
171            _ => false,
172        }
173    }
174
175    /// Reset the animation to initial state
176    pub fn reset(&mut self) {
177        self.frame = 0;
178        self.wave_position = 0;
179        self.wave_direction = WaveDirection::Forward;
180        self.last_tick = None;
181        self.sparkle_seed = 0;
182    }
183
184    /// Start the animation
185    pub fn start(&mut self) {
186        self.active = true;
187    }
188
189    /// Stop the animation
190    pub fn stop(&mut self) {
191        self.active = false;
192    }
193
194    /// Check if the animation is active
195    pub fn is_active(&self) -> bool {
196        self.active
197    }
198
199    /// Get the current interpolation factor (0.0 to 1.0)
200    pub fn interpolation_factor(&self) -> f32 {
201        // Use a sine wave for smooth oscillation
202        let radians = (self.frame as f32 / 255.0) * std::f32::consts::PI * 2.0;
203        (radians.sin() + 1.0) / 2.0
204    }
205}
206
207/// Style configuration for animated text
208#[derive(Debug, Clone)]
209pub struct AnimatedTextStyle {
210    /// Animation effect type
211    pub effect: AnimatedTextEffect,
212    /// Primary/base color
213    pub primary_color: Color,
214    /// Secondary color (for pulse/wave effects)
215    pub secondary_color: Color,
216    /// Text modifiers (bold, italic, etc.)
217    pub modifiers: Modifier,
218    /// Width of the wave highlight (in characters)
219    pub wave_width: usize,
220    /// Background color (optional)
221    pub background: Option<Color>,
222    /// Rainbow colors for rainbow effect
223    pub rainbow_colors: Vec<Color>,
224}
225
226impl Default for AnimatedTextStyle {
227    fn default() -> Self {
228        Self {
229            effect: AnimatedTextEffect::Pulse,
230            primary_color: Color::White,
231            secondary_color: Color::Cyan,
232            modifiers: Modifier::empty(),
233            wave_width: 3,
234            background: None,
235            rainbow_colors: vec![
236                Color::Red,
237                Color::Yellow,
238                Color::Green,
239                Color::Cyan,
240                Color::Blue,
241                Color::Magenta,
242            ],
243        }
244    }
245}
246
247impl From<&crate::theme::Theme> for AnimatedTextStyle {
248    fn from(theme: &crate::theme::Theme) -> Self {
249        let p = &theme.palette;
250        Self {
251            effect: AnimatedTextEffect::Pulse,
252            primary_color: p.text,
253            secondary_color: p.secondary,
254            modifiers: Modifier::empty(),
255            wave_width: 3,
256            background: None,
257            rainbow_colors: vec![
258                Color::Red,
259                Color::Yellow,
260                Color::Green,
261                Color::Cyan,
262                Color::Blue,
263                Color::Magenta,
264            ],
265        }
266    }
267}
268
269impl AnimatedTextStyle {
270    /// Create a new style with default values
271    pub fn new() -> Self {
272        Self::default()
273    }
274
275    /// Create a pulse effect style
276    pub fn pulse(primary: Color, secondary: Color) -> Self {
277        Self {
278            effect: AnimatedTextEffect::Pulse,
279            primary_color: primary,
280            secondary_color: secondary,
281            ..Default::default()
282        }
283    }
284
285    /// Create a wave effect style
286    pub fn wave(base: Color, highlight: Color) -> Self {
287        Self {
288            effect: AnimatedTextEffect::Wave,
289            primary_color: base,
290            secondary_color: highlight,
291            wave_width: 3,
292            ..Default::default()
293        }
294    }
295
296    /// Create a rainbow effect style
297    pub fn rainbow() -> Self {
298        Self {
299            effect: AnimatedTextEffect::Rainbow,
300            ..Default::default()
301        }
302    }
303
304    /// Create a gradient shift effect style
305    pub fn gradient_shift(start: Color, end: Color) -> Self {
306        Self {
307            effect: AnimatedTextEffect::GradientShift,
308            primary_color: start,
309            secondary_color: end,
310            ..Default::default()
311        }
312    }
313
314    /// Create a sparkle effect style
315    pub fn sparkle(base: Color, sparkle: Color) -> Self {
316        Self {
317            effect: AnimatedTextEffect::Sparkle,
318            primary_color: base,
319            secondary_color: sparkle,
320            ..Default::default()
321        }
322    }
323
324    /// Set the animation effect
325    pub fn effect(mut self, effect: AnimatedTextEffect) -> Self {
326        self.effect = effect;
327        self
328    }
329
330    /// Set the primary color
331    pub fn primary_color(mut self, color: Color) -> Self {
332        self.primary_color = color;
333        self
334    }
335
336    /// Set the secondary color
337    pub fn secondary_color(mut self, color: Color) -> Self {
338        self.secondary_color = color;
339        self
340    }
341
342    /// Set text modifiers
343    pub fn modifiers(mut self, modifiers: Modifier) -> Self {
344        self.modifiers = modifiers;
345        self
346    }
347
348    /// Add bold modifier
349    pub fn bold(mut self) -> Self {
350        self.modifiers |= Modifier::BOLD;
351        self
352    }
353
354    /// Add italic modifier
355    pub fn italic(mut self) -> Self {
356        self.modifiers |= Modifier::ITALIC;
357        self
358    }
359
360    /// Set the wave width
361    pub fn wave_width(mut self, width: usize) -> Self {
362        self.wave_width = width.max(1);
363        self
364    }
365
366    /// Set the background color
367    pub fn background(mut self, color: Color) -> Self {
368        self.background = Some(color);
369        self
370    }
371
372    /// Set custom rainbow colors
373    pub fn rainbow_colors(mut self, colors: Vec<Color>) -> Self {
374        if !colors.is_empty() {
375            self.rainbow_colors = colors;
376        }
377        self
378    }
379
380    // Preset styles
381
382    /// Success style (green pulse)
383    pub fn success() -> Self {
384        Self::pulse(Color::Green, Color::LightGreen).bold()
385    }
386
387    /// Warning style (yellow pulse)
388    pub fn warning() -> Self {
389        Self::pulse(Color::Yellow, Color::LightYellow).bold()
390    }
391
392    /// Error style (red pulse)
393    pub fn error() -> Self {
394        Self::pulse(Color::Red, Color::LightRed).bold()
395    }
396
397    /// Info style (blue wave)
398    pub fn info() -> Self {
399        Self::wave(Color::Blue, Color::Cyan)
400    }
401
402    /// Loading style (cyan wave)
403    pub fn loading() -> Self {
404        Self::wave(Color::DarkGray, Color::Cyan).wave_width(5)
405    }
406
407    /// Highlight style (yellow sparkle)
408    pub fn highlight() -> Self {
409        Self::sparkle(Color::White, Color::Yellow)
410    }
411}
412
413/// An animated text widget with color effects
414///
415/// Displays text with animated color transitions, including:
416/// - Pulse: Color oscillates between two values
417/// - Wave: A highlight travels back and forth
418/// - Rainbow: Colors cycle across the text
419#[derive(Debug, Clone)]
420pub struct AnimatedText<'a> {
421    /// The text to display
422    text: &'a str,
423    /// Reference to the animation state
424    state: &'a AnimatedTextState,
425    /// Style configuration
426    style: AnimatedTextStyle,
427}
428
429impl<'a> AnimatedText<'a> {
430    /// Create a new animated text widget
431    pub fn new(text: &'a str, state: &'a AnimatedTextState) -> Self {
432        Self {
433            text,
434            state,
435            style: AnimatedTextStyle::default(),
436        }
437    }
438
439    /// Set the style
440    pub fn style(mut self, style: AnimatedTextStyle) -> Self {
441        self.style = style;
442        self
443    }
444
445    /// Apply a theme to derive the style
446    pub fn theme(self, theme: &crate::theme::Theme) -> Self {
447        self.style(AnimatedTextStyle::from(theme))
448    }
449
450    /// Set the effect directly
451    pub fn effect(mut self, effect: AnimatedTextEffect) -> Self {
452        self.style.effect = effect;
453        self
454    }
455
456    /// Set colors directly (primary and secondary)
457    pub fn colors(mut self, primary: Color, secondary: Color) -> Self {
458        self.style.primary_color = primary;
459        self.style.secondary_color = secondary;
460        self
461    }
462
463    /// Get the display width of the text
464    pub fn display_width(&self) -> usize {
465        self.text.width()
466    }
467
468    /// Interpolate between two colors based on factor (0.0 to 1.0)
469    fn interpolate_color(c1: Color, c2: Color, factor: f32) -> Color {
470        match (c1, c2) {
471            (Color::Rgb(r1, g1, b1), Color::Rgb(r2, g2, b2)) => {
472                let r = (r1 as f32 + (r2 as f32 - r1 as f32) * factor) as u8;
473                let g = (g1 as f32 + (g2 as f32 - g1 as f32) * factor) as u8;
474                let b = (b1 as f32 + (b2 as f32 - b1 as f32) * factor) as u8;
475                Color::Rgb(r, g, b)
476            }
477            _ => {
478                // For non-RGB colors, just switch at midpoint
479                if factor < 0.5 { c1 } else { c2 }
480            }
481        }
482    }
483
484    /// Get color for pulse effect
485    fn pulse_color(&self) -> Color {
486        let factor = self.state.interpolation_factor();
487        Self::interpolate_color(self.style.primary_color, self.style.secondary_color, factor)
488    }
489
490    /// Get color for a specific character position in wave effect
491    fn wave_color(&self, char_index: usize) -> Color {
492        let wave_center = self.state.wave_position;
493        let half_width = self.style.wave_width / 2;
494        let start = wave_center.saturating_sub(half_width);
495        let end = wave_center + half_width + 1;
496
497        if char_index >= start && char_index < end {
498            // Calculate intensity based on distance from center
499            let distance = char_index.abs_diff(wave_center);
500            let max_distance = half_width.max(1);
501            let intensity = 1.0 - (distance as f32 / max_distance as f32);
502            Self::interpolate_color(
503                self.style.primary_color,
504                self.style.secondary_color,
505                intensity,
506            )
507        } else {
508            self.style.primary_color
509        }
510    }
511
512    /// Get color for a specific character position in rainbow effect
513    fn rainbow_color(&self, char_index: usize) -> Color {
514        let colors = &self.style.rainbow_colors;
515        if colors.is_empty() {
516            return self.style.primary_color;
517        }
518
519        // Offset the rainbow based on frame for animation
520        let offset = (self.state.frame as usize) / 16;
521        let color_index = (char_index + offset) % colors.len();
522        colors[color_index]
523    }
524
525    /// Get color for gradient shift effect
526    fn gradient_color(&self, char_index: usize, text_width: usize) -> Color {
527        if text_width == 0 {
528            return self.style.primary_color;
529        }
530
531        // Calculate position in gradient (0.0 to 1.0)
532        let base_position = char_index as f32 / text_width.max(1) as f32;
533
534        // Shift the gradient based on frame
535        let shift = self.state.frame as f32 / 255.0;
536        let position = (base_position + shift) % 1.0;
537
538        Self::interpolate_color(
539            self.style.primary_color,
540            self.style.secondary_color,
541            position,
542        )
543    }
544
545    /// Check if a character should sparkle
546    fn should_sparkle(&self, char_index: usize) -> bool {
547        // Simple pseudo-random based on position and seed
548        let hash = char_index
549            .wrapping_mul(31)
550            .wrapping_add(self.state.sparkle_seed as usize);
551        hash % 8 == 0 // ~12.5% chance
552    }
553
554    /// Get color for sparkle effect
555    fn sparkle_color(&self, char_index: usize) -> Color {
556        if self.should_sparkle(char_index) {
557            self.style.secondary_color
558        } else {
559            self.style.primary_color
560        }
561    }
562}
563
564impl Widget for AnimatedText<'_> {
565    fn render(self, area: Rect, buf: &mut Buffer) {
566        if area.width == 0 || area.height == 0 {
567            return;
568        }
569
570        let text_width = self.text.width();
571        let mut x = area.x;
572        let y = area.y;
573
574        // Build base style with modifiers
575        let base_style = Style::default().add_modifier(self.style.modifiers);
576
577        let base_style = if let Some(bg) = self.style.background {
578            base_style.bg(bg)
579        } else {
580            base_style
581        };
582
583        // Render each character with its color
584        for (char_index, ch) in self.text.chars().enumerate() {
585            let ch_width = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
586
587            if x >= area.x + area.width {
588                break;
589            }
590
591            // Get color based on effect type
592            let fg_color = match self.style.effect {
593                AnimatedTextEffect::Pulse => self.pulse_color(),
594                AnimatedTextEffect::Wave => self.wave_color(char_index),
595                AnimatedTextEffect::Rainbow => self.rainbow_color(char_index),
596                AnimatedTextEffect::GradientShift => self.gradient_color(char_index, text_width),
597                AnimatedTextEffect::Sparkle => self.sparkle_color(char_index),
598            };
599
600            let style = base_style.fg(fg_color);
601
602            // Only render if it fits
603            if x as usize + ch_width <= (area.x + area.width) as usize {
604                buf.set_string(x, y, ch.to_string(), style);
605                x += ch_width as u16;
606            }
607        }
608
609        // Clear rest of the area if text is shorter
610        while x < area.x + area.width {
611            buf.set_string(x, y, " ", base_style);
612            x += 1;
613        }
614    }
615}
616
617#[cfg(test)]
618mod tests {
619    use super::*;
620
621    #[test]
622    fn test_animated_text_state_new() {
623        let state = AnimatedTextState::new();
624        assert_eq!(state.frame, 0);
625        assert_eq!(state.wave_position, 0);
626        assert!(state.active);
627    }
628
629    #[test]
630    fn test_animated_text_state_with_interval() {
631        let state = AnimatedTextState::with_interval(100);
632        assert_eq!(state.interval, Duration::from_millis(100));
633    }
634
635    #[test]
636    fn test_animated_text_state_reset() {
637        let mut state = AnimatedTextState::new();
638        state.frame = 128;
639        state.wave_position = 10;
640        state.wave_direction = WaveDirection::Backward;
641
642        state.reset();
643
644        assert_eq!(state.frame, 0);
645        assert_eq!(state.wave_position, 0);
646        assert_eq!(state.wave_direction, WaveDirection::Forward);
647    }
648
649    #[test]
650    fn test_animated_text_state_start_stop() {
651        let mut state = AnimatedTextState::new();
652        assert!(state.is_active());
653
654        state.stop();
655        assert!(!state.is_active());
656
657        state.start();
658        assert!(state.is_active());
659    }
660
661    #[test]
662    fn test_animated_text_state_interpolation() {
663        let mut state = AnimatedTextState::new();
664
665        // At frame 0, should be near 0.5 (sin(0) = 0, (0+1)/2 = 0.5)
666        let factor = state.interpolation_factor();
667        assert!((factor - 0.5).abs() < 0.1);
668
669        // At frame 64 (quarter turn), should be near 1.0
670        state.frame = 64;
671        let factor = state.interpolation_factor();
672        assert!(factor > 0.8);
673
674        // At frame 192 (three-quarter turn), should be near 0.0
675        state.frame = 192;
676        let factor = state.interpolation_factor();
677        assert!(factor < 0.2);
678    }
679
680    #[test]
681    fn test_animated_text_style_presets() {
682        let pulse = AnimatedTextStyle::pulse(Color::Red, Color::Blue);
683        assert_eq!(pulse.effect, AnimatedTextEffect::Pulse);
684        assert_eq!(pulse.primary_color, Color::Red);
685        assert_eq!(pulse.secondary_color, Color::Blue);
686
687        let wave = AnimatedTextStyle::wave(Color::White, Color::Yellow);
688        assert_eq!(wave.effect, AnimatedTextEffect::Wave);
689
690        let rainbow = AnimatedTextStyle::rainbow();
691        assert_eq!(rainbow.effect, AnimatedTextEffect::Rainbow);
692    }
693
694    #[test]
695    fn test_animated_text_style_builder() {
696        let style = AnimatedTextStyle::new()
697            .effect(AnimatedTextEffect::Wave)
698            .primary_color(Color::Green)
699            .secondary_color(Color::Cyan)
700            .wave_width(5)
701            .bold();
702
703        assert_eq!(style.effect, AnimatedTextEffect::Wave);
704        assert_eq!(style.primary_color, Color::Green);
705        assert_eq!(style.secondary_color, Color::Cyan);
706        assert_eq!(style.wave_width, 5);
707        assert!(style.modifiers.contains(Modifier::BOLD));
708    }
709
710    #[test]
711    fn test_animated_text_style_presets_themed() {
712        let success = AnimatedTextStyle::success();
713        assert_eq!(success.primary_color, Color::Green);
714
715        let warning = AnimatedTextStyle::warning();
716        assert_eq!(warning.primary_color, Color::Yellow);
717
718        let error = AnimatedTextStyle::error();
719        assert_eq!(error.primary_color, Color::Red);
720
721        let info = AnimatedTextStyle::info();
722        assert_eq!(info.effect, AnimatedTextEffect::Wave);
723    }
724
725    #[test]
726    fn test_animated_text_display_width() {
727        let state = AnimatedTextState::new();
728        let text = AnimatedText::new("Hello", &state);
729        assert_eq!(text.display_width(), 5);
730
731        let text = AnimatedText::new("Hello World", &state);
732        assert_eq!(text.display_width(), 11);
733    }
734
735    #[test]
736    fn test_animated_text_render() {
737        let state = AnimatedTextState::new();
738        let text = AnimatedText::new("Test", &state);
739
740        let mut buf = Buffer::empty(Rect::new(0, 0, 10, 1));
741        text.render(Rect::new(0, 0, 10, 1), &mut buf);
742        // Just verify it doesn't panic
743    }
744
745    #[test]
746    fn test_animated_text_render_wave() {
747        let mut state = AnimatedTextState::new();
748        state.wave_position = 2;
749
750        let style = AnimatedTextStyle::wave(Color::White, Color::Yellow);
751        let text = AnimatedText::new("Hello", &state).style(style);
752
753        let mut buf = Buffer::empty(Rect::new(0, 0, 10, 1));
754        text.render(Rect::new(0, 0, 10, 1), &mut buf);
755        // Just verify it doesn't panic
756    }
757
758    #[test]
759    fn test_animated_text_render_rainbow() {
760        let state = AnimatedTextState::new();
761        let style = AnimatedTextStyle::rainbow();
762        let text = AnimatedText::new("Rainbow!", &state).style(style);
763
764        let mut buf = Buffer::empty(Rect::new(0, 0, 10, 1));
765        text.render(Rect::new(0, 0, 10, 1), &mut buf);
766        // Just verify it doesn't panic
767    }
768
769    #[test]
770    fn test_animated_text_render_empty_area() {
771        let state = AnimatedTextState::new();
772        let text = AnimatedText::new("Test", &state);
773
774        let mut buf = Buffer::empty(Rect::new(0, 0, 0, 0));
775        text.render(Rect::new(0, 0, 0, 0), &mut buf);
776        // Should not panic on empty area
777    }
778
779    #[test]
780    fn test_interpolate_color_rgb() {
781        // Test RGB interpolation
782        let c1 = Color::Rgb(0, 0, 0);
783        let c2 = Color::Rgb(255, 255, 255);
784
785        let result = AnimatedText::interpolate_color(c1, c2, 0.5);
786        if let Color::Rgb(r, g, b) = result {
787            assert!((r as i16 - 127).abs() <= 1);
788            assert!((g as i16 - 127).abs() <= 1);
789            assert!((b as i16 - 127).abs() <= 1);
790        } else {
791            panic!("Expected RGB color");
792        }
793    }
794
795    #[test]
796    fn test_interpolate_color_non_rgb() {
797        // Non-RGB colors should switch at midpoint
798        let c1 = Color::Red;
799        let c2 = Color::Blue;
800
801        assert_eq!(AnimatedText::interpolate_color(c1, c2, 0.3), Color::Red);
802        assert_eq!(AnimatedText::interpolate_color(c1, c2, 0.7), Color::Blue);
803    }
804
805    #[test]
806    fn test_wave_direction_changes() {
807        let mut state = AnimatedTextState::new();
808        state.interval = Duration::from_millis(0); // Immediate ticks
809        state.last_tick = Some(Instant::now() - Duration::from_secs(1));
810
811        // Move forward
812        let text_width = 10;
813        for _ in 0..15 {
814            state.tick_with_text_width(text_width);
815        }
816
817        // Should have hit the end and reversed
818        assert_eq!(state.wave_direction, WaveDirection::Backward);
819    }
820}