diff --git a/Cargo.toml b/Cargo.toml index 7346730..9571e8b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,8 @@ clap = { version = "4.5.43", features = ["cargo", "derive"] } crossterm = "0.29.0" rand = "0.9.2" ezemoji = "2.0.0" +toml = "0.9.5" +serde = { version = "1.0.219", features = ["derive"] } [dev-dependencies] pretty_assertions = "1.4.1" diff --git a/src/cli.rs b/src/cli.rs index 8711000..4cff326 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,21 +1,157 @@ use super::{AUTHOR, Direction, MAXSPEED, MINSPEED}; use clap::{Parser, crate_description, crate_name, crate_version}; use ezemoji::{CharGroup, CharWidth, GroupKind, MultiRange}; +use serde::{Deserialize, Deserializer, Serialize}; +use std::path::PathBuf; + use std::str::FromStr; +#[derive(Debug, Deserialize)] +struct RangeDef { + start: u32, + end: u32, +} + +impl From for std::ops::Range { + fn from(r: RangeDef) -> Self { + r.start..r.end + } +} + +fn deserialize_ranges<'de, D>(deserializer: D) -> Result>, D::Error> +where + D: Deserializer<'de>, +{ + let defs: Vec = Vec::deserialize(deserializer)?; + Ok(defs.into_iter().map(|r| r.into()).collect()) +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Group { + #[serde(deserialize_with = "deserialize_ranges")] + pub range: Vec>, + pub width: u8, +} + +impl Group { + pub fn width(&self) -> u8 { + self.width + } + + pub fn len(&self) -> usize { + self.range.iter().map(|r| (r.end - r.start) as usize).sum() + } +} + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Config { + pub groups: std::collections::BTreeMap, +} + +pub fn load_config() -> Option { + #[cfg(windows)] + let config_path = { + let appdata = std::env::var("APPDATA").unwrap(); + PathBuf::from(appdata) + .join("rusty-rain") + .join("config.toml") + }; + + #[cfg(unix)] + let config_path = { + let home = std::env::var("HOME").unwrap(); + PathBuf::from(home) + .join(".config") + .join("rusty-rain") + .join("config.toml") + }; + + if !config_path.exists() { + return None; + } + + let string_config = std::fs::read_to_string(&config_path).unwrap_or_default(); + let config: Config = match toml::from_str(&string_config) { + Ok(config) => config, + Err(err) => { + eprintln!("failed to parse config: {err}"); + return None; + } + }; + + Some(config) +} + #[derive(Debug, Clone)] -pub struct CharGroupKind(pub CharGroup); +pub enum Grouping { + EzEmoji(CharGroup), + Custom(Group), +} + +impl Grouping { + pub fn name(&self) -> GroupKind { + match self { + Grouping::EzEmoji(group) => group.name, + Grouping::Custom(_) => GroupKind::Custom("custom"), + } + } + + pub fn width(&self) -> u8 { + match self { + Grouping::EzEmoji(group) => group.width(), + Grouping::Custom(group) => group.width(), + } + } + + pub fn len(&self) -> usize { + match self { + Grouping::EzEmoji(group) => group.len, + Grouping::Custom(group) => group.len(), + } + } -impl FromStr for CharGroupKind { + pub fn nth_char(&self, index: usize) -> Option { + match self { + Grouping::EzEmoji(group) => group.nth_char(index), + Grouping::Custom(group) => { + let index = index as u32; + let mut i = 0u32; + for range in group.range.iter() { + let step = range.end - range.start; + if index >= i && index < i + step { + let offset = index - i; + return char::from_u32(range.start + offset); + } + i += step; + } + None + } + } + } +} + +impl From for Grouping { + fn from(value: CharGroup) -> Self { + Grouping::EzEmoji(value) + } +} + +impl From for Grouping { + fn from(value: Group) -> Self { + Grouping::Custom(value) + } +} + +impl FromStr for Grouping { type Err = String; fn from_str(s: &str) -> Result { match s.to_lowercase().as_str() { - // Idea was brought up to use these nerd fonts icons by + // Idea was brought up to use these chars together by // [tonogdlp](https://github.com/tonogdlp) in PR // https://github.com/cowboy8625/ezemoji/pull/4 // Once merged into ezemoji we can remove this - "classic" => Ok(CharGroupKind(CharGroup::new( + "classic" => Ok(Grouping::from(CharGroup::new( GroupKind::Custom("Classic"), MultiRange::new(&[ ezemoji::JAP_RANGE, @@ -35,7 +171,7 @@ impl FromStr for CharGroupKind { // [hasecilu](https://github.com/hasecilu) in PR // https://github.com/cowboy8625/ezemoji/pull/5 // Once merged into ezemoji we can remove this - "opensource" => Ok(CharGroupKind(CharGroup::new( + "opensource" => Ok(Grouping::from(CharGroup::new( GroupKind::Custom("OpenSource"), MultiRange::new(&[ 62208..62210, @@ -61,7 +197,7 @@ impl FromStr for CharGroupKind { // [hasecilu](https://github.com/hasecilu) in PR // https://github.com/cowboy8625/ezemoji/pull/5 // Once merged into ezemoji we can remove this - "pglangs" => Ok(CharGroupKind(CharGroup::new( + "pglangs" => Ok(Grouping::from(CharGroup::new( GroupKind::Custom("ProgrammingLanguages"), MultiRange::new(&[ // From all Nerd Fonts @@ -114,7 +250,20 @@ impl FromStr for CharGroupKind { ]), CharWidth::Double, ))), - name => Ok(CharGroupKind(CharGroup::from_str(name)?)), + name => match CharGroup::from_str(name) { + Ok(group) => Ok(Grouping::from(group)), + Err(_) => { + let Some(config) = load_config() else { + return Err("group not found".to_string()); + }; + + if let Some(group) = config.groups.get(name) { + Ok(Grouping::from(group.clone())) + } else { + Err("group not found".to_string()) + } + } + }, } } } @@ -186,7 +335,7 @@ pub struct Cli { #[arg(short, long, default_value_t = false)] pub shade: bool, #[arg(short, long, help = HELP_CHARS, default_value = "bin")] - pub group: CharGroupKind, + pub group: Grouping, #[arg(short = 'C', long, help = HELP_COLORS, default_value_t = String::from("green"))] pub color: String, #[arg(short = 'H', long, help = HELP_HEAD, default_value_t = String::from("white"))] diff --git a/src/main.rs b/src/main.rs index 4ff6068..00cc34e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,12 +8,18 @@ use crossterm::{ style::{Color, Print, SetForegroundColor}, terminal, }; -use std::time::{Duration, Instant}; +use rand::Rng; use std::{ io::{BufWriter, Stdout, Write, stdout}, str::FromStr, + time::{Duration, Instant}, }; +#[cfg(test)] +use rand::SeedableRng; + +use crate::cli::Grouping; + const MAXSPEED: u64 = 0; const MINSPEED: u64 = 200; const POLL_INTERVAL: Duration = Duration::from_millis(50); @@ -26,13 +32,6 @@ const AUTHOR: &str = " Email: cowboy8625@protonmail.com "; -use rand::Rng; - -#[cfg(test)] -use rand::SeedableRng; - -use crate::cli::CharGroupKind; - /// rand crate wrapper for testing. /// being able to have deterministic tests is important #[derive(Debug)] @@ -136,7 +135,7 @@ impl FromStr for Direction { } #[derive(Debug)] -struct Rain<'group, const LENGTH: usize> { +struct Rain { /// Random number generator wrapper for testing purposes rng: Random, /// Characters to use for the rain @@ -165,8 +164,8 @@ struct Rain<'group, const LENGTH: usize> { queue: Vec, /// Speed of the rain speed: std::ops::Range, - /// Group of characters defined by a slice &'static [u32] - group: &'group CharGroupKind, + /// Character width + char_width: usize, /// Width of the terminal /// NOTE: This value is not a true width of the terminal but size in visible characters /// 🌕 is a single character but takes up 2 columns and so the width value would count this as @@ -181,18 +180,17 @@ struct Rain<'group, const LENGTH: usize> { previous_screen_buffer: Vec, } -impl<'group, const LENGTH: usize> Rain<'group, LENGTH> { +impl Rain { const MIN_LENGTH_OF_RAIN: usize = 4; const MAX_LENGTH_OFFSET_OF_RAIN: usize = 4; - fn new(mut width: usize, height: usize, settings: &'group cli::Cli) -> Self { - width /= settings.group.0.width() as usize; + fn new(mut width: usize, height: usize, settings: &cli::Cli) -> Self { + width /= settings.group.width() as usize; let mut rng = Random::default(); - let char_length = settings.group.0.len(); + let char_length = settings.group.len(); let chars: [char; LENGTH] = std::array::from_fn(|_| { settings .group - .0 .nth_char(rng.random_range(0..char_length)) .unwrap_or('#') // fallback character }); @@ -246,7 +244,7 @@ impl<'group, const LENGTH: usize> Rain<'group, LENGTH> { body_colors, chars, directions: vec![settings.direction; width], - group: &settings.group, + char_width: settings.group.width() as usize, head_colors: vec![settings.head_color().into(); width], height, positions: vec![0; width], @@ -410,11 +408,10 @@ impl<'group, const LENGTH: usize> Rain<'group, LENGTH> { } if redraw_screen && matches!(self.directions[0], Direction::Left | Direction::Right) { - let char_width = self.group.0.width() as usize; for (y, chunk) in self.screen_buffer.chunks(self.width).enumerate() { let screen = chunk .iter() - .map(|c| c.display(char_width)) + .map(|c| c.display(self.char_width)) .collect::(); queue!(w, cursor::MoveTo(0, y as u16), Print(screen))?; @@ -424,11 +421,10 @@ impl<'group, const LENGTH: usize> Rain<'group, LENGTH> { return Ok(()); } else if redraw_screen { execute!(w, cursor::MoveTo(0, 0))?; - let char_width = self.group.0.width() as usize; let screen = self .screen_buffer .iter() - .map(|c| c.display(char_width)) + .map(|c| c.display(self.char_width)) .collect::(); execute!(w, Print(screen))?; @@ -436,11 +432,9 @@ impl<'group, const LENGTH: usize> Rain<'group, LENGTH> { return Ok(()); } - let group_width = self.group.0.width() as usize; - for idx in self.queue.drain(..) { let cell = &self.screen_buffer[idx]; - let x = (idx % self.width) * group_width; + let x = (idx % self.width) * self.char_width; let y = idx / self.width; if cell.is_visible() { @@ -455,7 +449,7 @@ impl<'group, const LENGTH: usize> Rain<'group, LENGTH> { w, cursor::MoveTo(x as u16, y as u16), SetForegroundColor(cell.color), - Print(" ".repeat(group_width)) + Print(" ".repeat(self.char_width)) )?; } @@ -572,14 +566,25 @@ fn main() -> std::io::Result<()> { // visibly expand. // example:  . let extra_width = matches!( - settings.group.0.name, + settings.group.name(), ezemoji::GroupKind::Custom("OpenSource") | ezemoji::GroupKind::Custom("ProgrammingLanguages") ); - for char in settings.group.0.iter() { - print!("{char}"); - if extra_width { - print!(" "); + match settings.group { + Grouping::EzEmoji(group) => { + for char in group.iter() { + print!("{char}"); + if extra_width { + print!(" "); + } + } + } + Grouping::Custom(group) => { + for range in group.range.iter() { + for cp in range.clone() { + print!("{}", std::char::from_u32(cp).unwrap_or('🤦')); + } + } } } return Ok(()); diff --git a/src/test.rs b/src/test.rs index 11a12d6..b8a49e6 100644 --- a/src/test.rs +++ b/src/test.rs @@ -1,4 +1,4 @@ -use crate::cli::CharGroupKind; +use crate::cli::Grouping; use super::{Parser, Rain, cli::Cli}; use ezemoji::CharGroup; @@ -29,13 +29,12 @@ impl Default for SnapshotOptions { fn display(id: usize, window: &mut String, rain: &Rain) { let width = rain.width; let height = rain.height; - let char_width = rain.group.0.width() as usize; let id_str = format!("{:02X}", id); write!( window, "{:-^width$}\n", id_str, - width = width * char_width + 5 + width = width * rain.char_width + 5 ) .unwrap(); for (i, chunk) in rain.screen_buffer.chunks(width).enumerate() { @@ -45,7 +44,7 @@ fn display(id: usize, window: &mut String, rain: &Rain) { "{}|", &chunk .iter() - .map(|c| c.display(char_width)) + .map(|c| c.display(rain.char_width)) .collect::() ) .unwrap(); @@ -66,7 +65,7 @@ fn set_up_snapshot(options: SnapshotOptions) { direction, } = options; let mut cli = Cli::parse(); - cli.group = CharGroupKind(group); + cli.group = Grouping::from(group); cli.direction = direction; let mut rain = Rain::<1024>::new(width, height, &cli); let mut window = String::new();