Skip to main content

matrix_rain/
state.rs

1//! Per-frame animation state carried across [`MatrixRain`](crate::MatrixRain)
2//! renders.
3
4use alloc::vec::Vec;
5use core::cell::Cell;
6use core::marker::PhantomData;
7
8#[cfg(feature = "std")]
9use core::time::Duration;
10#[cfg(feature = "std")]
11use std::time::Instant;
12
13use rand::rngs::SmallRng;
14use rand::{Rng, SeedableRng};
15use ratatui::layout::Rect;
16
17use crate::config::MatrixConfig;
18use crate::stream::Stream;
19
20#[cfg(feature = "std")]
21const MAX_CATCHUP_TICKS: u32 = 4;
22
23/// Per-frame animation state for a [`MatrixRain`](crate::MatrixRain) widget.
24///
25/// Holds one stream per terminal column, a seeded RNG, timing bookkeeping,
26/// and a cached terminal color count. The same state instance must be passed
27/// across consecutive renders so the animation continues from frame to frame.
28///
29/// `MatrixRainState` is `Send` but not `Sync` — it's designed for
30/// single-threaded use (render takes `&mut self`).
31///
32/// # Example
33///
34/// ```
35/// use matrix_rain::{MatrixConfig, MatrixRain, MatrixRainState};
36/// use ratatui::buffer::Buffer;
37/// use ratatui::layout::Rect;
38/// use ratatui::widgets::StatefulWidget;
39///
40/// let cfg = MatrixConfig::default();
41/// let mut state = MatrixRainState::with_seed(42);
42/// let mut buf = Buffer::empty(Rect::new(0, 0, 40, 12));
43/// MatrixRain::new(&cfg).render(Rect::new(0, 0, 40, 12), &mut buf, &mut state);
44/// assert_eq!(state.streams_len(), 40);
45/// ```
46pub struct MatrixRainState {
47    streams: Vec<Stream>,
48    #[cfg(feature = "std")]
49    last_tick: Option<Instant>,
50    #[cfg(feature = "std")]
51    accum: Duration,
52    frame: u64,
53    rng: SmallRng,
54    last_area: Option<Rect>,
55    color_count: Option<u16>,
56    last_config: Option<MatrixConfig>,
57    paused: bool,
58    _not_sync: PhantomData<Cell<()>>,
59}
60
61impl MatrixRainState {
62    /// Create a new state seeded from system entropy.
63    ///
64    /// Use [`with_seed`](Self::with_seed) instead when you need reproducible
65    /// output (snapshot tests, screenshots, `--seed` in the binary).
66    ///
67    /// **Requires the `std` feature** (entropy comes from `getrandom`).
68    /// `no_std` callers must use [`with_seed`](Self::with_seed).
69    #[cfg(feature = "std")]
70    pub fn new() -> Self {
71        Self::from_rng(SmallRng::from_entropy())
72    }
73
74    /// Create a new state with a deterministic RNG seed.
75    ///
76    /// Two states constructed with the same seed and driven through the same
77    /// area/config sequence produce identical streams.
78    ///
79    /// # Example
80    ///
81    /// ```
82    /// use matrix_rain::MatrixRainState;
83    /// let a = MatrixRainState::with_seed(42);
84    /// let b = MatrixRainState::with_seed(42);
85    /// assert_eq!(a.streams_len(), b.streams_len());
86    /// ```
87    pub fn with_seed(seed: u64) -> Self {
88        Self::from_rng(SmallRng::seed_from_u64(seed))
89    }
90
91    fn from_rng(rng: SmallRng) -> Self {
92        Self {
93            streams: Vec::new(),
94            #[cfg(feature = "std")]
95            last_tick: None,
96            #[cfg(feature = "std")]
97            accum: Duration::ZERO,
98            frame: 0,
99            rng,
100            last_area: None,
101            color_count: None,
102            last_config: None,
103            paused: false,
104            _not_sync: PhantomData,
105        }
106    }
107
108    /// Advance the animation by exactly one frame, regardless of wall-clock
109    /// time. Bypasses pause.
110    ///
111    /// Uses the area and configuration cached by the most recent
112    /// [`MatrixRain::render`](crate::MatrixRain) call; before the first
113    /// render, this is a silent no-op. `last_tick` is **not** touched, so
114    /// mixing manual ticks with wall-clock-driven renders will drift over
115    /// time — pick one driving mode per session.
116    pub fn tick(&mut self) {
117        let area = match self.last_area {
118            Some(a) if a.width > 0 && a.height > 0 => a,
119            _ => return,
120        };
121        let Some(config) = self.last_config.take() else {
122            return;
123        };
124        self.apply_one_tick(area, &config);
125        self.last_config = Some(config);
126    }
127
128    /// Clear streams, timing, cached area/config, frame counter, and the
129    /// paused flag. RNG state and cached color count are preserved.
130    ///
131    /// After reset, the next render is treated as a first render (applies
132    /// exactly one tick).
133    pub fn reset(&mut self) {
134        self.streams.clear();
135        #[cfg(feature = "std")]
136        {
137            self.last_tick = None;
138            self.accum = Duration::ZERO;
139        }
140        self.last_area = None;
141        self.last_config = None;
142        self.frame = 0;
143        self.paused = false;
144    }
145
146    /// Pause wall-clock-driven advance. Subsequent `render()` / `advance()` calls
147    /// still handle resize and paint the current state, but do not move streams
148    /// forward. Manual `tick()` is unaffected. Idempotent.
149    pub fn pause(&mut self) {
150        self.paused = true;
151    }
152
153    /// Resume wall-clock-driven advance after a `pause()`. Discards any
154    /// previously-recorded `last_tick`/`accum` so the next render is treated
155    /// as a first render (exactly one tick applied) — preventing the
156    /// catch-up-cap stutter that an accumulated pause-time would otherwise
157    /// trigger. Idempotent.
158    pub fn resume(&mut self) {
159        self.paused = false;
160        #[cfg(feature = "std")]
161        {
162            self.last_tick = None;
163            self.accum = Duration::ZERO;
164        }
165    }
166
167    /// Returns whether wall-clock advance is currently suppressed.
168    pub fn is_paused(&self) -> bool {
169        self.paused
170    }
171
172    /// Returns the number of column streams currently allocated.
173    ///
174    /// After a render into a non-empty area, this equals `area.width as usize`.
175    /// After a render into an empty area (`width == 0` or `height == 0`),
176    /// returns `0`.
177    pub fn streams_len(&self) -> usize {
178        self.streams.len()
179    }
180
181    pub(crate) fn streams(&self) -> &[Stream] {
182        &self.streams
183    }
184
185    pub(crate) fn color_count(&self) -> Option<u16> {
186        self.color_count
187    }
188
189    /// Override the cached terminal color count, suppressing auto-detection on the next render.
190    /// Useful for forcing a specific gradient tier (16-color collapse for accessibility,
191    /// 256-color quantization, or u16::MAX for the smooth-interpolation path) and for
192    /// deterministic testing where TERM/COLORTERM should not influence rendering.
193    pub fn set_color_count(&mut self, count: u16) {
194        self.color_count = Some(count);
195    }
196
197    pub(crate) fn advance(&mut self, area: Rect, config: &MatrixConfig) {
198        if area.width == 0 || area.height == 0 {
199            self.streams.clear();
200            #[cfg(feature = "std")]
201            {
202                self.last_tick = None;
203                self.accum = Duration::ZERO;
204            }
205            self.last_area = None;
206            return;
207        }
208
209        self.handle_resize(area, config);
210
211        #[cfg(feature = "std")]
212        if !self.paused {
213            let now = Instant::now();
214            let ticks = self.compute_tick_budget(now, config);
215            for _ in 0..ticks {
216                self.apply_one_tick(area, config);
217            }
218            self.last_tick = Some(now);
219        }
220
221        self.last_area = Some(area);
222        self.last_config = Some(config.clone());
223    }
224
225    fn handle_resize(&mut self, area: Rect, config: &MatrixConfig) {
226        let prev = self.last_area;
227        let new_w = area.width as usize;
228
229        let width_changed = prev.map_or(true, |p| p.width != area.width);
230        let height_changed = prev.map_or(false, |p| p.height != area.height);
231
232        if width_changed {
233            if self.streams.len() < new_w {
234                for _ in self.streams.len()..new_w {
235                    self.streams
236                        .push(Stream::new_idle(config.max_trail, &mut self.rng));
237                }
238            } else if self.streams.len() > new_w {
239                self.streams.truncate(new_w);
240            }
241        }
242
243        if height_changed {
244            let max_head = (area.height as f32) + (config.max_trail as f32);
245            for stream in &mut self.streams {
246                if stream.is_active() {
247                    let clamped = stream.head_row().clamp(0.0, max_head);
248                    stream.set_head_row(clamped);
249                    if (clamped - stream.length() as f32) >= area.height as f32 {
250                        stream.force_retire(&mut self.rng);
251                    }
252                }
253            }
254        }
255    }
256
257    #[cfg(feature = "std")]
258    fn compute_tick_budget(&mut self, now: Instant, config: &MatrixConfig) -> u32 {
259        let ticks_per_sec = (config.fps as f32) * config.speed;
260        if !ticks_per_sec.is_finite() || ticks_per_sec <= 0.0 {
261            self.accum = Duration::ZERO;
262            return 0;
263        }
264
265        match self.last_tick {
266            None => {
267                self.accum = Duration::ZERO;
268                1
269            }
270            Some(prev) => {
271                let elapsed = now.saturating_duration_since(prev);
272                let total_secs = elapsed.as_secs_f32() + self.accum.as_secs_f32();
273                let total_ticks = total_secs * ticks_per_sec;
274                if !total_ticks.is_finite() {
275                    self.accum = Duration::ZERO;
276                    return 0;
277                }
278                let ticks = (total_ticks.floor() as u32).min(MAX_CATCHUP_TICKS);
279                let leftover_ticks = (total_ticks - ticks as f32).max(0.0);
280                let leftover_secs = leftover_ticks / ticks_per_sec;
281                self.accum = Duration::from_secs_f32(leftover_secs.max(0.0));
282                ticks
283            }
284        }
285    }
286
287    fn apply_one_tick(&mut self, area: Rect, config: &MatrixConfig) {
288        let chars = config.charset.chars();
289        for stream in &mut self.streams {
290            stream.tick(area.height, config.fps, &mut self.rng);
291        }
292        if config.mutation_rate > 0.0 {
293            for stream in &mut self.streams {
294                stream.mutate(&mut self.rng, chars, config.mutation_rate);
295            }
296        }
297        if config.glitch > 0.0 {
298            for stream in &mut self.streams {
299                stream.glitch_roll(&mut self.rng, config.glitch);
300            }
301        }
302        for stream in &mut self.streams {
303            if stream.is_ready_to_spawn() && self.rng.gen::<f32>() < config.density {
304                stream.spawn(
305                    &mut self.rng,
306                    chars,
307                    config.min_trail,
308                    config.max_trail,
309                    config.fps,
310                );
311            }
312        }
313        self.frame = self.frame.wrapping_add(1);
314    }
315}
316
317#[cfg(feature = "std")]
318impl Default for MatrixRainState {
319    fn default() -> Self {
320        Self::new()
321    }
322}
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327
328    fn area(w: u16, h: u16) -> Rect {
329        Rect::new(0, 0, w, h)
330    }
331
332    #[test]
333    fn new_starts_with_no_streams_no_timing() {
334        let s = MatrixRainState::new();
335        assert!(s.streams.is_empty());
336        assert!(s.last_tick.is_none());
337        assert!(s.last_area.is_none());
338        assert_eq!(s.frame, 0);
339    }
340
341    #[test]
342    fn first_render_budget_is_one_tick() {
343        let mut s = MatrixRainState::with_seed(0);
344        let cfg = MatrixConfig::default();
345        let ticks = s.compute_tick_budget(Instant::now(), &cfg);
346        assert_eq!(ticks, 1);
347        assert_eq!(s.accum, Duration::ZERO);
348    }
349
350    #[test]
351    fn first_render_allocates_streams_per_column() {
352        let mut s = MatrixRainState::with_seed(0);
353        let cfg = MatrixConfig::default();
354        s.advance(area(12, 10), &cfg);
355        assert_eq!(s.streams().len(), 12);
356        assert_eq!(s.frame, 1);
357        assert!(s.last_tick.is_some());
358    }
359
360    #[test]
361    fn width_resize_grows_and_shrinks_streams() {
362        let mut s = MatrixRainState::with_seed(0);
363        let cfg = MatrixConfig::default();
364        s.advance(area(5, 10), &cfg);
365        assert_eq!(s.streams().len(), 5);
366        s.advance(area(10, 10), &cfg);
367        assert_eq!(s.streams().len(), 10);
368        s.advance(area(3, 10), &cfg);
369        assert_eq!(s.streams().len(), 3);
370    }
371
372    #[test]
373    fn empty_area_clears_streams_and_resets_first_render_path() {
374        let mut s = MatrixRainState::with_seed(0);
375        let cfg = MatrixConfig::default();
376        s.advance(area(10, 10), &cfg);
377        let frame_after_first = s.frame;
378
379        s.advance(area(0, 10), &cfg);
380        assert_eq!(s.streams().len(), 0);
381        assert!(s.last_tick.is_none());
382        assert!(s.last_area.is_none());
383
384        s.advance(area(10, 10), &cfg);
385        assert_eq!(s.frame, frame_after_first + 1);
386    }
387
388    #[test]
389    fn empty_area_height_zero_also_handled() {
390        let mut s = MatrixRainState::with_seed(0);
391        let cfg = MatrixConfig::default();
392        s.advance(area(10, 0), &cfg);
393        assert_eq!(s.streams().len(), 0);
394        assert!(s.last_tick.is_none());
395    }
396
397    #[test]
398    fn tick_before_first_render_is_noop() {
399        let mut s = MatrixRainState::with_seed(0);
400        s.tick();
401        assert_eq!(s.frame, 0);
402        assert!(s.last_tick.is_none());
403    }
404
405    #[test]
406    fn tick_after_first_render_advances_one_frame() {
407        let mut s = MatrixRainState::with_seed(0);
408        let cfg = MatrixConfig::default();
409        s.advance(area(10, 20), &cfg);
410        let frame_before = s.frame;
411        let last_tick_before = s.last_tick;
412        s.tick();
413        assert_eq!(s.frame, frame_before + 1);
414        assert_eq!(
415            s.last_tick, last_tick_before,
416            "tick() must not touch last_tick"
417        );
418    }
419
420    #[test]
421    fn reset_clears_streams_and_timing_keeps_color_count() {
422        let mut s = MatrixRainState::with_seed(42);
423        let cfg = MatrixConfig::default();
424        s.advance(area(10, 20), &cfg);
425        s.set_color_count(256);
426        s.reset();
427        assert_eq!(s.streams().len(), 0);
428        assert!(s.last_tick.is_none());
429        assert!(s.last_area.is_none());
430        assert_eq!(s.frame, 0);
431        assert_eq!(s.color_count(), Some(256));
432    }
433
434    #[test]
435    fn deterministic_with_same_seed() {
436        let cfg = MatrixConfig::default();
437        let mut a = MatrixRainState::with_seed(0xC0FFEE);
438        let mut b = MatrixRainState::with_seed(0xC0FFEE);
439        a.advance(area(15, 15), &cfg);
440        b.advance(area(15, 15), &cfg);
441        assert_eq!(a.streams().len(), b.streams().len());
442        for (sa, sb) in a.streams().iter().zip(b.streams()) {
443            assert_eq!(sa.is_active(), sb.is_active());
444            assert_eq!(sa.length(), sb.length());
445            assert_eq!(sa.head_row(), sb.head_row());
446        }
447    }
448
449    #[test]
450    fn catchup_cap_limits_huge_elapsed() {
451        let mut s = MatrixRainState::with_seed(0);
452        let cfg = MatrixConfig::default();
453        s.last_tick = Some(Instant::now() - Duration::from_secs(60));
454        let ticks = s.compute_tick_budget(Instant::now(), &cfg);
455        assert_eq!(ticks, MAX_CATCHUP_TICKS);
456    }
457
458    #[test]
459    fn sub_tick_render_carries_remainder() {
460        let mut s = MatrixRainState::with_seed(0);
461        let cfg = MatrixConfig::default();
462        let now = Instant::now();
463        s.last_tick = Some(now - Duration::from_micros(500));
464        let ticks = s.compute_tick_budget(now, &cfg);
465        assert_eq!(ticks, 0);
466        assert!(s.accum > Duration::ZERO);
467    }
468
469    #[test]
470    fn pathological_zero_fps_no_panic() {
471        let mut s = MatrixRainState::with_seed(0);
472        let cfg = MatrixConfig {
473            fps: 0,
474            ..MatrixConfig::default()
475        };
476        assert_eq!(s.compute_tick_budget(Instant::now(), &cfg), 0);
477    }
478
479    #[test]
480    fn color_count_default_none_then_set() {
481        let mut s = MatrixRainState::new();
482        assert!(s.color_count().is_none());
483        s.set_color_count(16);
484        assert_eq!(s.color_count(), Some(16));
485    }
486
487    #[test]
488    fn state_is_send() {
489        fn assert_send<T: Send>() {}
490        assert_send::<MatrixRainState>();
491    }
492
493    #[test]
494    fn mutation_rate_zero_keeps_glyphs_unchanged_per_tick() {
495        // Tall area so the stream we're tracking can't retire mid-test.
496        let cfg = MatrixConfig::builder()
497            .fps(30)
498            .density(1.0)
499            .mutation_rate(0.0)
500            .min_trail(8)
501            .max_trail(8)
502            .charset(crate::charset::CharSet::Custom(vec!['a', 'b', 'c']))
503            .build()
504            .unwrap();
505        let mut s = MatrixRainState::with_seed(0x1234);
506        s.advance(area(8, 400), &cfg);
507        for _ in 0..15 {
508            s.apply_one_tick(area(8, 400), &cfg);
509        }
510        let idx = s.streams.iter().position(|st| st.is_active()).expect("active");
511        let before: Vec<char> = s.streams[idx].glyphs().to_vec();
512        s.apply_one_tick(area(8, 400), &cfg);
513        assert!(s.streams[idx].is_active());
514        assert_eq!(s.streams[idx].glyphs(), before.as_slice());
515    }
516
517    #[test]
518    fn pause_freezes_frame_advance_in_render_path() {
519        let cfg = MatrixConfig::default();
520        let mut s = MatrixRainState::with_seed(0xBABE);
521        s.advance(area(8, 20), &cfg);
522        let frame_after_first = s.frame;
523        assert!(frame_after_first > 0);
524
525        s.pause();
526        assert!(s.is_paused());
527        // Many renders while paused — frame counter must not advance.
528        for _ in 0..50 {
529            s.advance(area(8, 20), &cfg);
530        }
531        assert_eq!(s.frame, frame_after_first);
532        // last_area is still cached (so resize handling stays consistent).
533        assert_eq!(s.last_area, Some(area(8, 20)));
534    }
535
536    #[test]
537    fn resume_clears_last_tick_so_next_render_is_first_render() {
538        let cfg = MatrixConfig::default();
539        let mut s = MatrixRainState::with_seed(0xBABE);
540        s.advance(area(8, 20), &cfg);
541        s.pause();
542        s.advance(area(8, 20), &cfg);
543
544        s.resume();
545        assert!(!s.is_paused());
546        assert!(s.last_tick.is_none());
547        assert_eq!(s.accum, Duration::ZERO);
548
549        let frame_before = s.frame;
550        s.advance(area(8, 20), &cfg);
551        assert_eq!(
552            s.frame,
553            frame_before + 1,
554            "post-resume render should apply exactly one tick (first-render path)"
555        );
556    }
557
558    #[test]
559    fn tick_bypasses_pause() {
560        let cfg = MatrixConfig::default();
561        let mut s = MatrixRainState::with_seed(0xBABE);
562        s.advance(area(8, 20), &cfg);
563        s.pause();
564        let frame_before = s.frame;
565        s.tick();
566        assert_eq!(s.frame, frame_before + 1);
567        assert!(s.is_paused(), "tick must not implicitly resume");
568    }
569
570    #[test]
571    fn pause_and_resume_are_idempotent() {
572        let mut s = MatrixRainState::new();
573        s.pause();
574        s.pause();
575        assert!(s.is_paused());
576        s.resume();
577        s.resume();
578        assert!(!s.is_paused());
579    }
580
581    #[test]
582    fn reset_clears_paused_state() {
583        let mut s = MatrixRainState::new();
584        s.pause();
585        s.reset();
586        assert!(!s.is_paused());
587    }
588
589    #[test]
590    fn resize_while_paused_still_resizes_streams() {
591        let cfg = MatrixConfig::default();
592        let mut s = MatrixRainState::with_seed(0xBABE);
593        s.advance(area(8, 20), &cfg);
594        s.pause();
595        s.advance(area(16, 20), &cfg);
596        assert_eq!(s.streams.len(), 16);
597        s.advance(area(4, 20), &cfg);
598        assert_eq!(s.streams.len(), 4);
599    }
600
601    #[test]
602    fn glitch_zero_leaves_flags_unset_after_apply_one_tick() {
603        let cfg = MatrixConfig::builder()
604            .fps(30)
605            .density(1.0)
606            .glitch(0.0)
607            .build()
608            .unwrap();
609        let mut s = MatrixRainState::with_seed(0xFEED);
610        s.advance(area(8, 200), &cfg);
611        for _ in 0..10 {
612            s.apply_one_tick(area(8, 200), &cfg);
613        }
614        for stream in &s.streams {
615            if stream.is_active() {
616                for i in 0..stream.length() {
617                    assert!(!stream.is_glitched(i));
618                }
619            }
620        }
621    }
622
623    #[test]
624    fn glitch_one_sets_all_flags_after_apply_one_tick() {
625        let cfg = MatrixConfig::builder()
626            .fps(30)
627            .density(1.0)
628            .glitch(1.0)
629            .min_trail(6)
630            .max_trail(6)
631            .build()
632            .unwrap();
633        let mut s = MatrixRainState::with_seed(0xFEED);
634        s.advance(area(8, 200), &cfg);
635        for _ in 0..15 {
636            s.apply_one_tick(area(8, 200), &cfg);
637        }
638        let stream = s.streams.iter().find(|st| st.is_active()).expect("active");
639        for i in 0..stream.length() {
640            assert!(stream.is_glitched(i), "cell {i} should be glitched at rate=1.0");
641        }
642    }
643
644    #[test]
645    fn mutation_rate_one_changes_at_least_one_glyph_per_tick() {
646        // Charset of 2 → each cell has 50% chance of flipping per tick when rate=1.
647        // Across 8 cells the prob all stay same is (0.5)^8 = 1/256; with a fixed
648        // seed this is deterministic.
649        let cfg = MatrixConfig::builder()
650            .fps(30)
651            .density(1.0)
652            .mutation_rate(1.0)
653            .min_trail(8)
654            .max_trail(8)
655            .charset(crate::charset::CharSet::Custom(vec!['a', 'b']))
656            .build()
657            .unwrap();
658        let mut s = MatrixRainState::with_seed(0xABCD);
659        s.advance(area(8, 400), &cfg);
660        for _ in 0..15 {
661            s.apply_one_tick(area(8, 400), &cfg);
662        }
663        let idx = s.streams.iter().position(|st| st.is_active()).expect("active");
664        let before: Vec<char> = s.streams[idx].glyphs().to_vec();
665        s.apply_one_tick(area(8, 400), &cfg);
666        assert!(s.streams[idx].is_active());
667        let changed = s.streams[idx]
668            .glyphs()
669            .iter()
670            .zip(before.iter())
671            .filter(|(a, b)| a != b)
672            .count();
673        assert!(changed > 0, "expected at least one glyph to mutate");
674        for g in s.streams[idx].glyphs() {
675            assert!(['a', 'b'].contains(g), "mutated glyph {g} not from charset");
676        }
677    }
678}