Skip to main content

matrix_rain/
state.rs

1use std::cell::Cell;
2use std::marker::PhantomData;
3use std::time::{Duration, Instant};
4
5use rand::rngs::SmallRng;
6use rand::{Rng, SeedableRng};
7use ratatui::layout::Rect;
8
9use crate::config::MatrixConfig;
10use crate::stream::Stream;
11
12const MAX_CATCHUP_TICKS: u32 = 4;
13
14pub struct MatrixRainState {
15    streams: Vec<Stream>,
16    last_tick: Option<Instant>,
17    accum: Duration,
18    frame: u64,
19    rng: SmallRng,
20    last_area: Option<Rect>,
21    color_count: Option<u16>,
22    last_config: Option<MatrixConfig>,
23    _not_sync: PhantomData<Cell<()>>,
24}
25
26impl MatrixRainState {
27    pub fn new() -> Self {
28        Self::from_rng(SmallRng::from_entropy())
29    }
30
31    pub fn with_seed(seed: u64) -> Self {
32        Self::from_rng(SmallRng::seed_from_u64(seed))
33    }
34
35    fn from_rng(rng: SmallRng) -> Self {
36        Self {
37            streams: Vec::new(),
38            last_tick: None,
39            accum: Duration::ZERO,
40            frame: 0,
41            rng,
42            last_area: None,
43            color_count: None,
44            last_config: None,
45            _not_sync: PhantomData,
46        }
47    }
48
49    pub fn tick(&mut self) {
50        let area = match self.last_area {
51            Some(a) if a.width > 0 && a.height > 0 => a,
52            _ => return,
53        };
54        let Some(config) = self.last_config.take() else {
55            return;
56        };
57        self.apply_one_tick(area, &config);
58        self.last_config = Some(config);
59    }
60
61    pub fn reset(&mut self) {
62        self.streams.clear();
63        self.last_tick = None;
64        self.accum = Duration::ZERO;
65        self.last_area = None;
66        self.last_config = None;
67        self.frame = 0;
68    }
69
70    pub fn streams_len(&self) -> usize {
71        self.streams.len()
72    }
73
74    pub(crate) fn streams(&self) -> &[Stream] {
75        &self.streams
76    }
77
78    pub(crate) fn color_count(&self) -> Option<u16> {
79        self.color_count
80    }
81
82    /// Override the cached terminal color count, suppressing auto-detection on the next render.
83    /// Useful for forcing a specific gradient tier (16-color collapse for accessibility,
84    /// 256-color quantization, or u16::MAX for the smooth-interpolation path) and for
85    /// deterministic testing where TERM/COLORTERM should not influence rendering.
86    pub fn set_color_count(&mut self, count: u16) {
87        self.color_count = Some(count);
88    }
89
90    pub(crate) fn advance(&mut self, area: Rect, config: &MatrixConfig) {
91        if area.width == 0 || area.height == 0 {
92            self.streams.clear();
93            self.last_tick = None;
94            self.accum = Duration::ZERO;
95            self.last_area = None;
96            return;
97        }
98
99        self.handle_resize(area, config);
100
101        let now = Instant::now();
102        let ticks = self.compute_tick_budget(now, config);
103        for _ in 0..ticks {
104            self.apply_one_tick(area, config);
105        }
106
107        self.last_tick = Some(now);
108        self.last_area = Some(area);
109        self.last_config = Some(config.clone());
110    }
111
112    fn handle_resize(&mut self, area: Rect, config: &MatrixConfig) {
113        let prev = self.last_area;
114        let new_w = area.width as usize;
115
116        let width_changed = prev.map_or(true, |p| p.width != area.width);
117        let height_changed = prev.map_or(false, |p| p.height != area.height);
118
119        if width_changed {
120            if self.streams.len() < new_w {
121                for _ in self.streams.len()..new_w {
122                    self.streams
123                        .push(Stream::new_idle(config.max_trail, &mut self.rng));
124                }
125            } else if self.streams.len() > new_w {
126                self.streams.truncate(new_w);
127            }
128        }
129
130        if height_changed {
131            let max_head = (area.height as f32) + (config.max_trail as f32);
132            for stream in &mut self.streams {
133                if stream.is_active() {
134                    let clamped = stream.head_row().clamp(0.0, max_head);
135                    stream.set_head_row(clamped);
136                    if (clamped - stream.length() as f32) >= area.height as f32 {
137                        stream.force_retire(&mut self.rng);
138                    }
139                }
140            }
141        }
142    }
143
144    fn compute_tick_budget(&mut self, now: Instant, config: &MatrixConfig) -> u32 {
145        let ticks_per_sec = (config.fps as f32) * config.speed;
146        if !ticks_per_sec.is_finite() || ticks_per_sec <= 0.0 {
147            self.accum = Duration::ZERO;
148            return 0;
149        }
150
151        match self.last_tick {
152            None => {
153                self.accum = Duration::ZERO;
154                1
155            }
156            Some(prev) => {
157                let elapsed = now.saturating_duration_since(prev);
158                let total_secs = elapsed.as_secs_f32() + self.accum.as_secs_f32();
159                let total_ticks = total_secs * ticks_per_sec;
160                if !total_ticks.is_finite() {
161                    self.accum = Duration::ZERO;
162                    return 0;
163                }
164                let ticks = (total_ticks.floor() as u32).min(MAX_CATCHUP_TICKS);
165                let leftover_ticks = (total_ticks - ticks as f32).max(0.0);
166                let leftover_secs = leftover_ticks / ticks_per_sec;
167                self.accum = Duration::from_secs_f32(leftover_secs.max(0.0));
168                ticks
169            }
170        }
171    }
172
173    fn apply_one_tick(&mut self, area: Rect, config: &MatrixConfig) {
174        let chars = config.charset.chars();
175        for stream in &mut self.streams {
176            stream.tick(area.height, config.fps, &mut self.rng);
177        }
178        for stream in &mut self.streams {
179            if stream.is_ready_to_spawn() && self.rng.gen::<f32>() < config.density {
180                stream.spawn(
181                    &mut self.rng,
182                    chars,
183                    config.min_trail,
184                    config.max_trail,
185                    config.fps,
186                );
187            }
188        }
189        self.frame = self.frame.wrapping_add(1);
190    }
191}
192
193impl Default for MatrixRainState {
194    fn default() -> Self {
195        Self::new()
196    }
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202
203    fn area(w: u16, h: u16) -> Rect {
204        Rect::new(0, 0, w, h)
205    }
206
207    #[test]
208    fn new_starts_with_no_streams_no_timing() {
209        let s = MatrixRainState::new();
210        assert!(s.streams.is_empty());
211        assert!(s.last_tick.is_none());
212        assert!(s.last_area.is_none());
213        assert_eq!(s.frame, 0);
214    }
215
216    #[test]
217    fn first_render_budget_is_one_tick() {
218        let mut s = MatrixRainState::with_seed(0);
219        let cfg = MatrixConfig::default();
220        let ticks = s.compute_tick_budget(Instant::now(), &cfg);
221        assert_eq!(ticks, 1);
222        assert_eq!(s.accum, Duration::ZERO);
223    }
224
225    #[test]
226    fn first_render_allocates_streams_per_column() {
227        let mut s = MatrixRainState::with_seed(0);
228        let cfg = MatrixConfig::default();
229        s.advance(area(12, 10), &cfg);
230        assert_eq!(s.streams().len(), 12);
231        assert_eq!(s.frame, 1);
232        assert!(s.last_tick.is_some());
233    }
234
235    #[test]
236    fn width_resize_grows_and_shrinks_streams() {
237        let mut s = MatrixRainState::with_seed(0);
238        let cfg = MatrixConfig::default();
239        s.advance(area(5, 10), &cfg);
240        assert_eq!(s.streams().len(), 5);
241        s.advance(area(10, 10), &cfg);
242        assert_eq!(s.streams().len(), 10);
243        s.advance(area(3, 10), &cfg);
244        assert_eq!(s.streams().len(), 3);
245    }
246
247    #[test]
248    fn empty_area_clears_streams_and_resets_first_render_path() {
249        let mut s = MatrixRainState::with_seed(0);
250        let cfg = MatrixConfig::default();
251        s.advance(area(10, 10), &cfg);
252        let frame_after_first = s.frame;
253
254        s.advance(area(0, 10), &cfg);
255        assert_eq!(s.streams().len(), 0);
256        assert!(s.last_tick.is_none());
257        assert!(s.last_area.is_none());
258
259        s.advance(area(10, 10), &cfg);
260        assert_eq!(s.frame, frame_after_first + 1);
261    }
262
263    #[test]
264    fn empty_area_height_zero_also_handled() {
265        let mut s = MatrixRainState::with_seed(0);
266        let cfg = MatrixConfig::default();
267        s.advance(area(10, 0), &cfg);
268        assert_eq!(s.streams().len(), 0);
269        assert!(s.last_tick.is_none());
270    }
271
272    #[test]
273    fn tick_before_first_render_is_noop() {
274        let mut s = MatrixRainState::with_seed(0);
275        s.tick();
276        assert_eq!(s.frame, 0);
277        assert!(s.last_tick.is_none());
278    }
279
280    #[test]
281    fn tick_after_first_render_advances_one_frame() {
282        let mut s = MatrixRainState::with_seed(0);
283        let cfg = MatrixConfig::default();
284        s.advance(area(10, 20), &cfg);
285        let frame_before = s.frame;
286        let last_tick_before = s.last_tick;
287        s.tick();
288        assert_eq!(s.frame, frame_before + 1);
289        assert_eq!(
290            s.last_tick, last_tick_before,
291            "tick() must not touch last_tick"
292        );
293    }
294
295    #[test]
296    fn reset_clears_streams_and_timing_keeps_color_count() {
297        let mut s = MatrixRainState::with_seed(42);
298        let cfg = MatrixConfig::default();
299        s.advance(area(10, 20), &cfg);
300        s.set_color_count(256);
301        s.reset();
302        assert_eq!(s.streams().len(), 0);
303        assert!(s.last_tick.is_none());
304        assert!(s.last_area.is_none());
305        assert_eq!(s.frame, 0);
306        assert_eq!(s.color_count(), Some(256));
307    }
308
309    #[test]
310    fn deterministic_with_same_seed() {
311        let cfg = MatrixConfig::default();
312        let mut a = MatrixRainState::with_seed(0xC0FFEE);
313        let mut b = MatrixRainState::with_seed(0xC0FFEE);
314        a.advance(area(15, 15), &cfg);
315        b.advance(area(15, 15), &cfg);
316        assert_eq!(a.streams().len(), b.streams().len());
317        for (sa, sb) in a.streams().iter().zip(b.streams()) {
318            assert_eq!(sa.is_active(), sb.is_active());
319            assert_eq!(sa.length(), sb.length());
320            assert_eq!(sa.head_row(), sb.head_row());
321        }
322    }
323
324    #[test]
325    fn catchup_cap_limits_huge_elapsed() {
326        let mut s = MatrixRainState::with_seed(0);
327        let cfg = MatrixConfig::default();
328        s.last_tick = Some(Instant::now() - Duration::from_secs(60));
329        let ticks = s.compute_tick_budget(Instant::now(), &cfg);
330        assert_eq!(ticks, MAX_CATCHUP_TICKS);
331    }
332
333    #[test]
334    fn sub_tick_render_carries_remainder() {
335        let mut s = MatrixRainState::with_seed(0);
336        let cfg = MatrixConfig::default();
337        let now = Instant::now();
338        s.last_tick = Some(now - Duration::from_micros(500));
339        let ticks = s.compute_tick_budget(now, &cfg);
340        assert_eq!(ticks, 0);
341        assert!(s.accum > Duration::ZERO);
342    }
343
344    #[test]
345    fn pathological_zero_fps_no_panic() {
346        let mut s = MatrixRainState::with_seed(0);
347        let cfg = MatrixConfig {
348            fps: 0,
349            ..MatrixConfig::default()
350        };
351        assert_eq!(s.compute_tick_budget(Instant::now(), &cfg), 0);
352    }
353
354    #[test]
355    fn color_count_default_none_then_set() {
356        let mut s = MatrixRainState::new();
357        assert!(s.color_count().is_none());
358        s.set_color_count(16);
359        assert_eq!(s.color_count(), Some(16));
360    }
361
362    #[test]
363    fn state_is_send() {
364        fn assert_send<T: Send>() {}
365        assert_send::<MatrixRainState>();
366    }
367}