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