use std::{
env, fs,
io::{self, Write, stdout},
path::{Path, PathBuf},
thread,
time::{Duration, Instant},
};
use clap::{CommandFactory, Parser, ValueEnum};
use clap_complete::{Shell, generate};
use crossterm::{
cursor::{Hide, MoveTo, Show},
event::{self, Event, KeyCode, KeyEventKind, KeyModifiers},
execute, queue,
style::{Color, Print, ResetColor, SetForegroundColor},
terminal::{self, Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen},
};
use rand::{Rng, SeedableRng, rngs::StdRng};
use serde::Deserialize;
#[derive(Parser, Debug)]
#[command(
name = "cyber-rain",
version,
about = "A Rust-powered digital rain terminal visualizer.",
after_help = "Controls: q/Esc/Ctrl-C quit, Space/p pause, arrows adjust speed/density, 1-6 palettes, b bursts, m messages, s status."
)]
struct Cli {
#[arg(long, value_parser = clap::value_parser!(u64).range(1..=240))]
fps: Option<u64>,
#[arg(short, long, value_parser = parse_density)]
density: Option<f32>,
#[arg(short, long, value_parser = clap::value_parser!(u16).range(1..=10))]
speed: Option<u16>,
#[arg(short, long, value_enum)]
palette: Option<Palette>,
#[arg(long, value_enum)]
preset: Option<Preset>,
#[arg(short, long)]
message: Option<String>,
#[arg(long, value_name = "PATH")]
message_file: Option<PathBuf>,
#[arg(long)]
ascii: bool,
#[arg(long)]
seed: Option<u64>,
#[arg(long)]
no_alt_screen: bool,
#[arg(long)]
calm: bool,
#[arg(long)]
no_status: bool,
#[arg(long, value_parser = clap::value_parser!(u64).range(1..))]
duration: Option<u64>,
#[arg(long)]
demo: bool,
#[arg(long, value_name = "PATH")]
config: Option<PathBuf>,
#[arg(long)]
no_config: bool,
#[arg(long)]
print_config_template: bool,
#[arg(long, value_name = "PATH")]
theme: Option<PathBuf>,
#[arg(long)]
stats: bool,
#[arg(long, value_parser = clap::value_parser!(u64).range(1..))]
benchmark: Option<u64>,
#[arg(long, default_value_t = 120, value_parser = clap::value_parser!(u16).range(20..=400))]
benchmark_width: u16,
#[arg(long, default_value_t = 40, value_parser = clap::value_parser!(u16).range(10..=200))]
benchmark_height: u16,
#[arg(long, value_name = "PATH", num_args = 0..=1, default_missing_value = "man/cyber-rain.1")]
generate_man: Option<PathBuf>,
#[arg(long, value_enum, value_name = "SHELL")]
generate_completion: Option<Shell>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
enum Palette {
Green,
Cyan,
Amber,
Crimson,
Ghost,
Rainbow,
}
#[derive(Clone, Copy, Debug, ValueEnum)]
enum Preset {
Classic,
Ghost,
Cyberpunk,
Calm,
Chaos,
}
struct Config {
fps: u64,
density: f32,
speed: u16,
palette: Palette,
theme: Option<RenderTheme>,
message: Vec<char>,
message_enabled: bool,
glyphs: Option<Vec<char>>,
ascii: bool,
seed: Option<u64>,
alt_screen: bool,
bursts: bool,
show_status: bool,
duration: Option<Duration>,
stats: bool,
}
#[derive(Clone, Debug)]
struct RenderTheme {
name: String,
glyphs: Option<String>,
head: Rgb,
hot: Rgb,
tail: Rgb,
dim: Rgb,
}
#[derive(Clone, Copy, Debug, Deserialize)]
struct Rgb(u8, u8, u8);
#[derive(Debug, Deserialize)]
#[serde(rename_all = "kebab-case")]
struct FileConfig {
fps: Option<u64>,
density: Option<f32>,
speed: Option<u16>,
palette: Option<String>,
preset: Option<String>,
message: Option<String>,
message_file: Option<PathBuf>,
ascii: Option<bool>,
seed: Option<u64>,
alt_screen: Option<bool>,
bursts: Option<bool>,
show_status: Option<bool>,
duration: Option<u64>,
stats: Option<bool>,
theme: Option<PathBuf>,
glyphs: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "kebab-case")]
struct ThemeFile {
name: Option<String>,
glyphs: Option<String>,
colors: ThemeColors,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "kebab-case")]
struct ThemeColors {
head: Rgb,
hot: Option<Rgb>,
tail: Rgb,
dim: Option<Rgb>,
}
impl TryFrom<Cli> for Config {
type Error = io::Error;
fn try_from(cli: Cli) -> io::Result<Self> {
let mut config = Self::default();
let mut message = String::new();
let mut theme_path = None;
if let Some(file_config) = load_file_config(cli.config.as_deref(), cli.no_config)? {
if let Some(preset) = file_config.preset.as_deref() {
config.apply_preset(parse_preset(preset)?);
}
config.apply_file_config(file_config, &mut message, &mut theme_path)?;
}
if let Some(preset) = cli.preset {
config.apply_preset(preset);
}
if cli.demo {
config.apply_demo();
message = config.message.iter().collect();
}
if let Some(fps) = cli.fps {
config.fps = fps;
}
if let Some(density) = cli.density {
config.density = density;
}
if let Some(speed) = cli.speed {
config.speed = speed;
}
if let Some(palette) = cli.palette {
config.palette = palette;
}
if let Some(seed) = cli.seed {
config.seed = Some(seed);
}
if cli.ascii {
config.ascii = true;
}
if cli.calm {
config.bursts = false;
}
if cli.no_status {
config.show_status = false;
}
if cli.stats {
config.stats = true;
}
if let Some(path) = cli.theme {
theme_path = Some(path);
}
if let Some(cli_message) = cli.message {
message = cli_message;
}
if let Some(path) = cli.message_file {
append_message_file(&mut message, &path)?;
}
config.message = message
.chars()
.map(|ch| if ch.is_control() { ' ' } else { ch })
.collect();
config.message_enabled = !config.message.is_empty();
if cli.no_alt_screen {
config.alt_screen = false;
}
if let Some(duration) = cli.duration {
config.duration = Some(Duration::from_secs(duration));
}
if let Some(path) = theme_path {
let theme = load_theme_file(&path)?;
if config.glyphs.is_none() {
config.glyphs = theme.glyphs.as_deref().and_then(normalize_glyphs);
}
config.theme = Some(theme);
}
Ok(config)
}
}
impl Default for Config {
fn default() -> Self {
Self {
fps: 60,
density: 0.82,
speed: 7,
palette: Palette::Green,
theme: None,
message: Vec::new(),
message_enabled: false,
glyphs: None,
ascii: false,
seed: None,
alt_screen: true,
bursts: true,
show_status: true,
duration: None,
stats: false,
}
}
}
impl Config {
fn apply_file_config(
&mut self,
file_config: FileConfig,
message: &mut String,
theme_path: &mut Option<PathBuf>,
) -> io::Result<()> {
if let Some(fps) = file_config.fps {
self.fps = fps.clamp(1, 240);
}
if let Some(density) = file_config.density {
self.density = density.clamp(0.05, 1.0);
}
if let Some(speed) = file_config.speed {
self.speed = speed.clamp(1, 10);
}
if let Some(palette) = file_config.palette {
self.palette = parse_palette(&palette)?;
}
if let Some(config_message) = file_config.message {
*message = config_message;
}
if let Some(path) = file_config.message_file {
append_message_file(message, &path)?;
}
if let Some(ascii) = file_config.ascii {
self.ascii = ascii;
}
if let Some(seed) = file_config.seed {
self.seed = Some(seed);
}
if let Some(alt_screen) = file_config.alt_screen {
self.alt_screen = alt_screen;
}
if let Some(bursts) = file_config.bursts {
self.bursts = bursts;
}
if let Some(show_status) = file_config.show_status {
self.show_status = show_status;
}
if let Some(duration) = file_config.duration {
self.duration = Some(Duration::from_secs(duration));
}
if let Some(stats) = file_config.stats {
self.stats = stats;
}
if let Some(theme) = file_config.theme {
*theme_path = Some(theme);
}
if let Some(glyphs) = file_config.glyphs {
self.glyphs = normalize_glyphs(&glyphs);
}
Ok(())
}
fn apply_preset(&mut self, preset: Preset) {
match preset {
Preset::Classic => {
self.palette = Palette::Green;
self.density = 0.82;
self.speed = 6;
self.bursts = false;
}
Preset::Ghost => {
self.palette = Palette::Ghost;
self.density = 0.48;
self.speed = 4;
self.bursts = false;
}
Preset::Cyberpunk => {
self.palette = Palette::Rainbow;
self.density = 0.9;
self.speed = 8;
self.bursts = true;
}
Preset::Calm => {
self.palette = Palette::Cyan;
self.density = 0.42;
self.speed = 3;
self.bursts = false;
}
Preset::Chaos => {
self.palette = Palette::Rainbow;
self.density = 1.0;
self.speed = 10;
self.bursts = true;
}
}
}
fn apply_demo(&mut self) {
self.palette = Palette::Rainbow;
self.density = 0.92;
self.speed = 8;
self.seed = Some(1999);
self.bursts = true;
self.message = "wake up neo".chars().collect();
self.message_enabled = true;
}
fn color(&self, intensity: u8, x: u16, y: u16, frame: u64, hot: bool) -> Color {
if let Some(theme) = &self.theme {
theme.color(intensity, hot)
} else {
self.palette.color(intensity, x, y, frame, hot)
}
}
}
impl RenderTheme {
fn color(&self, intensity: u8, hot: bool) -> Color {
let rgb = if hot && intensity > 220 {
self.hot
} else {
let ratio = intensity as f32 / 255.0;
let low = if intensity < 80 { self.dim } else { self.tail };
low.mix(self.head, ratio.powf(1.35))
};
Color::Rgb {
r: rgb.0,
g: rgb.1,
b: rgb.2,
}
}
}
impl Rgb {
fn mix(self, other: Self, ratio: f32) -> Self {
let ratio = ratio.clamp(0.0, 1.0);
let mix_channel = |a: u8, b: u8| {
let a = a as f32;
let b = b as f32;
(a + (b - a) * ratio).round().clamp(0.0, 255.0) as u8
};
Self(
mix_channel(self.0, other.0),
mix_channel(self.1, other.1),
mix_channel(self.2, other.2),
)
}
}
fn load_file_config(path: Option<&Path>, skip_default: bool) -> io::Result<Option<FileConfig>> {
let Some(path) = path
.map(Path::to_path_buf)
.or_else(|| (!skip_default).then(default_config_path).flatten())
else {
return Ok(None);
};
if !path.exists() {
if path == default_config_path().unwrap_or_default() {
return Ok(None);
}
return Err(io::Error::new(
io::ErrorKind::NotFound,
format!("config file not found: {}", path.display()),
));
}
let body = fs::read_to_string(&path)?;
toml::from_str(&body).map(Some).map_err(|source| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("failed to parse config file {}: {source}", path.display()),
)
})
}
fn default_config_path() -> Option<PathBuf> {
env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.or_else(|| env::var_os("HOME").map(|home| PathBuf::from(home).join(".config")))
.map(|dir| dir.join("cyber-rain").join("config.toml"))
}
fn append_message_file(message: &mut String, path: &Path) -> io::Result<()> {
let file_message = fs::read_to_string(path).map_err(|source| {
io::Error::new(
source.kind(),
format!("failed to read message file {}: {source}", path.display()),
)
})?;
if !message.is_empty() && !file_message.is_empty() {
message.push('\n');
}
message.push_str(&file_message);
Ok(())
}
fn load_theme_file(path: &Path) -> io::Result<RenderTheme> {
let body = fs::read_to_string(path).map_err(|source| {
io::Error::new(
source.kind(),
format!("failed to read theme file {}: {source}", path.display()),
)
})?;
let theme: ThemeFile = toml::from_str(&body).map_err(|source| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("failed to parse theme file {}: {source}", path.display()),
)
})?;
Ok(RenderTheme {
name: theme.name.unwrap_or_else(|| {
path.file_stem()
.and_then(|stem| stem.to_str())
.unwrap_or("custom")
.to_string()
}),
glyphs: theme.glyphs,
head: theme.colors.head,
hot: theme.colors.hot.unwrap_or(theme.colors.head),
tail: theme.colors.tail,
dim: theme.colors.dim.unwrap_or(theme.colors.tail),
})
}
fn normalize_glyphs(glyphs: &str) -> Option<Vec<char>> {
let glyphs = glyphs
.chars()
.filter(|ch| !ch.is_control() && !ch.is_whitespace())
.collect::<Vec<_>>();
(!glyphs.is_empty()).then_some(glyphs)
}
fn parse_palette(value: &str) -> io::Result<Palette> {
match value.trim().to_ascii_lowercase().as_str() {
"green" => Ok(Palette::Green),
"cyan" => Ok(Palette::Cyan),
"amber" => Ok(Palette::Amber),
"crimson" => Ok(Palette::Crimson),
"ghost" => Ok(Palette::Ghost),
"rainbow" => Ok(Palette::Rainbow),
other => Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("unknown palette in config: {other}"),
)),
}
}
fn parse_preset(value: &str) -> io::Result<Preset> {
match value.trim().to_ascii_lowercase().as_str() {
"classic" => Ok(Preset::Classic),
"ghost" => Ok(Preset::Ghost),
"cyberpunk" => Ok(Preset::Cyberpunk),
"calm" => Ok(Preset::Calm),
"chaos" => Ok(Preset::Chaos),
other => Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("unknown preset in config: {other}"),
)),
}
}
fn config_template() -> &'static str {
r#"# cyber-rain config
# Save as ~/.config/cyber-rain/config.toml
preset = "cyberpunk"
fps = 60
density = 0.82
speed = 7
palette = "green"
message = "follow the white rabbit"
ascii = false
seed = 1999
alt-screen = true
bursts = true
show-status = true
stats = false
# Optional:
# message-file = "/path/to/transmissions.txt"
# theme = "/path/to/theme.toml"
# glyphs = "0123456789ABCDEF"
"#
}
fn write_man_page(path: &Path) -> io::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let command = Cli::command();
let man = clap_mangen::Man::new(command);
let mut buffer = Vec::new();
man.render(&mut buffer)?;
fs::write(path, buffer)
}
struct TerminalSession {
alt_screen: bool,
}
impl TerminalSession {
fn enter(stdout: &mut impl Write, alt_screen: bool) -> io::Result<Self> {
terminal::enable_raw_mode()?;
if alt_screen {
execute!(stdout, EnterAlternateScreen, Clear(ClearType::All), Hide)?;
} else {
execute!(stdout, Clear(ClearType::All), Hide)?;
}
Ok(Self { alt_screen })
}
}
impl Drop for TerminalSession {
fn drop(&mut self) {
let mut stdout = stdout();
let _ = execute!(stdout, ResetColor, Show);
if self.alt_screen {
let _ = execute!(stdout, LeaveAlternateScreen);
}
let _ = terminal::disable_raw_mode();
}
}
struct Matrix {
config: Config,
columns: Vec<Column>,
rng: StdRng,
width: u16,
height: u16,
frame: u64,
burst_frames: u16,
}
impl Matrix {
fn new(config: Config, width: u16, height: u16) -> Self {
let mut rng = match config.seed {
Some(seed) => StdRng::seed_from_u64(seed),
None => StdRng::from_entropy(),
};
let columns = build_columns(width, height, &config, &mut rng);
Self {
config,
columns,
rng,
width,
height,
frame: 0,
burst_frames: 0,
}
}
fn resize(&mut self, width: u16, height: u16, stdout: &mut impl Write) -> io::Result<()> {
self.width = width;
self.height = height;
self.columns = build_columns(width, height, &self.config, &mut self.rng);
queue!(stdout, Clear(ClearType::All))?;
Ok(())
}
fn draw_frame(&mut self, stdout: &mut impl Write, paused: bool) -> io::Result<()> {
if paused {
self.draw_status(stdout, "paused")?;
return Ok(());
}
self.frame = self.frame.wrapping_add(1);
if self.config.bursts && self.burst_frames == 0 && self.rng.gen_ratio(1, 520) {
self.burst_frames = self.rng.gen_range(24..=72);
}
let burst_active = self.burst_frames > 0;
let frame = self.frame;
let config = &self.config;
let height = self.height;
let rng = &mut self.rng;
for column in &mut self.columns {
let force_step = burst_active && rng.gen_bool(0.38);
column.tick(stdout, config, height, frame, rng, force_step)?;
}
if self.burst_frames > 0 {
self.burst_frames -= 1;
}
self.draw_status(stdout, if burst_active { "burst" } else { "live" })?;
Ok(())
}
fn draw_status(&self, stdout: &mut impl Write, state: &str) -> io::Result<()> {
if !self.config.show_status {
return Ok(());
}
if self.height < 2 || self.width < 28 {
return Ok(());
}
let label = format!(
" cyber-rain | {state} | {} | speed {} | density {:>3}% | b:{} | m:{} | s status | q quit ",
self.config
.theme
.as_ref()
.map(|theme| theme.name.as_str())
.unwrap_or_else(|| self.config.palette.name()),
self.config.speed,
(self.config.density * 100.0).round() as u16,
if self.config.bursts { "on" } else { "off" },
if self.config.message_enabled {
"on"
} else {
"off"
}
);
let clipped: String = label.chars().take(self.width as usize).collect();
queue!(
stdout,
MoveTo(0, self.height - 1),
SetForegroundColor(Color::DarkGrey),
Print(format!("{clipped:<width$}", width = self.width as usize)),
ResetColor
)?;
Ok(())
}
fn rebuild(&mut self, stdout: &mut impl Write) -> io::Result<()> {
self.columns = build_columns(self.width, self.height, &self.config, &mut self.rng);
queue!(stdout, Clear(ClearType::All))?;
Ok(())
}
fn adjust_speed(&mut self, delta: i16, stdout: &mut impl Write) -> io::Result<()> {
self.config.speed = adjust_u16(self.config.speed, delta, 1, 10);
self.rebuild(stdout)
}
fn adjust_density(&mut self, delta: f32, stdout: &mut impl Write) -> io::Result<()> {
self.config.density = (self.config.density + delta).clamp(0.05, 1.0);
self.rebuild(stdout)
}
fn set_palette(&mut self, palette: Palette) {
self.config.palette = palette;
}
fn toggle_bursts(&mut self) {
self.config.bursts = !self.config.bursts;
if !self.config.bursts {
self.burst_frames = 0;
}
}
fn toggle_message(&mut self) {
if !self.config.message.is_empty() {
self.config.message_enabled = !self.config.message_enabled;
}
}
fn toggle_status(&mut self, stdout: &mut impl Write) -> io::Result<()> {
if self.config.show_status {
self.clear_status(stdout)?;
}
self.config.show_status = !self.config.show_status;
Ok(())
}
fn clear_status(&self, stdout: &mut impl Write) -> io::Result<()> {
if self.height > 0 {
queue!(
stdout,
MoveTo(0, self.height - 1),
ResetColor,
Print(" ".repeat(self.width as usize))
)?;
}
Ok(())
}
}
struct Column {
x: u16,
y: i16,
trail: u16,
step_every: u16,
clock: u16,
hot: bool,
glyphs: Vec<char>,
message: Option<Vec<char>>,
message_index: usize,
}
impl Column {
fn random(x: u16, height: u16, config: &Config, rng: &mut StdRng) -> Self {
let mut column = Self {
x,
y: 0,
trail: 8,
step_every: 1,
clock: rng.gen_range(0..=u16::MAX),
hot: false,
glyphs: Vec::new(),
message: None,
message_index: 0,
};
column.respawn(height, config, rng);
column
}
fn respawn(&mut self, height: u16, config: &Config, rng: &mut StdRng) {
let height = height.max(2);
let max_trail = (height / 2).clamp(6, 34);
let base_delay = match config.speed {
1 => 9,
2 => 7,
3 => 6,
4 => 5,
5 => 4,
6 | 7 => 3,
8 | 9 => 2,
_ => 1,
};
let jitter = rng.gen_range(0..=1);
self.y = -(rng.gen_range(0..height) as i16);
self.trail = rng.gen_range(4..=max_trail);
self.step_every = (base_delay + jitter).max(1);
self.hot = rng.gen_bool(0.09);
self.message_index = 0;
self.glyphs = (0..=self.trail)
.map(|_| random_glyph(config, rng))
.collect();
self.message =
if config.message_enabled && !config.message.is_empty() && rng.gen_ratio(1, 11) {
Some(config.message.clone())
} else {
None
};
}
fn tick(
&mut self,
stdout: &mut impl Write,
config: &Config,
height: u16,
frame: u64,
rng: &mut StdRng,
force_step: bool,
) -> io::Result<()> {
self.clock = self.clock.wrapping_add(1);
let should_step = self.clock.checked_rem(self.step_every) == Some(0);
if !force_step && !should_step {
return Ok(());
}
self.y += 1;
self.advance_glyphs(config, rng);
self.clear_tail(stdout, height)?;
self.draw_trail(stdout, config, height, frame, rng, force_step)?;
if self.y - self.trail as i16 > height as i16 {
self.respawn(height, config, rng);
}
Ok(())
}
fn clear_tail(&self, stdout: &mut impl Write, height: u16) -> io::Result<()> {
let row = self.y - self.trail as i16 - 1;
if row >= 0 && row < height as i16 {
queue!(stdout, MoveTo(self.x, row as u16), Print(" "))?;
}
Ok(())
}
fn draw_trail(
&mut self,
stdout: &mut impl Write,
config: &Config,
height: u16,
frame: u64,
_rng: &mut StdRng,
burst_hot: bool,
) -> io::Result<()> {
for offset in (1..=self.trail).rev() {
let row = self.y - offset as i16;
if row < 0 || row >= height as i16 {
continue;
}
let intensity = tail_intensity(offset, self.trail);
let color = config.color(intensity, self.x, row as u16, frame, self.hot || burst_hot);
queue!(
stdout,
MoveTo(self.x, row as u16),
SetForegroundColor(color),
Print(self.glyphs.get(offset as usize).copied().unwrap_or('?'))
)?;
}
if self.y >= 0 && self.y < height as i16 {
let color = config.color(255, self.x, self.y as u16, frame, true);
queue!(
stdout,
MoveTo(self.x, self.y as u16),
SetForegroundColor(color),
Print(self.glyphs.first().copied().unwrap_or('?')),
ResetColor
)?;
}
Ok(())
}
fn advance_glyphs(&mut self, config: &Config, rng: &mut StdRng) {
let expected_len = self.trail as usize + 1;
if self.glyphs.len() != expected_len {
self.glyphs = (0..expected_len)
.map(|_| random_glyph(config, rng))
.collect();
}
let head = self.next_head_glyph(config, rng);
self.glyphs.rotate_right(1);
self.glyphs[0] = head;
if self.glyphs.len() > 3 && rng.gen_bool(0.16) {
let index = rng.gen_range(1..self.glyphs.len());
self.glyphs[index] = random_glyph(config, rng);
}
}
fn next_head_glyph(&mut self, config: &Config, rng: &mut StdRng) -> char {
if let Some(chars) = self.message.as_ref() {
let ch = chars[self.message_index % chars.len()];
self.message_index += 1;
if self.message_index >= chars.len() {
self.message = None;
self.message_index = 0;
}
return ch;
}
random_glyph(config, rng)
}
}
impl Palette {
fn name(&self) -> &'static str {
match self {
Self::Green => "green",
Self::Cyan => "cyan",
Self::Amber => "amber",
Self::Crimson => "crimson",
Self::Ghost => "ghost",
Self::Rainbow => "rainbow",
}
}
fn from_hotkey(ch: char) -> Option<Self> {
match ch {
'1' => Some(Self::Green),
'2' => Some(Self::Cyan),
'3' => Some(Self::Amber),
'4' => Some(Self::Crimson),
'5' => Some(Self::Ghost),
'6' => Some(Self::Rainbow),
_ => None,
}
}
fn color(&self, intensity: u8, x: u16, y: u16, frame: u64, hot: bool) -> Color {
if hot && intensity > 220 {
return match self {
Self::Amber => Color::Rgb {
r: 255,
g: 252,
b: 194,
},
Self::Crimson => Color::Rgb {
r: 255,
g: 220,
b: 220,
},
Self::Ghost => Color::White,
_ => Color::Rgb {
r: 225,
g: 255,
b: 225,
},
};
}
match self {
Self::Green => Color::Rgb {
r: intensity / 5,
g: intensity,
b: intensity / 4,
},
Self::Cyan => Color::Rgb {
r: intensity / 5,
g: intensity,
b: intensity,
},
Self::Amber => Color::Rgb {
r: intensity,
g: intensity.saturating_mul(3) / 5,
b: intensity / 9,
},
Self::Crimson => Color::Rgb {
r: intensity,
g: intensity / 6,
b: intensity / 4,
},
Self::Ghost => Color::Rgb {
r: intensity,
g: intensity,
b: intensity,
},
Self::Rainbow => rainbow_color(intensity, x, y, frame),
}
}
}
fn build_columns(width: u16, height: u16, config: &Config, rng: &mut StdRng) -> Vec<Column> {
let mut columns = Vec::with_capacity((width as f32 * config.density) as usize);
for x in 0..width {
if rng.gen_bool(config.density as f64) {
columns.push(Column::random(x, height, config, rng));
}
}
columns
}
fn random_glyph(config: &Config, rng: &mut StdRng) -> char {
const ASCII: &[u8] = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz@#$%&*+=?";
if let Some(glyphs) = &config.glyphs {
if !glyphs.is_empty() {
return glyphs[rng.gen_range(0..glyphs.len())];
}
}
if config.ascii {
return ASCII[rng.gen_range(0..ASCII.len())] as char;
}
match rng.gen_range(0..100) {
0..=56 => char::from_u32(rng.gen_range(0xFF66..=0xFF9D)).unwrap_or('?'),
57..=76 => char::from_u32(rng.gen_range(0x30..=0x39)).unwrap_or('?'),
77..=92 => ASCII[rng.gen_range(0..ASCII.len())] as char,
_ => char::from_u32(rng.gen_range(0x2500..=0x257F)).unwrap_or('?'),
}
}
fn tail_intensity(offset: u16, trail: u16) -> u8 {
let trail = trail.max(1);
let distance = offset.saturating_sub(1) as f32 / trail as f32;
let fade = 1.0 - distance;
(28.0 + fade.powf(1.65) * 178.0) as u8
}
fn rainbow_color(intensity: u8, x: u16, y: u16, frame: u64) -> Color {
let wheel = ((frame as u16 / 2)
.wrapping_add(x.wrapping_mul(7))
.wrapping_add(y.wrapping_mul(3)))
% 1536;
let sector = wheel / 256;
let offset = (wheel % 256) as u8;
let down = 255_u8.saturating_sub(offset);
let (r, g, b) = match sector {
0 => (255, offset, 0),
1 => (down, 255, 0),
2 => (0, 255, offset),
3 => (0, down, 255),
4 => (offset, 0, 255),
_ => (255, 0, down),
};
Color::Rgb {
r: scale_channel(r, intensity),
g: scale_channel(g, intensity),
b: scale_channel(b, intensity),
}
}
fn scale_channel(channel: u8, intensity: u8) -> u8 {
((channel as u16 * intensity as u16) / 255) as u8
}
fn adjust_u16(value: u16, delta: i16, min: u16, max: u16) -> u16 {
if delta.is_negative() {
value.saturating_sub(delta.unsigned_abs()).clamp(min, max)
} else {
value.saturating_add(delta as u16).clamp(min, max)
}
}
fn parse_density(value: &str) -> Result<f32, String> {
let density = value
.parse::<f32>()
.map_err(|_| "density must be a number between 0.05 and 1.0".to_string())?;
if (0.05..=1.0).contains(&density) {
Ok(density)
} else {
Err("density must be between 0.05 and 1.0".to_string())
}
}
fn handle_event(
matrix: &mut Matrix,
stdout: &mut impl Write,
paused: &mut bool,
) -> io::Result<bool> {
match event::read()? {
Event::Key(key) if key.kind == KeyEventKind::Press => match key.code {
KeyCode::Esc | KeyCode::Char('q') => Ok(true),
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => Ok(true),
KeyCode::Up => {
matrix.adjust_speed(1, stdout)?;
Ok(false)
}
KeyCode::Down => {
matrix.adjust_speed(-1, stdout)?;
Ok(false)
}
KeyCode::Right => {
matrix.adjust_density(0.05, stdout)?;
Ok(false)
}
KeyCode::Left => {
matrix.adjust_density(-0.05, stdout)?;
Ok(false)
}
KeyCode::Char(ch) if Palette::from_hotkey(ch).is_some() => {
if let Some(palette) = Palette::from_hotkey(ch) {
matrix.set_palette(palette);
}
Ok(false)
}
KeyCode::Char('b') => {
matrix.toggle_bursts();
Ok(false)
}
KeyCode::Char('m') => {
matrix.toggle_message();
Ok(false)
}
KeyCode::Char('s') => {
matrix.toggle_status(stdout)?;
Ok(false)
}
KeyCode::Char(' ') | KeyCode::Char('p') => {
*paused = !*paused;
Ok(false)
}
_ => Ok(false),
},
Event::Resize(width, height) => {
matrix.resize(width, height, stdout)?;
Ok(false)
}
_ => Ok(false),
}
}
struct RuntimeStats {
started_at: Instant,
frames: u64,
missed_frames: u64,
}
impl RuntimeStats {
fn new() -> Self {
Self {
started_at: Instant::now(),
frames: 0,
missed_frames: 0,
}
}
fn fps(&self) -> f64 {
let seconds = self.started_at.elapsed().as_secs_f64();
if seconds > 0.0 {
self.frames as f64 / seconds
} else {
0.0
}
}
}
fn run() -> io::Result<()> {
let cli = Cli::parse();
if cli.print_config_template {
print!("{}", config_template());
return Ok(());
}
if let Some(path) = cli.generate_man.as_deref() {
write_man_page(path)?;
return Ok(());
}
if let Some(shell) = cli.generate_completion {
let mut command = Cli::command();
let name = command.get_name().to_string();
generate(shell, &mut command, name, &mut stdout());
return Ok(());
}
let benchmark = cli.benchmark;
let benchmark_width = cli.benchmark_width;
let benchmark_height = cli.benchmark_height;
let config = Config::try_from(cli)?;
if let Some(seconds) = benchmark {
return run_benchmark(
config,
benchmark_width,
benchmark_height,
Duration::from_secs(seconds),
);
}
run_terminal(config)
}
fn run_terminal(config: Config) -> io::Result<()> {
let frame_delay = Duration::from_nanos(1_000_000_000 / config.fps);
let stdout = stdout();
let mut stdout = io::BufWriter::with_capacity(256 * 1024, stdout);
let (width, height) = terminal::size()?;
let alt_screen = config.alt_screen;
let mut matrix = Matrix::new(config, width, height);
let terminal = TerminalSession::enter(&mut stdout, alt_screen)?;
let mut paused = false;
let mut next_frame = Instant::now();
let started_at = Instant::now();
let mut stats = RuntimeStats::new();
loop {
if matrix
.config
.duration
.is_some_and(|duration| started_at.elapsed() >= duration)
{
break;
}
let mut should_quit = false;
while event::poll(Duration::from_millis(0))? {
if handle_event(&mut matrix, &mut stdout, &mut paused)? {
should_quit = true;
break;
}
}
if should_quit {
break;
}
matrix.draw_frame(&mut stdout, paused)?;
stats.frames += 1;
stdout.flush()?;
next_frame += frame_delay;
let now = Instant::now();
if let Some(remaining) = next_frame.checked_duration_since(now) {
thread::sleep(remaining);
} else {
stats.missed_frames += 1;
next_frame = now;
}
}
let show_stats = matrix.config.stats;
let final_frames = stats.frames;
let final_fps = stats.fps();
let missed_frames = stats.missed_frames;
drop(terminal);
if show_stats {
eprintln!(
"cyber-rain stats: frames={final_frames} avg_fps={final_fps:.1} missed_frames={missed_frames} size={}x{}",
matrix.width, matrix.height
);
}
Ok(())
}
fn run_benchmark(
mut config: Config,
width: u16,
height: u16,
duration: Duration,
) -> io::Result<()> {
config.show_status = false;
let mut matrix = Matrix::new(config, width, height);
let mut sink = io::sink();
let started = Instant::now();
let mut frames = 0_u64;
while started.elapsed() < duration {
matrix.draw_frame(&mut sink, false)?;
frames += 1;
}
let seconds = started.elapsed().as_secs_f64();
let fps = frames as f64 / seconds.max(0.001);
println!(
"cyber-rain benchmark: frames={frames} seconds={seconds:.2} fps={fps:.1} size={}x{} columns={}",
width,
height,
matrix.columns.len()
);
Ok(())
}
fn main() -> io::Result<()> {
run()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn density_parser_rejects_out_of_range_values() {
assert!(parse_density("0.5").is_ok());
assert!(parse_density("0.01").is_err());
assert!(parse_density("1.2").is_err());
}
#[test]
fn tail_gets_brighter_near_the_head() {
assert!(tail_intensity(1, 20) > tail_intensity(20, 20));
}
#[test]
fn seeded_ascii_glyphs_are_ascii() {
let mut rng = StdRng::seed_from_u64(7);
let config = Config {
ascii: true,
..Config::default()
};
for _ in 0..128 {
assert!(random_glyph(&config, &mut rng).is_ascii());
}
}
#[test]
fn demo_mode_applies_polished_defaults() {
let cli = Cli::try_parse_from(["cyber-rain", "--demo", "--duration", "1"]).unwrap();
let config = Config::try_from(cli).unwrap();
assert_eq!(config.palette, Palette::Rainbow);
assert_eq!(config.speed, 8);
assert_eq!(config.seed, Some(1999));
assert!(config.message_enabled);
assert_eq!(config.duration, Some(Duration::from_secs(1)));
}
#[test]
fn explicit_flags_override_presets() {
let cli = Cli::try_parse_from([
"cyber-rain",
"--preset",
"calm",
"--speed",
"9",
"--palette",
"amber",
])
.unwrap();
let config = Config::try_from(cli).unwrap();
assert_eq!(config.speed, 9);
assert_eq!(config.palette, Palette::Amber);
assert!(!config.bursts);
}
#[test]
fn message_file_appends_to_inline_message() {
let path = std::env::temp_dir().join(format!(
"cyber-rain-message-{}-{}.txt",
std::process::id(),
1
));
fs::write(&path, "rabbit\nhole").unwrap();
let cli = Cli::try_parse_from([
"cyber-rain",
"--message",
"wake",
"--message-file",
path.to_str().unwrap(),
])
.unwrap();
let config = Config::try_from(cli).unwrap();
let message = config.message.iter().collect::<String>();
let _ = fs::remove_file(path);
assert_eq!(message, "wake rabbit hole");
}
#[test]
fn config_file_can_set_defaults() {
let path = std::env::temp_dir().join(format!(
"cyber-rain-config-{}-{}.toml",
std::process::id(),
1
));
fs::write(
&path,
r#"
preset = "ghost"
speed = 9
message = "stored"
show-status = false
"#,
)
.unwrap();
let cli = Cli::try_parse_from(["cyber-rain", "--config", path.to_str().unwrap()]).unwrap();
let config = Config::try_from(cli).unwrap();
let _ = fs::remove_file(path);
assert_eq!(config.palette, Palette::Ghost);
assert_eq!(config.speed, 9);
assert!(!config.show_status);
assert_eq!(config.message.iter().collect::<String>(), "stored");
}
#[test]
fn theme_file_sets_custom_glyphs_and_colors() {
let path = std::env::temp_dir().join(format!(
"cyber-rain-theme-{}-{}.toml",
std::process::id(),
1
));
fs::write(
&path,
r#"
name = "test-theme"
glyphs = "XYZ"
[colors]
head = [255, 255, 255]
hot = [255, 0, 0]
tail = [0, 255, 0]
dim = [0, 32, 0]
"#,
)
.unwrap();
let cli = Cli::try_parse_from(["cyber-rain", "--theme", path.to_str().unwrap()]).unwrap();
let config = Config::try_from(cli).unwrap();
let _ = fs::remove_file(path);
assert_eq!(config.theme.as_ref().unwrap().name, "test-theme");
assert_eq!(config.glyphs.as_ref().unwrap().len(), 3);
}
}