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 367 lines 13 kB view raw
1//! Animation system: parameter targeting, ping-pong interpolation, and frame loop. 2 3use std::fmt; 4use std::io::{self, Write}; 5use std::str::FromStr; 6use std::thread; 7use std::time::Duration; 8 9use serde::{Deserialize, Serialize}; 10 11use crate::clood::{self, Clood, EyeState}; 12 13// ── Animatable parameters ─────────────────────────────────────────────────── 14 15/// Every parameter that can be targeted by `--anim`. 16/// Serializes as its lowercase string name (e.g. "mood", "leftarm"). 17#[derive(Debug, Clone, Copy, PartialEq, Eq)] 18pub enum AnimParam { 19 Mood, 20 Glance, 21 LeftEye, 22 RightEye, 23 LeftEyeClosed, 24 RightEyeClosed, 25 Round, 26 LeftArm, 27 RightArm, 28 LeftSlope, 29 RightSlope, 30 Height, 31 Width, 32 ArmSize, 33 LegSize, 34 NumLegs, 35} 36 37impl FromStr for AnimParam { 38 type Err = String; 39 40 fn from_str(s: &str) -> Result<Self, Self::Err> { 41 match s { 42 "mood" => Ok(Self::Mood), 43 "glance" => Ok(Self::Glance), 44 "lefteye" => Ok(Self::LeftEye), 45 "righteye" => Ok(Self::RightEye), 46 "lefteyeclosed" => Ok(Self::LeftEyeClosed), 47 "righteyeclosed" => Ok(Self::RightEyeClosed), 48 "round" => Ok(Self::Round), 49 "leftarm" => Ok(Self::LeftArm), 50 "rightarm" => Ok(Self::RightArm), 51 "leftslope" => Ok(Self::LeftSlope), 52 "rightslope" => Ok(Self::RightSlope), 53 "height" => Ok(Self::Height), 54 "width" => Ok(Self::Width), 55 "armsize" => Ok(Self::ArmSize), 56 "legsize" => Ok(Self::LegSize), 57 "numlegs" => Ok(Self::NumLegs), 58 _ => Err(format!( 59 "unknown parameter '{}'. Valid: mood, glance, lefteye, righteye, \ 60 lefteyeclosed, righteyeclosed, round, leftarm, rightarm, \ 61 leftslope, rightslope, height, width, armsize, legsize, numlegs", 62 s 63 )), 64 } 65 } 66} 67 68impl fmt::Display for AnimParam { 69 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 70 let name = match self { 71 Self::Mood => "mood", 72 Self::Glance => "glance", 73 Self::LeftEye => "lefteye", 74 Self::RightEye => "righteye", 75 Self::LeftEyeClosed => "lefteyeclosed", 76 Self::RightEyeClosed => "righteyeclosed", 77 Self::Round => "round", 78 Self::LeftArm => "leftarm", 79 Self::RightArm => "rightarm", 80 Self::LeftSlope => "leftslope", 81 Self::RightSlope => "rightslope", 82 Self::Height => "height", 83 Self::Width => "width", 84 Self::ArmSize => "armsize", 85 Self::LegSize => "legsize", 86 Self::NumLegs => "numlegs", 87 }; 88 write!(f, "{}", name) 89 } 90} 91 92impl Serialize for AnimParam { 93 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> { 94 serializer.serialize_str(&self.to_string()) 95 } 96} 97 98impl<'de> Deserialize<'de> for AnimParam { 99 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> { 100 let s = String::deserialize(deserializer)?; 101 AnimParam::from_str(&s).map_err(serde::de::Error::custom) 102 } 103} 104 105// ── Animation spec ────────────────────────────────────────────────────────── 106 107/// A single animation: ping-pong a parameter between two values. 108#[derive(Debug, Clone, Serialize, Deserialize)] 109pub struct Animation { 110 pub param: AnimParam, 111 pub min: i32, 112 pub max: i32, 113} 114 115impl Animation { 116 /// Parse "param:min:max" (e.g. "mood:-1:3"). 117 /// The last colon separates min from max, allowing negative min values. 118 pub fn parse(s: &str) -> Result<Self, String> { 119 let first_colon = s 120 .find(':') 121 .ok_or_else(|| format!("expected param:min:max, got '{}'", s))?; 122 123 let param_str = &s[..first_colon]; 124 let range_str = &s[first_colon + 1..]; 125 126 let last_colon = range_str 127 .rfind(':') 128 .ok_or_else(|| format!("expected min:max in '{}', got '{}'", s, range_str))?; 129 130 let param = AnimParam::from_str(param_str)?; 131 let min = range_str[..last_colon] 132 .parse::<i32>() 133 .map_err(|e| format!("invalid min value in '{}': {}", s, e))?; 134 let max = range_str[last_colon + 1..] 135 .parse::<i32>() 136 .map_err(|e| format!("invalid max value in '{}': {}", s, e))?; 137 138 Ok(Animation { param, min, max }) 139 } 140 141 /// Compute the value at a given tick, ping-ponging between min and max. 142 /// Tick 0 → min, ticks increase toward max, then bounce back. 143 pub fn value_at(&self, tick: usize) -> i32 { 144 let range = self.min.abs_diff(self.max) as usize; 145 if range == 0 { 146 return self.min; 147 } 148 let direction = if self.max >= self.min { 1i32 } else { -1 }; 149 let cycle_length = range * 2; 150 let position = tick % cycle_length; 151 152 if position <= range { 153 self.min + (position as i32) * direction 154 } else { 155 self.max - ((position - range) as i32) * direction 156 } 157 } 158} 159 160// ── Frame state ───────────────────────────────────────────────────────────── 161 162/// Mutable snapshot of all animatable parameter values for one frame. 163/// Starts from base values, then animations override individual fields. 164struct FrameState { 165 mood: i32, 166 glance: i32, 167 left_eye_offset: i32, 168 right_eye_offset: i32, 169 left_eye_closed: i32, 170 right_eye_closed: i32, 171 round: i32, 172 left_arm_offset: i32, 173 right_arm_offset: i32, 174 left_arm_slope: i32, 175 right_arm_slope: i32, 176 height: usize, 177 width: usize, 178 arm_size: usize, 179 leg_size: usize, 180 num_legs: usize, 181} 182 183impl FrameState { 184 /// Initialize from a base Clood configuration. 185 fn from_clood(c: &Clood) -> Self { 186 FrameState { 187 mood: c.mood, 188 glance: c.glance, 189 left_eye_offset: c.left_eye.offset, 190 right_eye_offset: c.right_eye.offset, 191 left_eye_closed: if c.left_eye.closed { 1 } else { 0 }, 192 right_eye_closed: if c.right_eye.closed { 1 } else { 0 }, 193 round: c.round as i32, 194 left_arm_offset: c.left_arm_offset, 195 right_arm_offset: c.right_arm_offset, 196 left_arm_slope: c.left_arm_slope, 197 right_arm_slope: c.right_arm_slope, 198 height: c.height, 199 width: c.width, 200 arm_size: c.arm_size, 201 leg_size: c.leg_size, 202 num_legs: c.num_legs, 203 } 204 } 205 206 /// Apply an animation's current value to the appropriate field. 207 fn apply(&mut self, param: AnimParam, value: i32) { 208 match param { 209 AnimParam::Mood => self.mood = value, 210 AnimParam::Glance => self.glance = value, 211 AnimParam::LeftEye => self.left_eye_offset = value, 212 AnimParam::RightEye => self.right_eye_offset = value, 213 AnimParam::LeftEyeClosed => self.left_eye_closed = value, 214 AnimParam::RightEyeClosed => self.right_eye_closed = value, 215 AnimParam::Round => self.round = value, 216 AnimParam::LeftArm => self.left_arm_offset = value, 217 AnimParam::RightArm => self.right_arm_offset = value, 218 AnimParam::LeftSlope => self.left_arm_slope = value, 219 AnimParam::RightSlope => self.right_arm_slope = value, 220 AnimParam::Height => self.height = value.max(3) as usize, 221 AnimParam::Width => self.width = value.max(4) as usize, 222 AnimParam::ArmSize => self.arm_size = value.max(0) as usize, 223 AnimParam::LegSize => self.leg_size = value.max(0) as usize, 224 AnimParam::NumLegs => self.num_legs = value.max(0) as usize, 225 } 226 } 227 228 /// Convert back to a Clood, preserving colors from the base. 229 fn to_clood(&self, base: &Clood) -> Clood { 230 Clood { 231 width: self.width, 232 height: self.height, 233 round: self.round.max(0) as usize, 234 mood: self.mood, 235 glance: self.glance, 236 left_eye: EyeState { 237 offset: self.left_eye_offset, 238 closed: self.left_eye_closed != 0, 239 }, 240 right_eye: EyeState { 241 offset: self.right_eye_offset, 242 closed: self.right_eye_closed != 0, 243 }, 244 arm_size: self.arm_size, 245 left_arm_offset: self.left_arm_offset, 246 right_arm_offset: self.right_arm_offset, 247 left_arm_slope: self.left_arm_slope, 248 right_arm_slope: self.right_arm_slope, 249 num_legs: self.num_legs, 250 leg_size: self.leg_size, 251 body_color: base.body_color, 252 eye_color: base.eye_color, 253 } 254 } 255} 256 257// ── Terminal helpers ───────────────────────────────────────────────────────── 258 259/// ANSI escape: hide the cursor. 260fn hide_cursor() { 261 print!("\x1b[?25l"); 262 let _ = io::stdout().flush(); 263} 264 265/// ANSI escape: show the cursor. 266fn show_cursor() { 267 print!("\x1b[?25h"); 268 let _ = io::stdout().flush(); 269} 270 271/// Write a frame to stdout, moving the cursor up to overwrite the previous frame. 272/// Handles the case where the previous frame was taller (clears leftover lines). 273fn write_frame(frame: &str, current_height: usize, prev_height: usize) { 274 if prev_height > 0 { 275 print!("\x1b[{}A", prev_height); 276 } 277 278 if prev_height > current_height { 279 for line in frame.lines() { 280 print!("{}\x1b[K\n", line); 281 } 282 for _ in 0..(prev_height - current_height) { 283 print!("\x1b[K\n"); 284 } 285 print!("\x1b[{}A", prev_height - current_height); 286 } else { 287 for line in frame.lines() { 288 print!("{}\x1b[K\n", line); 289 } 290 } 291 292 let _ = io::stdout().flush(); 293} 294 295// ── Animation loop ────────────────────────────────────────────────────────── 296 297/// Compute the cycle length for a set of animations (LCM of all ping-pong periods). 298fn cycle_length(animations: &[Animation]) -> usize { 299 let mut len = 1usize; 300 for anim in animations { 301 let range = anim.min.abs_diff(anim.max) as usize; 302 if range > 0 { 303 let period = range * 2; 304 len = lcm(len, period); 305 } 306 } 307 len 308} 309 310fn gcd(mut a: usize, mut b: usize) -> usize { 311 while b != 0 { 312 let t = b; 313 b = a % b; 314 a = t; 315 } 316 a 317} 318 319fn lcm(a: usize, b: usize) -> usize { 320 if a == 0 || b == 0 { 1 } else { a / gcd(a, b) * b } 321} 322 323/// Run an infinite animation loop, cycling through a sequence of animation sets. 324/// Each set plays for one full ping-pong cycle before advancing to the next. 325pub fn run_loop(base: &Clood, sequence: &[Vec<Animation>], fps: u32) -> ! { 326 let frame_delay = Duration::from_millis(1000 / fps.max(1) as u64); 327 let mut tick: usize = 0; 328 let mut prev_height: usize = 0; 329 let mut seq_index: usize = 0; 330 331 // Precompute cycle lengths for each animation set. 332 let cycle_lengths: Vec<usize> = sequence.iter().map(|s| cycle_length(s)).collect(); 333 334 hide_cursor(); 335 336 ctrlc::set_handler(move || { 337 show_cursor(); 338 std::process::exit(0); 339 }) 340 .expect("Failed to set Ctrl-C handler"); 341 342 loop { 343 let animations = &sequence[seq_index]; 344 345 let mut state = FrameState::from_clood(base); 346 for anim in animations { 347 state.apply(anim.param, anim.value_at(tick)); 348 } 349 let frame_clood = state.to_clood(base); 350 351 let frame = clood::render(&frame_clood); 352 let height = frame_clood.sanitized().rendered_height(); 353 354 write_frame(&frame, height, prev_height); 355 356 prev_height = height; 357 tick = tick.wrapping_add(1); 358 359 // Advance to next animation set after one full cycle. 360 if sequence.len() > 1 && tick >= cycle_lengths[seq_index] { 361 seq_index = (seq_index + 1) % sequence.len(); 362 tick = 0; 363 } 364 365 thread::sleep(frame_delay); 366 } 367}