Skip to main content

matrix_rain/
state.rs

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