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.

refactor: split into modules - color, clood, animation

- src/color.rs: Color struct (hex parsing, random, block rendering)
- src/clood.rs: Clood + EyeState types, render(), leg layout
- src/animation.rs: AnimParam enum, Animation, FrameState, run_loop()
- src/main.rs: CLI arg parsing and wiring

+620 -593
+291
src/animation.rs
··· 1 + //! Animation system: parameter targeting, ping-pong interpolation, and frame loop. 2 + 3 + use std::fmt; 4 + use std::io::{self, Write}; 5 + use std::str::FromStr; 6 + use std::thread; 7 + use std::time::Duration; 8 + 9 + use crate::clood::{self, Clood, EyeState}; 10 + 11 + // ── Animatable parameters ─────────────────────────────────────────────────── 12 + 13 + /// Every parameter that can be targeted by `--anim`. 14 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 15 + pub enum AnimParam { 16 + Mood, 17 + LeftEye, 18 + RightEye, 19 + LeftEyeClosed, 20 + RightEyeClosed, 21 + Round, 22 + LeftArm, 23 + RightArm, 24 + Height, 25 + Width, 26 + ArmSize, 27 + LegSize, 28 + NumLegs, 29 + } 30 + 31 + impl FromStr for AnimParam { 32 + type Err = String; 33 + 34 + fn from_str(s: &str) -> Result<Self, Self::Err> { 35 + match s { 36 + "mood" => Ok(Self::Mood), 37 + "lefteye" => Ok(Self::LeftEye), 38 + "righteye" => Ok(Self::RightEye), 39 + "lefteyeclosed" => Ok(Self::LeftEyeClosed), 40 + "righteyeclosed" => Ok(Self::RightEyeClosed), 41 + "round" => Ok(Self::Round), 42 + "leftarm" => Ok(Self::LeftArm), 43 + "rightarm" => Ok(Self::RightArm), 44 + "height" => Ok(Self::Height), 45 + "width" => Ok(Self::Width), 46 + "armsize" => Ok(Self::ArmSize), 47 + "legsize" => Ok(Self::LegSize), 48 + "numlegs" => Ok(Self::NumLegs), 49 + _ => Err(format!( 50 + "unknown parameter '{}'. Valid: mood, lefteye, righteye, \ 51 + lefteyeclosed, righteyeclosed, round, leftarm, rightarm, \ 52 + height, width, armsize, legsize, numlegs", 53 + s 54 + )), 55 + } 56 + } 57 + } 58 + 59 + impl fmt::Display for AnimParam { 60 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 61 + let name = match self { 62 + Self::Mood => "mood", 63 + Self::LeftEye => "lefteye", 64 + Self::RightEye => "righteye", 65 + Self::LeftEyeClosed => "lefteyeclosed", 66 + Self::RightEyeClosed => "righteyeclosed", 67 + Self::Round => "round", 68 + Self::LeftArm => "leftarm", 69 + Self::RightArm => "rightarm", 70 + Self::Height => "height", 71 + Self::Width => "width", 72 + Self::ArmSize => "armsize", 73 + Self::LegSize => "legsize", 74 + Self::NumLegs => "numlegs", 75 + }; 76 + write!(f, "{}", name) 77 + } 78 + } 79 + 80 + // ── Animation spec ────────────────────────────────────────────────────────── 81 + 82 + /// A single animation: ping-pong a parameter between two values. 83 + #[derive(Debug, Clone)] 84 + pub struct Animation { 85 + pub param: AnimParam, 86 + pub min: i32, 87 + pub max: i32, 88 + } 89 + 90 + impl Animation { 91 + /// Parse "param:min:max" (e.g. "mood:-1:3"). 92 + /// The last colon separates min from max, allowing negative min values. 93 + pub fn parse(s: &str) -> Result<Self, String> { 94 + let first_colon = s 95 + .find(':') 96 + .ok_or_else(|| format!("expected param:min:max, got '{}'", s))?; 97 + 98 + let param_str = &s[..first_colon]; 99 + let range_str = &s[first_colon + 1..]; 100 + 101 + let last_colon = range_str 102 + .rfind(':') 103 + .ok_or_else(|| format!("expected min:max in '{}', got '{}'", s, range_str))?; 104 + 105 + let param = AnimParam::from_str(param_str)?; 106 + let min = range_str[..last_colon] 107 + .parse::<i32>() 108 + .map_err(|e| format!("invalid min value in '{}': {}", s, e))?; 109 + let max = range_str[last_colon + 1..] 110 + .parse::<i32>() 111 + .map_err(|e| format!("invalid max value in '{}': {}", s, e))?; 112 + 113 + Ok(Animation { param, min, max }) 114 + } 115 + 116 + /// Compute the value at a given tick, ping-ponging between min and max. 117 + /// Tick 0 → min, ticks increase toward max, then bounce back. 118 + pub fn value_at(&self, tick: usize) -> i32 { 119 + let range = self.min.abs_diff(self.max) as usize; 120 + if range == 0 { 121 + return self.min; 122 + } 123 + let direction = if self.max >= self.min { 1i32 } else { -1 }; 124 + let cycle_length = range * 2; 125 + let position = tick % cycle_length; 126 + 127 + if position <= range { 128 + self.min + (position as i32) * direction 129 + } else { 130 + self.max - ((position - range) as i32) * direction 131 + } 132 + } 133 + } 134 + 135 + // ── Frame state ───────────────────────────────────────────────────────────── 136 + 137 + /// Mutable snapshot of all animatable parameter values for one frame. 138 + /// Starts from base values, then animations override individual fields. 139 + struct FrameState { 140 + mood: i32, 141 + left_eye_offset: i32, 142 + right_eye_offset: i32, 143 + left_eye_closed: i32, 144 + right_eye_closed: i32, 145 + round: i32, 146 + left_arm_offset: i32, 147 + right_arm_offset: i32, 148 + height: usize, 149 + width: usize, 150 + arm_size: usize, 151 + leg_size: usize, 152 + num_legs: usize, 153 + } 154 + 155 + impl FrameState { 156 + /// Initialize from a base Clood configuration. 157 + fn from_clood(c: &Clood) -> Self { 158 + FrameState { 159 + mood: c.mood, 160 + left_eye_offset: c.left_eye.offset, 161 + right_eye_offset: c.right_eye.offset, 162 + left_eye_closed: if c.left_eye.closed { 1 } else { 0 }, 163 + right_eye_closed: if c.right_eye.closed { 1 } else { 0 }, 164 + round: c.round as i32, 165 + left_arm_offset: c.left_arm_offset, 166 + right_arm_offset: c.right_arm_offset, 167 + height: c.height, 168 + width: c.width, 169 + arm_size: c.arm_size, 170 + leg_size: c.leg_size, 171 + num_legs: c.num_legs, 172 + } 173 + } 174 + 175 + /// Apply an animation's current value to the appropriate field. 176 + fn apply(&mut self, param: AnimParam, value: i32) { 177 + match param { 178 + AnimParam::Mood => self.mood = value, 179 + AnimParam::LeftEye => self.left_eye_offset = value, 180 + AnimParam::RightEye => self.right_eye_offset = value, 181 + AnimParam::LeftEyeClosed => self.left_eye_closed = value, 182 + AnimParam::RightEyeClosed => self.right_eye_closed = value, 183 + AnimParam::Round => self.round = value, 184 + AnimParam::LeftArm => self.left_arm_offset = value, 185 + AnimParam::RightArm => self.right_arm_offset = value, 186 + AnimParam::Height => self.height = value.max(3) as usize, 187 + AnimParam::Width => self.width = value.max(4) as usize, 188 + AnimParam::ArmSize => self.arm_size = value.max(0) as usize, 189 + AnimParam::LegSize => self.leg_size = value.max(0) as usize, 190 + AnimParam::NumLegs => self.num_legs = value.max(0) as usize, 191 + } 192 + } 193 + 194 + /// Convert back to a Clood, preserving colors from the base. 195 + fn to_clood(&self, base: &Clood) -> Clood { 196 + Clood { 197 + width: self.width, 198 + height: self.height, 199 + round: self.round.max(0) as usize, 200 + mood: self.mood, 201 + left_eye: EyeState { 202 + offset: self.left_eye_offset, 203 + closed: self.left_eye_closed != 0, 204 + }, 205 + right_eye: EyeState { 206 + offset: self.right_eye_offset, 207 + closed: self.right_eye_closed != 0, 208 + }, 209 + arm_size: self.arm_size, 210 + left_arm_offset: self.left_arm_offset, 211 + right_arm_offset: self.right_arm_offset, 212 + num_legs: self.num_legs, 213 + leg_size: self.leg_size, 214 + body_color: base.body_color, 215 + eye_color: base.eye_color, 216 + } 217 + } 218 + } 219 + 220 + // ── Terminal helpers ───────────────────────────────────────────────────────── 221 + 222 + /// ANSI escape: hide the cursor. 223 + fn hide_cursor() { 224 + print!("\x1b[?25l"); 225 + let _ = io::stdout().flush(); 226 + } 227 + 228 + /// ANSI escape: show the cursor. 229 + fn show_cursor() { 230 + print!("\x1b[?25h"); 231 + let _ = io::stdout().flush(); 232 + } 233 + 234 + /// Write a frame to stdout, moving the cursor up to overwrite the previous frame. 235 + /// Handles the case where the previous frame was taller (clears leftover lines). 236 + fn write_frame(frame: &str, current_height: usize, prev_height: usize) { 237 + if prev_height > 0 { 238 + print!("\x1b[{}A", prev_height); 239 + } 240 + 241 + if prev_height > current_height { 242 + for line in frame.lines() { 243 + print!("{}\x1b[K\n", line); 244 + } 245 + for _ in 0..(prev_height - current_height) { 246 + print!("\x1b[K\n"); 247 + } 248 + print!("\x1b[{}A", prev_height - current_height); 249 + } else { 250 + for line in frame.lines() { 251 + print!("{}\x1b[K\n", line); 252 + } 253 + } 254 + 255 + let _ = io::stdout().flush(); 256 + } 257 + 258 + // ── Animation loop ────────────────────────────────────────────────────────── 259 + 260 + /// Run an infinite animation loop, rendering at the given FPS. 261 + pub fn run_loop(base: &Clood, animations: &[Animation], fps: u32) -> ! { 262 + let frame_delay = Duration::from_millis(1000 / fps.max(1) as u64); 263 + let mut tick: usize = 0; 264 + let mut prev_height: usize = 0; 265 + 266 + hide_cursor(); 267 + 268 + ctrlc::set_handler(move || { 269 + show_cursor(); 270 + std::process::exit(0); 271 + }) 272 + .expect("Failed to set Ctrl-C handler"); 273 + 274 + loop { 275 + let mut state = FrameState::from_clood(base); 276 + for anim in animations { 277 + state.apply(anim.param, anim.value_at(tick)); 278 + } 279 + let frame_clood = state.to_clood(base); 280 + 281 + let frame = clood::render(&frame_clood); 282 + let height = frame_clood.sanitized().rendered_height(); 283 + 284 + write_frame(&frame, height, prev_height); 285 + 286 + prev_height = height; 287 + tick = tick.wrapping_add(1); 288 + 289 + thread::sleep(frame_delay); 290 + } 291 + }
+256
src/clood.rs
··· 1 + //! Core clood type, rendering, and leg layout. 2 + 3 + use crate::color::Color; 4 + 5 + // ── Types ─────────────────────────────────────────────────────────────────── 6 + 7 + /// Configuration for one eye: vertical offset and open/closed state. 8 + #[derive(Debug, Clone, Copy)] 9 + pub struct EyeState { 10 + /// Vertical offset from the mood baseline. Positive = higher. 11 + pub offset: i32, 12 + /// Whether the eye is closed (drawn as a half block). 13 + pub closed: bool, 14 + } 15 + 16 + /// All resolved parameters for rendering a single frame. 17 + #[derive(Debug, Clone)] 18 + pub struct Clood { 19 + /// Body width in columns (minimum 4). 20 + pub width: usize, 21 + /// Body height in rows (minimum 3). 22 + pub height: usize, 23 + /// Corner rounding: how many columns to cut from top corners (0 to width/2). 24 + pub round: usize, 25 + 26 + /// Eye mood baseline offset from center. Positive = eyes higher. 27 + pub mood: i32, 28 + pub left_eye: EyeState, 29 + pub right_eye: EyeState, 30 + 31 + /// Width of each arm in columns. 32 + pub arm_size: usize, 33 + /// Vertical offset of left arm from default position. Positive = higher. 34 + pub left_arm_offset: i32, 35 + /// Vertical offset of right arm from default position. Positive = higher. 36 + pub right_arm_offset: i32, 37 + 38 + /// Number of legs (0 for legless, typically even). 39 + pub num_legs: usize, 40 + /// Length of each leg in rows. 41 + pub leg_size: usize, 42 + 43 + pub body_color: Color, 44 + pub eye_color: Color, 45 + } 46 + 47 + impl Clood { 48 + /// Ensure all dimensions are within valid bounds. 49 + pub fn sanitized(&self) -> Clood { 50 + let mut c = self.clone(); 51 + c.width = c.width.max(4); 52 + c.height = c.height.max(3); 53 + c.round = c.round.min(c.width / 2); 54 + c 55 + } 56 + 57 + /// Total rendered height including legs (for cursor repositioning). 58 + pub fn rendered_height(&self) -> usize { 59 + let body_height = self.height.max(3); 60 + let leg_rows = if self.num_legs == 0 { 0 } else { self.leg_size }; 61 + body_height + leg_rows 62 + } 63 + } 64 + 65 + // ── Rendering ─────────────────────────────────────────────────────────────── 66 + 67 + /// Render a clood to a string of ANSI-colored unicode blocks. 68 + pub fn render(clood: &Clood) -> String { 69 + let c = clood.sanitized(); 70 + 71 + let body_block = c.body_color.full_block(); 72 + let eye_open = c.eye_color.full_block(); 73 + let eye_closed = c.eye_color.half_block(); 74 + let space = " "; 75 + 76 + // ── Eye positions ─────────────────────────────────────────────────── 77 + let eye_baseline = compute_eye_baseline(c.height, c.mood); 78 + let left_eye_row = clamp_to_body(eye_baseline - c.left_eye.offset, c.height); 79 + let right_eye_row = clamp_to_body(eye_baseline - c.right_eye.offset, c.height); 80 + 81 + // Eyes sit at ~1/3 and ~2/3 across the body width. 82 + let left_eye_col = c.width / 3; 83 + let right_eye_col = c.width - 1 - (c.width / 3); 84 + 85 + // ── Arm positions ─────────────────────────────────────────────────── 86 + // Arms default to one row below the lower eye. 87 + let arm_default_row = (left_eye_row.max(right_eye_row) + 1).min(c.height - 1); 88 + let left_arm_row = clamp_row(arm_default_row as i32 - c.left_arm_offset, c.height); 89 + let right_arm_row = clamp_row(arm_default_row as i32 - c.right_arm_offset, c.height); 90 + 91 + let arm_pad = c.arm_size; 92 + let total_cols = arm_pad + c.width + arm_pad; 93 + 94 + let mut output = String::new(); 95 + 96 + // ── Body rows (including arms and eyes) ───────────────────────────── 97 + for row in 0..c.height { 98 + let corner_cut = corner_rounding(row, c.round); 99 + 100 + for col in 0..total_cols { 101 + let region = classify_column(col, arm_pad, c.width); 102 + 103 + match region { 104 + ColumnRegion::LeftArm if row == left_arm_row => { 105 + output.push_str(&body_block); 106 + } 107 + ColumnRegion::RightArm if row == right_arm_row => { 108 + output.push_str(&body_block); 109 + } 110 + ColumnRegion::Body(body_col) => { 111 + if body_col < corner_cut || body_col >= c.width - corner_cut { 112 + output.push_str(space); 113 + } else if row == left_eye_row && body_col == left_eye_col { 114 + output.push_str(if c.left_eye.closed { &eye_closed } else { &eye_open }); 115 + } else if row == right_eye_row && body_col == right_eye_col { 116 + output.push_str(if c.right_eye.closed { &eye_closed } else { &eye_open }); 117 + } else { 118 + output.push_str(&body_block); 119 + } 120 + } 121 + _ => output.push_str(space), 122 + } 123 + } 124 + output.push('\n'); 125 + } 126 + 127 + // ── Leg rows ──────────────────────────────────────────────────────── 128 + let legs = LegLayout::compute(c.width, c.num_legs); 129 + let leg_rows = if c.num_legs == 0 { 0 } else { c.leg_size }; 130 + let leg_pad = (arm_pad as i32 + legs.inset).max(0) as usize; 131 + 132 + for _ in 0..leg_rows { 133 + for _ in 0..leg_pad { 134 + output.push_str(space); 135 + } 136 + for &has_leg in &legs.columns { 137 + output.push_str(if has_leg { &body_block } else { space }); 138 + } 139 + output.push('\n'); 140 + } 141 + 142 + output 143 + } 144 + 145 + // ── Helpers ───────────────────────────────────────────────────────────────── 146 + 147 + /// Which region of the output grid a column falls in. 148 + enum ColumnRegion { 149 + LeftArm, 150 + Body(usize), // column index within the body 151 + RightArm, 152 + } 153 + 154 + /// Classify a column index into a region. 155 + fn classify_column(col: usize, arm_pad: usize, body_width: usize) -> ColumnRegion { 156 + if col < arm_pad { 157 + ColumnRegion::LeftArm 158 + } else if col < arm_pad + body_width { 159 + ColumnRegion::Body(col - arm_pad) 160 + } else { 161 + ColumnRegion::RightArm 162 + } 163 + } 164 + 165 + /// Compute the eye baseline row from body height and mood. 166 + /// Returns a signed value for further offset arithmetic. 167 + fn compute_eye_baseline(height: usize, mood: i32) -> i32 { 168 + let midpoint = height as i32 / 2; 169 + (midpoint - mood).clamp(1, height as i32 - 2) 170 + } 171 + 172 + /// Clamp a row into valid body interior rows (1..height-2). 173 + fn clamp_to_body(row: i32, height: usize) -> usize { 174 + row.clamp(1, height as i32 - 2) as usize 175 + } 176 + 177 + /// Clamp a row value to 0..height-1. 178 + fn clamp_row(row: i32, height: usize) -> usize { 179 + row.clamp(0, height as i32 - 1) as usize 180 + } 181 + 182 + /// How many columns to cut from each side of a body row for corner rounding. 183 + /// Row 0 gets the full `round` cut; each subsequent row gets one less. 184 + fn corner_rounding(row: usize, round: usize) -> usize { 185 + round.saturating_sub(row) 186 + } 187 + 188 + // ── Leg layout ────────────────────────────────────────────────────────────── 189 + 190 + /// The computed layout for legs beneath the body. 191 + struct LegLayout { 192 + /// Boolean mask: true at each column where a leg is drawn. 193 + columns: Vec<bool>, 194 + /// How much the leg span is offset from the body left edge. 195 + /// Positive = inset (narrower), negative = outset (wider). 196 + inset: i32, 197 + } 198 + 199 + impl LegLayout { 200 + /// Compute the optimal leg placement for the given body width and leg count. 201 + /// 202 + /// Prefers inset by 1 (legs narrower than body) for a cleaner look. 203 + /// Falls back to full body width, then outset by 1 if needed. 204 + /// Adjacent legs always have at least 1 gap between them. 205 + fn compute(body_width: usize, num_legs: usize) -> Self { 206 + if num_legs == 0 { 207 + return LegLayout { 208 + columns: vec![false; body_width], 209 + inset: 0, 210 + }; 211 + } 212 + 213 + // Minimum span to guarantee gaps: N legs need 2N-1 columns. 214 + let min_span = if num_legs <= 1 { 1 } else { 2 * num_legs - 1 }; 215 + 216 + // Try from narrowest to widest: inset → flush → outset. 217 + let (span, inset) = if body_width >= 4 && body_width - 2 >= min_span { 218 + (body_width - 2, 1i32) 219 + } else if body_width >= min_span { 220 + (body_width, 0i32) 221 + } else { 222 + (body_width + 2, -1i32) 223 + }; 224 + 225 + let mut columns = vec![false; span]; 226 + 227 + if num_legs == 1 { 228 + columns[span / 2] = true; 229 + return LegLayout { columns, inset }; 230 + } 231 + 232 + // Distribute legs with symmetric gap widths. 233 + // Extra-wide gaps are centered in the middle for visual balance. 234 + let num_gaps = num_legs - 1; 235 + let base_gap = (span - 1) / num_gaps; 236 + let extra_gaps = (span - 1) % num_gaps; 237 + 238 + let mut gap_sizes = vec![base_gap; num_gaps]; 239 + let first_wide = (num_gaps - extra_gaps) / 2; 240 + for gap in gap_sizes.iter_mut().skip(first_wide).take(extra_gaps) { 241 + *gap += 1; 242 + } 243 + 244 + let mut col = 0usize; 245 + for (i, _) in (0..num_legs).enumerate() { 246 + if col < span { 247 + columns[col] = true; 248 + } 249 + if i < num_gaps { 250 + col += gap_sizes[i]; 251 + } 252 + } 253 + 254 + LegLayout { columns, inset } 255 + } 256 + }
+47
src/color.rs
··· 1 + //! RGB color type with hex parsing, random generation, and ANSI block rendering. 2 + 3 + use rand::Rng; 4 + 5 + /// An RGB color, used for body and eye rendering. 6 + #[derive(Debug, Clone, Copy)] 7 + pub struct Color { 8 + pub r: u8, 9 + pub g: u8, 10 + pub b: u8, 11 + } 12 + 13 + impl Color { 14 + /// Parse a hex color string like "#88DDFF" or "88DDFF". 15 + /// Returns None if the string is too short or contains invalid hex. 16 + pub fn from_hex(hex: &str) -> Option<Color> { 17 + let hex = hex.trim_start_matches('#'); 18 + if hex.len() < 6 { 19 + return None; 20 + } 21 + Some(Color { 22 + r: u8::from_str_radix(&hex[0..2], 16).ok()?, 23 + g: u8::from_str_radix(&hex[2..4], 16).ok()?, 24 + b: u8::from_str_radix(&hex[4..6], 16).ok()?, 25 + }) 26 + } 27 + 28 + /// Generate a random color with each channel in 40..=255 29 + /// (avoids very dark colors that vanish on dark terminals). 30 + pub fn random(rng: &mut impl Rng) -> Color { 31 + Color { 32 + r: rng.gen_range(40..=255), 33 + g: rng.gen_range(40..=255), 34 + b: rng.gen_range(40..=255), 35 + } 36 + } 37 + 38 + /// Full block character `█` in this color using ANSI true color. 39 + pub fn full_block(&self) -> String { 40 + format!("\x1b[38;2;{};{};{}m\u{2588}\x1b[0m", self.r, self.g, self.b) 41 + } 42 + 43 + /// Lower half block `▄` in this color (used for closed eyes). 44 + pub fn half_block(&self) -> String { 45 + format!("\x1b[38;2;{};{};{}m\u{2584}\x1b[0m", self.r, self.g, self.b) 46 + } 47 + }
+26 -593
src/main.rs
··· 1 - use clap::Parser; 2 - use rand::Rng; 3 - use std::fmt; 4 - use std::io::{self, Write}; 5 - use std::str::FromStr; 6 - use std::thread; 7 - use std::time::Duration; 8 - 9 - // ── Color ─────────────────────────────────────────────────────────────────── 10 - 11 - /// An RGB color, used for body and eye rendering. 12 - #[derive(Debug, Clone, Copy)] 13 - struct Color { 14 - r: u8, 15 - g: u8, 16 - b: u8, 17 - } 18 - 19 - impl Color { 20 - /// Parse a hex color string like "#88DDFF" or "88DDFF". 21 - /// Returns None if the string is too short or contains invalid hex. 22 - fn from_hex(hex: &str) -> Option<Color> { 23 - let hex = hex.trim_start_matches('#'); 24 - if hex.len() < 6 { 25 - return None; 26 - } 27 - Some(Color { 28 - r: u8::from_str_radix(&hex[0..2], 16).ok()?, 29 - g: u8::from_str_radix(&hex[2..4], 16).ok()?, 30 - b: u8::from_str_radix(&hex[4..6], 16).ok()?, 31 - }) 32 - } 33 - 34 - /// Generate a random color with each channel in 40..=255 35 - /// (avoids very dark colors that vanish on dark terminals). 36 - fn random(rng: &mut impl Rng) -> Color { 37 - Color { 38 - r: rng.gen_range(40..=255), 39 - g: rng.gen_range(40..=255), 40 - b: rng.gen_range(40..=255), 41 - } 42 - } 43 - 44 - /// Full block character `█` in this color using ANSI true color. 45 - fn full_block(&self) -> String { 46 - format!("\x1b[38;2;{};{};{}m\u{2588}\x1b[0m", self.r, self.g, self.b) 47 - } 48 - 49 - /// Lower half block `▄` in this color (used for closed eyes). 50 - fn half_block(&self) -> String { 51 - format!("\x1b[38;2;{};{};{}m\u{2584}\x1b[0m", self.r, self.g, self.b) 52 - } 53 - } 54 - 55 - // ── Animatable parameters ─────────────────────────────────────────────────── 56 - 57 - /// Every parameter that can be targeted by `--anim`. 58 - #[derive(Debug, Clone, Copy, PartialEq, Eq)] 59 - enum AnimParam { 60 - Mood, 61 - LeftEye, 62 - RightEye, 63 - LeftEyeClosed, 64 - RightEyeClosed, 65 - Round, 66 - LeftArm, 67 - RightArm, 68 - Height, 69 - Width, 70 - ArmSize, 71 - LegSize, 72 - NumLegs, 73 - } 74 - 75 - impl FromStr for AnimParam { 76 - type Err = String; 77 - 78 - fn from_str(s: &str) -> Result<Self, Self::Err> { 79 - match s { 80 - "mood" => Ok(Self::Mood), 81 - "lefteye" => Ok(Self::LeftEye), 82 - "righteye" => Ok(Self::RightEye), 83 - "lefteyeclosed" => Ok(Self::LeftEyeClosed), 84 - "righteyeclosed" => Ok(Self::RightEyeClosed), 85 - "round" => Ok(Self::Round), 86 - "leftarm" => Ok(Self::LeftArm), 87 - "rightarm" => Ok(Self::RightArm), 88 - "height" => Ok(Self::Height), 89 - "width" => Ok(Self::Width), 90 - "armsize" => Ok(Self::ArmSize), 91 - "legsize" => Ok(Self::LegSize), 92 - "numlegs" => Ok(Self::NumLegs), 93 - _ => Err(format!("unknown parameter '{}'. Valid: mood, lefteye, righteye, \ 94 - lefteyeclosed, righteyeclosed, round, leftarm, rightarm, \ 95 - height, width, armsize, legsize, numlegs", s)), 96 - } 97 - } 98 - } 99 - 100 - impl fmt::Display for AnimParam { 101 - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 102 - let name = match self { 103 - Self::Mood => "mood", 104 - Self::LeftEye => "lefteye", 105 - Self::RightEye => "righteye", 106 - Self::LeftEyeClosed => "lefteyeclosed", 107 - Self::RightEyeClosed => "righteyeclosed", 108 - Self::Round => "round", 109 - Self::LeftArm => "leftarm", 110 - Self::RightArm => "rightarm", 111 - Self::Height => "height", 112 - Self::Width => "width", 113 - Self::ArmSize => "armsize", 114 - Self::LegSize => "legsize", 115 - Self::NumLegs => "numlegs", 116 - }; 117 - write!(f, "{}", name) 118 - } 119 - } 120 - 121 - // ── Animation spec ────────────────────────────────────────────────────────── 122 - 123 - /// A single animation: ping-pong a parameter between two values. 124 - #[derive(Debug, Clone)] 125 - struct Animation { 126 - param: AnimParam, 127 - min: i32, 128 - max: i32, 129 - } 130 - 131 - impl Animation { 132 - /// Parse "param:min:max" (e.g. "mood:-1:3"). 133 - /// The last colon separates min from max, allowing negative min values. 134 - fn parse(s: &str) -> Result<Self, String> { 135 - let first_colon = s.find(':') 136 - .ok_or_else(|| format!("expected param:min:max, got '{}'", s))?; 137 - 138 - let param_str = &s[..first_colon]; 139 - let range_str = &s[first_colon + 1..]; 140 - 141 - let last_colon = range_str.rfind(':') 142 - .ok_or_else(|| format!("expected min:max in '{}', got '{}'", s, range_str))?; 143 - 144 - let param = AnimParam::from_str(param_str)?; 145 - let min = range_str[..last_colon].parse::<i32>() 146 - .map_err(|e| format!("invalid min value in '{}': {}", s, e))?; 147 - let max = range_str[last_colon + 1..].parse::<i32>() 148 - .map_err(|e| format!("invalid max value in '{}': {}", s, e))?; 149 - 150 - Ok(Animation { param, min, max }) 151 - } 152 - 153 - /// Compute the value at a given tick, ping-ponging between min and max. 154 - /// Tick 0 → min, ticks increase toward max, then bounce back. 155 - fn value_at(&self, tick: usize) -> i32 { 156 - let range = self.min.abs_diff(self.max) as usize; 157 - if range == 0 { 158 - return self.min; 159 - } 160 - let direction = if self.max >= self.min { 1i32 } else { -1 }; 161 - let cycle_length = range * 2; 162 - let position = tick % cycle_length; 163 - 164 - if position <= range { 165 - self.min + (position as i32) * direction 166 - } else { 167 - self.max - ((position - range) as i32) * direction 168 - } 169 - } 170 - } 171 - 172 - // ── Clood parameters ──────────────────────────────────────────────────────── 173 - 174 - /// Configuration for one eye: vertical offset and open/closed state. 175 - #[derive(Debug, Clone, Copy)] 176 - struct EyeState { 177 - /// Vertical offset from the mood baseline. Positive = higher. 178 - offset: i32, 179 - /// Whether the eye is closed (drawn as a half block). 180 - closed: bool, 181 - } 182 - 183 - /// All resolved parameters for rendering a single frame. 184 - #[derive(Debug, Clone)] 185 - struct Clood { 186 - /// Body width in columns (minimum 4). 187 - width: usize, 188 - /// Body height in rows (minimum 3). 189 - height: usize, 190 - /// Corner rounding: how many columns to cut from top corners (0 to width/2). 191 - round: usize, 192 - 193 - /// Eye mood baseline offset from center. Positive = eyes higher. 194 - mood: i32, 195 - left_eye: EyeState, 196 - right_eye: EyeState, 1 + //! CLI entry point: parse arguments, build a Clood, render or animate. 197 2 198 - /// Width of each arm in columns. 199 - arm_size: usize, 200 - /// Vertical offset of left arm from default position. Positive = higher. 201 - left_arm_offset: i32, 202 - /// Vertical offset of right arm from default position. Positive = higher. 203 - right_arm_offset: i32, 3 + mod animation; 4 + mod clood; 5 + mod color; 204 6 205 - /// Number of legs (0 for legless, typically even). 206 - num_legs: usize, 207 - /// Length of each leg in rows. 208 - leg_size: usize, 7 + use clap::Parser; 8 + use rand::Rng; 209 9 210 - body_color: Color, 211 - eye_color: Color, 212 - } 213 - 214 - impl Clood { 215 - /// Ensure all dimensions are within valid bounds. 216 - fn sanitized(&self) -> Clood { 217 - let mut c = self.clone(); 218 - c.width = c.width.max(4); 219 - c.height = c.height.max(3); 220 - c.round = c.round.min(c.width / 2); 221 - c 222 - } 223 - 224 - /// Total rendered height including legs (for cursor repositioning). 225 - fn rendered_height(&self) -> usize { 226 - let body_height = self.height.max(3); 227 - let leg_rows = if self.num_legs == 0 { 0 } else { self.leg_size }; 228 - body_height + leg_rows 229 - } 230 - } 231 - 232 - // ── Leg layout ────────────────────────────────────────────────────────────── 233 - 234 - /// The computed layout for legs beneath the body. 235 - struct LegLayout { 236 - /// Boolean mask: true at each column where a leg is drawn. 237 - columns: Vec<bool>, 238 - /// How much the leg span is offset from the body left edge. 239 - /// Positive = inset (narrower), negative = outset (wider). 240 - inset: i32, 241 - } 242 - 243 - impl LegLayout { 244 - /// Compute the optimal leg placement for the given body width and leg count. 245 - /// 246 - /// Prefers inset by 1 (legs narrower than body) for a cleaner look. 247 - /// Falls back to full body width, then outset by 1 if needed. 248 - /// Adjacent legs always have at least 1 gap between them. 249 - fn compute(body_width: usize, num_legs: usize) -> Self { 250 - if num_legs == 0 { 251 - return LegLayout { 252 - columns: vec![false; body_width], 253 - inset: 0, 254 - }; 255 - } 256 - 257 - // Minimum span to guarantee gaps: N legs need 2N-1 columns. 258 - let min_span = if num_legs <= 1 { 1 } else { 2 * num_legs - 1 }; 259 - 260 - // Try from narrowest to widest: inset → flush → outset. 261 - let (span, inset) = if body_width >= 4 && body_width - 2 >= min_span { 262 - (body_width - 2, 1i32) 263 - } else if body_width >= min_span { 264 - (body_width, 0i32) 265 - } else { 266 - (body_width + 2, -1i32) 267 - }; 268 - 269 - let mut columns = vec![false; span]; 270 - 271 - if num_legs == 1 { 272 - columns[span / 2] = true; 273 - return LegLayout { columns, inset }; 274 - } 275 - 276 - // Distribute legs with symmetric gap widths. 277 - // Extra-wide gaps are centered in the middle for visual balance. 278 - let num_gaps = num_legs - 1; 279 - let base_gap = (span - 1) / num_gaps; 280 - let extra_gaps = (span - 1) % num_gaps; 281 - 282 - let mut gap_sizes = vec![base_gap; num_gaps]; 283 - let first_wide = (num_gaps - extra_gaps) / 2; 284 - for gap in gap_sizes.iter_mut().skip(first_wide).take(extra_gaps) { 285 - *gap += 1; 286 - } 287 - 288 - let mut col = 0usize; 289 - for (i, _) in (0..num_legs).enumerate() { 290 - if col < span { 291 - columns[col] = true; 292 - } 293 - if i < num_gaps { 294 - col += gap_sizes[i]; 295 - } 296 - } 297 - 298 - LegLayout { columns, inset } 299 - } 300 - } 301 - 302 - // ── Rendering ─────────────────────────────────────────────────────────────── 303 - 304 - /// Render a clood to a string of ANSI-colored unicode blocks. 305 - fn render(clood: &Clood) -> String { 306 - let c = clood.sanitized(); 307 - 308 - let body_block = c.body_color.full_block(); 309 - let eye_open = c.eye_color.full_block(); 310 - let eye_closed = c.eye_color.half_block(); 311 - let space = " "; 312 - 313 - // ── Eye positions ─────────────────────────────────────────────────── 314 - let eye_baseline_row = compute_eye_baseline(c.height, c.mood); 315 - let left_eye_row = clamp_to_body(eye_baseline_row - c.left_eye.offset, c.height); 316 - let right_eye_row = clamp_to_body(eye_baseline_row - c.right_eye.offset, c.height); 317 - 318 - // Eyes sit at ~1/3 and ~2/3 across the body width. 319 - let left_eye_col = c.width / 3; 320 - let right_eye_col = c.width - 1 - (c.width / 3); 321 - 322 - // ── Arm positions ─────────────────────────────────────────────────── 323 - // Arms default to one row below the lower eye. 324 - let arm_default_row = (left_eye_row.max(right_eye_row) + 1).min(c.height - 1); 325 - let left_arm_row = clamp_row(arm_default_row as i32 - c.left_arm_offset, c.height); 326 - let right_arm_row = clamp_row(arm_default_row as i32 - c.right_arm_offset, c.height); 327 - 328 - let arm_pad = c.arm_size; 329 - let total_cols = arm_pad + c.width + arm_pad; 330 - 331 - let mut output = String::new(); 332 - 333 - // ── Body rows (including arms and eyes) ───────────────────────────── 334 - for row in 0..c.height { 335 - let corner_cut = corner_rounding(row, c.round); 336 - 337 - for col in 0..total_cols { 338 - let region = classify_column(col, arm_pad, c.width); 339 - 340 - match region { 341 - ColumnRegion::LeftArm if row == left_arm_row => { 342 - output.push_str(&body_block); 343 - } 344 - ColumnRegion::RightArm if row == right_arm_row => { 345 - output.push_str(&body_block); 346 - } 347 - ColumnRegion::Body(body_col) => { 348 - if body_col < corner_cut || body_col >= c.width - corner_cut { 349 - output.push_str(space); 350 - } else if row == left_eye_row && body_col == left_eye_col { 351 - output.push_str(if c.left_eye.closed { &eye_closed } else { &eye_open }); 352 - } else if row == right_eye_row && body_col == right_eye_col { 353 - output.push_str(if c.right_eye.closed { &eye_closed } else { &eye_open }); 354 - } else { 355 - output.push_str(&body_block); 356 - } 357 - } 358 - _ => output.push_str(space), 359 - } 360 - } 361 - output.push('\n'); 362 - } 363 - 364 - // ── Leg rows ──────────────────────────────────────────────────────── 365 - let legs = LegLayout::compute(c.width, c.num_legs); 366 - let leg_rows = if c.num_legs == 0 { 0 } else { c.leg_size }; 367 - let leg_pad = (arm_pad as i32 + legs.inset).max(0) as usize; 368 - 369 - for _ in 0..leg_rows { 370 - for _ in 0..leg_pad { 371 - output.push_str(space); 372 - } 373 - for &has_leg in &legs.columns { 374 - output.push_str(if has_leg { &body_block } else { space }); 375 - } 376 - output.push('\n'); 377 - } 378 - 379 - output 380 - } 381 - 382 - /// Which region of the output grid a column falls in. 383 - enum ColumnRegion { 384 - LeftArm, 385 - Body(usize), // inner value is the column index within the body 386 - RightArm, 387 - } 388 - 389 - /// Classify a column index into a region. 390 - fn classify_column(col: usize, arm_pad: usize, body_width: usize) -> ColumnRegion { 391 - if col < arm_pad { 392 - ColumnRegion::LeftArm 393 - } else if col < arm_pad + body_width { 394 - ColumnRegion::Body(col - arm_pad) 395 - } else { 396 - ColumnRegion::RightArm 397 - } 398 - } 399 - 400 - /// Compute the eye baseline row from body height and mood. 401 - /// Returns a signed value for further offset arithmetic. 402 - fn compute_eye_baseline(height: usize, mood: i32) -> i32 { 403 - let midpoint = height as i32 / 2; 404 - (midpoint - mood).clamp(1, height as i32 - 2) 405 - } 406 - 407 - /// Clamp a row into valid body interior rows (1..height-2). 408 - fn clamp_to_body(row: i32, height: usize) -> usize { 409 - row.clamp(1, height as i32 - 2) as usize 410 - } 411 - 412 - /// Clamp a row value to 0..height-1. 413 - fn clamp_row(row: i32, height: usize) -> usize { 414 - row.clamp(0, height as i32 - 1) as usize 415 - } 416 - 417 - /// How many columns to cut from each side of a body row for corner rounding. 418 - /// Row 0 gets the full `round` cut; each subsequent row gets one less. 419 - fn corner_rounding(row: usize, round: usize) -> usize { 420 - round.saturating_sub(row) 421 - } 422 - 423 - // ── Animation state ───────────────────────────────────────────────────────── 424 - 425 - /// Mutable snapshot of all animatable parameter values for one frame. 426 - /// Starts from base values, then animations override individual fields. 427 - struct AnimState { 428 - mood: i32, 429 - left_eye_offset: i32, 430 - right_eye_offset: i32, 431 - left_eye_closed: i32, 432 - right_eye_closed: i32, 433 - round: i32, 434 - left_arm_offset: i32, 435 - right_arm_offset: i32, 436 - height: usize, 437 - width: usize, 438 - arm_size: usize, 439 - leg_size: usize, 440 - num_legs: usize, 441 - } 442 - 443 - impl AnimState { 444 - /// Initialize from a base Clood configuration. 445 - fn from_clood(c: &Clood) -> Self { 446 - AnimState { 447 - mood: c.mood, 448 - left_eye_offset: c.left_eye.offset, 449 - right_eye_offset: c.right_eye.offset, 450 - left_eye_closed: if c.left_eye.closed { 1 } else { 0 }, 451 - right_eye_closed: if c.right_eye.closed { 1 } else { 0 }, 452 - round: c.round as i32, 453 - left_arm_offset: c.left_arm_offset, 454 - right_arm_offset: c.right_arm_offset, 455 - height: c.height, 456 - width: c.width, 457 - arm_size: c.arm_size, 458 - leg_size: c.leg_size, 459 - num_legs: c.num_legs, 460 - } 461 - } 462 - 463 - /// Apply an animation's current value to the appropriate field. 464 - fn apply(&mut self, param: AnimParam, value: i32) { 465 - match param { 466 - AnimParam::Mood => self.mood = value, 467 - AnimParam::LeftEye => self.left_eye_offset = value, 468 - AnimParam::RightEye => self.right_eye_offset = value, 469 - AnimParam::LeftEyeClosed => self.left_eye_closed = value, 470 - AnimParam::RightEyeClosed => self.right_eye_closed = value, 471 - AnimParam::Round => self.round = value, 472 - AnimParam::LeftArm => self.left_arm_offset = value, 473 - AnimParam::RightArm => self.right_arm_offset = value, 474 - AnimParam::Height => self.height = value.max(3) as usize, 475 - AnimParam::Width => self.width = value.max(4) as usize, 476 - AnimParam::ArmSize => self.arm_size = value.max(0) as usize, 477 - AnimParam::LegSize => self.leg_size = value.max(0) as usize, 478 - AnimParam::NumLegs => self.num_legs = value.max(0) as usize, 479 - } 480 - } 481 - 482 - /// Convert back to a Clood, preserving colors from the base. 483 - fn to_clood(&self, base: &Clood) -> Clood { 484 - Clood { 485 - width: self.width, 486 - height: self.height, 487 - round: self.round.max(0) as usize, 488 - mood: self.mood, 489 - left_eye: EyeState { 490 - offset: self.left_eye_offset, 491 - closed: self.left_eye_closed != 0, 492 - }, 493 - right_eye: EyeState { 494 - offset: self.right_eye_offset, 495 - closed: self.right_eye_closed != 0, 496 - }, 497 - arm_size: self.arm_size, 498 - left_arm_offset: self.left_arm_offset, 499 - right_arm_offset: self.right_arm_offset, 500 - num_legs: self.num_legs, 501 - leg_size: self.leg_size, 502 - body_color: base.body_color, 503 - eye_color: base.eye_color, 504 - } 505 - } 506 - } 507 - 508 - // ── Terminal helpers ───────────────────────────────────────────────────────── 509 - 510 - /// ANSI escape: hide the cursor. 511 - fn hide_cursor() { 512 - print!("\x1b[?25l"); 513 - let _ = io::stdout().flush(); 514 - } 515 - 516 - /// ANSI escape: show the cursor. 517 - fn show_cursor() { 518 - print!("\x1b[?25h"); 519 - let _ = io::stdout().flush(); 520 - } 521 - 522 - /// Write a frame to stdout, moving the cursor up to overwrite the previous frame. 523 - /// Handles the case where the previous frame was taller (clears leftover lines). 524 - fn write_frame(frame: &str, current_height: usize, prev_height: usize) { 525 - if prev_height > 0 { 526 - print!("\x1b[{}A", prev_height); 527 - } 528 - 529 - if prev_height > current_height { 530 - // Frame got shorter: print frame, clear leftover lines, move cursor back. 531 - for line in frame.lines() { 532 - print!("{}\x1b[K\n", line); 533 - } 534 - for _ in 0..(prev_height - current_height) { 535 - print!("\x1b[K\n"); 536 - } 537 - print!("\x1b[{}A", prev_height - current_height); 538 - } else { 539 - for line in frame.lines() { 540 - print!("{}\x1b[K\n", line); 541 - } 542 - } 543 - 544 - let _ = io::stdout().flush(); 545 - } 546 - 547 - // ── CLI ───────────────────────────────────────────────────────────────────── 10 + use animation::Animation; 11 + use clood::{Clood, EyeState}; 12 + use color::Color; 548 13 549 14 /// Generate a cute unicode block character (clood) in your terminal. 550 15 /// ··· 622 87 fps: u32, 623 88 } 624 89 625 - // ── Main ──────────────────────────────────────────────────────────────────── 626 - 627 90 fn main() { 628 91 let args = Args::parse(); 629 92 let mut rng = rand::thread_rng(); ··· 647 110 right_arm_offset: args.rightarm, 648 111 num_legs: args.numlegs.unwrap_or_else(|| rng.gen_range(0..=4) * 2), 649 112 leg_size: args.legsize.unwrap_or_else(|| rng.gen_range(2..=4)), 650 - body_color: args.color.as_deref() 113 + body_color: args 114 + .color 115 + .as_deref() 651 116 .and_then(Color::from_hex) 652 117 .unwrap_or_else(|| { 653 118 if args.color.is_some() { ··· 655 120 } 656 121 Color::random(&mut rng) 657 122 }), 658 - eye_color: args.eyecolor.as_deref() 123 + eye_color: args 124 + .eyecolor 125 + .as_deref() 659 126 .and_then(Color::from_hex) 660 127 .unwrap_or_else(|| { 661 128 if args.eyecolor.is_some() { ··· 666 133 }; 667 134 668 135 // Parse animation specs. 669 - let animations: Vec<Animation> = args.anims.iter() 670 - .filter_map(|s| { 671 - match Animation::parse(s) { 672 - Ok(anim) => Some(anim), 673 - Err(msg) => { 674 - eprintln!("Warning: {}", msg); 675 - None 676 - } 136 + let animations: Vec<Animation> = args 137 + .anims 138 + .iter() 139 + .filter_map(|s| match Animation::parse(s) { 140 + Ok(anim) => Some(anim), 141 + Err(msg) => { 142 + eprintln!("Warning: {}", msg); 143 + None 677 144 } 678 145 }) 679 146 .collect(); 680 147 681 148 if animations.is_empty() { 682 - print!("{}", render(&base)); 683 - return; 684 - } 685 - 686 - run_animation_loop(&base, &animations, args.fps); 687 - } 688 - 689 - /// Run an infinite animation loop, rendering at the given FPS. 690 - fn run_animation_loop(base: &Clood, animations: &[Animation], fps: u32) -> ! { 691 - let frame_delay = Duration::from_millis(1000 / fps.max(1) as u64); 692 - let mut tick: usize = 0; 693 - let mut prev_height: usize = 0; 694 - 695 - hide_cursor(); 696 - 697 - // Restore cursor visibility on Ctrl-C. 698 - ctrlc::set_handler(move || { 699 - show_cursor(); 700 - std::process::exit(0); 701 - }).expect("Failed to set Ctrl-C handler"); 702 - 703 - loop { 704 - let mut state = AnimState::from_clood(base); 705 - for anim in animations { 706 - state.apply(anim.param, anim.value_at(tick)); 707 - } 708 - let frame_clood = state.to_clood(base); 709 - 710 - let frame = render(&frame_clood); 711 - let height = frame_clood.sanitized().rendered_height(); 712 - 713 - write_frame(&frame, height, prev_height); 714 - 715 - prev_height = height; 716 - tick = tick.wrapping_add(1); 717 - 718 - thread::sleep(frame_delay); 149 + print!("{}", clood::render(&base)); 150 + } else { 151 + animation::run_loop(&base, &animations, args.fps); 719 152 } 720 153 }