1use ratatui::buffer::Buffer;
4use ratatui::layout::Rect;
5use ratatui::style::{Color, Modifier, Style};
6use ratatui::widgets::StatefulWidget;
7
8use crate::config::MatrixConfig;
9use crate::state::MatrixRainState;
10use crate::stream::Stream;
11use crate::theme::ColorRamp;
12
13#[cfg_attr(not(feature = "std"), allow(dead_code))]
14const TRUECOLOR_SENTINEL: u16 = u16::MAX;
15
16pub struct MatrixRain<'a> {
51 config: &'a MatrixConfig,
52}
53
54impl<'a> MatrixRain<'a> {
55 pub fn new(config: &'a MatrixConfig) -> Self {
59 Self { config }
60 }
61}
62
63impl<'a> StatefulWidget for MatrixRain<'a> {
64 type State = MatrixRainState;
65
66 fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
67 state.advance(area, self.config);
68 if area.width == 0 || area.height == 0 {
69 return;
70 }
71
72 if state.color_count().is_none() {
73 state.set_color_count(detect_color_count());
74 }
75 let tier = Tier::from_count(state.color_count().unwrap_or(8));
76
77 let ramp = self.config.theme.ramp();
78 let head_white = self.config.head_white;
79 let bold_head = self.config.bold_head;
80 let background = self.config.background;
81
82 for (col, stream) in state.streams().iter().enumerate() {
83 if !stream.is_active() {
84 continue;
85 }
86 paint_stream(
87 stream, area, buf, &ramp, head_white, bold_head, background, tier, col as u16,
88 );
89 }
90 }
91}
92
93#[derive(Clone, Copy, Debug, PartialEq, Eq)]
94enum Tier {
95 Truecolor,
96 Color256,
97 Color16,
98}
99
100impl Tier {
101 fn from_count(count: u16) -> Self {
102 if count > 256 {
103 Tier::Truecolor
104 } else if count == 256 {
105 Tier::Color256
106 } else {
107 Tier::Color16
108 }
109 }
110}
111
112#[cfg(feature = "std")]
113fn detect_color_count() -> u16 {
114 let truecolor = std::env::var("COLORTERM")
119 .map(|v| matches!(v.trim(), "truecolor" | "24bit"))
120 .unwrap_or(false);
121 if truecolor {
122 return TRUECOLOR_SENTINEL;
123 }
124 std::env::var("TERM")
129 .map(|t| if t.contains("256color") { 256u16 } else { 8 })
130 .unwrap_or(8)
131}
132
133#[cfg(not(feature = "std"))]
138fn detect_color_count() -> u16 {
139 8
140}
141
142fn paint_stream(
143 stream: &Stream,
144 area: Rect,
145 buf: &mut Buffer,
146 ramp: &ColorRamp,
147 head_white: bool,
148 bold_head: bool,
149 background: Option<Color>,
150 tier: Tier,
151 col: u16,
152) {
153 let head_int = stream.head_row() as i32;
158 let length = stream.length();
159 let glyphs = stream.glyphs();
160 let buf_area = buf.area;
161
162 for i in 0..length {
163 let screen_row_i = head_int - i as i32;
164 if screen_row_i < 0 || screen_row_i >= area.height as i32 {
165 continue;
166 }
167 let screen_row = screen_row_i as u16;
168
169 let Some(glyph) = glyphs.get(i as usize).copied() else {
170 continue;
171 };
172
173 let mut color = pick_color(ramp, head_white, i, length, tier);
174 if i > 0 && stream.is_glitched(i) {
175 color = ramp.head;
176 }
177
178 if should_skip(i, length, color, ramp.fade, background) {
179 continue;
180 }
181
182 let Some(x) = area.x.checked_add(col) else {
183 continue;
184 };
185 let Some(y) = area.y.checked_add(screen_row) else {
186 continue;
187 };
188
189 let buf_max_x = buf_area.x.saturating_add(buf_area.width);
190 let buf_max_y = buf_area.y.saturating_add(buf_area.height);
191 if x < buf_area.x || x >= buf_max_x || y < buf_area.y || y >= buf_max_y {
192 continue;
193 }
194
195 let mut style = Style::default().fg(color);
196 if i == 0 && bold_head {
197 style = style.add_modifier(Modifier::BOLD);
198 }
199
200 let cell = &mut buf[(x, y)];
201 cell.set_char(glyph);
202 cell.set_style(style);
203 }
204}
205
206fn pick_color(ramp: &ColorRamp, head_white: bool, i: u16, length: u16, tier: Tier) -> Color {
207 if i == 0 {
208 return if head_white { ramp.head } else { ramp.bright };
209 }
210 let denom = length.saturating_sub(1).max(1);
211 let t = (i as f32) / (denom as f32);
212
213 match tier {
214 Tier::Truecolor => interpolate_smooth(ramp, t),
215 Tier::Color256 => pick_nearest_stop(ramp, t),
216 Tier::Color16 => pick_named_zone(ramp, t),
217 }
218}
219
220fn pick_nearest_stop(ramp: &ColorRamp, t: f32) -> Color {
221 let stops = [ramp.head, ramp.bright, ramp.mid, ramp.dim, ramp.fade];
222 let idx = ((t * 4.0 + 0.5) as usize).min(4);
224 stops[idx]
225}
226
227fn interpolate_smooth(ramp: &ColorRamp, t: f32) -> Color {
228 let stops = [ramp.head, ramp.bright, ramp.mid, ramp.dim, ramp.fade];
229 let scaled = (t.clamp(0.0, 1.0)) * 4.0;
230 let lo = (scaled as usize).min(4);
232 let hi = (lo + 1).min(4);
233 let local = scaled - lo as f32;
234 let (lr, lg, lb) = to_rgb(stops[lo]);
235 let (hr, hg, hb) = to_rgb(stops[hi]);
236 let r = ((1.0 - local) * lr as f32 + local * hr as f32 + 0.5) as u8;
238 let g = ((1.0 - local) * lg as f32 + local * hg as f32 + 0.5) as u8;
239 let b = ((1.0 - local) * lb as f32 + local * hb as f32 + 0.5) as u8;
240 Color::Rgb(r, g, b)
241}
242
243fn pick_named_zone(ramp: &ColorRamp, t: f32) -> Color {
244 let stop = if t < 0.34 {
245 ramp.bright
246 } else if t < 0.67 {
247 ramp.mid
248 } else {
249 ramp.fade
250 };
251 nearest_named(stop)
252}
253
254fn should_skip(i: u16, length: u16, color: Color, fade: Color, background: Option<Color>) -> bool {
255 if let Some(bg) = background {
256 return color == bg;
257 }
258 if i == 0 {
259 return false;
260 }
261 if color == fade {
262 return true;
263 }
264 let denom = length.saturating_sub(1).max(1);
265 let t = (i as f32) / (denom as f32);
266 t >= 0.875
267}
268
269fn to_rgb(c: Color) -> (u8, u8, u8) {
270 match c {
271 Color::Rgb(r, g, b) => (r, g, b),
272 Color::Black => (0, 0, 0),
273 Color::Red => (128, 0, 0),
274 Color::Green => (0, 128, 0),
275 Color::Yellow => (128, 128, 0),
276 Color::Blue => (0, 0, 128),
277 Color::Magenta => (128, 0, 128),
278 Color::Cyan => (0, 128, 128),
279 Color::Gray => (192, 192, 192),
280 Color::DarkGray => (128, 128, 128),
281 Color::LightRed => (255, 0, 0),
282 Color::LightGreen => (0, 255, 0),
283 Color::LightYellow => (255, 255, 0),
284 Color::LightBlue => (0, 0, 255),
285 Color::LightMagenta => (255, 0, 255),
286 Color::LightCyan => (0, 255, 255),
287 Color::White => (255, 255, 255),
288 Color::Indexed(_) | Color::Reset => (255, 255, 255),
289 }
290}
291
292const NAMED_PALETTE: &[(Color, (u8, u8, u8))] = &[
293 (Color::Black, (0, 0, 0)),
294 (Color::Red, (128, 0, 0)),
295 (Color::Green, (0, 128, 0)),
296 (Color::Yellow, (128, 128, 0)),
297 (Color::Blue, (0, 0, 128)),
298 (Color::Magenta, (128, 0, 128)),
299 (Color::Cyan, (0, 128, 128)),
300 (Color::Gray, (192, 192, 192)),
301 (Color::DarkGray, (128, 128, 128)),
302 (Color::LightRed, (255, 0, 0)),
303 (Color::LightGreen, (0, 255, 0)),
304 (Color::LightYellow, (255, 255, 0)),
305 (Color::LightBlue, (0, 0, 255)),
306 (Color::LightMagenta, (255, 0, 255)),
307 (Color::LightCyan, (0, 255, 255)),
308 (Color::White, (255, 255, 255)),
309];
310
311fn nearest_named(target: Color) -> Color {
312 let (tr, tg, tb) = to_rgb(target);
313 let mut best = NAMED_PALETTE[0].0;
314 let mut best_dist = u32::MAX;
315 for &(named, (nr, ng, nb)) in NAMED_PALETTE {
316 let dr = (tr as i32 - nr as i32).unsigned_abs();
317 let dg = (tg as i32 - ng as i32).unsigned_abs();
318 let db = (tb as i32 - nb as i32).unsigned_abs();
319 let dist = dr * dr + dg * dg + db * db;
320 if dist < best_dist {
321 best_dist = dist;
322 best = named;
323 }
324 }
325 best
326}
327
328#[cfg(test)]
329mod tests {
330 use super::*;
331
332 fn fully_active_config(seed_density: f32) -> MatrixConfig {
333 MatrixConfig {
334 density: seed_density,
335 ..MatrixConfig::default()
336 }
337 }
338
339 fn classic_ramp() -> ColorRamp {
340 ColorRamp {
341 head: Color::Rgb(0xFF, 0xFF, 0xFF),
342 bright: Color::Rgb(0xCC, 0xFF, 0xCC),
343 mid: Color::Rgb(0x00, 0xFF, 0x00),
344 dim: Color::Rgb(0x00, 0x99, 0x00),
345 fade: Color::Rgb(0x00, 0x33, 0x00),
346 }
347 }
348
349 #[test]
350 fn render_with_zero_width_area_is_noop() {
351 let cfg = MatrixConfig::default();
352 let mut state = MatrixRainState::with_seed(0);
353 let mut buf = Buffer::empty(Rect::new(0, 0, 10, 10));
354 MatrixRain::new(&cfg).render(Rect::new(0, 0, 0, 10), &mut buf, &mut state);
355 }
356
357 #[test]
358 fn render_with_zero_height_area_is_noop() {
359 let cfg = MatrixConfig::default();
360 let mut state = MatrixRainState::with_seed(0);
361 let mut buf = Buffer::empty(Rect::new(0, 0, 10, 10));
362 MatrixRain::new(&cfg).render(Rect::new(0, 0, 10, 0), &mut buf, &mut state);
363 }
364
365 #[test]
366 fn does_not_paint_outside_widget_area() {
367 let cfg = fully_active_config(1.0);
368 let mut state = MatrixRainState::with_seed(42);
369 let buf_area = Rect::new(0, 0, 20, 20);
370 let mut buf = Buffer::empty(buf_area);
371 for y in 0..20 {
372 for x in 0..20 {
373 buf[(x, y)].set_char('#');
374 }
375 }
376 let widget_area = Rect::new(5, 5, 10, 10);
377 for _ in 0..50 {
378 MatrixRain::new(&cfg).render(widget_area, &mut buf, &mut state);
379 state.tick();
380 }
381 for y in 0..20 {
382 for x in 0..20 {
383 let inside = (5..15).contains(&x) && (5..15).contains(&y);
384 if !inside {
385 assert_eq!(
386 buf[(x, y)].symbol(),
387 "#",
388 "cell ({x},{y}) outside widget area was modified"
389 );
390 }
391 }
392 }
393 }
394
395 #[test]
396 fn paints_at_least_some_cells_with_high_density() {
397 let cfg = fully_active_config(1.0);
398 let mut state = MatrixRainState::with_seed(42);
399 let mut buf = Buffer::empty(Rect::new(0, 0, 20, 20));
400 let widget_area = Rect::new(0, 0, 20, 20);
401 for _ in 0..120 {
402 MatrixRain::new(&cfg).render(widget_area, &mut buf, &mut state);
403 state.tick();
404 }
405 let mut painted = 0;
406 for y in 0..20 {
407 for x in 0..20 {
408 let sym = buf[(x, y)].symbol();
409 if !sym.is_empty() && sym != " " {
410 painted += 1;
411 }
412 }
413 }
414 assert!(painted > 0, "expected some cells to be painted");
415 }
416
417 #[test]
418 fn honors_non_zero_origin() {
419 let cfg = fully_active_config(1.0);
420 let mut state = MatrixRainState::with_seed(42);
421 let mut buf = Buffer::empty(Rect::new(0, 0, 30, 30));
422 let widget_area = Rect::new(7, 11, 8, 8);
423 for _ in 0..120 {
424 MatrixRain::new(&cfg).render(widget_area, &mut buf, &mut state);
425 state.tick();
426 }
427 let mut painted_inside = 0;
428 let mut painted_outside = 0;
429 for y in 0..30 {
430 for x in 0..30 {
431 let sym = buf[(x, y)].symbol();
432 if !sym.is_empty() && sym != " " {
433 let inside = (7..15).contains(&x) && (11..19).contains(&y);
434 if inside {
435 painted_inside += 1;
436 } else {
437 painted_outside += 1;
438 }
439 }
440 }
441 }
442 assert!(painted_inside > 0, "no cells painted inside offset area");
443 assert_eq!(painted_outside, 0, "cells painted outside offset area");
444 }
445
446 #[test]
447 fn resize_between_renders_does_not_panic() {
448 let cfg = MatrixConfig::default();
449 let mut state = MatrixRainState::with_seed(42);
450 let sizes = [(20u16, 20u16), (5, 30), (40, 5), (1, 1), (0, 10), (15, 15)];
451 for (w, h) in sizes {
452 let mut buf = Buffer::empty(Rect::new(0, 0, w.max(1), h.max(1)));
453 MatrixRain::new(&cfg).render(Rect::new(0, 0, w, h), &mut buf, &mut state);
454 }
455 }
456
457 #[test]
458 fn tier_from_count_buckets() {
459 assert_eq!(Tier::from_count(8), Tier::Color16);
460 assert_eq!(Tier::from_count(15), Tier::Color16);
461 assert_eq!(Tier::from_count(16), Tier::Color16);
462 assert_eq!(Tier::from_count(255), Tier::Color16);
463 assert_eq!(Tier::from_count(256), Tier::Color256);
464 assert_eq!(Tier::from_count(257), Tier::Truecolor);
465 assert_eq!(Tier::from_count(u16::MAX), Tier::Truecolor);
466 }
467
468 #[test]
469 fn nearest_stop_endpoints() {
470 let r = classic_ramp();
471 assert_eq!(pick_nearest_stop(&r, 0.0), r.head);
472 assert_eq!(pick_nearest_stop(&r, 1.0), r.fade);
473 assert_eq!(pick_nearest_stop(&r, 0.5), r.mid);
474 }
475
476 #[test]
477 fn smooth_interpolation_endpoints_match_stops() {
478 let r = classic_ramp();
479 assert_eq!(interpolate_smooth(&r, 0.0), r.head);
480 assert_eq!(interpolate_smooth(&r, 1.0), r.fade);
481 assert_eq!(interpolate_smooth(&r, 0.25), r.bright);
482 assert_eq!(interpolate_smooth(&r, 0.5), r.mid);
483 assert_eq!(interpolate_smooth(&r, 0.75), r.dim);
484 }
485
486 #[test]
487 fn smooth_interpolation_midpoint_is_between_stops() {
488 let r = classic_ramp();
489 match interpolate_smooth(&r, 0.125) {
491 Color::Rgb(rr, gg, bb) => {
492 assert!(rr > 204 && rr < 255, "r out of range: {rr}");
494 assert_eq!(gg, 255);
495 assert!(bb > 204 && bb < 255, "b out of range: {bb}");
496 }
497 _ => panic!("expected Rgb"),
498 }
499 }
500
501 #[test]
502 fn named_zone_collapses_to_named_colors() {
503 let r = classic_ramp();
504 let early = pick_named_zone(&r, 0.1);
506 let mid = pick_named_zone(&r, 0.5);
507 let late = pick_named_zone(&r, 0.9);
508 for c in [early, mid, late] {
510 assert!(
511 !matches!(c, Color::Rgb(..) | Color::Indexed(..)),
512 "Color16 path returned non-named color: {c:?}"
513 );
514 }
515 }
516
517 #[test]
518 fn nearest_named_white_for_white_input() {
519 assert_eq!(nearest_named(Color::Rgb(0xFF, 0xFF, 0xFF)), Color::White);
520 assert_eq!(nearest_named(Color::Rgb(0x00, 0x00, 0x00)), Color::Black);
521 assert_eq!(nearest_named(Color::Rgb(0x00, 0xFF, 0x00)), Color::LightGreen);
522 }
523
524 #[test]
525 fn pick_color_head_respects_head_white() {
526 let r = classic_ramp();
527 for tier in [Tier::Truecolor, Tier::Color256, Tier::Color16] {
528 assert_eq!(pick_color(&r, true, 0, 10, tier), r.head);
529 assert_eq!(pick_color(&r, false, 0, 10, tier), r.bright);
530 }
531 }
532
533 #[test]
534 fn skip_when_color_matches_background() {
535 let r = classic_ramp();
536 assert!(should_skip(3, 10, Color::Black, r.fade, Some(Color::Black)));
537 assert!(!should_skip(3, 10, Color::Green, r.fade, Some(Color::Black)));
538 }
539
540 #[test]
541 fn skip_fade_zone_when_background_none() {
542 let r = classic_ramp();
543 assert!(should_skip(9, 10, r.fade, r.fade, None));
545 assert!(!should_skip(0, 10, r.head, r.fade, None));
547 assert!(!should_skip(4, 10, r.mid, r.fade, None));
549 }
550
551 #[test]
552 fn detection_caches_into_state_after_first_render() {
553 let cfg = MatrixConfig::default();
554 let mut state = MatrixRainState::with_seed(0);
555 assert!(state.color_count().is_none());
556 let mut buf = Buffer::empty(Rect::new(0, 0, 5, 5));
557 MatrixRain::new(&cfg).render(Rect::new(0, 0, 5, 5), &mut buf, &mut state);
558 assert!(state.color_count().is_some());
559 }
560
561 #[test]
562 fn detection_does_not_overwrite_pre_set_count() {
563 let cfg = MatrixConfig::default();
564 let mut state = MatrixRainState::with_seed(0);
565 state.set_color_count(42);
566 let mut buf = Buffer::empty(Rect::new(0, 0, 5, 5));
567 MatrixRain::new(&cfg).render(Rect::new(0, 0, 5, 5), &mut buf, &mut state);
568 assert_eq!(state.color_count(), Some(42));
569 }
570
571 #[test]
572 fn renders_under_each_tier_without_panic() {
573 let cfg = fully_active_config(1.0);
574 for forced in [16u16, 256, TRUECOLOR_SENTINEL] {
575 let mut state = MatrixRainState::with_seed(0xBEEF);
576 state.set_color_count(forced);
577 let mut buf = Buffer::empty(Rect::new(0, 0, 20, 10));
578 for _ in 0..30 {
579 MatrixRain::new(&cfg).render(Rect::new(0, 0, 20, 10), &mut buf, &mut state);
580 state.tick();
581 }
582 }
583 }
584}