1use 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
17pub 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 pub fn new() -> Self {
59 Self::from_rng(SmallRng::from_entropy())
60 }
61
62 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 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 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 pub fn pause(&mut self) {
133 self.paused = true;
134 }
135
136 pub fn resume(&mut self) {
142 self.paused = false;
143 self.last_tick = None;
144 self.accum = Duration::ZERO;
145 }
146
147 pub fn is_paused(&self) -> bool {
149 self.paused
150 }
151
152 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 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 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 for _ in 0..50 {
503 s.advance(area(8, 20), &cfg);
504 }
505 assert_eq!(s.frame, frame_after_first);
506 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 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}