1use 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#[derive(Clone, Debug)]
29pub struct MatrixConfig {
30 pub charset: CharSet,
32 pub theme: Theme,
34 pub fps: u16,
37 pub speed: f32,
40 pub density: f32,
44 pub min_trail: u16,
46 pub max_trail: u16,
48 pub mutation_rate: f32,
52 pub bold_head: bool,
54 pub head_white: bool,
57 pub glitch: f32,
62 pub background: Option<Color>,
67}
68
69impl MatrixConfig {
70 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)]
98pub struct MatrixConfigBuilder {
119 config: MatrixConfig,
120}
121
122impl MatrixConfigBuilder {
123 pub fn new() -> Self {
125 Self {
126 config: MatrixConfig::default(),
127 }
128 }
129
130 pub fn charset(mut self, charset: CharSet) -> Self {
132 self.config.charset = charset;
133 self
134 }
135
136 pub fn theme(mut self, theme: Theme) -> Self {
138 self.config.theme = theme;
139 self
140 }
141
142 pub fn fps(mut self, fps: u16) -> Self {
144 self.config.fps = fps;
145 self
146 }
147
148 pub fn speed(mut self, speed: f32) -> Self {
150 self.config.speed = speed;
151 self
152 }
153
154 pub fn density(mut self, density: f32) -> Self {
156 self.config.density = density;
157 self
158 }
159
160 pub fn min_trail(mut self, min_trail: u16) -> Self {
162 self.config.min_trail = min_trail;
163 self
164 }
165
166 pub fn max_trail(mut self, max_trail: u16) -> Self {
168 self.config.max_trail = max_trail;
169 self
170 }
171
172 pub fn mutation_rate(mut self, mutation_rate: f32) -> Self {
174 self.config.mutation_rate = mutation_rate;
175 self
176 }
177
178 pub fn bold_head(mut self, bold_head: bool) -> Self {
180 self.config.bold_head = bold_head;
181 self
182 }
183
184 pub fn head_white(mut self, head_white: bool) -> Self {
187 self.config.head_white = head_white;
188 self
189 }
190
191 pub fn glitch(mut self, glitch: f32) -> Self {
194 self.config.glitch = glitch;
195 self
196 }
197
198 pub fn background(mut self, background: Option<Color>) -> Self {
201 self.config.background = background;
202 self
203 }
204
205 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
279pub 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}