Skip to main content

matrix_rain/
theme.rs

1//! Color themes and the 5-stop [`ColorRamp`] that backs each one.
2
3use ratatui::style::Color;
4
5/// Color theme controlling the trail gradient.
6///
7/// Each variant resolves to a 5-stop [`ColorRamp`]. Used by the widget's
8/// renderer; the actual rendering tier (smooth interpolation, nearest-of-5
9/// quantization, or 3-zone named-color collapse) is picked separately based
10/// on detected terminal color depth — see the crate-level docs.
11///
12/// # Example
13///
14/// ```
15/// use matrix_rain::{MatrixConfig, Theme};
16///
17/// let cfg = MatrixConfig::builder().theme(Theme::Amber).build().unwrap();
18/// assert!(matches!(cfg.theme, Theme::Amber));
19/// ```
20#[derive(Clone, Debug, PartialEq, Eq)]
21pub enum Theme {
22    /// White head over a green trail. The canonical Matrix look.
23    /// Stops: `0xFFFFFF → 0xCCFFCC → 0x00FF00 → 0x009900 → 0x003300`.
24    ClassicGreen,
25    /// White head over a warm amber trail.
26    Amber,
27    /// White head over a cyan trail.
28    Cyan,
29    /// White head over a red trail.
30    Red,
31    /// White head with a rainbow trail (red → yellow → green → blue).
32    /// Most visually distinctive on truecolor terminals; collapses to
33    /// adjacent named colors on lower tiers.
34    Rainbow,
35    /// User-supplied 5-stop ramp.
36    Custom(/// The ramp to use.
37        ColorRamp),
38}
39
40/// Five-stop color ramp. `head` is the brightest cell (typically white);
41/// stops degrade through `bright`, `mid`, `dim`, to the visible-but-faint
42/// `fade` at the tail.
43///
44/// The renderer maps cell positions in `[0, length-1]` to these stops:
45///
46/// - **Truecolor**: linear lerp between adjacent stops.
47/// - **256-color**: nearest of the 5 stops.
48/// - **16-color**: collapsed into 3 zones — `bright` (early trail),
49///   `mid` (middle), `fade` (tail) — each mapped to the nearest named color.
50///
51/// # Example
52///
53/// ```
54/// use matrix_rain::{ColorRamp, Theme};
55/// use ratatui::style::Color;
56///
57/// let monochrome = ColorRamp {
58///     head: Color::White,
59///     bright: Color::Gray,
60///     mid: Color::DarkGray,
61///     dim: Color::DarkGray,
62///     fade: Color::Black,
63/// };
64/// let theme = Theme::Custom(monochrome);
65/// ```
66#[derive(Clone, Copy, Debug, PartialEq, Eq)]
67pub struct ColorRamp {
68    /// Brightest stop. The head cell uses this when `head_white` is true.
69    pub head: Color,
70    /// Bright stop just below the head. Also used by the head when
71    /// `head_white` is false.
72    pub bright: Color,
73    /// Middle of the gradient.
74    pub mid: Color,
75    /// Dim stop near the tail.
76    pub dim: Color,
77    /// Faintest visible stop at the very end of the trail.
78    pub fade: Color,
79}
80
81const CLASSIC_GREEN: ColorRamp = ColorRamp {
82    head: Color::Rgb(0xFF, 0xFF, 0xFF),
83    bright: Color::Rgb(0xCC, 0xFF, 0xCC),
84    mid: Color::Rgb(0x00, 0xFF, 0x00),
85    dim: Color::Rgb(0x00, 0x99, 0x00),
86    fade: Color::Rgb(0x00, 0x33, 0x00),
87};
88
89const AMBER: ColorRamp = ColorRamp {
90    head: Color::Rgb(0xFF, 0xFF, 0xFF),
91    bright: Color::Rgb(0xFF, 0xE5, 0xB4),
92    mid: Color::Rgb(0xFF, 0xAA, 0x00),
93    dim: Color::Rgb(0xB3, 0x6B, 0x00),
94    fade: Color::Rgb(0x4D, 0x2E, 0x00),
95};
96
97const CYAN: ColorRamp = ColorRamp {
98    head: Color::Rgb(0xFF, 0xFF, 0xFF),
99    bright: Color::Rgb(0xCC, 0xFF, 0xFF),
100    mid: Color::Rgb(0x00, 0xFF, 0xFF),
101    dim: Color::Rgb(0x00, 0x88, 0x99),
102    fade: Color::Rgb(0x00, 0x22, 0x33),
103};
104
105const RED: ColorRamp = ColorRamp {
106    head: Color::Rgb(0xFF, 0xFF, 0xFF),
107    bright: Color::Rgb(0xFF, 0xCC, 0xCC),
108    mid: Color::Rgb(0xFF, 0x33, 0x00),
109    dim: Color::Rgb(0x99, 0x11, 0x00),
110    fade: Color::Rgb(0x33, 0x00, 0x00),
111};
112
113// Rainbow: 4 distinct hues across the trail with a white head. The smooth-interpolation
114// path will lerp between adjacent stops, producing a vertical hue gradient inside each
115// drop. On 256-color / 16-color terminals the 5-stop quantisation still reads as colorful.
116const RAINBOW: ColorRamp = ColorRamp {
117    head: Color::Rgb(0xFF, 0xFF, 0xFF),
118    bright: Color::Rgb(0xFF, 0x00, 0x00),
119    mid: Color::Rgb(0xFF, 0xFF, 0x00),
120    dim: Color::Rgb(0x00, 0xFF, 0x00),
121    fade: Color::Rgb(0x00, 0x66, 0xFF),
122};
123
124impl Theme {
125    pub(crate) fn ramp(&self) -> ColorRamp {
126        match self {
127            Self::ClassicGreen => CLASSIC_GREEN,
128            Self::Amber => AMBER,
129            Self::Cyan => CYAN,
130            Self::Red => RED,
131            Self::Rainbow => RAINBOW,
132            Self::Custom(ramp) => *ramp,
133        }
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    fn assert_distinct_stops(theme: Theme) {
142        let r = theme.ramp();
143        let stops = [r.head, r.bright, r.mid, r.dim, r.fade];
144        for i in 0..stops.len() {
145            for j in (i + 1)..stops.len() {
146                assert_ne!(stops[i], stops[j], "{theme:?}: stops {i} and {j} collide");
147            }
148        }
149    }
150
151    fn assert_white_head(theme: Theme) {
152        assert_eq!(theme.ramp().head, Color::Rgb(0xFF, 0xFF, 0xFF));
153    }
154
155    #[test]
156    fn classic_green_ramp() {
157        assert_white_head(Theme::ClassicGreen);
158        assert_distinct_stops(Theme::ClassicGreen);
159    }
160
161    #[test]
162    fn amber_ramp() {
163        assert_white_head(Theme::Amber);
164        assert_distinct_stops(Theme::Amber);
165    }
166
167    #[test]
168    fn cyan_ramp() {
169        assert_white_head(Theme::Cyan);
170        assert_distinct_stops(Theme::Cyan);
171    }
172
173    #[test]
174    fn red_ramp() {
175        assert_white_head(Theme::Red);
176        assert_distinct_stops(Theme::Red);
177    }
178
179    #[test]
180    fn rainbow_ramp_has_diverse_hues() {
181        assert_white_head(Theme::Rainbow);
182        assert_distinct_stops(Theme::Rainbow);
183        // Sanity: rainbow's mid/dim/fade should span the hue wheel — no two share dominant channel.
184        let r = Theme::Rainbow.ramp();
185        let channels = |c: Color| match c {
186            Color::Rgb(r, g, b) => (r, g, b),
187            _ => panic!("expected Rgb"),
188        };
189        let (mr, mg, _) = channels(r.mid);
190        let (_, dg, _) = channels(r.dim);
191        assert!(mr >= 0x80 && mg >= 0x80, "mid should be warm");
192        assert!(dg >= 0x80, "dim should have strong green");
193    }
194
195    #[test]
196    fn custom_passthrough() {
197        let ramp = ColorRamp {
198            head: Color::Red,
199            bright: Color::LightRed,
200            mid: Color::Yellow,
201            dim: Color::DarkGray,
202            fade: Color::Black,
203        };
204        assert_eq!(Theme::Custom(ramp).ramp(), ramp);
205    }
206}