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 AnimatedTextStyle {
248    /// Create a new style with default values
249    pub fn new() -> Self {
250        Self::default()
251    }
252
253    /// Create a pulse effect style
254    pub fn pulse(primary: Color, secondary: Color) -> Self {
255        Self {
256            effect: AnimatedTextEffect::Pulse,
257            primary_color: primary,
258            secondary_color: secondary,
259            ..Default::default()
260        }
261    }
262
263    /// Create a wave effect style
264    pub fn wave(base: Color, highlight: Color) -> Self {
265        Self {
266            effect: AnimatedTextEffect::Wave,
267            primary_color: base,
268            secondary_color: highlight,
269            wave_width: 3,
270            ..Default::default()
271        }
272    }
273
274    /// Create a rainbow effect style
275    pub fn rainbow() -> Self {
276        Self {
277            effect: AnimatedTextEffect::Rainbow,
278            ..Default::default()
279        }
280    }
281
282    /// Create a gradient shift effect style
283    pub fn gradient_shift(start: Color, end: Color) -> Self {
284        Self {
285            effect: AnimatedTextEffect::GradientShift,
286            primary_color: start,
287            secondary_color: end,
288            ..Default::default()
289        }
290    }
291
292    /// Create a sparkle effect style
293    pub fn sparkle(base: Color, sparkle: Color) -> Self {
294        Self {
295            effect: AnimatedTextEffect::Sparkle,
296            primary_color: base,
297            secondary_color: sparkle,
298            ..Default::default()
299        }
300    }
301
302    /// Set the animation effect
303    pub fn effect(mut self, effect: AnimatedTextEffect) -> Self {
304        self.effect = effect;
305        self
306    }
307
308    /// Set the primary color
309    pub fn primary_color(mut self, color: Color) -> Self {
310        self.primary_color = color;
311        self
312    }
313
314    /// Set the secondary color
315    pub fn secondary_color(mut self, color: Color) -> Self {
316        self.secondary_color = color;
317        self
318    }
319
320    /// Set text modifiers
321    pub fn modifiers(mut self, modifiers: Modifier) -> Self {
322        self.modifiers = modifiers;
323        self
324    }
325
326    /// Add bold modifier
327    pub fn bold(mut self) -> Self {
328        self.modifiers = self.modifiers | Modifier::BOLD;
329        self
330    }
331
332    /// Add italic modifier
333    pub fn italic(mut self) -> Self {
334        self.modifiers = self.modifiers | Modifier::ITALIC;
335        self
336    }
337
338    /// Set the wave width
339    pub fn wave_width(mut self, width: usize) -> Self {
340        self.wave_width = width.max(1);
341        self
342    }
343
344    /// Set the background color
345    pub fn background(mut self, color: Color) -> Self {
346        self.background = Some(color);
347        self
348    }
349
350    /// Set custom rainbow colors
351    pub fn rainbow_colors(mut self, colors: Vec<Color>) -> Self {
352        if !colors.is_empty() {
353            self.rainbow_colors = colors;
354        }
355        self
356    }
357
358    // Preset styles
359
360    /// Success style (green pulse)
361    pub fn success() -> Self {
362        Self::pulse(Color::Green, Color::LightGreen)
363            .bold()
364    }
365
366    /// Warning style (yellow pulse)
367    pub fn warning() -> Self {
368        Self::pulse(Color::Yellow, Color::LightYellow)
369            .bold()
370    }
371
372    /// Error style (red pulse)
373    pub fn error() -> Self {
374        Self::pulse(Color::Red, Color::LightRed)
375            .bold()
376    }
377
378    /// Info style (blue wave)
379    pub fn info() -> Self {
380        Self::wave(Color::Blue, Color::Cyan)
381    }
382
383    /// Loading style (cyan wave)
384    pub fn loading() -> Self {
385        Self::wave(Color::DarkGray, Color::Cyan)
386            .wave_width(5)
387    }
388
389    /// Highlight style (yellow sparkle)
390    pub fn highlight() -> Self {
391        Self::sparkle(Color::White, Color::Yellow)
392    }
393}
394
395/// An animated text widget with color effects
396///
397/// Displays text with animated color transitions, including:
398/// - Pulse: Color oscillates between two values
399/// - Wave: A highlight travels back and forth
400/// - Rainbow: Colors cycle across the text
401#[derive(Debug, Clone)]
402pub struct AnimatedText<'a> {
403    /// The text to display
404    text: &'a str,
405    /// Reference to the animation state
406    state: &'a AnimatedTextState,
407    /// Style configuration
408    style: AnimatedTextStyle,
409}
410
411impl<'a> AnimatedText<'a> {
412    /// Create a new animated text widget
413    pub fn new(text: &'a str, state: &'a AnimatedTextState) -> Self {
414        Self {
415            text,
416            state,
417            style: AnimatedTextStyle::default(),
418        }
419    }
420
421    /// Set the style
422    pub fn style(mut self, style: AnimatedTextStyle) -> Self {
423        self.style = style;
424        self
425    }
426
427    /// Set the effect directly
428    pub fn effect(mut self, effect: AnimatedTextEffect) -> Self {
429        self.style.effect = effect;
430        self
431    }
432
433    /// Set colors directly (primary and secondary)
434    pub fn colors(mut self, primary: Color, secondary: Color) -> Self {
435        self.style.primary_color = primary;
436        self.style.secondary_color = secondary;
437        self
438    }
439
440    /// Get the display width of the text
441    pub fn display_width(&self) -> usize {
442        self.text.width()
443    }
444
445    /// Interpolate between two colors based on factor (0.0 to 1.0)
446    fn interpolate_color(c1: Color, c2: Color, factor: f32) -> Color {
447        match (c1, c2) {
448            (Color::Rgb(r1, g1, b1), Color::Rgb(r2, g2, b2)) => {
449                let r = (r1 as f32 + (r2 as f32 - r1 as f32) * factor) as u8;
450                let g = (g1 as f32 + (g2 as f32 - g1 as f32) * factor) as u8;
451                let b = (b1 as f32 + (b2 as f32 - b1 as f32) * factor) as u8;
452                Color::Rgb(r, g, b)
453            }
454            _ => {
455                // For non-RGB colors, just switch at midpoint
456                if factor < 0.5 { c1 } else { c2 }
457            }
458        }
459    }
460
461    /// Get color for pulse effect
462    fn pulse_color(&self) -> Color {
463        let factor = self.state.interpolation_factor();
464        Self::interpolate_color(self.style.primary_color, self.style.secondary_color, factor)
465    }
466
467    /// Get color for a specific character position in wave effect
468    fn wave_color(&self, char_index: usize) -> Color {
469        let wave_center = self.state.wave_position;
470        let half_width = self.style.wave_width / 2;
471        let start = wave_center.saturating_sub(half_width);
472        let end = wave_center + half_width + 1;
473
474        if char_index >= start && char_index < end {
475            // Calculate intensity based on distance from center
476            let distance = if char_index >= wave_center {
477                char_index - wave_center
478            } else {
479                wave_center - char_index
480            };
481            let max_distance = half_width.max(1);
482            let intensity = 1.0 - (distance as f32 / max_distance as f32);
483            Self::interpolate_color(
484                self.style.primary_color,
485                self.style.secondary_color,
486                intensity,
487            )
488        } else {
489            self.style.primary_color
490        }
491    }
492
493    /// Get color for a specific character position in rainbow effect
494    fn rainbow_color(&self, char_index: usize) -> Color {
495        let colors = &self.style.rainbow_colors;
496        if colors.is_empty() {
497            return self.style.primary_color;
498        }
499
500        // Offset the rainbow based on frame for animation
501        let offset = (self.state.frame as usize) / 16;
502        let color_index = (char_index + offset) % colors.len();
503        colors[color_index]
504    }
505
506    /// Get color for gradient shift effect
507    fn gradient_color(&self, char_index: usize, text_width: usize) -> Color {
508        if text_width == 0 {
509            return self.style.primary_color;
510        }
511
512        // Calculate position in gradient (0.0 to 1.0)
513        let base_position = char_index as f32 / text_width.max(1) as f32;
514
515        // Shift the gradient based on frame
516        let shift = self.state.frame as f32 / 255.0;
517        let position = (base_position + shift) % 1.0;
518
519        Self::interpolate_color(self.style.primary_color, self.style.secondary_color, position)
520    }
521
522    /// Check if a character should sparkle
523    fn should_sparkle(&self, char_index: usize) -> bool {
524        // Simple pseudo-random based on position and seed
525        let hash = char_index.wrapping_mul(31).wrapping_add(self.state.sparkle_seed as usize);
526        hash % 8 == 0 // ~12.5% chance
527    }
528
529    /// Get color for sparkle effect
530    fn sparkle_color(&self, char_index: usize) -> Color {
531        if self.should_sparkle(char_index) {
532            self.style.secondary_color
533        } else {
534            self.style.primary_color
535        }
536    }
537}
538
539impl Widget for AnimatedText<'_> {
540    fn render(self, area: Rect, buf: &mut Buffer) {
541        if area.width == 0 || area.height == 0 {
542            return;
543        }
544
545        let text_width = self.text.width();
546        let mut x = area.x;
547        let y = area.y;
548
549        // Build base style with modifiers
550        let base_style = Style::default()
551            .add_modifier(self.style.modifiers);
552
553        let base_style = if let Some(bg) = self.style.background {
554            base_style.bg(bg)
555        } else {
556            base_style
557        };
558
559        // Render each character with its color
560        let mut char_index = 0;
561        for ch in self.text.chars() {
562            let ch_width = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
563
564            if x >= area.x + area.width {
565                break;
566            }
567
568            // Get color based on effect type
569            let fg_color = match self.style.effect {
570                AnimatedTextEffect::Pulse => self.pulse_color(),
571                AnimatedTextEffect::Wave => self.wave_color(char_index),
572                AnimatedTextEffect::Rainbow => self.rainbow_color(char_index),
573                AnimatedTextEffect::GradientShift => self.gradient_color(char_index, text_width),
574                AnimatedTextEffect::Sparkle => self.sparkle_color(char_index),
575            };
576
577            let style = base_style.fg(fg_color);
578
579            // Only render if it fits
580            if x as usize + ch_width <= (area.x + area.width) as usize {
581                buf.set_string(x, y, ch.to_string(), style);
582                x += ch_width as u16;
583            }
584
585            char_index += 1;
586        }
587
588        // Clear rest of the area if text is shorter
589        while x < area.x + area.width {
590            buf.set_string(x, y, " ", base_style);
591            x += 1;
592        }
593    }
594}
595
596#[cfg(test)]
597mod tests {
598    use super::*;
599
600    #[test]
601    fn test_animated_text_state_new() {
602        let state = AnimatedTextState::new();
603        assert_eq!(state.frame, 0);
604        assert_eq!(state.wave_position, 0);
605        assert!(state.active);
606    }
607
608    #[test]
609    fn test_animated_text_state_with_interval() {
610        let state = AnimatedTextState::with_interval(100);
611        assert_eq!(state.interval, Duration::from_millis(100));
612    }
613
614    #[test]
615    fn test_animated_text_state_reset() {
616        let mut state = AnimatedTextState::new();
617        state.frame = 128;
618        state.wave_position = 10;
619        state.wave_direction = WaveDirection::Backward;
620
621        state.reset();
622
623        assert_eq!(state.frame, 0);
624        assert_eq!(state.wave_position, 0);
625        assert_eq!(state.wave_direction, WaveDirection::Forward);
626    }
627
628    #[test]
629    fn test_animated_text_state_start_stop() {
630        let mut state = AnimatedTextState::new();
631        assert!(state.is_active());
632
633        state.stop();
634        assert!(!state.is_active());
635
636        state.start();
637        assert!(state.is_active());
638    }
639
640    #[test]
641    fn test_animated_text_state_interpolation() {
642        let mut state = AnimatedTextState::new();
643
644        // At frame 0, should be near 0.5 (sin(0) = 0, (0+1)/2 = 0.5)
645        let factor = state.interpolation_factor();
646        assert!((factor - 0.5).abs() < 0.1);
647
648        // At frame 64 (quarter turn), should be near 1.0
649        state.frame = 64;
650        let factor = state.interpolation_factor();
651        assert!(factor > 0.8);
652
653        // At frame 192 (three-quarter turn), should be near 0.0
654        state.frame = 192;
655        let factor = state.interpolation_factor();
656        assert!(factor < 0.2);
657    }
658
659    #[test]
660    fn test_animated_text_style_presets() {
661        let pulse = AnimatedTextStyle::pulse(Color::Red, Color::Blue);
662        assert_eq!(pulse.effect, AnimatedTextEffect::Pulse);
663        assert_eq!(pulse.primary_color, Color::Red);
664        assert_eq!(pulse.secondary_color, Color::Blue);
665
666        let wave = AnimatedTextStyle::wave(Color::White, Color::Yellow);
667        assert_eq!(wave.effect, AnimatedTextEffect::Wave);
668
669        let rainbow = AnimatedTextStyle::rainbow();
670        assert_eq!(rainbow.effect, AnimatedTextEffect::Rainbow);
671    }
672
673    #[test]
674    fn test_animated_text_style_builder() {
675        let style = AnimatedTextStyle::new()
676            .effect(AnimatedTextEffect::Wave)
677            .primary_color(Color::Green)
678            .secondary_color(Color::Cyan)
679            .wave_width(5)
680            .bold();
681
682        assert_eq!(style.effect, AnimatedTextEffect::Wave);
683        assert_eq!(style.primary_color, Color::Green);
684        assert_eq!(style.secondary_color, Color::Cyan);
685        assert_eq!(style.wave_width, 5);
686        assert!(style.modifiers.contains(Modifier::BOLD));
687    }
688
689    #[test]
690    fn test_animated_text_style_presets_themed() {
691        let success = AnimatedTextStyle::success();
692        assert_eq!(success.primary_color, Color::Green);
693
694        let warning = AnimatedTextStyle::warning();
695        assert_eq!(warning.primary_color, Color::Yellow);
696
697        let error = AnimatedTextStyle::error();
698        assert_eq!(error.primary_color, Color::Red);
699
700        let info = AnimatedTextStyle::info();
701        assert_eq!(info.effect, AnimatedTextEffect::Wave);
702    }
703
704    #[test]
705    fn test_animated_text_display_width() {
706        let state = AnimatedTextState::new();
707        let text = AnimatedText::new("Hello", &state);
708        assert_eq!(text.display_width(), 5);
709
710        let text = AnimatedText::new("Hello World", &state);
711        assert_eq!(text.display_width(), 11);
712    }
713
714    #[test]
715    fn test_animated_text_render() {
716        let state = AnimatedTextState::new();
717        let text = AnimatedText::new("Test", &state);
718
719        let mut buf = Buffer::empty(Rect::new(0, 0, 10, 1));
720        text.render(Rect::new(0, 0, 10, 1), &mut buf);
721        // Just verify it doesn't panic
722    }
723
724    #[test]
725    fn test_animated_text_render_wave() {
726        let mut state = AnimatedTextState::new();
727        state.wave_position = 2;
728
729        let style = AnimatedTextStyle::wave(Color::White, Color::Yellow);
730        let text = AnimatedText::new("Hello", &state).style(style);
731
732        let mut buf = Buffer::empty(Rect::new(0, 0, 10, 1));
733        text.render(Rect::new(0, 0, 10, 1), &mut buf);
734        // Just verify it doesn't panic
735    }
736
737    #[test]
738    fn test_animated_text_render_rainbow() {
739        let state = AnimatedTextState::new();
740        let style = AnimatedTextStyle::rainbow();
741        let text = AnimatedText::new("Rainbow!", &state).style(style);
742
743        let mut buf = Buffer::empty(Rect::new(0, 0, 10, 1));
744        text.render(Rect::new(0, 0, 10, 1), &mut buf);
745        // Just verify it doesn't panic
746    }
747
748    #[test]
749    fn test_animated_text_render_empty_area() {
750        let state = AnimatedTextState::new();
751        let text = AnimatedText::new("Test", &state);
752
753        let mut buf = Buffer::empty(Rect::new(0, 0, 0, 0));
754        text.render(Rect::new(0, 0, 0, 0), &mut buf);
755        // Should not panic on empty area
756    }
757
758    #[test]
759    fn test_interpolate_color_rgb() {
760        // Test RGB interpolation
761        let c1 = Color::Rgb(0, 0, 0);
762        let c2 = Color::Rgb(255, 255, 255);
763
764        let result = AnimatedText::interpolate_color(c1, c2, 0.5);
765        if let Color::Rgb(r, g, b) = result {
766            assert!((r as i16 - 127).abs() <= 1);
767            assert!((g as i16 - 127).abs() <= 1);
768            assert!((b as i16 - 127).abs() <= 1);
769        } else {
770            panic!("Expected RGB color");
771        }
772    }
773
774    #[test]
775    fn test_interpolate_color_non_rgb() {
776        // Non-RGB colors should switch at midpoint
777        let c1 = Color::Red;
778        let c2 = Color::Blue;
779
780        assert_eq!(AnimatedText::interpolate_color(c1, c2, 0.3), Color::Red);
781        assert_eq!(AnimatedText::interpolate_color(c1, c2, 0.7), Color::Blue);
782    }
783
784    #[test]
785    fn test_wave_direction_changes() {
786        let mut state = AnimatedTextState::new();
787        state.interval = Duration::from_millis(0); // Immediate ticks
788        state.last_tick = Some(Instant::now() - Duration::from_secs(1));
789
790        // Move forward
791        let text_width = 10;
792        for _ in 0..15 {
793            state.tick_with_text_width(text_width);
794        }
795
796        // Should have hit the end and reversed
797        assert_eq!(state.wave_direction, WaveDirection::Backward);
798    }
799}