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.

feat: save/load named animations in clood.json

- --saveanim <name>: saves current --anim specs to disk
- clood <character> <animation>: recall both from save file
- clood buddy --anim mood:-1:3 --saveanim wave: define on existing char
- Save file now has 'characters' and 'animations' sections
- AnimParam and Animation are now serializable (serde)

+104 -36
+17 -1
src/animation.rs
··· 6 6 use std::thread; 7 7 use std::time::Duration; 8 8 9 + use serde::{Deserialize, Serialize}; 10 + 9 11 use crate::clood::{self, Clood, EyeState}; 10 12 11 13 // ── Animatable parameters ─────────────────────────────────────────────────── 12 14 13 15 /// Every parameter that can be targeted by `--anim`. 16 + /// Serializes as its lowercase string name (e.g. "mood", "leftarm"). 14 17 #[derive(Debug, Clone, Copy, PartialEq, Eq)] 15 18 pub enum AnimParam { 16 19 Mood, ··· 77 80 } 78 81 } 79 82 83 + impl Serialize for AnimParam { 84 + fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> { 85 + serializer.serialize_str(&self.to_string()) 86 + } 87 + } 88 + 89 + impl<'de> Deserialize<'de> for AnimParam { 90 + fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> { 91 + let s = String::deserialize(deserializer)?; 92 + AnimParam::from_str(&s).map_err(serde::de::Error::custom) 93 + } 94 + } 95 + 80 96 // ── Animation spec ────────────────────────────────────────────────────────── 81 97 82 98 /// A single animation: ping-pong a parameter between two values. 83 - #[derive(Debug, Clone)] 99 + #[derive(Debug, Clone, Serialize, Deserialize)] 84 100 pub struct Animation { 85 101 pub param: AnimParam, 86 102 pub min: i32,
+87 -35
src/main.rs
··· 1 1 //! CLI entry point: parse arguments, build a Clood, render or animate. 2 - //! Supports saving/loading named characters from ~/.config/clood.json. 2 + //! Supports saving/loading named characters and animations from ~/.config/clood.json. 3 3 4 4 mod animation; 5 5 mod clood; ··· 11 11 12 12 use clap::Parser; 13 13 use rand::Rng; 14 + use serde::{Deserialize, Serialize}; 14 15 15 16 use animation::Animation; 16 17 use clood::{Clood, EyeState}; ··· 23 24 /// All parameters are randomized by default. Specify a flag to pin that value. 24 25 /// Animate parameters with --anim <param>:<min>:<max>, e.g. --anim mood:-1:3 25 26 /// 26 - /// Save a character with --save <name>, recall it with: clood <name> 27 + /// Save characters with --save <name>, animations with --saveanim <name>. 28 + /// Recall with: clood <character> [animation] 27 29 #[derive(Parser, Debug)] 28 30 #[command(name = "clood", version, about)] 29 31 struct Args { 30 32 /// Load a saved character by name 31 - #[arg(value_name = "NAME")] 33 + #[arg(value_name = "CHARACTER")] 32 34 name: Option<String>, 33 35 36 + /// Load a saved animation by name (requires a character name) 37 + #[arg(value_name = "ANIMATION")] 38 + anim_name: Option<String>, 39 + 34 40 /// Save the current character under this name 35 41 #[arg(long)] 36 42 save: Option<String>, 43 + 44 + /// Save the current animations under this name 45 + #[arg(long)] 46 + saveanim: Option<String>, 37 47 38 48 /// Body height in rows [default: random 4..8] 39 49 #[arg(long)] ··· 104 114 fps: u32, 105 115 } 106 116 107 - // ── Save/load ─────────────────────────────────────────────────────────────── 117 + // ── Save file ─────────────────────────────────────────────────────────────── 118 + 119 + /// Top-level structure of ~/.config/clood.json. 120 + #[derive(Debug, Default, Serialize, Deserialize)] 121 + struct SaveFile { 122 + #[serde(default)] 123 + characters: HashMap<String, Clood>, 124 + #[serde(default)] 125 + animations: HashMap<String, Vec<Animation>>, 126 + } 108 127 109 - /// Path to the saved characters file. 128 + /// Path to the saved data file. 110 129 fn config_path() -> PathBuf { 111 130 dirs::home_dir() 112 131 .unwrap_or_else(|| PathBuf::from(".")) ··· 114 133 .join("clood.json") 115 134 } 116 135 117 - /// Load all saved characters from disk. 118 - fn load_saved() -> HashMap<String, Clood> { 136 + /// Load the save file from disk. 137 + fn load_save_file() -> SaveFile { 119 138 let path = config_path(); 120 139 match fs::read_to_string(&path) { 121 140 Ok(contents) => serde_json::from_str(&contents).unwrap_or_else(|e| { 122 141 eprintln!("Warning: couldn't parse {}: {}", path.display(), e); 123 - HashMap::new() 142 + SaveFile::default() 124 143 }), 125 - Err(_) => HashMap::new(), 144 + Err(_) => SaveFile::default(), 126 145 } 127 146 } 128 147 129 - /// Save a character to disk, merging with any existing entries. 130 - fn save_character(name: &str, clood: &Clood) { 148 + /// Write the save file to disk. 149 + fn write_save_file(save: &SaveFile) { 131 150 let path = config_path(); 132 - let mut saved = load_saved(); 133 - saved.insert(name.to_string(), clood.clone()); 134 - 135 151 if let Some(parent) = path.parent() { 136 152 let _ = fs::create_dir_all(parent); 137 153 } 138 - 139 - match serde_json::to_string_pretty(&saved) { 154 + match serde_json::to_string_pretty(save) { 140 155 Ok(json) => { 141 156 if let Err(e) = fs::write(&path, json) { 142 157 eprintln!("Error: couldn't write {}: {}", path.display(), e); 143 - } else { 144 - eprintln!("Saved '{}' to {}", name, path.display()); 145 158 } 146 159 } 147 160 Err(e) => eprintln!("Error: couldn't serialize: {}", e), ··· 153 166 fn main() { 154 167 let args = Args::parse(); 155 168 let mut rng = rand::thread_rng(); 169 + let mut save_file = load_save_file(); 156 170 157 - // If a name is given, try to load it from the save file. 171 + // Resolve the character: from a saved name or from CLI args. 158 172 let base = if let Some(ref name) = args.name { 159 - let saved = load_saved(); 160 - match saved.get(name) { 173 + match save_file.characters.get(name) { 161 174 Some(clood) => clood.clone(), 162 175 None => { 163 176 eprintln!("Unknown character '{}'. Saved characters:", name); 164 - if saved.is_empty() { 177 + if save_file.characters.is_empty() { 165 178 eprintln!(" (none — use --save <name> to create one)"); 166 179 } else { 167 - for key in saved.keys() { 180 + for key in save_file.characters.keys() { 168 181 eprintln!(" {}", key); 169 182 } 170 183 } ··· 175 188 build_clood_from_args(&args, &mut rng) 176 189 }; 177 190 178 - // Save if requested. 191 + // Resolve animations: from CLI --anim flags, a saved animation name, or both. 192 + let mut animations = parse_cli_animations(&args); 193 + 194 + if let Some(ref anim_name) = args.anim_name { 195 + match save_file.animations.get(anim_name) { 196 + Some(saved_anims) => animations.extend(saved_anims.iter().cloned()), 197 + None => { 198 + eprintln!("Unknown animation '{}'. Saved animations:", anim_name); 199 + if save_file.animations.is_empty() { 200 + eprintln!(" (none — use --saveanim <name> to create one)"); 201 + } else { 202 + for key in save_file.animations.keys() { 203 + eprintln!(" {}", key); 204 + } 205 + } 206 + std::process::exit(1); 207 + } 208 + } 209 + } 210 + 211 + // Save character if requested. 179 212 if let Some(ref save_name) = args.save { 180 - save_character(save_name, &base); 213 + save_file 214 + .characters 215 + .insert(save_name.clone(), base.clone()); 216 + eprintln!("Saved character '{}'", save_name); 217 + write_save_file(&save_file); 218 + } 219 + 220 + // Save animation if requested. 221 + if let Some(ref save_name) = args.saveanim { 222 + if animations.is_empty() { 223 + eprintln!("No animations to save. Use --anim to define some first."); 224 + std::process::exit(1); 225 + } 226 + save_file 227 + .animations 228 + .insert(save_name.clone(), animations.clone()); 229 + eprintln!("Saved animation '{}'", save_name); 230 + write_save_file(&save_file); 181 231 } 182 232 183 - // Parse animation specs. 184 - let animations: Vec<Animation> = args 185 - .anims 233 + // Render or animate. 234 + if animations.is_empty() { 235 + print!("{}", clood::render(&base)); 236 + } else { 237 + animation::run_loop(&base, &animations, args.fps); 238 + } 239 + } 240 + 241 + /// Parse --anim CLI flags into Animation specs. 242 + fn parse_cli_animations(args: &Args) -> Vec<Animation> { 243 + args.anims 186 244 .iter() 187 245 .filter_map(|s| match Animation::parse(s) { 188 246 Ok(anim) => Some(anim), ··· 191 249 None 192 250 } 193 251 }) 194 - .collect(); 195 - 196 - if animations.is_empty() { 197 - print!("{}", clood::render(&base)); 198 - } else { 199 - animation::run_loop(&base, &animations, args.fps); 200 - } 252 + .collect() 201 253 } 202 254 203 255 /// Build a Clood from CLI arguments, randomizing any unspecified values.