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