1use ratatui::style::Color;
2
3use crate::charset::CharSet;
4use crate::error::MatrixError;
5use crate::theme::Theme;
6
7#[derive(Clone, Debug)]
8pub struct MatrixConfig {
9 pub charset: CharSet,
10 pub theme: Theme,
11 pub fps: u16,
12 pub speed: f32,
13 pub density: f32,
14 pub min_trail: u16,
15 pub max_trail: u16,
16 pub mutation_rate: f32,
17 pub bold_head: bool,
18 pub head_white: bool,
19 pub glitch: f32,
20 pub background: Option<Color>,
21}
22
23impl MatrixConfig {
24 pub fn builder() -> MatrixConfigBuilder {
25 MatrixConfigBuilder::new()
26 }
27}
28
29impl Default for MatrixConfig {
30 fn default() -> Self {
31 Self {
32 charset: CharSet::Matrix,
33 theme: Theme::ClassicGreen,
34 fps: 30,
35 speed: 1.0,
36 density: 0.6,
37 min_trail: 6,
38 max_trail: 20,
39 mutation_rate: 0.05,
40 bold_head: true,
41 head_white: true,
42 glitch: 0.0,
43 background: None,
44 }
45 }
46}
47
48#[derive(Clone, Debug)]
49pub struct MatrixConfigBuilder {
50 config: MatrixConfig,
51}
52
53impl MatrixConfigBuilder {
54 pub fn new() -> Self {
55 Self {
56 config: MatrixConfig::default(),
57 }
58 }
59
60 pub fn charset(mut self, charset: CharSet) -> Self {
61 self.config.charset = charset;
62 self
63 }
64
65 pub fn theme(mut self, theme: Theme) -> Self {
66 self.config.theme = theme;
67 self
68 }
69
70 pub fn fps(mut self, fps: u16) -> Self {
71 self.config.fps = fps;
72 self
73 }
74
75 pub fn speed(mut self, speed: f32) -> Self {
76 self.config.speed = speed;
77 self
78 }
79
80 pub fn density(mut self, density: f32) -> Self {
81 self.config.density = density;
82 self
83 }
84
85 pub fn min_trail(mut self, min_trail: u16) -> Self {
86 self.config.min_trail = min_trail;
87 self
88 }
89
90 pub fn max_trail(mut self, max_trail: u16) -> Self {
91 self.config.max_trail = max_trail;
92 self
93 }
94
95 pub fn mutation_rate(mut self, mutation_rate: f32) -> Self {
96 self.config.mutation_rate = mutation_rate;
97 self
98 }
99
100 pub fn bold_head(mut self, bold_head: bool) -> Self {
101 self.config.bold_head = bold_head;
102 self
103 }
104
105 pub fn head_white(mut self, head_white: bool) -> Self {
106 self.config.head_white = head_white;
107 self
108 }
109
110 pub fn glitch(mut self, glitch: f32) -> Self {
111 self.config.glitch = glitch;
112 self
113 }
114
115 pub fn background(mut self, background: Option<Color>) -> Self {
116 self.config.background = background;
117 self
118 }
119
120 pub fn build(self) -> Result<MatrixConfig, MatrixError> {
121 let c = &self.config;
122
123 if c.fps < 1 {
124 return Err(invalid("fps must be >= 1"));
125 }
126 if !c.speed.is_finite() || c.speed <= 0.0 {
127 return Err(invalid(&format!(
128 "speed must be a positive finite number (got {})",
129 c.speed
130 )));
131 }
132 if !c.density.is_finite() || !(0.0..=1.0).contains(&c.density) {
133 return Err(invalid(&format!(
134 "density must be a finite number in [0.0, 1.0] (got {})",
135 c.density
136 )));
137 }
138 if c.min_trail < 1 {
139 return Err(invalid("min_trail must be >= 1"));
140 }
141 if c.min_trail > c.max_trail {
142 return Err(invalid(&format!(
143 "min_trail ({}) must be <= max_trail ({})",
144 c.min_trail, c.max_trail
145 )));
146 }
147 if c.max_trail > MAX_TRAIL_LIMIT {
148 return Err(invalid(&format!(
149 "max_trail ({}) exceeds limit of {}",
150 c.max_trail, MAX_TRAIL_LIMIT
151 )));
152 }
153 if !c.mutation_rate.is_finite() || !(0.0..=1.0).contains(&c.mutation_rate) {
154 return Err(invalid(&format!(
155 "mutation_rate must be a finite number in [0.0, 1.0] (got {})",
156 c.mutation_rate
157 )));
158 }
159 if !c.glitch.is_finite() || !(0.0..=1.0).contains(&c.glitch) {
160 return Err(invalid(&format!(
161 "glitch must be a finite number in [0.0, 1.0] (got {})",
162 c.glitch
163 )));
164 }
165 c.charset.validate()?;
166
167 Ok(self.config)
168 }
169}
170
171pub const MAX_TRAIL_LIMIT: u16 = 1024;
172
173fn invalid(msg: &str) -> MatrixError {
174 MatrixError::InvalidConfig(msg.to_string())
175}
176
177impl Default for MatrixConfigBuilder {
178 fn default() -> Self {
179 Self::new()
180 }
181}
182
183#[cfg(test)]
184mod tests {
185 use super::*;
186
187 #[test]
188 fn defaults_match_spec() {
189 let cfg = MatrixConfig::default();
190 assert_eq!(cfg.fps, 30);
191 assert_eq!(cfg.speed, 1.0);
192 assert_eq!(cfg.density, 0.6);
193 assert!(cfg.bold_head);
194 assert!(cfg.head_white);
195 assert!(matches!(cfg.charset, CharSet::Matrix));
196 assert!(matches!(cfg.theme, Theme::ClassicGreen));
197 assert_eq!(cfg.glitch, 0.0);
198 assert_eq!(cfg.background, None);
199 assert!(cfg.min_trail >= 1);
200 assert!(cfg.max_trail >= cfg.min_trail);
201 }
202
203 #[test]
204 fn builder_chains_overrides() {
205 let cfg = MatrixConfig::builder()
206 .fps(60)
207 .density(0.3)
208 .bold_head(false)
209 .build()
210 .expect("build should succeed");
211 assert_eq!(cfg.fps, 60);
212 assert_eq!(cfg.density, 0.3);
213 assert!(!cfg.bold_head);
214 assert!(cfg.head_white, "untouched fields keep defaults");
215 }
216
217 #[test]
218 fn builder_default_round_trip() {
219 let cfg = MatrixConfig::builder().build().unwrap();
220 let default = MatrixConfig::default();
221 assert_eq!(cfg.fps, default.fps);
222 assert_eq!(cfg.speed, default.speed);
223 assert_eq!(cfg.density, default.density);
224 assert_eq!(cfg.min_trail, default.min_trail);
225 assert_eq!(cfg.max_trail, default.max_trail);
226 }
227
228 #[test]
229 fn build_rejects_empty_custom_charset() {
230 let err = MatrixConfig::builder()
231 .charset(CharSet::Custom(vec![]))
232 .build()
233 .unwrap_err();
234 assert!(matches!(err, MatrixError::EmptyCharset));
235 }
236
237 #[test]
238 fn build_rejects_control_chars_in_custom() {
239 let err = MatrixConfig::builder()
240 .charset(CharSet::Custom(vec!['a', '\n']))
241 .build()
242 .unwrap_err();
243 assert!(matches!(err, MatrixError::InvalidConfig(_)));
244 }
245
246 fn invalid_err(r: Result<MatrixConfig, MatrixError>, expected_keyword: &str) {
247 match r {
248 Err(MatrixError::InvalidConfig(msg)) => assert!(
249 msg.contains(expected_keyword),
250 "expected '{expected_keyword}' in error, got: {msg}"
251 ),
252 other => panic!("expected InvalidConfig containing '{expected_keyword}', got {other:?}"),
253 }
254 }
255
256 #[test]
257 fn build_rejects_fps_zero() {
258 invalid_err(MatrixConfig::builder().fps(0).build(), "fps");
259 }
260
261 #[test]
262 fn build_rejects_speed_zero() {
263 invalid_err(MatrixConfig::builder().speed(0.0).build(), "speed");
264 }
265
266 #[test]
267 fn build_rejects_speed_negative() {
268 invalid_err(MatrixConfig::builder().speed(-0.5).build(), "speed");
269 }
270
271 #[test]
272 fn build_rejects_speed_nan() {
273 invalid_err(MatrixConfig::builder().speed(f32::NAN).build(), "speed");
274 }
275
276 #[test]
277 fn build_rejects_speed_infinite() {
278 invalid_err(MatrixConfig::builder().speed(f32::INFINITY).build(), "speed");
279 }
280
281 #[test]
282 fn build_rejects_density_above_one() {
283 invalid_err(MatrixConfig::builder().density(1.1).build(), "density");
284 }
285
286 #[test]
287 fn build_rejects_density_negative() {
288 invalid_err(MatrixConfig::builder().density(-0.1).build(), "density");
289 }
290
291 #[test]
292 fn build_rejects_density_nan() {
293 invalid_err(MatrixConfig::builder().density(f32::NAN).build(), "density");
294 }
295
296 #[test]
297 fn build_rejects_min_trail_zero() {
298 invalid_err(MatrixConfig::builder().min_trail(0).build(), "min_trail");
299 }
300
301 #[test]
302 fn build_rejects_min_greater_than_max_trail() {
303 invalid_err(
304 MatrixConfig::builder().min_trail(10).max_trail(5).build(),
305 "min_trail",
306 );
307 }
308
309 #[test]
310 fn build_rejects_max_trail_above_limit() {
311 invalid_err(
312 MatrixConfig::builder()
313 .min_trail(1)
314 .max_trail(MAX_TRAIL_LIMIT + 1)
315 .build(),
316 "max_trail",
317 );
318 }
319
320 #[test]
321 fn build_rejects_mutation_rate_above_one() {
322 invalid_err(
323 MatrixConfig::builder().mutation_rate(1.1).build(),
324 "mutation_rate",
325 );
326 }
327
328 #[test]
329 fn build_rejects_mutation_rate_negative() {
330 invalid_err(
331 MatrixConfig::builder().mutation_rate(-0.1).build(),
332 "mutation_rate",
333 );
334 }
335
336 #[test]
337 fn build_rejects_mutation_rate_nan() {
338 invalid_err(
339 MatrixConfig::builder().mutation_rate(f32::NAN).build(),
340 "mutation_rate",
341 );
342 }
343
344 #[test]
345 fn build_rejects_glitch_above_one() {
346 invalid_err(MatrixConfig::builder().glitch(1.1).build(), "glitch");
347 }
348
349 #[test]
350 fn build_rejects_glitch_negative() {
351 invalid_err(MatrixConfig::builder().glitch(-0.1).build(), "glitch");
352 }
353
354 #[test]
355 fn build_rejects_glitch_nan() {
356 invalid_err(MatrixConfig::builder().glitch(f32::NAN).build(), "glitch");
357 }
358
359 #[test]
360 fn build_accepts_density_boundaries() {
361 assert!(MatrixConfig::builder().density(0.0).build().is_ok());
362 assert!(MatrixConfig::builder().density(1.0).build().is_ok());
363 }
364
365 #[test]
366 fn build_accepts_min_equals_max_trail() {
367 assert!(MatrixConfig::builder()
368 .min_trail(5)
369 .max_trail(5)
370 .build()
371 .is_ok());
372 }
373
374 #[test]
375 fn build_accepts_mutation_rate_boundaries() {
376 assert!(MatrixConfig::builder().mutation_rate(0.0).build().is_ok());
377 assert!(MatrixConfig::builder().mutation_rate(1.0).build().is_ok());
378 }
379
380 #[test]
381 fn build_accepts_glitch_boundaries() {
382 assert!(MatrixConfig::builder().glitch(0.0).build().is_ok());
383 assert!(MatrixConfig::builder().glitch(1.0).build().is_ok());
384 }
385
386 #[test]
387 fn build_accepts_max_trail_at_limit() {
388 assert!(MatrixConfig::builder()
389 .min_trail(1)
390 .max_trail(MAX_TRAIL_LIMIT)
391 .build()
392 .is_ok());
393 }
394}