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: major cleanup with proper types, structs, and error handling

Reorganized into clean logical modules:

- Color struct with from_hex(), random(), full_block(), half_block()
- AnimParam enum (replaces raw string matching) with FromStr/Display
- Animation struct with proper parse() returning Result
- EyeState struct groups eye offset + closed state
- Clood struct replaces flat CloodParams with named fields
- AnimState handles parameter override/restore for animation frames
- LegLayout struct encapsulates leg positioning logic
- ColumnRegion enum for body/arm classification
- Terminal helpers: hide_cursor(), show_cursor(), write_frame()

Edge cases fixed:
- Color::from_hex returns Option (no panics on short/invalid strings)
- Invalid --color/--eyecolor warns and falls back to random
- Invalid --anim specs produce descriptive error messages
- Unknown anim params list all valid options
- Animation tick uses wrapping_add to prevent overflow
- All row/col clamping uses signed arithmetic to avoid underflow
- sanitized() ensures minimum dimensions before rendering

+604 -386
+604 -386
src/main.rs
··· 1 1 use clap::Parser; 2 2 use rand::Rng; 3 + use std::fmt; 3 4 use std::io::{self, Write}; 5 + use std::str::FromStr; 4 6 use std::thread; 5 7 use std::time::Duration; 6 8 7 - /// Generate a cute unicode block character (clood) in your terminal. 8 - /// All parameters are randomized by default (between 1x and 2x base values). 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. 13 - #[derive(Parser, Debug)] 14 - #[command(name = "clood", version, about)] 15 - struct Args { 16 - /// Height of the body in rows [default: random 4..8] 17 - #[arg(long)] 18 - height: Option<usize>, 19 - 20 - /// Width of the body in columns [default: random 8..16] 21 - #[arg(long)] 22 - width: Option<usize>, 23 - 24 - /// Width of each arm in columns [default: random 2..4] 25 - #[arg(long)] 26 - armsize: Option<usize>, 27 - 28 - /// Number of legs, even number [default: random even 0..8] 29 - #[arg(long)] 30 - numlegs: Option<usize>, 31 - 32 - /// Length of each leg in rows [default: random 2..4] 33 - #[arg(long)] 34 - legsize: Option<usize>, 35 - 36 - /// Color of the body/arms/legs as hex (e.g. #88DDFF) [default: random] 37 - #[arg(long)] 38 - color: Option<String>, 9 + // ── Color ─────────────────────────────────────────────────────────────────── 39 10 40 - /// Color of the eyes as hex (e.g. #000000) [default: random] 41 - #[arg(long)] 42 - eyecolor: Option<String>, 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 + } 43 18 44 - /// How far up the eyes are from the baseline (halfway up body). Positive = higher, negative = lower. [default: random -2..2] 45 - #[arg(long, allow_hyphen_values = true)] 46 - mood: Option<i32>, 47 - 48 - /// Vertical offset of left eye from mood baseline. Positive = higher, negative = lower. [default: 0] 49 - #[arg(long, default_value_t = 0, allow_hyphen_values = true)] 50 - lefteye: i32, 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 + } 51 33 52 - /// Vertical offset of right eye from mood baseline. Positive = higher, negative = lower. [default: 0] 53 - #[arg(long, default_value_t = 0, allow_hyphen_values = true)] 54 - righteye: i32, 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 + } 55 43 56 - /// Left eye closed (0=open, 1=closed, draws a half block). Animatable. [default: 0] 57 - #[arg(long, default_value_t = 0)] 58 - lefteyeclosed: i32, 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 + } 59 48 60 - /// Right eye closed (0=open, 1=closed, draws a half block). Animatable. [default: 0] 61 - #[arg(long, default_value_t = 0)] 62 - righteyeclosed: i32, 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 + } 63 54 64 - /// Corner rounding amount (0 to width/2). Cuts blocks from top corners. [default: 0] 65 - #[arg(long, default_value_t = 0)] 66 - round: usize, 55 + // ── Animatable parameters ─────────────────────────────────────────────────── 67 56 68 - /// Vertical offset of left arm from default (one row below eyes). Positive = higher, negative = lower. [default: 0] 69 - #[arg(long, default_value_t = 0, allow_hyphen_values = true)] 70 - leftarm: i32, 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 + } 71 74 72 - /// Vertical offset of right arm from default (one row below eyes). Positive = higher, negative = lower. [default: 0] 73 - #[arg(long, default_value_t = 0, allow_hyphen_values = true)] 74 - rightarm: i32, 75 + impl FromStr for AnimParam { 76 + type Err = String; 75 77 76 - /// Animate a parameter: <param>:<min>:<max> (ping-pong loop). Can be repeated. 77 - /// Supported params: mood, leftarm, rightarm, height, width, armsize, legsize, numlegs 78 - #[arg(long = "anim", value_name = "PARAM:MIN:MAX", action = clap::ArgAction::Append)] 79 - anims: Vec<String>, 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 + } 80 99 81 - /// Animation speed in frames per second [default: 4] 82 - #[arg(long, default_value_t = 4)] 83 - fps: u32, 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 + } 84 119 } 85 120 86 - /// Parsed animation spec: which parameter to cycle, and between what values. 121 + // ── Animation spec ────────────────────────────────────────────────────────── 122 + 123 + /// A single animation: ping-pong a parameter between two values. 87 124 #[derive(Debug, Clone)] 88 - struct Anim { 89 - param: String, 125 + struct Animation { 126 + param: AnimParam, 90 127 min: i32, 91 128 max: i32, 92 129 } 93 130 94 - impl Anim { 95 - fn parse(s: &str) -> Option<Anim> { 96 - // Format: param:min:max (e.g. mood:-1:3) 97 - let parts: Vec<&str> = s.splitn(2, ':').collect(); 98 - if parts.len() != 2 { 99 - return None; 100 - } 101 - let param = parts[0].to_string(); 102 - // The rest is min:max, but min could be negative like -1:3 103 - let range_str = parts[1]; 104 - // Find the last colon to split min and max 105 - let last_colon = range_str.rfind(':')?; 106 - let min_str = &range_str[..last_colon]; 107 - let max_str = &range_str[last_colon + 1..]; 108 - let min = min_str.parse::<i32>().ok()?; 109 - let max = max_str.parse::<i32>().ok()?; 110 - Some(Anim { param, min, max }) 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 }) 111 151 } 112 152 113 - /// Get the value at a given tick (ping-pong between min and max). 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. 114 155 fn value_at(&self, tick: usize) -> i32 { 115 - let range = (self.max - self.min).unsigned_abs() as usize; 156 + let range = self.min.abs_diff(self.max) as usize; 116 157 if range == 0 { 117 158 return self.min; 118 159 } 119 - let cycle = range * 2; 120 - let pos = tick % cycle; 121 - if pos <= range { 122 - self.min + pos as i32 * (self.max - self.min).signum() 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 123 166 } else { 124 - self.max - (pos - range) as i32 * (self.max - self.min).signum() 167 + self.max - ((position - range) as i32) * direction 125 168 } 126 169 } 127 170 } 128 171 129 - /// All resolved parameters for a single frame of rendering. 130 - struct CloodParams { 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). 131 187 width: usize, 188 + /// Body height in rows (minimum 3). 132 189 height: usize, 133 - armsize: usize, 134 - numlegs: usize, 135 - legsize: usize, 136 - mood: i32, 137 - lefteye: i32, 138 - righteye: i32, 139 - lefteyeclosed: bool, 140 - righteyeclosed: bool, 190 + /// Corner rounding: how many columns to cut from top corners (0 to width/2). 141 191 round: usize, 142 - leftarm: i32, 143 - rightarm: i32, 144 - body_color: (u8, u8, u8), 145 - eye_color: (u8, u8, u8), 146 - } 192 + 193 + /// Eye mood baseline offset from center. Positive = eyes higher. 194 + mood: i32, 195 + left_eye: EyeState, 196 + right_eye: EyeState, 147 197 148 - fn parse_hex_color(hex: &str) -> (u8, u8, u8) { 149 - let hex = hex.trim_start_matches('#'); 150 - let r = u8::from_str_radix(&hex[0..2], 16).unwrap_or(0); 151 - let g = u8::from_str_radix(&hex[2..4], 16).unwrap_or(0); 152 - let b = u8::from_str_radix(&hex[4..6], 16).unwrap_or(0); 153 - (r, g, b) 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, 204 + 205 + /// Number of legs (0 for legless, typically even). 206 + num_legs: usize, 207 + /// Length of each leg in rows. 208 + leg_size: usize, 209 + 210 + body_color: Color, 211 + eye_color: Color, 154 212 } 155 213 156 - fn random_color(rng: &mut impl Rng) -> (u8, u8, u8) { 157 - (rng.gen_range(40..=255), rng.gen_range(40..=255), rng.gen_range(40..=255)) 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 + } 158 230 } 159 231 160 - fn colored_block(r: u8, g: u8, b: u8) -> String { 161 - format!("\x1b[38;2;{};{};{}m\u{2588}\x1b[0m", r, g, b) 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, 162 241 } 163 242 164 - /// Build a leg row mask. Chooses the best span for the legs: 165 - /// 1. Inset by 1 from each edge (preferred, looks smoother) 166 - /// 2. Full body width (if inset is too cramped) 167 - /// 3. Outset by 1 beyond each edge (if full width is still too cramped) 168 - /// "Too cramped" = adjacent legs would touch (need at least 1 gap between each). 169 - /// Returns (mask vec, inset amount). Positive inset = legs narrower than body, 170 - /// negative inset (stored as 0 with wider mask) = legs wider than body. 171 - fn leg_mask(width: usize, numlegs: usize) -> (Vec<bool>, i32) { 172 - if numlegs == 0 { 173 - return (vec![false; width], 0); 174 - } 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 + } 175 256 176 - // Minimum span to have at least 1 gap between each adjacent leg: 177 - // N legs need span >= 2N - 1 (each leg + gap except after last) 178 - let min_span = if numlegs <= 1 { 1 } else { 2 * numlegs - 1 }; 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 }; 179 259 180 - // Try inset first (1 col in from each side), then full width, then outset 181 - let (span, inset): (usize, i32) = if width >= 4 && width - 2 >= min_span { 182 - (width - 2, 1) // inset by 1 183 - } else if width >= min_span { 184 - (width, 0) // full body width 185 - } else { 186 - (width + 2, -1) // outset by 1 187 - }; 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 + }; 188 268 189 - let mut mask = vec![false; span]; 269 + let mut columns = vec![false; span]; 190 270 191 - if numlegs == 1 { 192 - mask[span / 2] = true; 193 - return (mask, inset); 194 - } 271 + if num_legs == 1 { 272 + columns[span / 2] = true; 273 + return LegLayout { columns, inset }; 274 + } 195 275 196 - // Distribute legs symmetrically: put extra-wide gaps in the middle 197 - let num_gaps = numlegs - 1; 198 - let base_gap = (span - 1) / num_gaps; 199 - let remainder = (span - 1) % num_gaps; 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; 200 281 201 - let mut gaps = vec![base_gap; num_gaps]; 202 - let start = (num_gaps - remainder) / 2; 203 - for g in gaps.iter_mut().skip(start).take(remainder) { 204 - *g += 1; 205 - } 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 + } 206 287 207 - let mut col = 0usize; 208 - for i in 0..numlegs { 209 - if col < span { 210 - mask[col] = true; 211 - } 212 - if i < num_gaps { 213 - col += gaps[i]; 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 + } 214 296 } 215 - } 216 297 217 - (mask, inset) 298 + LegLayout { columns, inset } 299 + } 218 300 } 219 301 220 - fn render(p: &CloodParams) -> String { 221 - let width = p.width.max(4); 222 - let height = p.height.max(3); 223 - let round = p.round.min(width / 2); 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(); 224 307 225 - let body_block = colored_block(p.body_color.0, p.body_color.1, p.body_color.2); 226 - let eye_block = colored_block(p.eye_color.0, p.eye_color.1, p.eye_color.2); 227 - // Closed eye: lower half block (▄) — looks like a squinting/shut eye 228 - let left_eye_closed_block = format!( 229 - "\x1b[38;2;{};{};{}m\u{2584}\x1b[0m", 230 - p.eye_color.0, p.eye_color.1, p.eye_color.2 231 - ); 232 - let right_eye_closed_block = format!( 233 - "\x1b[38;2;{};{};{}m\u{2584}\x1b[0m", 234 - p.eye_color.0, p.eye_color.1, p.eye_color.2 235 - ); 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(); 236 311 let space = " "; 237 312 238 - let baseline_row = (height as i32) / 2; 239 - let eye_base = (baseline_row - p.mood).clamp(1, height as i32 - 2); 240 - let left_eye_row = (eye_base - p.lefteye).clamp(1, height as i32 - 2) as usize; 241 - let right_eye_row = (eye_base - p.righteye).clamp(1, height as i32 - 2) as usize; 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); 242 317 243 - let eye_left_col = width / 3; 244 - let eye_right_col = width - 1 - (width / 3); 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); 245 321 246 - // Arm baseline: one row below the lower of the two eyes 247 - let arm_baseline = (left_eye_row.max(right_eye_row) + 1).min(height - 1); 248 - let left_arm_row = (arm_baseline as i32 - p.leftarm).clamp(0, height as i32 - 1) as usize; 249 - let right_arm_row = (arm_baseline as i32 - p.rightarm).clamp(0, height as i32 - 1) as usize; 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); 250 327 251 - let arm_pad = p.armsize; 252 - let total_width_chars = arm_pad + width + arm_pad; 253 - 254 - // Precompute rounding: for each row, how many cols are cut from each side 255 - // Row 0 gets `round` cut, row 1 gets `round-1`, etc. 256 - let round_cut = |row: usize| -> usize { 257 - if row < round { 258 - round - row 259 - } else { 260 - 0 261 - } 262 - }; 328 + let arm_pad = c.arm_size; 329 + let total_cols = arm_pad + c.width + arm_pad; 263 330 264 331 let mut output = String::new(); 265 332 266 - for row in 0..height { 267 - let cut = round_cut(row); 333 + // ── Body rows (including arms and eyes) ───────────────────────────── 334 + for row in 0..c.height { 335 + let corner_cut = corner_rounding(row, c.round); 268 336 269 - for col in 0..total_width_chars { 270 - let in_left_arm = col < arm_pad; 271 - let in_right_arm = col >= arm_pad + width; 272 - let in_body = col >= arm_pad && col < arm_pad + width; 273 - 274 - if in_body { 275 - let body_col = col - arm_pad; 337 + for col in 0..total_cols { 338 + let region = classify_column(col, arm_pad, c.width); 276 339 277 - // Check if this column is cut by rounding 278 - if body_col < cut || body_col >= width - cut { 279 - output.push_str(space); 280 - } else if row == left_eye_row && body_col == eye_left_col { 281 - if p.lefteyeclosed { 282 - output.push_str(&left_eye_closed_block); 283 - } else { 284 - output.push_str(&eye_block); 285 - } 286 - } else if row == right_eye_row && body_col == eye_right_col { 287 - if p.righteyeclosed { 288 - output.push_str(&right_eye_closed_block); 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 }); 289 354 } else { 290 - output.push_str(&eye_block); 355 + output.push_str(&body_block); 291 356 } 292 - } else { 293 - output.push_str(&body_block); 294 357 } 295 - } else if (in_left_arm && row == left_arm_row) || (in_right_arm && row == right_arm_row) { 296 - output.push_str(&body_block); 297 - } else { 298 - output.push_str(space); 358 + _ => output.push_str(space), 299 359 } 300 360 } 301 361 output.push('\n'); 302 362 } 303 363 304 - let (legs, inset) = leg_mask(width, p.numlegs); 305 - let effective_legsize = if p.numlegs == 0 { 0 } else { p.legsize }; 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; 306 368 307 - for _row in 0..effective_legsize { 308 - // inset > 0: legs narrower than body, add extra padding 309 - // inset < 0: legs wider than body, reduce padding 310 - let leg_pad = (arm_pad as i32 + inset).max(0) as usize; 369 + for _ in 0..leg_rows { 311 370 for _ in 0..leg_pad { 312 371 output.push_str(space); 313 372 } 314 - for col in 0..legs.len() { 315 - if legs[col] { 316 - output.push_str(&body_block); 317 - } else { 318 - output.push_str(space); 319 - } 373 + for &has_leg in &legs.columns { 374 + output.push_str(if has_leg { &body_block } else { space }); 320 375 } 321 376 output.push('\n'); 322 377 } ··· 324 379 output 325 380 } 326 381 327 - /// Calculate total rendered height for cursor movement. 328 - fn total_height(p: &CloodParams) -> usize { 329 - let height = p.height.max(3); 330 - let leg_rows = if p.numlegs == 0 { 0 } else { p.legsize }; 331 - height + leg_rows 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 ───────────────────────────────────────────────────────────────────── 548 + 549 + /// Generate a cute unicode block character (clood) in your terminal. 550 + /// 551 + /// All parameters are randomized by default. Specify a flag to pin that value. 552 + /// Animate parameters with --anim <param>:<min>:<max>, e.g. --anim mood:-1:3 553 + #[derive(Parser, Debug)] 554 + #[command(name = "clood", version, about)] 555 + struct Args { 556 + /// Body height in rows [default: random 4..8] 557 + #[arg(long)] 558 + height: Option<usize>, 559 + 560 + /// Body width in columns [default: random 8..16] 561 + #[arg(long)] 562 + width: Option<usize>, 563 + 564 + /// Width of each arm in columns [default: random 2..4] 565 + #[arg(long)] 566 + armsize: Option<usize>, 567 + 568 + /// Number of legs (typically even) [default: random even 0..8] 569 + #[arg(long)] 570 + numlegs: Option<usize>, 571 + 572 + /// Length of each leg in rows [default: random 2..4] 573 + #[arg(long)] 574 + legsize: Option<usize>, 575 + 576 + /// Body color as hex, e.g. #88DDFF [default: random] 577 + #[arg(long)] 578 + color: Option<String>, 579 + 580 + /// Eye color as hex, e.g. #000000 [default: random] 581 + #[arg(long)] 582 + eyecolor: Option<String>, 583 + 584 + /// Eye mood: vertical offset from center. Positive = higher. [default: random -2..2] 585 + #[arg(long, allow_hyphen_values = true)] 586 + mood: Option<i32>, 587 + 588 + /// Left eye vertical offset from mood baseline [default: 0] 589 + #[arg(long, default_value_t = 0, allow_hyphen_values = true)] 590 + lefteye: i32, 591 + 592 + /// Right eye vertical offset from mood baseline [default: 0] 593 + #[arg(long, default_value_t = 0, allow_hyphen_values = true)] 594 + righteye: i32, 595 + 596 + /// Left eye closed: 0=open, 1=closed (half block) [default: 0] 597 + #[arg(long, default_value_t = 0)] 598 + lefteyeclosed: i32, 599 + 600 + /// Right eye closed: 0=open, 1=closed (half block) [default: 0] 601 + #[arg(long, default_value_t = 0)] 602 + righteyeclosed: i32, 603 + 604 + /// Corner rounding (0 to width/2). Cuts blocks from top corners. [default: 0] 605 + #[arg(long, default_value_t = 0)] 606 + round: usize, 607 + 608 + /// Left arm vertical offset from default. Positive = higher. [default: 0] 609 + #[arg(long, default_value_t = 0, allow_hyphen_values = true)] 610 + leftarm: i32, 611 + 612 + /// Right arm vertical offset from default. Positive = higher. [default: 0] 613 + #[arg(long, default_value_t = 0, allow_hyphen_values = true)] 614 + rightarm: i32, 615 + 616 + /// Animate a parameter: <param>:<min>:<max> (ping-pong loop). Repeatable. 617 + #[arg(long = "anim", value_name = "PARAM:MIN:MAX", action = clap::ArgAction::Append)] 618 + anims: Vec<String>, 619 + 620 + /// Animation speed in frames per second [default: 4] 621 + #[arg(long, default_value_t = 4)] 622 + fps: u32, 332 623 } 624 + 625 + // ── Main ──────────────────────────────────────────────────────────────────── 333 626 334 627 fn main() { 335 628 let args = Args::parse(); 336 629 let mut rng = rand::thread_rng(); 337 630 338 - // Resolve base values (randomized if not specified) 339 - let base_width = args.width.unwrap_or_else(|| rng.gen_range(8..=16)).max(4); 340 - let base_height = args.height.unwrap_or_else(|| rng.gen_range(4..=8)).max(3); 341 - let base_armsize = args.armsize.unwrap_or_else(|| rng.gen_range(2..=4)); 342 - let base_numlegs = args.numlegs.unwrap_or_else(|| rng.gen_range(0..=4) * 2); 343 - let base_legsize = args.legsize.unwrap_or_else(|| rng.gen_range(2..=4)); 344 - let base_mood = args.mood.unwrap_or_else(|| rng.gen_range(-2..=2)); 345 - let base_lefteye = args.lefteye; 346 - let base_righteye = args.righteye; 347 - let base_lefteyeclosed = args.lefteyeclosed; 348 - let base_righteyeclosed = args.righteyeclosed; 349 - let base_round = args.round; 350 - let base_leftarm = args.leftarm; 351 - let base_rightarm = args.rightarm; 352 - 353 - let body_color = args.color 354 - .as_deref() 355 - .map(parse_hex_color) 356 - .unwrap_or_else(|| random_color(&mut rng)); 357 - 358 - let eye_color = args.eyecolor 359 - .as_deref() 360 - .map(parse_hex_color) 361 - .unwrap_or_else(|| random_color(&mut rng)); 631 + // Build the base clood from CLI args (unspecified values are randomized). 632 + let base = Clood { 633 + width: args.width.unwrap_or_else(|| rng.gen_range(8..=16)), 634 + height: args.height.unwrap_or_else(|| rng.gen_range(4..=8)), 635 + round: args.round, 636 + mood: args.mood.unwrap_or_else(|| rng.gen_range(-2..=2)), 637 + left_eye: EyeState { 638 + offset: args.lefteye, 639 + closed: args.lefteyeclosed != 0, 640 + }, 641 + right_eye: EyeState { 642 + offset: args.righteye, 643 + closed: args.righteyeclosed != 0, 644 + }, 645 + arm_size: args.armsize.unwrap_or_else(|| rng.gen_range(2..=4)), 646 + left_arm_offset: args.leftarm, 647 + right_arm_offset: args.rightarm, 648 + num_legs: args.numlegs.unwrap_or_else(|| rng.gen_range(0..=4) * 2), 649 + leg_size: args.legsize.unwrap_or_else(|| rng.gen_range(2..=4)), 650 + body_color: args.color.as_deref() 651 + .and_then(Color::from_hex) 652 + .unwrap_or_else(|| { 653 + if args.color.is_some() { 654 + eprintln!("Warning: invalid --color, using random"); 655 + } 656 + Color::random(&mut rng) 657 + }), 658 + eye_color: args.eyecolor.as_deref() 659 + .and_then(Color::from_hex) 660 + .unwrap_or_else(|| { 661 + if args.eyecolor.is_some() { 662 + eprintln!("Warning: invalid --eyecolor, using random"); 663 + } 664 + Color::random(&mut rng) 665 + }), 666 + }; 362 667 363 - // Parse animations 364 - let anims: Vec<Anim> = args.anims.iter() 668 + // Parse animation specs. 669 + let animations: Vec<Animation> = args.anims.iter() 365 670 .filter_map(|s| { 366 - Anim::parse(s).or_else(|| { 367 - eprintln!("Warning: invalid anim spec '{}', expected param:min:max", s); 368 - None 369 - }) 671 + match Animation::parse(s) { 672 + Ok(anim) => Some(anim), 673 + Err(msg) => { 674 + eprintln!("Warning: {}", msg); 675 + None 676 + } 677 + } 370 678 }) 371 679 .collect(); 372 680 373 - if anims.is_empty() { 374 - // Static render 375 - let params = CloodParams { 376 - width: base_width, 377 - height: base_height, 378 - armsize: base_armsize, 379 - numlegs: base_numlegs, 380 - legsize: base_legsize, 381 - mood: base_mood, 382 - lefteye: base_lefteye, 383 - righteye: base_righteye, 384 - lefteyeclosed: base_lefteyeclosed != 0, 385 - righteyeclosed: base_righteyeclosed != 0, 386 - round: base_round, 387 - leftarm: base_leftarm, 388 - rightarm: base_rightarm, 389 - body_color, 390 - eye_color, 391 - }; 392 - print!("{}", render(&params)); 681 + if animations.is_empty() { 682 + print!("{}", render(&base)); 393 683 return; 394 684 } 395 685 396 - // Animation loop 397 - let frame_delay = Duration::from_millis(1000 / args.fps.max(1) as u64); 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); 398 692 let mut tick: usize = 0; 399 693 let mut prev_height: usize = 0; 400 694 401 - // Hide cursor 402 - print!("\x1b[?25l"); 403 - let _ = io::stdout().flush(); 695 + hide_cursor(); 404 696 405 - // Handle Ctrl-C to restore cursor 697 + // Restore cursor visibility on Ctrl-C. 406 698 ctrlc::set_handler(move || { 407 - print!("\x1b[?25h"); 408 - let _ = io::stdout().flush(); 699 + show_cursor(); 409 700 std::process::exit(0); 410 701 }).expect("Failed to set Ctrl-C handler"); 411 702 412 703 loop { 413 - // Apply animation overrides 414 - let mut mood = base_mood; 415 - let mut lefteye = base_lefteye; 416 - let mut righteye = base_righteye; 417 - let mut lefteyeclosed = base_lefteyeclosed; 418 - let mut righteyeclosed = base_righteyeclosed; 419 - let mut round = base_round as i32; 420 - let mut leftarm = base_leftarm; 421 - let mut rightarm = base_rightarm; 422 - let mut height = base_height; 423 - let mut width = base_width; 424 - let mut armsize = base_armsize; 425 - let mut legsize = base_legsize; 426 - let mut numlegs = base_numlegs; 427 - 428 - for anim in &anims { 429 - let v = anim.value_at(tick); 430 - match anim.param.as_str() { 431 - "mood" => mood = v, 432 - "lefteye" => lefteye = v, 433 - "righteye" => righteye = v, 434 - "lefteyeclosed" => lefteyeclosed = v, 435 - "righteyeclosed" => righteyeclosed = v, 436 - "round" => round = v, 437 - "leftarm" => leftarm = v, 438 - "rightarm" => rightarm = v, 439 - "height" => height = v.max(3) as usize, 440 - "width" => width = v.max(4) as usize, 441 - "armsize" => armsize = v.max(0) as usize, 442 - "legsize" => legsize = v.max(0) as usize, 443 - "numlegs" => numlegs = v.max(0) as usize, 444 - _ => {} 445 - } 704 + let mut state = AnimState::from_clood(base); 705 + for anim in animations { 706 + state.apply(anim.param, anim.value_at(tick)); 446 707 } 708 + let frame_clood = state.to_clood(base); 447 709 448 - let params = CloodParams { 449 - width, 450 - height, 451 - armsize, 452 - numlegs, 453 - legsize, 454 - mood, 455 - lefteye, 456 - righteye, 457 - lefteyeclosed: lefteyeclosed != 0, 458 - righteyeclosed: righteyeclosed != 0, 459 - round: round.max(0) as usize, 460 - leftarm, 461 - rightarm, 462 - body_color, 463 - eye_color, 464 - }; 710 + let frame = render(&frame_clood); 711 + let height = frame_clood.sanitized().rendered_height(); 465 712 466 - // Move cursor up to overwrite previous frame 467 - if prev_height > 0 { 468 - print!("\x1b[{}A", prev_height); 469 - } 713 + write_frame(&frame, height, prev_height); 470 714 471 - let frame = render(&params); 472 - let h = total_height(&params); 473 - 474 - // Clear any leftover lines if previous frame was taller 475 - if prev_height > h { 476 - let frame_lines: Vec<&str> = frame.lines().collect(); 477 - let mut padded = String::new(); 478 - for line in &frame_lines { 479 - padded.push_str(line); 480 - padded.push_str("\x1b[K"); // clear to end of line 481 - padded.push('\n'); 482 - } 483 - for _ in 0..(prev_height - h) { 484 - padded.push_str("\x1b[K\n"); // clear leftover lines 485 - } 486 - // Move back up the extra lines 487 - padded.push_str(&format!("\x1b[{}A", prev_height - h)); 488 - print!("{}", padded); 489 - } else { 490 - // Just clear to end of each line 491 - for line in frame.lines() { 492 - print!("{}\x1b[K\n", line); 493 - } 494 - } 495 - 496 - let _ = io::stdout().flush(); 497 - prev_height = h; 498 - tick += 1; 715 + prev_height = height; 716 + tick = tick.wrapping_add(1); 499 717 500 718 thread::sleep(frame_delay); 501 719 }