Small library for generating claude-code like unicde block mascots, and providing animations when they do stuff
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}