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