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: add --anim parameter for ping-pong animation loops

New flags:
- --anim <param>:<min>:<max> — animate a parameter in a ping-pong
loop between min and max. Can be specified multiple times.
Supported params: mood, leftarm, rightarm, height, width,
armsize, legsize, numlegs
- --fps <n> — animation frame rate (default: 4)

Examples:
clood --anim mood:-1:3 # eyes bob up and down
clood --anim leftarm:-1:2 --anim rightarm:2:-1 # waving arms
clood --anim mood:-1:1 --anim leftarm:0:3 --fps 6

Uses cursor movement to redraw in-place. Hides cursor during
animation and restores it on Ctrl-C.

+311 -42
+72
Cargo.lock
··· 53 53 ] 54 54 55 55 [[package]] 56 + name = "bitflags" 57 + version = "2.11.0" 58 + source = "registry+https://github.com/rust-lang/crates.io-index" 59 + checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" 60 + 61 + [[package]] 62 + name = "block2" 63 + version = "0.6.2" 64 + source = "registry+https://github.com/rust-lang/crates.io-index" 65 + checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" 66 + dependencies = [ 67 + "objc2", 68 + ] 69 + 70 + [[package]] 56 71 name = "cfg-if" 57 72 version = "1.0.4" 58 73 source = "registry+https://github.com/rust-lang/crates.io-index" 59 74 checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" 60 75 61 76 [[package]] 77 + name = "cfg_aliases" 78 + version = "0.2.1" 79 + source = "registry+https://github.com/rust-lang/crates.io-index" 80 + checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" 81 + 82 + [[package]] 62 83 name = "clap" 63 84 version = "4.6.0" 64 85 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 103 124 version = "0.1.0" 104 125 dependencies = [ 105 126 "clap", 127 + "ctrlc", 106 128 "rand", 107 129 ] 108 130 ··· 113 135 checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" 114 136 115 137 [[package]] 138 + name = "ctrlc" 139 + version = "3.5.2" 140 + source = "registry+https://github.com/rust-lang/crates.io-index" 141 + checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162" 142 + dependencies = [ 143 + "dispatch2", 144 + "nix", 145 + "windows-sys", 146 + ] 147 + 148 + [[package]] 149 + name = "dispatch2" 150 + version = "0.3.1" 151 + source = "registry+https://github.com/rust-lang/crates.io-index" 152 + checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" 153 + dependencies = [ 154 + "bitflags", 155 + "block2", 156 + "libc", 157 + "objc2", 158 + ] 159 + 160 + [[package]] 116 161 name = "getrandom" 117 162 version = "0.2.17" 118 163 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 140 185 version = "0.2.183" 141 186 source = "registry+https://github.com/rust-lang/crates.io-index" 142 187 checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" 188 + 189 + [[package]] 190 + name = "nix" 191 + version = "0.31.2" 192 + source = "registry+https://github.com/rust-lang/crates.io-index" 193 + checksum = "5d6d0705320c1e6ba1d912b5e37cf18071b6c2e9b7fa8215a1e8a7651966f5d3" 194 + dependencies = [ 195 + "bitflags", 196 + "cfg-if", 197 + "cfg_aliases", 198 + "libc", 199 + ] 200 + 201 + [[package]] 202 + name = "objc2" 203 + version = "0.6.4" 204 + source = "registry+https://github.com/rust-lang/crates.io-index" 205 + checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" 206 + dependencies = [ 207 + "objc2-encode", 208 + ] 209 + 210 + [[package]] 211 + name = "objc2-encode" 212 + version = "4.1.0" 213 + source = "registry+https://github.com/rust-lang/crates.io-index" 214 + checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" 143 215 144 216 [[package]] 145 217 name = "once_cell_polyfill"
+1
Cargo.toml
··· 6 6 7 7 [dependencies] 8 8 clap = { version = "4", features = ["derive"] } 9 + ctrlc = "3" 9 10 rand = "0.8"
+238 -42
src/main.rs
··· 1 1 use clap::Parser; 2 2 use rand::Rng; 3 + use std::io::{self, Write}; 4 + use std::thread; 5 + use std::time::Duration; 3 6 4 7 /// Generate a cute unicode block character (clood) in your terminal. 5 8 /// All parameters are randomized by default (between 1x and 2x base values). 6 9 /// Specify a flag explicitly to pin that value. 10 + /// 11 + /// Animate parameters with --anim <param>:<min>:<max>, e.g. --anim mood:-1:3 12 + /// Multiple --anim flags can be combined. 7 13 #[derive(Parser, Debug)] 8 14 #[command(name = "clood", version, about)] 9 15 struct Args { ··· 39 45 #[arg(long, allow_hyphen_values = true)] 40 46 mood: Option<i32>, 41 47 42 - /// Vertical offset of left arm from eye row. Positive = higher, negative = lower. [default: 0] 48 + /// Vertical offset of left arm from default (one row below eyes). Positive = higher, negative = lower. [default: 0] 43 49 #[arg(long, default_value_t = 0, allow_hyphen_values = true)] 44 50 leftarm: i32, 45 51 46 - /// Vertical offset of right arm from eye row. Positive = higher, negative = lower. [default: 0] 52 + /// Vertical offset of right arm from default (one row below eyes). Positive = higher, negative = lower. [default: 0] 47 53 #[arg(long, default_value_t = 0, allow_hyphen_values = true)] 48 54 rightarm: i32, 55 + 56 + /// Animate a parameter: <param>:<min>:<max> (ping-pong loop). Can be repeated. 57 + /// Supported params: mood, leftarm, rightarm, height, width, armsize, legsize, numlegs 58 + #[arg(long = "anim", value_name = "PARAM:MIN:MAX", action = clap::ArgAction::Append)] 59 + anims: Vec<String>, 60 + 61 + /// Animation speed in frames per second [default: 4] 62 + #[arg(long, default_value_t = 4)] 63 + fps: u32, 64 + } 65 + 66 + /// Parsed animation spec: which parameter to cycle, and between what values. 67 + #[derive(Debug, Clone)] 68 + struct Anim { 69 + param: String, 70 + min: i32, 71 + max: i32, 72 + } 73 + 74 + impl Anim { 75 + fn parse(s: &str) -> Option<Anim> { 76 + // Format: param:min:max (e.g. mood:-1:3) 77 + let parts: Vec<&str> = s.splitn(2, ':').collect(); 78 + if parts.len() != 2 { 79 + return None; 80 + } 81 + let param = parts[0].to_string(); 82 + // The rest is min:max, but min could be negative like -1:3 83 + let range_str = parts[1]; 84 + // Find the last colon to split min and max 85 + let last_colon = range_str.rfind(':')?; 86 + let min_str = &range_str[..last_colon]; 87 + let max_str = &range_str[last_colon + 1..]; 88 + let min = min_str.parse::<i32>().ok()?; 89 + let max = max_str.parse::<i32>().ok()?; 90 + Some(Anim { param, min, max }) 91 + } 92 + 93 + /// Get the value at a given tick (ping-pong between min and max). 94 + fn value_at(&self, tick: usize) -> i32 { 95 + let range = (self.max - self.min).unsigned_abs() as usize; 96 + if range == 0 { 97 + return self.min; 98 + } 99 + let cycle = range * 2; 100 + let pos = tick % cycle; 101 + if pos <= range { 102 + self.min + pos as i32 * (self.max - self.min).signum() 103 + } else { 104 + self.max - (pos - range) as i32 * (self.max - self.min).signum() 105 + } 106 + } 107 + } 108 + 109 + /// All resolved parameters for a single frame of rendering. 110 + struct CloodParams { 111 + width: usize, 112 + height: usize, 113 + armsize: usize, 114 + numlegs: usize, 115 + legsize: usize, 116 + mood: i32, 117 + leftarm: i32, 118 + rightarm: i32, 119 + body_color: (u8, u8, u8), 120 + eye_color: (u8, u8, u8), 49 121 } 50 122 51 123 fn parse_hex_color(hex: &str) -> (u8, u8, u8) { ··· 64 136 format!("\x1b[38;2;{};{};{}m\u{2588}\x1b[0m", r, g, b) 65 137 } 66 138 67 - /// Build a leg row mask. If numlegs > width/2, legs extend 1 col outside the 68 - /// body on each side for extra space. Returns a vec of bools and the overhang amount. 69 139 fn leg_mask(width: usize, numlegs: usize) -> (Vec<bool>, usize) { 70 140 let overhang = if numlegs > width / 2 { 1 } else { 0 }; 71 141 let total = width + 2 * overhang; ··· 74 144 return (mask, overhang); 75 145 } 76 146 77 - // Full span: 0 through total-1 78 147 let span = total; 79 148 80 149 if numlegs == 1 { ··· 82 151 return (mask, overhang); 83 152 } 84 153 85 - // Distribute numlegs evenly across the full span 86 154 for i in 0..numlegs { 87 155 let col = (i * (span - 1)) / (numlegs - 1); 88 156 if col < total { ··· 93 161 (mask, overhang) 94 162 } 95 163 96 - fn main() { 97 - let args = Args::parse(); 98 - let mut rng = rand::thread_rng(); 164 + fn render(p: &CloodParams) -> String { 165 + let width = p.width.max(4); 166 + let height = p.height.max(3); 99 167 100 - // Defaults: random within range 101 - let width = args.width.unwrap_or_else(|| rng.gen_range(8..=16)).max(4); 102 - let height = args.height.unwrap_or_else(|| rng.gen_range(4..=8)).max(3); 103 - let armsize = args.armsize.unwrap_or_else(|| rng.gen_range(2..=4)); 104 - let numlegs = args.numlegs.unwrap_or_else(|| rng.gen_range(0..=4) * 2); // even: 0,2,4,6,8 105 - let legsize = args.legsize.unwrap_or_else(|| rng.gen_range(2..=4)); 106 - let mood = args.mood.unwrap_or_else(|| rng.gen_range(-2..=2)); 107 - 108 - let (cr, cg, cb) = args.color 109 - .as_deref() 110 - .map(parse_hex_color) 111 - .unwrap_or_else(|| random_color(&mut rng)); 112 - 113 - let (er, eg, eb) = args.eyecolor 114 - .as_deref() 115 - .map(parse_hex_color) 116 - .unwrap_or_else(|| random_color(&mut rng)); 117 - 118 - let body_block = colored_block(cr, cg, cb); 119 - let eye_block = colored_block(er, eg, eb); 168 + let body_block = colored_block(p.body_color.0, p.body_color.1, p.body_color.2); 169 + let eye_block = colored_block(p.eye_color.0, p.eye_color.1, p.eye_color.2); 120 170 let space = " "; 121 171 122 - // Eye row: baseline is halfway up the body (from bottom), mood shifts it 123 172 let baseline_row = (height as i32) / 2; 124 - let eye_row = (baseline_row - mood).clamp(1, height as i32 - 2) as usize; 173 + let eye_row = (baseline_row - p.mood).clamp(1, height as i32 - 2) as usize; 125 174 126 - // Eye positions: place two eyes symmetrically at ~1/3 and ~2/3 of width 127 175 let eye_left = width / 3; 128 176 let eye_right = width - 1 - (width / 3); 129 177 130 - // Arm rows: default one row below eyes, individually offset 131 178 let arm_baseline = (eye_row + 1).min(height - 1); 132 - let left_arm_row = (arm_baseline as i32 - args.leftarm).clamp(0, height as i32 - 1) as usize; 133 - let right_arm_row = (arm_baseline as i32 - args.rightarm).clamp(0, height as i32 - 1) as usize; 179 + let left_arm_row = (arm_baseline as i32 - p.leftarm).clamp(0, height as i32 - 1) as usize; 180 + let right_arm_row = (arm_baseline as i32 - p.rightarm).clamp(0, height as i32 - 1) as usize; 134 181 135 - let arm_pad = armsize; 182 + let arm_pad = p.armsize; 136 183 let total_width_chars = arm_pad + width + arm_pad; 137 184 138 185 let mut output = String::new(); 139 186 140 - // -- Body rows (with arms and eyes) -- 141 187 for row in 0..height { 142 188 for col in 0..total_width_chars { 143 189 let in_left_arm = col < arm_pad; ··· 160 206 output.push('\n'); 161 207 } 162 208 163 - // -- Leg rows -- 164 - let (legs, overhang) = leg_mask(width, numlegs); 165 - let effective_legsize = if numlegs == 0 { 0 } else { legsize }; 209 + let (legs, overhang) = leg_mask(width, p.numlegs); 210 + let effective_legsize = if p.numlegs == 0 { 0 } else { p.legsize }; 166 211 167 212 for _row in 0..effective_legsize { 168 - // Left padding: arm area minus overhang (legs can stick out 1 col past body) 169 213 let leg_pad = if arm_pad >= overhang { arm_pad - overhang } else { 0 }; 170 214 for _ in 0..leg_pad { 171 215 output.push_str(space); ··· 180 224 output.push('\n'); 181 225 } 182 226 183 - print!("{}", output); 227 + output 228 + } 229 + 230 + /// Calculate total rendered height for cursor movement. 231 + fn total_height(p: &CloodParams) -> usize { 232 + let height = p.height.max(3); 233 + let leg_rows = if p.numlegs == 0 { 0 } else { p.legsize }; 234 + height + leg_rows 235 + } 236 + 237 + fn main() { 238 + let args = Args::parse(); 239 + let mut rng = rand::thread_rng(); 240 + 241 + // Resolve base values (randomized if not specified) 242 + let base_width = args.width.unwrap_or_else(|| rng.gen_range(8..=16)).max(4); 243 + let base_height = args.height.unwrap_or_else(|| rng.gen_range(4..=8)).max(3); 244 + let base_armsize = args.armsize.unwrap_or_else(|| rng.gen_range(2..=4)); 245 + let base_numlegs = args.numlegs.unwrap_or_else(|| rng.gen_range(0..=4) * 2); 246 + let base_legsize = args.legsize.unwrap_or_else(|| rng.gen_range(2..=4)); 247 + let base_mood = args.mood.unwrap_or_else(|| rng.gen_range(-2..=2)); 248 + let base_leftarm = args.leftarm; 249 + let base_rightarm = args.rightarm; 250 + 251 + let body_color = args.color 252 + .as_deref() 253 + .map(parse_hex_color) 254 + .unwrap_or_else(|| random_color(&mut rng)); 255 + 256 + let eye_color = args.eyecolor 257 + .as_deref() 258 + .map(parse_hex_color) 259 + .unwrap_or_else(|| random_color(&mut rng)); 260 + 261 + // Parse animations 262 + let anims: Vec<Anim> = args.anims.iter() 263 + .filter_map(|s| { 264 + Anim::parse(s).or_else(|| { 265 + eprintln!("Warning: invalid anim spec '{}', expected param:min:max", s); 266 + None 267 + }) 268 + }) 269 + .collect(); 270 + 271 + if anims.is_empty() { 272 + // Static render 273 + let params = CloodParams { 274 + width: base_width, 275 + height: base_height, 276 + armsize: base_armsize, 277 + numlegs: base_numlegs, 278 + legsize: base_legsize, 279 + mood: base_mood, 280 + leftarm: base_leftarm, 281 + rightarm: base_rightarm, 282 + body_color, 283 + eye_color, 284 + }; 285 + print!("{}", render(&params)); 286 + return; 287 + } 288 + 289 + // Animation loop 290 + let frame_delay = Duration::from_millis(1000 / args.fps.max(1) as u64); 291 + let mut tick: usize = 0; 292 + let mut prev_height: usize = 0; 293 + 294 + // Hide cursor 295 + print!("\x1b[?25l"); 296 + let _ = io::stdout().flush(); 297 + 298 + // Handle Ctrl-C to restore cursor 299 + ctrlc::set_handler(move || { 300 + print!("\x1b[?25h"); 301 + let _ = io::stdout().flush(); 302 + std::process::exit(0); 303 + }).expect("Failed to set Ctrl-C handler"); 304 + 305 + loop { 306 + // Apply animation overrides 307 + let mut mood = base_mood; 308 + let mut leftarm = base_leftarm; 309 + let mut rightarm = base_rightarm; 310 + let mut height = base_height; 311 + let mut width = base_width; 312 + let mut armsize = base_armsize; 313 + let mut legsize = base_legsize; 314 + let mut numlegs = base_numlegs; 315 + 316 + for anim in &anims { 317 + let v = anim.value_at(tick); 318 + match anim.param.as_str() { 319 + "mood" => mood = v, 320 + "leftarm" => leftarm = v, 321 + "rightarm" => rightarm = v, 322 + "height" => height = v.max(3) as usize, 323 + "width" => width = v.max(4) as usize, 324 + "armsize" => armsize = v.max(0) as usize, 325 + "legsize" => legsize = v.max(0) as usize, 326 + "numlegs" => numlegs = v.max(0) as usize, 327 + _ => {} 328 + } 329 + } 330 + 331 + let params = CloodParams { 332 + width, 333 + height, 334 + armsize, 335 + numlegs, 336 + legsize, 337 + mood, 338 + leftarm, 339 + rightarm, 340 + body_color, 341 + eye_color, 342 + }; 343 + 344 + // Move cursor up to overwrite previous frame 345 + if prev_height > 0 { 346 + print!("\x1b[{}A", prev_height); 347 + } 348 + 349 + let frame = render(&params); 350 + let h = total_height(&params); 351 + 352 + // Clear any leftover lines if previous frame was taller 353 + if prev_height > h { 354 + let frame_lines: Vec<&str> = frame.lines().collect(); 355 + let mut padded = String::new(); 356 + for line in &frame_lines { 357 + padded.push_str(line); 358 + padded.push_str("\x1b[K"); // clear to end of line 359 + padded.push('\n'); 360 + } 361 + for _ in 0..(prev_height - h) { 362 + padded.push_str("\x1b[K\n"); // clear leftover lines 363 + } 364 + // Move back up the extra lines 365 + padded.push_str(&format!("\x1b[{}A", prev_height - h)); 366 + print!("{}", padded); 367 + } else { 368 + // Just clear to end of each line 369 + for line in frame.lines() { 370 + print!("{}\x1b[K\n", line); 371 + } 372 + } 373 + 374 + let _ = io::stdout().flush(); 375 + prev_height = h; 376 + tick += 1; 377 + 378 + thread::sleep(frame_delay); 379 + } 184 380 }