1use ratatui::style::Color;
5
6use crate::charset::CharSet;
7use crate::error::MatrixError;
8use crate::theme::Theme;
9
10#[derive(Clone, Debug)]
26pub struct MatrixConfig {
27 pub charset: CharSet,
29 pub theme: Theme,
31 pub fps: u16,
34 pub speed: f32,
37 pub density: f32,
41 pub min_trail: u16,
43 pub max_trail: u16,
45 pub mutation_rate: f32,
49 pub bold_head: bool,
51 pub head_white: bool,
54 pub glitch: f32,
59 pub background: Option<Color>,
64}
65
66impl MatrixConfig {
67 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)]
95pub struct MatrixConfigBuilder {
116 config: MatrixConfig,
117}
118
119impl MatrixConfigBuilder {
120 pub fn new() -> Self {
122 Self {
123 config: MatrixConfig::default(),
124 }
125 }
126
127 pub fn charset(mut self, charset: CharSet) -> Self {
129 self.config.charset = charset;
130 self
131 }
132
133 pub fn theme(mut self, theme: Theme) -> Self {
135 self.config.theme = theme;
136 self
137 }
138
139 pub fn fps(mut self, fps: u16) -> Self {
141 self.config.fps = fps;
142 self
143 }
144
145 pub fn speed(mut self, speed: f32) -> Self {
147 self.config.speed = speed;
148 self
149 }
150
151 pub fn density(mut self, density: f32) -> Self {
153 self.config.density = density;
154 self
155 }
156
157 pub fn min_trail(mut self, min_trail: u16) -> Self {
159 self.config.min_trail = min_trail;
160 self
161 }
162
163 pub fn max_trail(mut self, max_trail: u16) -> Self {
165 self.config.max_trail = max_trail;
166 self
167 }
168
169 pub fn mutation_rate(mut self, mutation_rate: f32) -> Self {
171 self.config.mutation_rate = mutation_rate;
172 self
173 }
174
175 pub fn bold_head(mut self, bold_head: bool) -> Self {
177 self.config.bold_head = bold_head;
178 self
179 }
180
181 pub fn head_white(mut self, head_white: bool) -> Self {
184 self.config.head_white = head_white;
185 self
186 }
187
188 pub fn glitch(mut self, glitch: f32) -> Self {
191 self.config.glitch = glitch;
192 self
193 }
194
195 pub fn background(mut self, background: Option<Color>) -> Self {
198 self.config.background = background;
199 self
200 }
201
202 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
276pub 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}