Small library for generating claude-code like unicde block mascots, and providing animations when they do stuff
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 362 lines 12 kB view raw
1//! CLI entry point: parse arguments, build a Clood, render or animate. 2//! Supports saving/loading named characters and animations from ~/.config/clood.json. 3 4mod animation; 5mod clood; 6mod color; 7 8use std::collections::HashMap; 9use std::fs; 10use std::path::PathBuf; 11 12use clap::Parser; 13use rand::Rng; 14use serde::{Deserialize, Serialize}; 15 16use animation::Animation; 17use clood::{Clood, EyeState}; 18use color::Color; 19 20// ── CLI ───────────────────────────────────────────────────────────────────── 21 22/// Generate a cute unicode block character (clood) in your terminal. 23/// 24/// All parameters are randomized by default. Specify a flag to pin that value. 25/// Animate parameters with --anim <param>:<min>:<max>, e.g. --anim mood:-1:3 26/// 27/// Save characters with --save <name>, animations with --saveanim <name>. 28/// Recall with: clood <character> [animation] 29#[derive(Parser, Debug)] 30#[command(name = "clood", version, about)] 31struct Args { 32 /// Load a saved character by name 33 #[arg(value_name = "CHARACTER")] 34 name: Option<String>, 35 36 /// Load a saved animation by name (requires a character name) 37 #[arg(value_name = "ANIMATION")] 38 anim_name: Option<String>, 39 40 /// Save the current character under this name 41 #[arg(long)] 42 save: Option<String>, 43 44 /// Save the current animations under this name 45 #[arg(long)] 46 saveanim: Option<String>, 47 48 /// Delete a saved character by name 49 #[arg(long)] 50 delete: Option<String>, 51 52 /// Delete a saved animation by name 53 #[arg(long)] 54 deleteanim: Option<String>, 55 56 /// Body height in rows [default: random 4..8] 57 #[arg(long)] 58 height: Option<usize>, 59 60 /// Body width in columns [default: random 8..16] 61 #[arg(long)] 62 width: Option<usize>, 63 64 /// Width of each arm in columns [default: random 2..4] 65 #[arg(long)] 66 armsize: Option<usize>, 67 68 /// Number of legs (typically even) [default: random even 0..8] 69 #[arg(long)] 70 numlegs: Option<usize>, 71 72 /// Length of each leg in rows [default: random 2..4] 73 #[arg(long)] 74 legsize: Option<usize>, 75 76 /// Body color as hex, e.g. #88DDFF [default: random] 77 #[arg(long)] 78 color: Option<String>, 79 80 /// Eye color as hex, e.g. #000000 [default: random] 81 #[arg(long)] 82 eyecolor: Option<String>, 83 84 /// Eye mood: vertical offset from center. Positive = higher. [default: random -2..2] 85 #[arg(long, allow_hyphen_values = true)] 86 mood: Option<i32>, 87 88 /// Horizontal eye offset. Positive = glance right, negative = left. [default: 0] 89 #[arg(long, default_value_t = 0, allow_hyphen_values = true)] 90 glance: i32, 91 92 /// Left eye vertical offset from mood baseline [default: 0] 93 #[arg(long, default_value_t = 0, allow_hyphen_values = true)] 94 lefteye: i32, 95 96 /// Right eye vertical offset from mood baseline [default: 0] 97 #[arg(long, default_value_t = 0, allow_hyphen_values = true)] 98 righteye: i32, 99 100 /// Left eye closed: 0=open, 1=closed (half block) [default: 0] 101 #[arg(long, default_value_t = 0)] 102 lefteyeclosed: i32, 103 104 /// Right eye closed: 0=open, 1=closed (half block) [default: 0] 105 #[arg(long, default_value_t = 0)] 106 righteyeclosed: i32, 107 108 /// Corner rounding (0 to width/2). Cuts blocks from top corners. [default: 0] 109 #[arg(long, default_value_t = 0)] 110 round: usize, 111 112 /// Left arm vertical offset from default. Positive = higher. [default: 0] 113 #[arg(long, default_value_t = 0, allow_hyphen_values = true)] 114 leftarm: i32, 115 116 /// Right arm vertical offset from default. Positive = higher. [default: 0] 117 #[arg(long, default_value_t = 0, allow_hyphen_values = true)] 118 rightarm: i32, 119 120 /// Left arm slope: each column outward steps this many rows up (+) or down (-). [default: 0] 121 #[arg(long, default_value_t = 0, allow_hyphen_values = true)] 122 leftslope: i32, 123 124 /// Right arm slope: each column outward steps this many rows up (+) or down (-). [default: 0] 125 #[arg(long, default_value_t = 0, allow_hyphen_values = true)] 126 rightslope: i32, 127 128 /// Animate a parameter: <param>:<min>:<max> (ping-pong loop). Repeatable. 129 #[arg(long = "anim", value_name = "PARAM:MIN:MAX", action = clap::ArgAction::Append)] 130 anims: Vec<String>, 131 132 /// Animation speed in frames per second [default: 4] 133 #[arg(long, default_value_t = 4)] 134 fps: u32, 135} 136 137// ── Save file ─────────────────────────────────────────────────────────────── 138 139/// Top-level structure of ~/.config/clood.json. 140#[derive(Debug, Default, Serialize, Deserialize)] 141struct SaveFile { 142 #[serde(default)] 143 characters: HashMap<String, Clood>, 144 #[serde(default)] 145 animations: HashMap<String, Vec<Animation>>, 146} 147 148/// Path to the saved data file. 149fn config_path() -> PathBuf { 150 dirs::home_dir() 151 .unwrap_or_else(|| PathBuf::from(".")) 152 .join(".config") 153 .join("clood.json") 154} 155 156/// Load the save file from disk. 157fn load_save_file() -> SaveFile { 158 let path = config_path(); 159 match fs::read_to_string(&path) { 160 Ok(contents) => serde_json::from_str(&contents).unwrap_or_else(|e| { 161 eprintln!("Warning: couldn't parse {}: {}", path.display(), e); 162 SaveFile::default() 163 }), 164 Err(_) => SaveFile::default(), 165 } 166} 167 168/// Write the save file to disk. 169fn write_save_file(save: &SaveFile) { 170 let path = config_path(); 171 if let Some(parent) = path.parent() { 172 let _ = fs::create_dir_all(parent); 173 } 174 match serde_json::to_string_pretty(save) { 175 Ok(json) => { 176 if let Err(e) = fs::write(&path, json) { 177 eprintln!("Error: couldn't write {}: {}", path.display(), e); 178 } 179 } 180 Err(e) => eprintln!("Error: couldn't serialize: {}", e), 181 } 182} 183 184// ── Main ──────────────────────────────────────────────────────────────────── 185 186fn main() { 187 let args = Args::parse(); 188 let mut rng = rand::thread_rng(); 189 let mut save_file = load_save_file(); 190 191 // Handle deletes first. 192 if let Some(ref name) = args.delete { 193 if save_file.characters.remove(name).is_some() { 194 eprintln!("Deleted character '{}'", name); 195 write_save_file(&save_file); 196 } else { 197 eprintln!("No character named '{}' to delete", name); 198 } 199 return; 200 } 201 if let Some(ref name) = args.deleteanim { 202 if save_file.animations.remove(name).is_some() { 203 eprintln!("Deleted animation '{}'", name); 204 write_save_file(&save_file); 205 } else { 206 eprintln!("No animation named '{}' to delete", name); 207 } 208 return; 209 } 210 211 // Resolve the character: "_" = random, named = lookup, none = from CLI args. 212 let base = if let Some(ref name) = args.name { 213 if name == "_" { 214 build_clood_from_args(&args, &mut rng) 215 } else { 216 match save_file.characters.get(name) { 217 Some(clood) => clood.clone(), 218 None => { 219 eprintln!("Unknown character '{}'. Saved characters:", name); 220 if save_file.characters.is_empty() { 221 eprintln!(" (none — use --save <name> to create one)"); 222 } else { 223 for key in save_file.characters.keys() { 224 eprintln!(" {}", key); 225 } 226 } 227 std::process::exit(1); 228 } 229 } 230 } 231 } else { 232 build_clood_from_args(&args, &mut rng) 233 }; 234 235 // Resolve animations: from CLI --anim flags, saved animation name(s), or both. 236 // The animation name can be comma-separated to chain multiple: "blink,wave,shrug" 237 let cli_animations = parse_cli_animations(&args); 238 239 let mut animation_sequence: Vec<Vec<Animation>> = Vec::new(); 240 241 if let Some(ref anim_names) = args.anim_name { 242 for anim_name in anim_names.split(',') { 243 let anim_name = anim_name.trim(); 244 if anim_name.is_empty() { 245 continue; 246 } 247 match save_file.animations.get(anim_name) { 248 Some(saved_anims) => { 249 // Merge CLI anims with this saved set 250 let mut combined = cli_animations.clone(); 251 combined.extend(saved_anims.iter().cloned()); 252 animation_sequence.push(combined); 253 } 254 None => { 255 eprintln!("Unknown animation '{}'. Saved animations:", anim_name); 256 if save_file.animations.is_empty() { 257 eprintln!(" (none — use --saveanim <name> to create one)"); 258 } else { 259 for key in save_file.animations.keys() { 260 eprintln!(" {}", key); 261 } 262 } 263 std::process::exit(1); 264 } 265 } 266 } 267 } else if !cli_animations.is_empty() { 268 animation_sequence.push(cli_animations.clone()); 269 } 270 271 // Flatten for saving (save only CLI-specified anims, not loaded ones). 272 let animations_flat = cli_animations; 273 274 // Save character if requested. 275 if let Some(ref save_name) = args.save { 276 save_file 277 .characters 278 .insert(save_name.clone(), base.clone()); 279 eprintln!("Saved character '{}'", save_name); 280 write_save_file(&save_file); 281 } 282 283 // Save animation if requested. 284 if let Some(ref save_name) = args.saveanim { 285 if animations_flat.is_empty() { 286 eprintln!("No animations to save. Use --anim to define some first."); 287 std::process::exit(1); 288 } 289 save_file 290 .animations 291 .insert(save_name.clone(), animations_flat); 292 eprintln!("Saved animation '{}'", save_name); 293 write_save_file(&save_file); 294 } 295 296 // Render or animate. 297 if animation_sequence.is_empty() { 298 print!("{}", clood::render(&base)); 299 } else { 300 animation::run_loop(&base, &animation_sequence, args.fps); 301 } 302} 303 304/// Parse --anim CLI flags into Animation specs. 305fn parse_cli_animations(args: &Args) -> Vec<Animation> { 306 args.anims 307 .iter() 308 .filter_map(|s| match Animation::parse(s) { 309 Ok(anim) => Some(anim), 310 Err(msg) => { 311 eprintln!("Warning: {}", msg); 312 None 313 } 314 }) 315 .collect() 316} 317 318/// Build a Clood from CLI arguments, randomizing any unspecified values. 319fn build_clood_from_args(args: &Args, rng: &mut impl Rng) -> Clood { 320 Clood { 321 width: args.width.unwrap_or_else(|| rng.gen_range(8..=16)), 322 height: args.height.unwrap_or_else(|| rng.gen_range(4..=8)), 323 round: args.round, 324 mood: args.mood.unwrap_or_else(|| rng.gen_range(-2..=2)), 325 glance: args.glance, 326 left_eye: EyeState { 327 offset: args.lefteye, 328 closed: args.lefteyeclosed != 0, 329 }, 330 right_eye: EyeState { 331 offset: args.righteye, 332 closed: args.righteyeclosed != 0, 333 }, 334 arm_size: args.armsize.unwrap_or_else(|| rng.gen_range(2..=4)), 335 left_arm_offset: args.leftarm, 336 right_arm_offset: args.rightarm, 337 left_arm_slope: args.leftslope, 338 right_arm_slope: args.rightslope, 339 num_legs: args.numlegs.unwrap_or_else(|| rng.gen_range(0..=4) * 2), 340 leg_size: args.legsize.unwrap_or_else(|| rng.gen_range(2..=4)), 341 body_color: args 342 .color 343 .as_deref() 344 .and_then(Color::from_hex) 345 .unwrap_or_else(|| { 346 if args.color.is_some() { 347 eprintln!("Warning: invalid --color, using random"); 348 } 349 Color::random(rng) 350 }), 351 eye_color: args 352 .eyecolor 353 .as_deref() 354 .and_then(Color::from_hex) 355 .unwrap_or_else(|| { 356 if args.eyecolor.is_some() { 357 eprintln!("Warning: invalid --eyecolor, using random"); 358 } 359 Color::random(rng) 360 }), 361 } 362}