Small library for generating claude-code like unicde block mascots, and providing animations when they do stuff
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 285 lines 11 kB view raw
1//! Core clood type, rendering, and leg layout. 2 3use serde::{Deserialize, Serialize}; 4 5use crate::color::Color; 6 7// ── Types ─────────────────────────────────────────────────────────────────── 8 9/// Configuration for one eye: vertical offset and open/closed state. 10#[derive(Debug, Clone, Copy, Serialize, Deserialize)] 11pub struct EyeState { 12 /// Vertical offset from the mood baseline. Positive = higher. 13 pub offset: i32, 14 /// Whether the eye is closed (drawn as a half block). 15 pub closed: bool, 16} 17 18/// All resolved parameters for rendering a single frame. 19#[derive(Debug, Clone, Serialize, Deserialize)] 20pub struct Clood { 21 /// Body width in columns (minimum 4). 22 pub width: usize, 23 /// Body height in rows (minimum 3). 24 pub height: usize, 25 /// Corner rounding: how many columns to cut from top corners (0 to width/2). 26 pub round: usize, 27 28 /// Eye mood baseline offset from center. Positive = eyes higher. 29 pub mood: i32, 30 /// Horizontal offset for both eyes. Positive = glance right, negative = left. 31 #[serde(default)] 32 pub glance: i32, 33 pub left_eye: EyeState, 34 pub right_eye: EyeState, 35 36 /// Width of each arm in columns. 37 pub arm_size: usize, 38 /// Vertical offset of left arm from default position. Positive = higher. 39 pub left_arm_offset: i32, 40 /// Vertical offset of right arm from default position. Positive = higher. 41 pub right_arm_offset: i32, 42 /// Slope of left arm: each column going outward steps this many rows up (positive) or down (negative). 43 #[serde(default)] 44 pub left_arm_slope: i32, 45 /// Slope of right arm: each column going outward steps this many rows up (positive) or down (negative). 46 #[serde(default)] 47 pub right_arm_slope: i32, 48 49 /// Number of legs (0 for legless, typically even). 50 pub num_legs: usize, 51 /// Length of each leg in rows. 52 pub leg_size: usize, 53 54 pub body_color: Color, 55 pub eye_color: Color, 56} 57 58impl Clood { 59 /// Ensure all dimensions are within valid bounds. 60 pub fn sanitized(&self) -> Clood { 61 let mut c = self.clone(); 62 c.width = c.width.max(4); 63 c.height = c.height.max(3); 64 c.round = c.round.min(c.width / 2); 65 c 66 } 67 68 /// Total rendered height including legs (for cursor repositioning). 69 pub fn rendered_height(&self) -> usize { 70 let body_height = self.height.max(3); 71 let leg_rows = if self.num_legs == 0 { 0 } else { self.leg_size }; 72 body_height + leg_rows 73 } 74} 75 76// ── Rendering ─────────────────────────────────────────────────────────────── 77 78/// Render a clood to a string of ANSI-colored unicode blocks. 79pub fn render(clood: &Clood) -> String { 80 let c = clood.sanitized(); 81 82 let body_block = c.body_color.full_block(); 83 let eye_open = c.eye_color.full_block(); 84 let eye_closed = c.eye_color.half_block_on(&c.body_color); 85 let space = " "; 86 87 // ── Eye positions ─────────────────────────────────────────────────── 88 let eye_baseline = compute_eye_baseline(c.height, c.mood); 89 let left_eye_row = clamp_to_body(eye_baseline - c.left_eye.offset, c.height); 90 let right_eye_row = clamp_to_body(eye_baseline - c.right_eye.offset, c.height); 91 92 // Eyes sit at ~1/3 and ~2/3 across the body width, shifted by glance. 93 // Clamped to stay within the body after corner rounding. 94 let base_left_col = c.width as i32 / 3 + c.glance; 95 let base_right_col = (c.width as i32 - 1 - c.width as i32 / 3) + c.glance; 96 let left_eye_col = base_left_col.clamp(1, c.width as i32 - 2) as usize; 97 let right_eye_col = base_right_col.clamp(1, c.width as i32 - 2) as usize; 98 99 // ── Arm positions ─────────────────────────────────────────────────── 100 // Arms default to one row below the lower eye. 101 let arm_default_row = (left_eye_row.max(right_eye_row) + 1).min(c.height - 1); 102 let left_arm_row = clamp_row(arm_default_row as i32 - c.left_arm_offset, c.height); 103 let right_arm_row = clamp_row(arm_default_row as i32 - c.right_arm_offset, c.height); 104 105 let arm_pad = c.arm_size; 106 let total_cols = arm_pad + c.width + arm_pad; 107 108 let mut output = String::new(); 109 110 // ── Body rows (including arms and eyes) ───────────────────────────── 111 for row in 0..c.height { 112 let corner_cut = corner_rounding(row, c.round); 113 114 for col in 0..total_cols { 115 let region = classify_column(col, arm_pad, c.width); 116 117 match region { 118 ColumnRegion::LeftArm => { 119 // Distance from body: col closest to body = arm_pad-1, farthest = 0 120 let dist = (arm_pad - 1 - col) as i32; 121 let arm_row_here = (left_arm_row as i32 - dist * c.left_arm_slope) 122 .clamp(0, c.height as i32 - 1) as usize; 123 if row == arm_row_here { 124 output.push_str(&body_block); 125 } else { 126 output.push_str(space); 127 } 128 } 129 ColumnRegion::RightArm => { 130 // Distance from body: col closest to body = arm_pad+width, farthest = last 131 let dist = (col - arm_pad - c.width) as i32; 132 let arm_row_here = (right_arm_row as i32 - dist * c.right_arm_slope) 133 .clamp(0, c.height as i32 - 1) as usize; 134 if row == arm_row_here { 135 output.push_str(&body_block); 136 } else { 137 output.push_str(space); 138 } 139 } 140 ColumnRegion::Body(body_col) => { 141 if body_col < corner_cut || body_col >= c.width - corner_cut { 142 output.push_str(space); 143 } else if row == left_eye_row && body_col == left_eye_col { 144 output.push_str(if c.left_eye.closed { &eye_closed } else { &eye_open }); 145 } else if row == right_eye_row && body_col == right_eye_col { 146 output.push_str(if c.right_eye.closed { &eye_closed } else { &eye_open }); 147 } else { 148 output.push_str(&body_block); 149 } 150 } 151 } 152 } 153 output.push('\n'); 154 } 155 156 // ── Leg rows ──────────────────────────────────────────────────────── 157 let legs = LegLayout::compute(c.width, c.num_legs); 158 let leg_rows = if c.num_legs == 0 { 0 } else { c.leg_size }; 159 let leg_pad = (arm_pad as i32 + legs.inset).max(0) as usize; 160 161 for _ in 0..leg_rows { 162 for _ in 0..leg_pad { 163 output.push_str(space); 164 } 165 for &has_leg in &legs.columns { 166 output.push_str(if has_leg { &body_block } else { space }); 167 } 168 output.push('\n'); 169 } 170 171 output 172} 173 174// ── Helpers ───────────────────────────────────────────────────────────────── 175 176/// Which region of the output grid a column falls in. 177enum ColumnRegion { 178 LeftArm, 179 Body(usize), // column index within the body 180 RightArm, 181} 182 183/// Classify a column index into a region. 184fn classify_column(col: usize, arm_pad: usize, body_width: usize) -> ColumnRegion { 185 if col < arm_pad { 186 ColumnRegion::LeftArm 187 } else if col < arm_pad + body_width { 188 ColumnRegion::Body(col - arm_pad) 189 } else { 190 ColumnRegion::RightArm 191 } 192} 193 194/// Compute the eye baseline row from body height and mood. 195/// Returns a signed value for further offset arithmetic. 196fn compute_eye_baseline(height: usize, mood: i32) -> i32 { 197 let midpoint = height as i32 / 2; 198 (midpoint - mood).clamp(1, height as i32 - 2) 199} 200 201/// Clamp a row into valid body interior rows (1..height-2). 202fn clamp_to_body(row: i32, height: usize) -> usize { 203 row.clamp(1, height as i32 - 2) as usize 204} 205 206/// Clamp a row value to 0..height-1. 207fn clamp_row(row: i32, height: usize) -> usize { 208 row.clamp(0, height as i32 - 1) as usize 209} 210 211/// How many columns to cut from each side of a body row for corner rounding. 212/// Row 0 gets the full `round` cut; each subsequent row gets one less. 213fn corner_rounding(row: usize, round: usize) -> usize { 214 round.saturating_sub(row) 215} 216 217// ── Leg layout ────────────────────────────────────────────────────────────── 218 219/// The computed layout for legs beneath the body. 220struct LegLayout { 221 /// Boolean mask: true at each column where a leg is drawn. 222 columns: Vec<bool>, 223 /// How much the leg span is offset from the body left edge. 224 /// Positive = inset (narrower), negative = outset (wider). 225 inset: i32, 226} 227 228impl LegLayout { 229 /// Compute the optimal leg placement for the given body width and leg count. 230 /// 231 /// Prefers inset by 1 (legs narrower than body) for a cleaner look. 232 /// Falls back to full body width, then outset by 1 if needed. 233 /// Adjacent legs always have at least 1 gap between them. 234 fn compute(body_width: usize, num_legs: usize) -> Self { 235 if num_legs == 0 { 236 return LegLayout { 237 columns: vec![false; body_width], 238 inset: 0, 239 }; 240 } 241 242 // Minimum span to guarantee gaps: N legs need 2N-1 columns. 243 let min_span = if num_legs <= 1 { 1 } else { 2 * num_legs - 1 }; 244 245 // Try from narrowest to widest: inset → flush → outset. 246 let (span, inset) = if body_width >= 4 && body_width - 2 >= min_span { 247 (body_width - 2, 1i32) 248 } else if body_width >= min_span { 249 (body_width, 0i32) 250 } else { 251 (body_width + 2, -1i32) 252 }; 253 254 let mut columns = vec![false; span]; 255 256 if num_legs == 1 { 257 columns[span / 2] = true; 258 return LegLayout { columns, inset }; 259 } 260 261 // Distribute legs with symmetric gap widths. 262 // Extra-wide gaps are centered in the middle for visual balance. 263 let num_gaps = num_legs - 1; 264 let base_gap = (span - 1) / num_gaps; 265 let extra_gaps = (span - 1) % num_gaps; 266 267 let mut gap_sizes = vec![base_gap; num_gaps]; 268 let first_wide = (num_gaps - extra_gaps) / 2; 269 for gap in gap_sizes.iter_mut().skip(first_wide).take(extra_gaps) { 270 *gap += 1; 271 } 272 273 let mut col = 0usize; 274 for (i, _) in (0..num_legs).enumerate() { 275 if col < span { 276 columns[col] = true; 277 } 278 if i < num_gaps { 279 col += gap_sizes[i]; 280 } 281 } 282 283 LegLayout { columns, inset } 284 } 285}