Small library for generating claude-code like unicde block mascots, and providing animations when they do stuff
1//! CLI entry point: parse arguments, build a Clood, render or animate.
2//! Supports saving/loading named characters and animations from ~/.config/clood.json.
3
4mod animation;
5mod clood;
6mod color;
7
8use std::collections::HashMap;
9use std::fs;
10use std::path::PathBuf;
11
12use clap::Parser;
13use rand::Rng;
14use serde::{Deserialize, Serialize};
15
16use animation::Animation;
17use clood::{Clood, EyeState};
18use color::Color;
19
20// ── CLI ─────────────────────────────────────────────────────────────────────
21
22/// Generate a cute unicode block character (clood) in your terminal.
23///
24/// All parameters are randomized by default. Specify a flag to pin that value.
25/// Animate parameters with --anim <param>:<min>:<max>, e.g. --anim mood:-1:3
26///
27/// Save characters with --save <name>, animations with --saveanim <name>.
28/// Recall with: clood <character> [animation]
29#[derive(Parser, Debug)]
30#[command(name = "clood", version, about)]
31struct Args {
32 /// Load a saved character by name
33 #[arg(value_name = "CHARACTER")]
34 name: Option<String>,
35
36 /// Load a saved animation by name (requires a character name)
37 #[arg(value_name = "ANIMATION")]
38 anim_name: Option<String>,
39
40 /// Save the current character under this name
41 #[arg(long)]
42 save: Option<String>,
43
44 /// Save the current animations under this name
45 #[arg(long)]
46 saveanim: Option<String>,
47
48 /// Delete a saved character by name
49 #[arg(long)]
50 delete: Option<String>,
51
52 /// Delete a saved animation by name
53 #[arg(long)]
54 deleteanim: Option<String>,
55
56 /// Body height in rows [default: random 4..8]
57 #[arg(long)]
58 height: Option<usize>,
59
60 /// Body width in columns [default: random 8..16]
61 #[arg(long)]
62 width: Option<usize>,
63
64 /// Width of each arm in columns [default: random 2..4]
65 #[arg(long)]
66 armsize: Option<usize>,
67
68 /// Number of legs (typically even) [default: random even 0..8]
69 #[arg(long)]
70 numlegs: Option<usize>,
71
72 /// Length of each leg in rows [default: random 2..4]
73 #[arg(long)]
74 legsize: Option<usize>,
75
76 /// Body color as hex, e.g. #88DDFF [default: random]
77 #[arg(long)]
78 color: Option<String>,
79
80 /// Eye color as hex, e.g. #000000 [default: random]
81 #[arg(long)]
82 eyecolor: Option<String>,
83
84 /// Eye mood: vertical offset from center. Positive = higher. [default: random -2..2]
85 #[arg(long, allow_hyphen_values = true)]
86 mood: Option<i32>,
87
88 /// Horizontal eye offset. Positive = glance right, negative = left. [default: 0]
89 #[arg(long, default_value_t = 0, allow_hyphen_values = true)]
90 glance: i32,
91
92 /// Left eye vertical offset from mood baseline [default: 0]
93 #[arg(long, default_value_t = 0, allow_hyphen_values = true)]
94 lefteye: i32,
95
96 /// Right eye vertical offset from mood baseline [default: 0]
97 #[arg(long, default_value_t = 0, allow_hyphen_values = true)]
98 righteye: i32,
99
100 /// Left eye closed: 0=open, 1=closed (half block) [default: 0]
101 #[arg(long, default_value_t = 0)]
102 lefteyeclosed: i32,
103
104 /// Right eye closed: 0=open, 1=closed (half block) [default: 0]
105 #[arg(long, default_value_t = 0)]
106 righteyeclosed: i32,
107
108 /// Corner rounding (0 to width/2). Cuts blocks from top corners. [default: 0]
109 #[arg(long, default_value_t = 0)]
110 round: usize,
111
112 /// Left arm vertical offset from default. Positive = higher. [default: 0]
113 #[arg(long, default_value_t = 0, allow_hyphen_values = true)]
114 leftarm: i32,
115
116 /// Right arm vertical offset from default. Positive = higher. [default: 0]
117 #[arg(long, default_value_t = 0, allow_hyphen_values = true)]
118 rightarm: i32,
119
120 /// Left arm slope: each column outward steps this many rows up (+) or down (-). [default: 0]
121 #[arg(long, default_value_t = 0, allow_hyphen_values = true)]
122 leftslope: i32,
123
124 /// Right arm slope: each column outward steps this many rows up (+) or down (-). [default: 0]
125 #[arg(long, default_value_t = 0, allow_hyphen_values = true)]
126 rightslope: i32,
127
128 /// Animate a parameter: <param>:<min>:<max> (ping-pong loop). Repeatable.
129 #[arg(long = "anim", value_name = "PARAM:MIN:MAX", action = clap::ArgAction::Append)]
130 anims: Vec<String>,
131
132 /// Animation speed in frames per second [default: 4]
133 #[arg(long, default_value_t = 4)]
134 fps: u32,
135}
136
137// ── Save file ───────────────────────────────────────────────────────────────
138
139/// Top-level structure of ~/.config/clood.json.
140#[derive(Debug, Default, Serialize, Deserialize)]
141struct SaveFile {
142 #[serde(default)]
143 characters: HashMap<String, Clood>,
144 #[serde(default)]
145 animations: HashMap<String, Vec<Animation>>,
146}
147
148/// Path to the saved data file.
149fn config_path() -> PathBuf {
150 dirs::home_dir()
151 .unwrap_or_else(|| PathBuf::from("."))
152 .join(".config")
153 .join("clood.json")
154}
155
156/// Load the save file from disk.
157fn load_save_file() -> SaveFile {
158 let path = config_path();
159 match fs::read_to_string(&path) {
160 Ok(contents) => serde_json::from_str(&contents).unwrap_or_else(|e| {
161 eprintln!("Warning: couldn't parse {}: {}", path.display(), e);
162 SaveFile::default()
163 }),
164 Err(_) => SaveFile::default(),
165 }
166}
167
168/// Write the save file to disk.
169fn write_save_file(save: &SaveFile) {
170 let path = config_path();
171 if let Some(parent) = path.parent() {
172 let _ = fs::create_dir_all(parent);
173 }
174 match serde_json::to_string_pretty(save) {
175 Ok(json) => {
176 if let Err(e) = fs::write(&path, json) {
177 eprintln!("Error: couldn't write {}: {}", path.display(), e);
178 }
179 }
180 Err(e) => eprintln!("Error: couldn't serialize: {}", e),
181 }
182}
183
184// ── Main ────────────────────────────────────────────────────────────────────
185
186fn main() {
187 let args = Args::parse();
188 let mut rng = rand::thread_rng();
189 let mut save_file = load_save_file();
190
191 // Handle deletes first.
192 if let Some(ref name) = args.delete {
193 if save_file.characters.remove(name).is_some() {
194 eprintln!("Deleted character '{}'", name);
195 write_save_file(&save_file);
196 } else {
197 eprintln!("No character named '{}' to delete", name);
198 }
199 return;
200 }
201 if let Some(ref name) = args.deleteanim {
202 if save_file.animations.remove(name).is_some() {
203 eprintln!("Deleted animation '{}'", name);
204 write_save_file(&save_file);
205 } else {
206 eprintln!("No animation named '{}' to delete", name);
207 }
208 return;
209 }
210
211 // Resolve the character: "_" = random, named = lookup, none = from CLI args.
212 let base = if let Some(ref name) = args.name {
213 if name == "_" {
214 build_clood_from_args(&args, &mut rng)
215 } else {
216 match save_file.characters.get(name) {
217 Some(clood) => clood.clone(),
218 None => {
219 eprintln!("Unknown character '{}'. Saved characters:", name);
220 if save_file.characters.is_empty() {
221 eprintln!(" (none — use --save <name> to create one)");
222 } else {
223 for key in save_file.characters.keys() {
224 eprintln!(" {}", key);
225 }
226 }
227 std::process::exit(1);
228 }
229 }
230 }
231 } else {
232 build_clood_from_args(&args, &mut rng)
233 };
234
235 // Resolve animations: from CLI --anim flags, saved animation name(s), or both.
236 // The animation name can be comma-separated to chain multiple: "blink,wave,shrug"
237 let cli_animations = parse_cli_animations(&args);
238
239 let mut animation_sequence: Vec<Vec<Animation>> = Vec::new();
240
241 if let Some(ref anim_names) = args.anim_name {
242 for anim_name in anim_names.split(',') {
243 let anim_name = anim_name.trim();
244 if anim_name.is_empty() {
245 continue;
246 }
247 match save_file.animations.get(anim_name) {
248 Some(saved_anims) => {
249 // Merge CLI anims with this saved set
250 let mut combined = cli_animations.clone();
251 combined.extend(saved_anims.iter().cloned());
252 animation_sequence.push(combined);
253 }
254 None => {
255 eprintln!("Unknown animation '{}'. Saved animations:", anim_name);
256 if save_file.animations.is_empty() {
257 eprintln!(" (none — use --saveanim <name> to create one)");
258 } else {
259 for key in save_file.animations.keys() {
260 eprintln!(" {}", key);
261 }
262 }
263 std::process::exit(1);
264 }
265 }
266 }
267 } else if !cli_animations.is_empty() {
268 animation_sequence.push(cli_animations.clone());
269 }
270
271 // Flatten for saving (save only CLI-specified anims, not loaded ones).
272 let animations_flat = cli_animations;
273
274 // Save character if requested.
275 if let Some(ref save_name) = args.save {
276 save_file
277 .characters
278 .insert(save_name.clone(), base.clone());
279 eprintln!("Saved character '{}'", save_name);
280 write_save_file(&save_file);
281 }
282
283 // Save animation if requested.
284 if let Some(ref save_name) = args.saveanim {
285 if animations_flat.is_empty() {
286 eprintln!("No animations to save. Use --anim to define some first.");
287 std::process::exit(1);
288 }
289 save_file
290 .animations
291 .insert(save_name.clone(), animations_flat);
292 eprintln!("Saved animation '{}'", save_name);
293 write_save_file(&save_file);
294 }
295
296 // Render or animate.
297 if animation_sequence.is_empty() {
298 print!("{}", clood::render(&base));
299 } else {
300 animation::run_loop(&base, &animation_sequence, args.fps);
301 }
302}
303
304/// Parse --anim CLI flags into Animation specs.
305fn parse_cli_animations(args: &Args) -> Vec<Animation> {
306 args.anims
307 .iter()
308 .filter_map(|s| match Animation::parse(s) {
309 Ok(anim) => Some(anim),
310 Err(msg) => {
311 eprintln!("Warning: {}", msg);
312 None
313 }
314 })
315 .collect()
316}
317
318/// Build a Clood from CLI arguments, randomizing any unspecified values.
319fn build_clood_from_args(args: &Args, rng: &mut impl Rng) -> Clood {
320 Clood {
321 width: args.width.unwrap_or_else(|| rng.gen_range(8..=16)),
322 height: args.height.unwrap_or_else(|| rng.gen_range(4..=8)),
323 round: args.round,
324 mood: args.mood.unwrap_or_else(|| rng.gen_range(-2..=2)),
325 glance: args.glance,
326 left_eye: EyeState {
327 offset: args.lefteye,
328 closed: args.lefteyeclosed != 0,
329 },
330 right_eye: EyeState {
331 offset: args.righteye,
332 closed: args.righteyeclosed != 0,
333 },
334 arm_size: args.armsize.unwrap_or_else(|| rng.gen_range(2..=4)),
335 left_arm_offset: args.leftarm,
336 right_arm_offset: args.rightarm,
337 left_arm_slope: args.leftslope,
338 right_arm_slope: args.rightslope,
339 num_legs: args.numlegs.unwrap_or_else(|| rng.gen_range(0..=4) * 2),
340 leg_size: args.legsize.unwrap_or_else(|| rng.gen_range(2..=4)),
341 body_color: args
342 .color
343 .as_deref()
344 .and_then(Color::from_hex)
345 .unwrap_or_else(|| {
346 if args.color.is_some() {
347 eprintln!("Warning: invalid --color, using random");
348 }
349 Color::random(rng)
350 }),
351 eye_color: args
352 .eyecolor
353 .as_deref()
354 .and_then(Color::from_hex)
355 .unwrap_or_else(|| {
356 if args.eyecolor.is_some() {
357 eprintln!("Warning: invalid --eyecolor, using random");
358 }
359 Color::random(rng)
360 }),
361 }
362}