//! Core clood type, rendering, and leg layout. use serde::{Deserialize, Serialize}; use crate::color::Color; // ── Types ─────────────────────────────────────────────────────────────────── /// Configuration for one eye: vertical offset and open/closed state. #[derive(Debug, Clone, Copy, Serialize, Deserialize)] pub struct EyeState { /// Vertical offset from the mood baseline. Positive = higher. pub offset: i32, /// Whether the eye is closed (drawn as a half block). pub closed: bool, } /// All resolved parameters for rendering a single frame. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Clood { /// Body width in columns (minimum 4). pub width: usize, /// Body height in rows (minimum 3). pub height: usize, /// Corner rounding: how many columns to cut from top corners (0 to width/2). pub round: usize, /// Eye mood baseline offset from center. Positive = eyes higher. pub mood: i32, /// Horizontal offset for both eyes. Positive = glance right, negative = left. #[serde(default)] pub glance: i32, pub left_eye: EyeState, pub right_eye: EyeState, /// Width of each arm in columns. pub arm_size: usize, /// Vertical offset of left arm from default position. Positive = higher. pub left_arm_offset: i32, /// Vertical offset of right arm from default position. Positive = higher. pub right_arm_offset: i32, /// Slope of left arm: each column going outward steps this many rows up (positive) or down (negative). #[serde(default)] pub left_arm_slope: i32, /// Slope of right arm: each column going outward steps this many rows up (positive) or down (negative). #[serde(default)] pub right_arm_slope: i32, /// Number of legs (0 for legless, typically even). pub num_legs: usize, /// Length of each leg in rows. pub leg_size: usize, pub body_color: Color, pub eye_color: Color, } impl Clood { /// Ensure all dimensions are within valid bounds. pub fn sanitized(&self) -> Clood { let mut c = self.clone(); c.width = c.width.max(4); c.height = c.height.max(3); c.round = c.round.min(c.width / 2); c } /// Total rendered height including legs (for cursor repositioning). pub fn rendered_height(&self) -> usize { let body_height = self.height.max(3); let leg_rows = if self.num_legs == 0 { 0 } else { self.leg_size }; body_height + leg_rows } } // ── Rendering ─────────────────────────────────────────────────────────────── /// Render a clood to a string of ANSI-colored unicode blocks. pub fn render(clood: &Clood) -> String { let c = clood.sanitized(); let body_block = c.body_color.full_block(); let eye_open = c.eye_color.full_block(); let eye_closed = c.eye_color.half_block_on(&c.body_color); let space = " "; // ── Eye positions ─────────────────────────────────────────────────── let eye_baseline = compute_eye_baseline(c.height, c.mood); let left_eye_row = clamp_to_body(eye_baseline - c.left_eye.offset, c.height); let right_eye_row = clamp_to_body(eye_baseline - c.right_eye.offset, c.height); // Eyes sit at ~1/3 and ~2/3 across the body width, shifted by glance. // Clamped to stay within the body after corner rounding. let base_left_col = c.width as i32 / 3 + c.glance; let base_right_col = (c.width as i32 - 1 - c.width as i32 / 3) + c.glance; let left_eye_col = base_left_col.clamp(1, c.width as i32 - 2) as usize; let right_eye_col = base_right_col.clamp(1, c.width as i32 - 2) as usize; // ── Arm positions ─────────────────────────────────────────────────── // Arms default to one row below the lower eye. let arm_default_row = (left_eye_row.max(right_eye_row) + 1).min(c.height - 1); let left_arm_row = clamp_row(arm_default_row as i32 - c.left_arm_offset, c.height); let right_arm_row = clamp_row(arm_default_row as i32 - c.right_arm_offset, c.height); let arm_pad = c.arm_size; let total_cols = arm_pad + c.width + arm_pad; let mut output = String::new(); // ── Body rows (including arms and eyes) ───────────────────────────── for row in 0..c.height { let corner_cut = corner_rounding(row, c.round); for col in 0..total_cols { let region = classify_column(col, arm_pad, c.width); match region { ColumnRegion::LeftArm => { // Distance from body: col closest to body = arm_pad-1, farthest = 0 let dist = (arm_pad - 1 - col) as i32; let arm_row_here = (left_arm_row as i32 - dist * c.left_arm_slope) .clamp(0, c.height as i32 - 1) as usize; if row == arm_row_here { output.push_str(&body_block); } else { output.push_str(space); } } ColumnRegion::RightArm => { // Distance from body: col closest to body = arm_pad+width, farthest = last let dist = (col - arm_pad - c.width) as i32; let arm_row_here = (right_arm_row as i32 - dist * c.right_arm_slope) .clamp(0, c.height as i32 - 1) as usize; if row == arm_row_here { output.push_str(&body_block); } else { output.push_str(space); } } ColumnRegion::Body(body_col) => { if body_col < corner_cut || body_col >= c.width - corner_cut { output.push_str(space); } else if row == left_eye_row && body_col == left_eye_col { output.push_str(if c.left_eye.closed { &eye_closed } else { &eye_open }); } else if row == right_eye_row && body_col == right_eye_col { output.push_str(if c.right_eye.closed { &eye_closed } else { &eye_open }); } else { output.push_str(&body_block); } } } } output.push('\n'); } // ── Leg rows ──────────────────────────────────────────────────────── let legs = LegLayout::compute(c.width, c.num_legs); let leg_rows = if c.num_legs == 0 { 0 } else { c.leg_size }; let leg_pad = (arm_pad as i32 + legs.inset).max(0) as usize; for _ in 0..leg_rows { for _ in 0..leg_pad { output.push_str(space); } for &has_leg in &legs.columns { output.push_str(if has_leg { &body_block } else { space }); } output.push('\n'); } output } // ── Helpers ───────────────────────────────────────────────────────────────── /// Which region of the output grid a column falls in. enum ColumnRegion { LeftArm, Body(usize), // column index within the body RightArm, } /// Classify a column index into a region. fn classify_column(col: usize, arm_pad: usize, body_width: usize) -> ColumnRegion { if col < arm_pad { ColumnRegion::LeftArm } else if col < arm_pad + body_width { ColumnRegion::Body(col - arm_pad) } else { ColumnRegion::RightArm } } /// Compute the eye baseline row from body height and mood. /// Returns a signed value for further offset arithmetic. fn compute_eye_baseline(height: usize, mood: i32) -> i32 { let midpoint = height as i32 / 2; (midpoint - mood).clamp(1, height as i32 - 2) } /// Clamp a row into valid body interior rows (1..height-2). fn clamp_to_body(row: i32, height: usize) -> usize { row.clamp(1, height as i32 - 2) as usize } /// Clamp a row value to 0..height-1. fn clamp_row(row: i32, height: usize) -> usize { row.clamp(0, height as i32 - 1) as usize } /// How many columns to cut from each side of a body row for corner rounding. /// Row 0 gets the full `round` cut; each subsequent row gets one less. fn corner_rounding(row: usize, round: usize) -> usize { round.saturating_sub(row) } // ── Leg layout ────────────────────────────────────────────────────────────── /// The computed layout for legs beneath the body. struct LegLayout { /// Boolean mask: true at each column where a leg is drawn. columns: Vec, /// How much the leg span is offset from the body left edge. /// Positive = inset (narrower), negative = outset (wider). inset: i32, } impl LegLayout { /// Compute the optimal leg placement for the given body width and leg count. /// /// Prefers inset by 1 (legs narrower than body) for a cleaner look. /// Falls back to full body width, then outset by 1 if needed. /// Adjacent legs always have at least 1 gap between them. fn compute(body_width: usize, num_legs: usize) -> Self { if num_legs == 0 { return LegLayout { columns: vec![false; body_width], inset: 0, }; } // Minimum span to guarantee gaps: N legs need 2N-1 columns. let min_span = if num_legs <= 1 { 1 } else { 2 * num_legs - 1 }; // Try from narrowest to widest: inset → flush → outset. let (span, inset) = if body_width >= 4 && body_width - 2 >= min_span { (body_width - 2, 1i32) } else if body_width >= min_span { (body_width, 0i32) } else { (body_width + 2, -1i32) }; let mut columns = vec![false; span]; if num_legs == 1 { columns[span / 2] = true; return LegLayout { columns, inset }; } // Distribute legs with symmetric gap widths. // Extra-wide gaps are centered in the middle for visual balance. let num_gaps = num_legs - 1; let base_gap = (span - 1) / num_gaps; let extra_gaps = (span - 1) % num_gaps; let mut gap_sizes = vec![base_gap; num_gaps]; let first_wide = (num_gaps - extra_gaps) / 2; for gap in gap_sizes.iter_mut().skip(first_wide).take(extra_gaps) { *gap += 1; } let mut col = 0usize; for (i, _) in (0..num_legs).enumerate() { if col < span { columns[col] = true; } if i < num_gaps { col += gap_sizes[i]; } } LegLayout { columns, inset } } }