Skip to main content

matrix_rain/
widget.rs

1//! The [`MatrixRain`] widget โ€” the public ratatui surface.
2
3use 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
15/// The ratatui widget rendering the Matrix digital rain effect.
16///
17/// `MatrixRain` borrows the [`MatrixConfig`] for the lifetime of the render
18/// call and consumes itself when rendered (per the
19/// [`StatefulWidget`](ratatui::widgets::StatefulWidget) contract). It is
20/// intentionally **stateful-only** โ€” there is no plain `Widget` implementation.
21/// Animation requires per-frame state (column streams, RNG, timing, cached
22/// color tier) that a stateless wrapper would either reset every frame or
23/// hide behind surprising global mutable state.
24///
25/// # Example
26///
27/// ```
28/// use matrix_rain::{MatrixConfig, MatrixRain, MatrixRainState};
29/// use ratatui::buffer::Buffer;
30/// use ratatui::layout::Rect;
31/// use ratatui::widgets::StatefulWidget;
32///
33/// let cfg = MatrixConfig::default();
34/// let mut state = MatrixRainState::with_seed(0xC0FFEE);
35/// let area = Rect::new(0, 0, 40, 12);
36/// let mut buf = Buffer::empty(area);
37///
38/// // The widget is constructed once per frame and consumed by render().
39/// MatrixRain::new(&cfg).render(area, &mut buf, &mut state);
40/// ```
41///
42/// Inside a ratatui `Terminal::draw` closure:
43///
44/// ```ignore
45/// terminal.draw(|f| {
46///     f.render_stateful_widget(MatrixRain::new(&cfg), f.size(), &mut state);
47/// })?;
48/// ```
49pub struct MatrixRain<'a> {
50    config: &'a MatrixConfig,
51}
52
53impl<'a> MatrixRain<'a> {
54    /// Create a new `MatrixRain` widget bound to the given configuration.
55    /// The config is borrowed for the lifetime of the widget; build it once
56    /// outside the render loop and pass `&cfg` here each frame.
57    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    // COLORTERM=truecolor|24bit is the de-facto standard for advertising
113    // 24-bit color support (alacritty, iTerm2, kitty, recent xterm, etc.).
114    // crossterm 0.27's available_color_count doesn't surface this, so we
115    // check it directly here. Signalled to the renderer via TRUECOLOR_SENTINEL.
116    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    // Best-effort TERM env sniff (the same logic crossterm uses). Returns 256
123    // when TERM mentions 256color, 8 otherwise. Detection failure / unrecognized
124    // values fall through to the 16-color path per spec ยง6.3 โ€” Tier::from_count
125    // collapses anything < 256 to Tier::Color16.
126    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        // t=0.125 sits between head (white) and bright (pale green).
472        match interpolate_smooth(&r, 0.125) {
473            Color::Rgb(rr, gg, bb) => {
474                // Should be between head (255,255,255) and bright (204,255,204).
475                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        // bright zone (early trail): 0xCCFFCC is closest to LightGreen (0,255,0)... actually it's pale green.
487        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        // All should be one of the 16 named variants (no Rgb).
491        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        // Tail cell (i=length-1, t=1.0) should be skipped when background is None.
526        assert!(should_skip(9, 10, r.fade, r.fade, None));
527        // Head cell never skipped.
528        assert!(!should_skip(0, 10, r.head, r.fade, None));
529        // Middle cell not skipped.
530        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}