Skip to main content

matrix_rain/
config.rs

1//! [`MatrixConfig`] (the read-only configuration consumed by the widget),
2//! its fluent [`MatrixConfigBuilder`], and the [`MAX_TRAIL_LIMIT`] cap.
3
4use alloc::format;
5use alloc::string::ToString;
6
7use ratatui::style::Color;
8
9use crate::charset::CharSet;
10use crate::error::MatrixError;
11use crate::theme::Theme;
12
13/// Read-only configuration for [`MatrixRain`](crate::MatrixRain).
14///
15/// Construct via [`MatrixConfig::builder`] (validated) or
16/// [`MatrixConfig::default`] (always valid, classic Matrix look). All fields
17/// are public so the struct also supports destructuring for inspection.
18///
19/// # Example
20///
21/// ```
22/// use matrix_rain::MatrixConfig;
23///
24/// let cfg = MatrixConfig::builder().fps(60).density(0.8).build().unwrap();
25/// assert_eq!(cfg.fps, 60);
26/// assert_eq!(cfg.density, 0.8);
27/// ```
28#[derive(Clone, Debug)]
29pub struct MatrixConfig {
30    /// Glyph source. Default: [`CharSet::Matrix`] (katakana + digits).
31    pub charset: CharSet,
32    /// Color theme. Default: [`Theme::ClassicGreen`].
33    pub theme: Theme,
34    /// Frames per second (must be `>= 1`). Acts as the wall-clock tick budget
35    /// when wall-clock driving and as the divisor for `speed`. Default: `30`.
36    pub fps: u16,
37    /// Global speed multiplier (must be finite and `> 0.0`). Scales the
38    /// effective tick rate consumed from wall-clock elapsed time. Default: `1.0`.
39    pub speed: f32,
40    /// Fraction of columns to keep active. `0.0` = no spawns ever; `1.0` =
41    /// every idle column attempts respawn each tick after cooldown. Must be
42    /// finite and in `[0.0, 1.0]`. Default: `0.6`.
43    pub density: f32,
44    /// Minimum trail length. Must be `>= 1` and `<= max_trail`. Default: `6`.
45    pub min_trail: u16,
46    /// Maximum trail length. Must be `<= MAX_TRAIL_LIMIT`. Default: `20`.
47    pub max_trail: u16,
48    /// Per-cell glyph-reroll probability per tick. Must be finite and in
49    /// `[0.0, 1.0]`. `0.0` freezes glyphs after spawn; `1.0` rerolls every
50    /// cell every frame. Default: `0.05`.
51    pub mutation_rate: f32,
52    /// Apply `Modifier::BOLD` to the head cell when true. Default: `true`.
53    pub bold_head: bool,
54    /// Use `ColorRamp.head` (typically white) for the head cell when true,
55    /// `ColorRamp.bright` when false. Default: `true`.
56    pub head_white: bool,
57    /// Per-cell color-flicker probability per tick. On hit, the cell renders
58    /// with `ColorRamp.head` instead of its gradient color (a "sparkle").
59    /// Head cell (i=0) is unaffected. Must be finite and in `[0.0, 1.0]`.
60    /// Default: `0.0` (off).
61    pub glitch: f32,
62    /// Optional background color. `None` (default) renders transparently,
63    /// skipping cells in the fade zone so the underlying buffer shows
64    /// through. `Some(c)` skips any cell whose computed color equals `c` —
65    /// useful when compositing over a known background.
66    pub background: Option<Color>,
67}
68
69impl MatrixConfig {
70    /// Returns a new [`MatrixConfigBuilder`] seeded with defaults.
71    /// Call setters to override fields, then [`build`](MatrixConfigBuilder::build)
72    /// to validate and produce a [`MatrixConfig`].
73    pub fn builder() -> MatrixConfigBuilder {
74        MatrixConfigBuilder::new()
75    }
76}
77
78impl Default for MatrixConfig {
79    fn default() -> Self {
80        Self {
81            charset: CharSet::Matrix,
82            theme: Theme::ClassicGreen,
83            fps: 30,
84            speed: 1.0,
85            density: 0.6,
86            min_trail: 6,
87            max_trail: 20,
88            mutation_rate: 0.05,
89            bold_head: true,
90            head_white: true,
91            glitch: 0.0,
92            background: None,
93        }
94    }
95}
96
97#[derive(Clone, Debug)]
98/// Fluent builder for [`MatrixConfig`].
99///
100/// Construct via [`MatrixConfig::builder`] or [`MatrixConfigBuilder::new`].
101/// Setters take and return `self` so they can be chained. Call
102/// [`build`](Self::build) to validate and produce a [`MatrixConfig`], or
103/// receive a [`MatrixError`] describing which invariant failed.
104///
105/// # Example
106///
107/// ```
108/// use matrix_rain::{CharSet, MatrixConfig, Theme};
109///
110/// let cfg = MatrixConfig::builder()
111///     .fps(60)
112///     .density(0.8)
113///     .charset(CharSet::Hex)
114///     .theme(Theme::Cyan)
115///     .build()
116///     .unwrap();
117/// ```
118pub struct MatrixConfigBuilder {
119    config: MatrixConfig,
120}
121
122impl MatrixConfigBuilder {
123    /// Returns a new builder seeded with [`MatrixConfig::default`].
124    pub fn new() -> Self {
125        Self {
126            config: MatrixConfig::default(),
127        }
128    }
129
130    /// Set the glyph source. See [`CharSet`].
131    pub fn charset(mut self, charset: CharSet) -> Self {
132        self.config.charset = charset;
133        self
134    }
135
136    /// Set the color theme. See [`Theme`].
137    pub fn theme(mut self, theme: Theme) -> Self {
138        self.config.theme = theme;
139        self
140    }
141
142    /// Set frames per second. Must be `>= 1`; [`build`](Self::build) rejects 0.
143    pub fn fps(mut self, fps: u16) -> Self {
144        self.config.fps = fps;
145        self
146    }
147
148    /// Set the global speed multiplier. Must be finite and `> 0.0`.
149    pub fn speed(mut self, speed: f32) -> Self {
150        self.config.speed = speed;
151        self
152    }
153
154    /// Set the column-activity density. Must be finite and in `[0.0, 1.0]`.
155    pub fn density(mut self, density: f32) -> Self {
156        self.config.density = density;
157        self
158    }
159
160    /// Set the minimum trail length. Must be `>= 1` and `<= max_trail`.
161    pub fn min_trail(mut self, min_trail: u16) -> Self {
162        self.config.min_trail = min_trail;
163        self
164    }
165
166    /// Set the maximum trail length. Must be `<= MAX_TRAIL_LIMIT`.
167    pub fn max_trail(mut self, max_trail: u16) -> Self {
168        self.config.max_trail = max_trail;
169        self
170    }
171
172    /// Set the per-cell glyph reroll probability per tick. Finite, `[0.0, 1.0]`.
173    pub fn mutation_rate(mut self, mutation_rate: f32) -> Self {
174        self.config.mutation_rate = mutation_rate;
175        self
176    }
177
178    /// Apply `Modifier::BOLD` to the head cell when `true`.
179    pub fn bold_head(mut self, bold_head: bool) -> Self {
180        self.config.bold_head = bold_head;
181        self
182    }
183
184    /// Use `ColorRamp.head` for the head cell when `true`, `ColorRamp.bright`
185    /// when `false`.
186    pub fn head_white(mut self, head_white: bool) -> Self {
187        self.config.head_white = head_white;
188        self
189    }
190
191    /// Set the per-cell glitch (color flicker) probability per tick.
192    /// Finite, `[0.0, 1.0]`. See [`MatrixConfig::glitch`].
193    pub fn glitch(mut self, glitch: f32) -> Self {
194        self.config.glitch = glitch;
195        self
196    }
197
198    /// Set the background color. `None` for transparent (skip cells in the
199    /// fade zone); `Some(c)` for a known compositing background.
200    pub fn background(mut self, background: Option<Color>) -> Self {
201        self.config.background = background;
202        self
203    }
204
205    /// Validate the configured fields and return a [`MatrixConfig`].
206    ///
207    /// # Errors
208    ///
209    /// Returns [`MatrixError::InvalidConfig`] if any of the following fails
210    /// (checked in order):
211    ///
212    /// - `fps >= 1`
213    /// - `speed` is finite and `> 0`
214    /// - `density` is finite and in `[0.0, 1.0]`
215    /// - `min_trail >= 1`
216    /// - `min_trail <= max_trail`
217    /// - `max_trail <= MAX_TRAIL_LIMIT`
218    /// - `mutation_rate` is finite and in `[0.0, 1.0]`
219    /// - `glitch` is finite and in `[0.0, 1.0]`
220    ///
221    /// Returns [`MatrixError::EmptyCharset`] when [`CharSet::Custom`]
222    /// resolves to an empty `Vec`, and [`MatrixError::InvalidConfig`] when
223    /// any character in a charset is a `char::is_control`.
224    ///
225    /// Boundary values are accepted: `density == 0.0` / `1.0`,
226    /// `min_trail == max_trail`, `mutation_rate == 0.0` / `1.0`,
227    /// `glitch == 0.0` / `1.0`.
228    pub fn build(self) -> Result<MatrixConfig, MatrixError> {
229        let c = &self.config;
230
231        if c.fps < 1 {
232            return Err(invalid("fps must be >= 1"));
233        }
234        if !c.speed.is_finite() || c.speed <= 0.0 {
235            return Err(invalid(&format!(
236                "speed must be a positive finite number (got {})",
237                c.speed
238            )));
239        }
240        if !c.density.is_finite() || !(0.0..=1.0).contains(&c.density) {
241            return Err(invalid(&format!(
242                "density must be a finite number in [0.0, 1.0] (got {})",
243                c.density
244            )));
245        }
246        if c.min_trail < 1 {
247            return Err(invalid("min_trail must be >= 1"));
248        }
249        if c.min_trail > c.max_trail {
250            return Err(invalid(&format!(
251                "min_trail ({}) must be <= max_trail ({})",
252                c.min_trail, c.max_trail
253            )));
254        }
255        if c.max_trail > MAX_TRAIL_LIMIT {
256            return Err(invalid(&format!(
257                "max_trail ({}) exceeds limit of {}",
258                c.max_trail, MAX_TRAIL_LIMIT
259            )));
260        }
261        if !c.mutation_rate.is_finite() || !(0.0..=1.0).contains(&c.mutation_rate) {
262            return Err(invalid(&format!(
263                "mutation_rate must be a finite number in [0.0, 1.0] (got {})",
264                c.mutation_rate
265            )));
266        }
267        if !c.glitch.is_finite() || !(0.0..=1.0).contains(&c.glitch) {
268            return Err(invalid(&format!(
269                "glitch must be a finite number in [0.0, 1.0] (got {})",
270                c.glitch
271            )));
272        }
273        c.charset.validate()?;
274
275        Ok(self.config)
276    }
277}
278
279/// Hard upper bound on [`MatrixConfig::max_trail`].
280///
281/// Exposed publicly so callers can read the cap when validating their own
282/// inputs; the builder enforces it in [`MatrixConfigBuilder::build`]. Set to
283/// `1024` — already an order of magnitude beyond any realistic terminal
284/// height; the cap exists to prevent accidental gigabyte glyph buffers from
285/// typos rather than to enforce a stylistic constraint.
286pub const MAX_TRAIL_LIMIT: u16 = 1024;
287
288fn invalid(msg: &str) -> MatrixError {
289    MatrixError::InvalidConfig(msg.to_string())
290}
291
292impl Default for MatrixConfigBuilder {
293    fn default() -> Self {
294        Self::new()
295    }
296}
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301
302    #[test]
303    fn defaults_match_spec() {
304        let cfg = MatrixConfig::default();
305        assert_eq!(cfg.fps, 30);
306        assert_eq!(cfg.speed, 1.0);
307        assert_eq!(cfg.density, 0.6);
308        assert!(cfg.bold_head);
309        assert!(cfg.head_white);
310        assert!(matches!(cfg.charset, CharSet::Matrix));
311        assert!(matches!(cfg.theme, Theme::ClassicGreen));
312        assert_eq!(cfg.glitch, 0.0);
313        assert_eq!(cfg.background, None);
314        assert!(cfg.min_trail >= 1);
315        assert!(cfg.max_trail >= cfg.min_trail);
316    }
317
318    #[test]
319    fn builder_chains_overrides() {
320        let cfg = MatrixConfig::builder()
321            .fps(60)
322            .density(0.3)
323            .bold_head(false)
324            .build()
325            .expect("build should succeed");
326        assert_eq!(cfg.fps, 60);
327        assert_eq!(cfg.density, 0.3);
328        assert!(!cfg.bold_head);
329        assert!(cfg.head_white, "untouched fields keep defaults");
330    }
331
332    #[test]
333    fn builder_default_round_trip() {
334        let cfg = MatrixConfig::builder().build().unwrap();
335        let default = MatrixConfig::default();
336        assert_eq!(cfg.fps, default.fps);
337        assert_eq!(cfg.speed, default.speed);
338        assert_eq!(cfg.density, default.density);
339        assert_eq!(cfg.min_trail, default.min_trail);
340        assert_eq!(cfg.max_trail, default.max_trail);
341    }
342
343    #[test]
344    fn build_rejects_empty_custom_charset() {
345        let err = MatrixConfig::builder()
346            .charset(CharSet::Custom(vec![]))
347            .build()
348            .unwrap_err();
349        assert!(matches!(err, MatrixError::EmptyCharset));
350    }
351
352    #[test]
353    fn build_rejects_control_chars_in_custom() {
354        let err = MatrixConfig::builder()
355            .charset(CharSet::Custom(vec!['a', '\n']))
356            .build()
357            .unwrap_err();
358        assert!(matches!(err, MatrixError::InvalidConfig(_)));
359    }
360
361    fn invalid_err(r: Result<MatrixConfig, MatrixError>, expected_keyword: &str) {
362        match r {
363            Err(MatrixError::InvalidConfig(msg)) => assert!(
364                msg.contains(expected_keyword),
365                "expected '{expected_keyword}' in error, got: {msg}"
366            ),
367            other => panic!("expected InvalidConfig containing '{expected_keyword}', got {other:?}"),
368        }
369    }
370
371    #[test]
372    fn build_rejects_fps_zero() {
373        invalid_err(MatrixConfig::builder().fps(0).build(), "fps");
374    }
375
376    #[test]
377    fn build_rejects_speed_zero() {
378        invalid_err(MatrixConfig::builder().speed(0.0).build(), "speed");
379    }
380
381    #[test]
382    fn build_rejects_speed_negative() {
383        invalid_err(MatrixConfig::builder().speed(-0.5).build(), "speed");
384    }
385
386    #[test]
387    fn build_rejects_speed_nan() {
388        invalid_err(MatrixConfig::builder().speed(f32::NAN).build(), "speed");
389    }
390
391    #[test]
392    fn build_rejects_speed_infinite() {
393        invalid_err(MatrixConfig::builder().speed(f32::INFINITY).build(), "speed");
394    }
395
396    #[test]
397    fn build_rejects_density_above_one() {
398        invalid_err(MatrixConfig::builder().density(1.1).build(), "density");
399    }
400
401    #[test]
402    fn build_rejects_density_negative() {
403        invalid_err(MatrixConfig::builder().density(-0.1).build(), "density");
404    }
405
406    #[test]
407    fn build_rejects_density_nan() {
408        invalid_err(MatrixConfig::builder().density(f32::NAN).build(), "density");
409    }
410
411    #[test]
412    fn build_rejects_min_trail_zero() {
413        invalid_err(MatrixConfig::builder().min_trail(0).build(), "min_trail");
414    }
415
416    #[test]
417    fn build_rejects_min_greater_than_max_trail() {
418        invalid_err(
419            MatrixConfig::builder().min_trail(10).max_trail(5).build(),
420            "min_trail",
421        );
422    }
423
424    #[test]
425    fn build_rejects_max_trail_above_limit() {
426        invalid_err(
427            MatrixConfig::builder()
428                .min_trail(1)
429                .max_trail(MAX_TRAIL_LIMIT + 1)
430                .build(),
431            "max_trail",
432        );
433    }
434
435    #[test]
436    fn build_rejects_mutation_rate_above_one() {
437        invalid_err(
438            MatrixConfig::builder().mutation_rate(1.1).build(),
439            "mutation_rate",
440        );
441    }
442
443    #[test]
444    fn build_rejects_mutation_rate_negative() {
445        invalid_err(
446            MatrixConfig::builder().mutation_rate(-0.1).build(),
447            "mutation_rate",
448        );
449    }
450
451    #[test]
452    fn build_rejects_mutation_rate_nan() {
453        invalid_err(
454            MatrixConfig::builder().mutation_rate(f32::NAN).build(),
455            "mutation_rate",
456        );
457    }
458
459    #[test]
460    fn build_rejects_glitch_above_one() {
461        invalid_err(MatrixConfig::builder().glitch(1.1).build(), "glitch");
462    }
463
464    #[test]
465    fn build_rejects_glitch_negative() {
466        invalid_err(MatrixConfig::builder().glitch(-0.1).build(), "glitch");
467    }
468
469    #[test]
470    fn build_rejects_glitch_nan() {
471        invalid_err(MatrixConfig::builder().glitch(f32::NAN).build(), "glitch");
472    }
473
474    #[test]
475    fn build_accepts_density_boundaries() {
476        assert!(MatrixConfig::builder().density(0.0).build().is_ok());
477        assert!(MatrixConfig::builder().density(1.0).build().is_ok());
478    }
479
480    #[test]
481    fn build_accepts_min_equals_max_trail() {
482        assert!(MatrixConfig::builder()
483            .min_trail(5)
484            .max_trail(5)
485            .build()
486            .is_ok());
487    }
488
489    #[test]
490    fn build_accepts_mutation_rate_boundaries() {
491        assert!(MatrixConfig::builder().mutation_rate(0.0).build().is_ok());
492        assert!(MatrixConfig::builder().mutation_rate(1.0).build().is_ok());
493    }
494
495    #[test]
496    fn build_accepts_glitch_boundaries() {
497        assert!(MatrixConfig::builder().glitch(0.0).build().is_ok());
498        assert!(MatrixConfig::builder().glitch(1.0).build().is_ok());
499    }
500
501    #[test]
502    fn build_accepts_max_trail_at_limit() {
503        assert!(MatrixConfig::builder()
504            .min_trail(1)
505            .max_trail(MAX_TRAIL_LIMIT)
506            .build()
507            .is_ok());
508    }
509}