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
13#[cfg_attr(not(feature = "std"), allow(dead_code))]
14const TRUECOLOR_SENTINEL: u16 = u16::MAX;
15
16/// The ratatui widget rendering the Matrix digital rain effect.
17///
18/// `MatrixRain` borrows the [`MatrixConfig`] for the lifetime of the render
19/// call and consumes itself when rendered (per the
20/// [`StatefulWidget`](ratatui::widgets::StatefulWidget) contract). It is
21/// intentionally **stateful-only** — there is no plain `Widget` implementation.
22/// Animation requires per-frame state (column streams, RNG, timing, cached
23/// color tier) that a stateless wrapper would either reset every frame or
24/// hide behind surprising global mutable state.
25///
26/// # Example
27///
28/// ```
29/// use matrix_rain::{MatrixConfig, MatrixRain, MatrixRainState};
30/// use ratatui::buffer::Buffer;
31/// use ratatui::layout::Rect;
32/// use ratatui::widgets::StatefulWidget;
33///
34/// let cfg = MatrixConfig::default();
35/// let mut state = MatrixRainState::with_seed(0xC0FFEE);
36/// let area = Rect::new(0, 0, 40, 12);
37/// let mut buf = Buffer::empty(area);
38///
39/// // The widget is constructed once per frame and consumed by render().
40/// MatrixRain::new(&cfg).render(area, &mut buf, &mut state);
41/// ```
42///
43/// Inside a ratatui `Terminal::draw` closure:
44///
45/// ```ignore
46/// terminal.draw(|f| {
47///     f.render_stateful_widget(MatrixRain::new(&cfg), f.size(), &mut state);
48/// })?;
49/// ```
50pub struct MatrixRain<'a> {
51    config: &'a MatrixConfig,
52}
53
54impl<'a> MatrixRain<'a> {
55    /// Create a new `MatrixRain` widget bound to the given configuration.
56    /// The config is borrowed for the lifetime of the widget; build it once
57    /// outside the render loop and pass `&cfg` here each frame.
58    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    // COLORTERM=truecolor|24bit is the de-facto standard for advertising
115    // 24-bit color support (alacritty, iTerm2, kitty, recent xterm, etc.).
116    // crossterm 0.28's available_color_count doesn't surface this, so we
117    // check it directly here. Signalled to the renderer via TRUECOLOR_SENTINEL.
118    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    // Best-effort TERM env sniff (the same logic crossterm uses). Returns 256
125    // when TERM mentions 256color, 8 otherwise. Detection failure / unrecognized
126    // values fall through to the 16-color path per spec §6.3 — Tier::from_count
127    // collapses anything < 256 to Tier::Color16.
128    std::env::var("TERM")
129        .map(|t| if t.contains("256color") { 256u16 } else { 8 })
130        .unwrap_or(8)
131}
132
133// no_std: no environment to sniff. Fall through to the 16-color path so
134// painting is always safe; embedded callers should call
135// `MatrixRainState::set_color_count` once at startup to pick a higher tier
136// if the display supports one.
137#[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    // `head_row` is invariantly >= 0 (clamped in handle_resize, only ever
154    // incremented by positive speed/fps in stream.tick), so truncation
155    // toward zero via `as i32` produces the same result as `floor()` —
156    // and works in no_std where the `floor` method is unavailable.
157    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    // `t * 4.0` is invariantly >= 0; round-half-up via `+ 0.5` then truncate.
223    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    // `scaled` is in [0.0, 4.0]; truncation toward zero == floor.
231    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    // Each blended channel is in [0.0, 255.0]; `+ 0.5` then truncate == round.
237    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        // t=0.125 sits between head (white) and bright (pale green).
490        match interpolate_smooth(&r, 0.125) {
491            Color::Rgb(rr, gg, bb) => {
492                // Should be between head (255,255,255) and bright (204,255,204).
493                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        // bright zone (early trail): 0xCCFFCC is closest to LightGreen (0,255,0)... actually it's pale green.
505        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        // All should be one of the 16 named variants (no Rgb).
509        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        // Tail cell (i=length-1, t=1.0) should be skipped when background is None.
544        assert!(should_skip(9, 10, r.fade, r.fade, None));
545        // Head cell never skipped.
546        assert!(!should_skip(0, 10, r.head, r.fade, None));
547        // Middle cell not skipped.
548        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}