Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

facett-core/src/effects.rs

//! **effects** — motion + bloom for the facett look. Pure egui, **glow backend
//! only**: everything here paints with layered alpha shapes on an
//! [`egui::Painter`], so it works without a custom GPU shader.
//!
//! Three layers:
//! 1. [`easing`] — `t∈[0,1] → [0,1]` tweens (cubic, back, elastic, bounce).
//! 2. **bloom helpers** — [`glow_rect`], [`glow_text`], [`shimmer`], and a tiny
//!    [`ParticleBurst`].
//! 3. [`RavenSprite`] — the signature effect: a raven flies in along a bezier
//!    arc with ease-out and *perches* on a target [`egui::Rect`], then idle-bobs.
//!
//! # Where the GPU path lands later
//! True bloom (a bright-pass + separable Gaussian on a HDR target) and the raven
//! as a textured/instanced sprite belong in the **wgpu kernel** (see
//! `.nornir/wgpu-plan.md`): they'd replace the layered-alpha [`glow_rect`] /
//! shape-painted raven here behind the same call sites. Until then this CPU path
//! is the reference and is fully headless-testable.

use egui::{Color32, Painter, Pos2, Rect, Stroke, Vec2, pos2, vec2};
use serde::{Deserialize, Serialize};

use crate::look::Motion;

/// `t∈[0,1] → [0,1]` tweening curves. Each is clamped at the ends so
/// `f(0.0) == 0.0` and `f(1.0) == 1.0` exactly (the demo + tests rely on it).
pub mod easing {
    /// Identity ramp.
    pub fn linear(t: f32) -> f32 {
        t.clamp(0.0, 1.0)
    }

    /// Smooth accelerate-then-decelerate (the workhorse).
    pub fn ease_in_out_cubic(t: f32) -> f32 {
        let t = t.clamp(0.0, 1.0);
        if t < 0.5 {
            4.0 * t * t * t
        } else {
            let f = -2.0 * t + 2.0;
            1.0 - f * f * f / 2.0
        }
    }

    /// Overshoots slightly past 1.0 near the end, then settles — a confident
    /// "snap into place". Endpoints are still exactly 0 and 1.
    pub fn ease_out_back(t: f32) -> f32 {
        let t = t.clamp(0.0, 1.0);
        const C1: f32 = 1.70158;
        const C3: f32 = C1 + 1.0;
        let f = t - 1.0;
        1.0 + C3 * f * f * f + C1 * f * f
    }

    /// Springy elastic settle (decaying sine). `f(0)=0`, `f(1)=1`.
    pub fn elastic(t: f32) -> f32 {
        let t = t.clamp(0.0, 1.0);
        if t == 0.0 || t == 1.0 {
            return t;
        }
        const C4: f32 = std::f32::consts::TAU / 3.0;
        2.0_f32.powf(-10.0 * t) * ((t * 10.0 - 0.75) * C4).sin() + 1.0
    }

    /// Gravity bounce-to-rest. `f(0)=0`, `f(1)=1`.
    pub fn bounce(t: f32) -> f32 {
        let t = t.clamp(0.0, 1.0);
        const N1: f32 = 7.5625;
        const D1: f32 = 2.75;
        if t < 1.0 / D1 {
            N1 * t * t
        } else if t < 2.0 / D1 {
            let t = t - 1.5 / D1;
            N1 * t * t + 0.75
        } else if t < 2.5 / D1 {
            let t = t - 2.25 / D1;
            N1 * t * t + 0.9375
        } else {
            let t = t - 2.625 / D1;
            N1 * t * t + 0.984375
        }
    }
}

/// A named easing curve — lets a [`Tween`] (or a theme [`Motion`]) carry *which*
/// curve to use, instead of every call site hard-coding an [`easing`] fn.
///
/// [`Motion`]: crate::look::Motion
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum Curve {
    /// Identity ramp.
    Linear,
    /// Smooth accelerate-then-decelerate — the workhorse (default).
    #[default]
    EaseInOutCubic,
    /// Confident overshoot-and-settle.
    EaseOutBack,
    /// Springy elastic settle.
    Elastic,
    /// Gravity bounce-to-rest.
    Bounce,
}

impl Curve {
    /// Apply the curve: `t∈[0,1] → [0,1]` (endpoints exact, clamped).
    pub fn apply(self, t: f32) -> f32 {
        match self {
            Curve::Linear => easing::linear(t),
            Curve::EaseInOutCubic => easing::ease_in_out_cubic(t),
            Curve::EaseOutBack => easing::ease_out_back(t),
            Curve::Elastic => easing::elastic(t),
            Curve::Bounce => easing::bounce(t),
        }
    }
}

/// **Injected-clock tween** of a scalar from `from`→`to` over `duration` seconds,
/// eased by a [`Curve`]. The single "animate a value over a duration" primitive the
/// form facets were missing (hover/selection/expand, drawer slide-ins).
///
/// Determinism (FC-7): a `Tween` holds **no egui state and no wall-clock** — you
/// evaluate it at the `elapsed` seconds *you* own (the same `dt`/clock model as
/// [`SmoothScroll`](crate::scroll_engine) and [`focus::motion_progress`](crate::focus::motion_progress)),
/// so snapshots stay reproducible. It is a pure value, `Copy`, cheap to rebuild each frame.
///
/// ```
/// use facett_core::effects::{Tween, Curve};
/// let t = Tween::new(0.0, 100.0, 0.2, Curve::EaseInOutCubic);
/// assert_eq!(t.value_at(0.0), 0.0);     // start
/// assert_eq!(t.value_at(0.2), 100.0);   // end
/// assert!(t.is_done(0.2));
/// ```
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Tween {
    /// Value at `elapsed <= 0`.
    pub from: f32,
    /// Value at `elapsed >= duration`.
    pub to: f32,
    /// Tween length in seconds. `<= 0` snaps instantly to `to` (honours `Motion::duration == 0`,
    /// the reduced-motion / Device setting).
    pub duration: f32,
    /// Easing curve.
    pub curve: Curve,
}

impl Tween {
    /// A tween `from`→`to` over `duration` seconds with `curve`.
    pub fn new(from: f32, to: f32, duration: f32, curve: Curve) -> Self {
        Self { from, to, duration, curve }
    }

    /// A standard-duration tween from the theme's [`Motion`](crate::look::Motion) token.
    pub fn from_motion(motion: &Motion, from: f32, to: f32, curve: Curve) -> Self {
        Self::new(from, to, motion.duration, curve)
    }

    /// A faster tween using [`Motion::fast`](crate::look::Motion) — for small/cheap transitions.
    pub fn from_motion_fast(motion: &Motion, from: f32, to: f32, curve: Curve) -> Self {
        Self::new(from, to, motion.fast, curve)
    }

    /// Eased value at `elapsed` seconds since the tween started.
    /// `value_at(<=0) == from`, `value_at(>=duration) == to`; `duration <= 0` ⇒ `to`.
    pub fn value_at(&self, elapsed: f32) -> f32 {
        if self.duration <= 0.0 {
            return self.to;
        }
        let t = (elapsed / self.duration).clamp(0.0, 1.0);
        self.from + (self.to - self.from) * self.curve.apply(t)
    }

    /// Eased progress `∈[0,1]` (the `to`-fraction) at `elapsed` — the lerp factor
    /// to drive geometry/colour interpolation yourself.
    pub fn progress_at(&self, elapsed: f32) -> f32 {
        if self.duration <= 0.0 {
            return 1.0;
        }
        self.curve.apply((elapsed / self.duration).clamp(0.0, 1.0))
    }

    /// Has the tween reached `to` by `elapsed`?
    pub fn is_done(&self, elapsed: f32) -> bool {
        self.duration <= 0.0 || elapsed >= self.duration
    }
}

/// Eased interpolation between two rectangles — the slide-over / drawer primitive
/// (interpolate an off-screen `from` rect to an on-screen `to` rect by the tween's
/// eased progress). Pairs with [`Tween`]/[`Curve`] to make a "half-over sliding
/// drawer" a one-call affordance over [`crate::overlay`].
pub fn tween_rect(from: Rect, to: Rect, eased_t: f32) -> Rect {
    let t = eased_t.clamp(0.0, 1.0);
    let lerp = |a: f32, b: f32| a + (b - a) * t;
    Rect::from_min_max(
        pos2(lerp(from.min.x, to.min.x), lerp(from.min.y, to.min.y)),
        pos2(lerp(from.max.x, to.max.x), lerp(from.max.y, to.max.y)),
    )
}

/// Injected-clock **fade tracker** keyed by a small `u64` id — the per-element
/// hover / selection *progress* the immediate-mode form facets were missing. Each
/// key holds a factor in `[0,1]` that eases toward `1` while the key is "lit" this
/// frame (hovered / selected) and toward `0` once it isn't; keys that settle back
/// at `0` are dropped, so the map only ever holds the handful of elements that are
/// currently mid-fade.
///
/// The per-frame protocol is three calls: [`begin`](Self::begin) (mark all keys
/// un-lit), [`lit`](Self::lit) for each element that should glow this frame, then
/// [`advance`](Self::advance) with your own `dt` once the frame is laid out. Read
/// the eased value with [`factor`](Self::factor) when you paint each element.
///
/// Determinism (FC-7): no egui state, no wall-clock — the factors live in the
/// caller's component struct and advance by the `dt` *you* own (pass
/// `ui.input(|i| i.stable_dt)`), so snapshots reproduce. `duration <= 0` snaps
/// instantly (honours `Motion::duration == 0`, the reduced-motion / Device setting).
#[derive(Clone, Debug, Default)]
pub struct FadeTrack {
    fades: std::collections::HashMap<u64, FadeState>,
}

#[derive(Clone, Copy, Debug)]
struct FadeState {
    factor: f32,
    lit: bool,
}

impl FadeTrack {
    /// Start a frame: mark every tracked key as *not* lit. Re-light the ones that
    /// should glow with [`lit`](Self::lit) before [`advance`](Self::advance).
    pub fn begin(&mut self) {
        for f in self.fades.values_mut() {
            f.lit = false;
        }
    }

    /// Mark `key` lit this frame (hovered / selected). Creates it at factor `0`
    /// (so it fades *in*) if it wasn't tracked yet.
    pub fn lit(&mut self, key: u64) {
        self.fades.entry(key).or_insert(FadeState { factor: 0.0, lit: false }).lit = true;
    }

    /// The current eased factor `∈[0,1]` for `key` (`0` if untracked) — multiply
    /// your highlight alpha by this when painting the element.
    pub fn factor(&self, key: u64) -> f32 {
        self.fades.get(&key).map(|f| f.factor).unwrap_or(0.0)
    }

    /// How many keys are currently tracked (lit or still fading out) — a
    /// host-readable "is motion live right now" signal for `state_json` proofs.
    pub fn active(&self) -> usize {
        self.fades.len()
    }

    /// Whether any tracked key is mid-fade (factor strictly between fully-off and
    /// fully-on) — i.e. a cross-fade is visibly animating this frame.
    pub fn is_animating(&self) -> bool {
        self.fades.values().any(|f| f.factor > 1e-3 && f.factor < 1.0 - 1e-3)
    }

    /// Advance every key toward its target (`lit → 1`, else `→ 0`) over `duration`
    /// seconds, dropping keys that have faded fully back to `0`. Returns whether any
    /// key is still mid-fade (the caller requests a repaint while so). `duration <= 0`
    /// snaps. Deterministic given the same factors + `dt`.
    pub fn advance(&mut self, dt: f32, duration: f32) -> bool {
        let step = if duration <= 0.0 { 1.0 } else { (dt.max(0.0) / duration).clamp(0.0, 1.0) };
        let mut animating = false;
        self.fades.retain(|_, f| {
            let target = if f.lit { 1.0 } else { 0.0 };
            let dist = target - f.factor;
            if dist.abs() <= step {
                f.factor = target;
            } else {
                f.factor += step * dist.signum();
                animating = true;
            }
            // Keep a key while it's lit or still visibly fading out.
            f.lit || f.factor > 1e-3
        });
        animating
    }

    /// Hash any `Hash` id (a row `&str`, a row index, a repo name) into the `u64`
    /// key space — the convenience bridge for facets whose element ids aren't
    /// already `u64`. Stable within a run (uses the std hasher).
    pub fn key(id: impl std::hash::Hash) -> u64 {
        use std::hash::Hasher as _;
        let mut h = std::collections::hash_map::DefaultHasher::new();
        id.hash(&mut h);
        h.finish()
    }
}

use crate::look::color::lerp_color;

/// Paint a soft **bloom** around `rect`: `layers` concentric rounded strokes that
/// fade out as they expand. `intensity∈[0,1]` scales the alpha (e.g. animate it
/// with a [`easing`] curve for a pulse). Cheap stand-in for true HDR bloom.
pub fn glow_rect(painter: &Painter, rect: Rect, color: Color32, intensity: f32, layers: u32) {
    let intensity = intensity.clamp(0.0, 1.0);
    let layers = layers.max(1);
    for i in 0..layers {
        let f = i as f32 / layers as f32; // 0 (inner) .. ~1 (outer)
        let grow = 1.0 + f * 7.0;
        let alpha = ((1.0 - f) * (1.0 - f) * 90.0 * intensity) as u8;
        if alpha == 0 {
            continue;
        }
        let c = Color32::from_rgba_unmultiplied(color.r(), color.g(), color.b(), alpha);
        painter.rect_stroke(
            rect.expand(grow),
            4.0 + grow,
            Stroke::new(1.5 + f * 2.0, c),
            egui::StrokeKind::Outside,
        );
    }
}

/// **Windows 11 "reveal highlight"** — the signature Fluent hover cue: a
/// cursor-follow edge glow that lights a control's border in proportion to how
/// near the pointer is, fading to dark beyond [`radius`](RevealHighlight::radius).
///
/// Built on the same layered-alpha edge approach as [`glow_rect`] (glow backend,
/// no GPU shader). Determinism (FC-1/FC-7): it holds **no egui state and no
/// wall-clock** — you pass the pointer position + rect each frame, so the same
/// inputs paint identically. It's the Windows-preset counterpart to macOS's
/// quieter highlight; gate it on [`NativeFeel::reveal_highlight`](crate::look::NativeFeel::reveal_highlight).
///
/// ```
/// use facett_core::effects::RevealHighlight;
/// use egui::{pos2, Rect, vec2, Color32};
/// let r = Rect::from_min_size(pos2(0.0, 0.0), vec2(100.0, 40.0));
/// let reveal = RevealHighlight::new(Color32::from_rgb(80, 160, 255));
/// assert_eq!(reveal.proximity(r, Some(pos2(50.0, 20.0))), 1.0); // pointer inside → full
/// assert_eq!(reveal.proximity(r, Some(pos2(9999.0, 9999.0))), 0.0); // far away → dark
/// assert_eq!(reveal.proximity(r, None), 0.0); // no pointer → dark
/// ```
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct RevealHighlight {
    /// Edge-glow colour (usually the theme `accent` or `glow` role).
    pub color: Color32,
    /// Distance (px) within which the pointer lights the edge; beyond it, dark.
    pub radius: f32,
    /// Peak edge intensity `∈[0,1]` when the pointer is on the control.
    pub intensity: f32,
    /// Number of glow layers (softer/heavier edge).
    pub layers: u32,
}

impl RevealHighlight {
    /// A reveal glow in `color` with Fluent-ish defaults (≈120px reach, soft).
    pub fn new(color: Color32) -> Self {
        Self { color, radius: 120.0, intensity: 0.8, layers: 4 }
    }

    /// Reach radius in px (builder).
    pub fn with_radius(mut self, radius: f32) -> Self {
        self.radius = radius.max(1.0);
        self
    }

    /// Peak intensity `∈[0,1]` (builder).
    pub fn with_intensity(mut self, intensity: f32) -> Self {
        self.intensity = intensity.clamp(0.0, 1.0);
        self
    }

    /// **Pointer proximity** `∈[0,1]` for `rect`: `1` when the pointer is on the
    /// control, decaying (quadratic falloff) to `0` at [`radius`](Self::radius) and
    /// for `None`/far pointers. The pure, testable core of the effect.
    pub fn proximity(&self, rect: Rect, pointer: Option<Pos2>) -> f32 {
        let Some(p) = pointer else { return 0.0 };
        // Distance from the pointer to the nearest point on the rect (0 if inside).
        let nx = p.x.clamp(rect.left(), rect.right());
        let ny = p.y.clamp(rect.top(), rect.bottom());
        let dist = (p - pos2(nx, ny)).length();
        if dist >= self.radius {
            return 0.0;
        }
        let t = 1.0 - dist / self.radius; // 1 at the edge, 0 at radius
        t * t // quadratic falloff — a tight, glowing pool around the cursor
    }

    /// Paint the reveal edge glow on `rect` for the current `pointer`, scaled by
    /// [`proximity`](Self::proximity)·[`intensity`](Self::intensity). No-op when the
    /// pointer is absent/far (proximity `0`). Pure painter work — no state, no clock.
    pub fn paint(&self, painter: &Painter, rect: Rect, pointer: Option<Pos2>, corner_radius: f32) {
        let lit = self.proximity(rect, pointer) * self.intensity;
        if lit <= 0.0 {
            return;
        }
        let layers = self.layers.max(1);
        for i in 0..layers {
            let f = i as f32 / layers as f32; // 0 (inner) .. ~1 (outer)
            let grow = f * 4.0;
            let alpha = ((1.0 - f) * 150.0 * lit) as u8;
            if alpha == 0 {
                continue;
            }
            let c = Color32::from_rgba_unmultiplied(self.color.r(), self.color.g(), self.color.b(), alpha);
            painter.rect_stroke(
                rect.expand(grow),
                corner_radius + grow,
                Stroke::new(1.0 + f, c),
                egui::StrokeKind::Outside,
            );
        }
    }

    /// Convenience: paint the reveal glow **gated** on `enabled` (pass
    /// `theme.native.reveal_highlight`) AND the active
    /// [`EffectsPolicy`](crate::look::EffectsPolicy) allowing decorative motion,
    /// reading the live pointer from `ui`. The one-call hover affordance for a
    /// Windows-preset control. No-op on the macOS/Neutral/Device presets.
    pub fn paint_in(&self, ui: &egui::Ui, rect: Rect, corner_radius: f32, enabled: bool) {
        if !enabled || !crate::look::effects_policy(ui).allows_decorative_motion() {
            return;
        }
        let pointer = ui.input(|i| i.pointer.hover_pos());
        self.paint(ui.painter(), rect, pointer, corner_radius);
    }
}

/// Draw `text` at `pos` with a coloured **glow halo** behind it (offset copies
/// in a ring), then the crisp text on top. `glow` is usually `theme.glow`.
#[allow(clippy::too_many_arguments)]
pub fn glow_text(
    painter: &Painter,
    pos: Pos2,
    anchor: egui::Align2,
    text: &str,
    font: egui::FontId,
    text_color: Color32,
    glow: Color32,
    intensity: f32,
) {
    let intensity = intensity.clamp(0.0, 1.0);
    let halo = Color32::from_rgba_unmultiplied(glow.r(), glow.g(), glow.b(), (70.0 * intensity) as u8);
    for r in [3.0_f32, 2.0, 1.0] {
        for k in 0..8 {
            let a = std::f32::consts::TAU * k as f32 / 8.0;
            let off = vec2(a.cos(), a.sin()) * r;
            painter.text(pos + off, anchor, text, font.clone(), halo);
        }
    }
    painter.text(pos, anchor, text, font, text_color);
}

/// Paint an animated **shimmer** sweep across `rect`: a diagonal highlight band
/// at phase `t∈[0,1]` (wrap `t` yourself, e.g. `(time * speed).fract()`). Built
/// from a handful of vertical alpha bars, brightest at the band centre.
pub fn shimmer(painter: &Painter, rect: Rect, color: Color32, t: f32) {
    let bars = 24;
    let band = 0.18; // half-width of the highlight, in [0,1] of the sweep
    let center = t.rem_euclid(1.0) * (1.0 + 2.0 * band) - band;
    for i in 0..bars {
        let x = (i as f32 + 0.5) / bars as f32; // 0..1 across the rect
        let d = (x - center).abs() / band;
        if d >= 1.0 {
            continue;
        }
        let g = (1.0 - d) * (1.0 - d); // brightness, 0 at edges → 1 at band centre
        // Brighten toward white at the band centre for a glinting highlight,
        // then carry that as a translucent overlay.
        let bright = lerp_color(color, Color32::WHITE, g * 0.6);
        let c = Color32::from_rgba_unmultiplied(bright.r(), bright.g(), bright.b(), (g * 130.0) as u8);
        let bx0 = rect.left() + x * rect.width();
        let bw = rect.width() / bars as f32 + 1.0;
        // a gentle diagonal: shear the bar top by the band offset
        let shear = (x - 0.5) * rect.height() * 0.25;
        let seg = Rect::from_min_max(pos2(bx0, rect.top() + shear), pos2(bx0 + bw, rect.bottom() + shear))
            .intersect(rect);
        painter.rect_filled(seg, 0.0, c);
    }
}

/// One particle: position, velocity, age. Internal to [`ParticleBurst`].
#[derive(Clone, Copy)]
struct Particle {
    pos: Pos2,
    vel: Vec2,
    /// Seconds lived.
    age: f32,
}

/// A tiny **particle burst** — circles fired outward from a point that fall under
/// gravity and fade out. Deterministic given the same `seed`, so a test can pin
/// it. Advance with [`ParticleBurst::update`], render with
/// [`ParticleBurst::paint`].
#[derive(Clone)]
pub struct ParticleBurst {
    particles: Vec<Particle>,
    color: Color32,
    /// px/s² downward.
    gravity: f32,
    /// Total lifetime of a particle, seconds.
    lifetime: f32,
    elapsed: f32,
}

impl ParticleBurst {
    /// Fire `count` particles from `origin` outward in a ring, with speeds
    /// jittered by `seed` (deterministic — no RNG crate).
    pub fn new(origin: Pos2, count: usize, color: Color32, seed: u64) -> Self {
        let mut h = seed ^ 0x9E37_79B9_7F4A_7C15;
        let mut rng = || {
            // SplitMix64 — deterministic, dependency-free.
            h = h.wrapping_add(0x9E37_79B9_7F4A_7C15);
            let mut z = h;
            z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
            z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
            ((z ^ (z >> 31)) as f64 / u64::MAX as f64) as f32
        };
        let particles = (0..count)
            .map(|i| {
                let a = std::f32::consts::TAU * i as f32 / count.max(1) as f32;
                let speed = 90.0 + rng() * 140.0;
                let up = 0.6 + rng() * 0.4; // bias upward so they arc nicely
                Particle { pos: origin, vel: vec2(a.cos() * speed, a.sin() * speed - 120.0 * up), age: 0.0 }
            })
            .collect();
        Self { particles, color, gravity: 520.0, lifetime: 1.1, elapsed: 0.0 }
    }

    /// Advance every particle by `dt` seconds (integrate velocity + gravity).
    pub fn update(&mut self, dt: f32) {
        let dt = dt.max(0.0);
        self.elapsed += dt;
        for p in &mut self.particles {
            p.vel.y += self.gravity * dt;
            p.pos += p.vel * dt;
            p.age += dt;
        }
    }

    /// All particles have aged past their lifetime — the burst is done and can
    /// be dropped.
    pub fn finished(&self) -> bool {
        self.elapsed >= self.lifetime
    }

    /// Paint each live particle as a fading circle.
    pub fn paint(&self, painter: &Painter) {
        for p in &self.particles {
            let life = (1.0 - p.age / self.lifetime).clamp(0.0, 1.0);
            if life <= 0.0 {
                continue;
            }
            let a = (life * 220.0) as u8;
            let c = Color32::from_rgba_unmultiplied(self.color.r(), self.color.g(), self.color.b(), a);
            painter.circle_filled(p.pos, 1.0 + life * 2.5, c);
        }
    }
}

/// Quadratic bezier point at `t∈[0,1]` for control points `p0,p1,p2`.
fn bezier2(p0: Pos2, p1: Pos2, p2: Pos2, t: f32) -> Pos2 {
    let u = 1.0 - t;
    let x = u * u * p0.x + 2.0 * u * t * p1.x + t * t * p2.x;
    let y = u * u * p0.y + 2.0 * u * t * p1.y + t * t * p2.y;
    pos2(x, y)
}

/// The flight duration (seconds) of a [`RavenSprite`] from launch to perch.
pub const RAVEN_FLIGHT_SECS: f32 = 1.4;

/// **The signature effect.** A stylized raven 🐦 (painted from shapes — no asset
/// dependency, so it never goes missing) that **flies in along a bezier arc with
/// ease-out and perches** on a target [`egui::Rect`], then idle-bobs.
///
/// Motion is **deterministic given `(start, target, elapsed)`** — that's what
/// makes [`RavenSprite::pos_at`] unit-testable. The egui-driven loop is:
///
/// ```ignore
/// raven.update(ctx);       // reads ctx.input(i.time), requests a repaint while flying
/// raven.paint(&painter);   // draws the bird at its current pos
/// ```
#[derive(Clone)]
pub struct RavenSprite {
    start: Pos2,
    target: Pos2,
    /// `None` until the first [`update`](Self::update) pins the launch time.
    launch_time: Option<f64>,
    /// Filled by [`update`](Self::update) each frame.
    current: Pos2,
    /// 0 while flying, 1 once perched.
    perched: bool,
    /// Seconds since launch (drives the idle bob once perched).
    elapsed: f32,
    color: Color32,
    /// Face left (-1) or right (+1) — set from the flight direction.
    facing: f32,
    scale: f32,
}

impl Default for RavenSprite {
    fn default() -> Self {
        Self::new()
    }
}

impl RavenSprite {
    /// A raven waiting off the top-left, ready to be aimed with [`fly_to`].
    pub fn new() -> Self {
        Self {
            start: pos2(-40.0, -40.0),
            target: pos2(0.0, 0.0),
            launch_time: None,
            current: pos2(-40.0, -40.0),
            perched: false,
            elapsed: 0.0,
            color: Color32::from_rgb(18, 18, 22),
            facing: 1.0,
            scale: 1.0,
        }
    }

    /// Launch point of the flight (defaults to off-screen top-left).
    pub fn from(mut self, start: Pos2) -> Self {
        self.start = start;
        self.current = start;
        self
    }

    /// Raven body colour (override the default near-black, e.g. for `hugin_noir`
    /// you might tint the beak/eye via `accent` separately).
    pub fn color(mut self, color: Color32) -> Self {
        self.color = color;
        self
    }

    /// Overall sprite scale (1.0 ≈ a ~14px-tall bird body).
    pub fn scale(mut self, scale: f32) -> Self {
        self.scale = scale.max(0.1);
        self
    }

    /// **Aim the raven** at `target`: it will perch centred on the top edge of
    /// the rect (like landing on a table row). Resets the flight clock.
    pub fn fly_to(mut self, target: Rect) -> Self {
        // Perch on the top edge, centred — feet just above the row.
        self.target = pos2(target.center().x, target.top());
        self.facing = if self.target.x >= self.start.x { 1.0 } else { -1.0 };
        self.launch_time = None;
        self.perched = false;
        self
    }

    /// The flight path control point — a high arc above the midpoint, so the
    /// raven swoops down onto the perch. Pure function of `start`/`target`.
    fn control(&self) -> Pos2 {
        let mid = pos2((self.start.x + self.target.x) * 0.5, (self.start.y + self.target.y) * 0.5);
        let span = (self.target - self.start).length().max(1.0);
        pos2(mid.x, mid.y - span * 0.45) // lift the arc
    }

    /// **Deterministic position** at `elapsed` seconds since launch — the testable
    /// core. Eases along the bezier arc with [`easing::ease_out_back`] (a
    /// confident landing), holds the perch afterwards. Adds a subtle idle bob
    /// once perched.
    pub fn pos_at(&self, elapsed: f32) -> Pos2 {
        if elapsed >= RAVEN_FLIGHT_SECS {
            // Perched: tiny vertical bob (±1.5px, ~0.6Hz).
            let bob = ((elapsed - RAVEN_FLIGHT_SECS) * std::f32::consts::TAU * 0.6).sin() * 1.5;
            return pos2(self.target.x, self.target.y + bob);
        }
        let lin = (elapsed / RAVEN_FLIGHT_SECS).clamp(0.0, 1.0);
        let t = easing::ease_out_back(lin);
        bezier2(self.start, self.control(), self.target, t)
    }

    /// True once the flight is over (raven is perched, idle-bobbing).
    pub fn is_perched(&self) -> bool {
        self.perched
    }

    /// Current sprite position (set by the last [`update`](Self::update)).
    pub fn pos(&self) -> Pos2 {
        self.current
    }

    /// Drive the animation from the egui clock. Pins the launch time on first
    /// call, advances `current`, and `request_repaint`s while still in flight so
    /// the animation keeps ticking. Call once per frame before [`paint`].
    pub fn update(&mut self, ctx: &egui::Context) {
        let now = ctx.input(|i| i.time);
        let launch = *self.launch_time.get_or_insert(now);
        let elapsed = (now - launch) as f32;
        self.advance(elapsed);
        if !self.perched {
            ctx.request_repaint();
        }
    }

    /// Time-driven core shared by [`update`] and the headless test: set the
    /// current pos + facing + perched flag for `elapsed` seconds since launch.
    pub fn advance(&mut self, elapsed: f32) {
        self.elapsed = elapsed;
        self.current = self.pos_at(elapsed);
        self.perched = elapsed >= RAVEN_FLIGHT_SECS;
    }

    /// Paint the raven at its current position. A stylized corvid built from a
    /// body ellipse-ish blob, swept wings, a wedge tail, a head, and a beak —
    /// wings flap while flying and fold once perched.
    pub fn paint(&self, painter: &Painter) {
        let c = self.current;
        let s = self.scale;
        let f = self.facing;
        let body = self.color;
        let stroke = Stroke::new(1.0 * s, body);

        // Wing phase: flapping in flight, near-folded when perched.
        let flap = if self.perched {
            0.15
        } else {
            // 0..1..0 flap, ~5Hz
            (self.elapsed * std::f32::consts::TAU * 5.0).sin() * 0.5 + 0.5
        };
        let wing_lift = (flap - 0.5) * 9.0 * s; // up/down sweep of the wingtip

        // Body: a rounded blob (overlapping circles read as a corvid body).
        painter.circle_filled(c, 5.0 * s, body);
        painter.circle_filled(c + vec2(-3.5 * s * f, 1.0 * s), 3.5 * s, body);

        // Tail: a wedge trailing behind (opposite the facing direction).
        let tail_root = c + vec2(-5.0 * s * f, 0.5 * s);
        painter.add(egui::Shape::convex_polygon(
            vec![
                tail_root,
                tail_root + vec2(-7.0 * s * f, -2.5 * s),
                tail_root + vec2(-7.5 * s * f, 1.0 * s),
                tail_root + vec2(-6.0 * s * f, 3.0 * s),
            ],
            body,
            stroke,
        ));

        // Wings: two swept triangles from the back, lifting with the flap.
        let shoulder = c + vec2(-s * f, -1.5 * s);
        let tip_far = shoulder + vec2(-9.0 * s * f, -wing_lift - 2.0 * s);
        let tip_near = shoulder + vec2(-4.0 * s * f, -wing_lift * 0.5 + 4.0 * s);
        painter.add(egui::Shape::convex_polygon(
            vec![shoulder, tip_far, tip_near],
            body,
            stroke,
        ));

        // Head + beak, leading the body in the facing direction.
        let head = c + vec2(4.5 * s * f, -2.5 * s);
        painter.circle_filled(head, 3.0 * s, body);
        let beak_color = Color32::from_rgb(40, 30, 18); // charcoal beak
        painter.add(egui::Shape::convex_polygon(
            vec![
                head + vec2(2.5 * s * f, -0.5 * s),
                head + vec2(6.5 * s * f, 0.5 * s),
                head + vec2(2.5 * s * f, 1.5 * s),
            ],
            beak_color,
            Stroke::NONE,
        ));
        // Eye: a tiny bright glint so it reads as alive.
        painter.circle_filled(head + vec2(1.2 * s * f, -0.8 * s), 0.8 * s, Color32::from_rgb(230, 220, 210));
    }
}

// ── ice drip (the Skaði "Skade Vinter" winter decor) ─────────────────────────

/// One icicle hanging from the top edge. Internal to [`IceDrip`].
#[derive(Clone, Copy)]
struct Icicle {
    /// Horizontal position across the top edge, fraction in `[0,1]`.
    x: f32,
    /// Hanging length, px.
    len: f32,
    /// Base half-width, px.
    half_w: f32,
    /// Per-icicle phase offset into the drip cycle, `[0,1)`.
    phase: f32,
    /// Per-icicle cycle-rate multiplier (so they don't all drip in lockstep).
    rate: f32,
}

/// **Ice-drip decor** — a row of icicles along the top edge of a rect with
/// **melt-droplets that fall under gravity, fade out, and recur**, plus a faint
/// **frost shimmer** along the edge. The signature decor of the Skaði *"Skade
/// Vinter"* winter scene (pairs with [`crate::look::Theme::skade_vinter`]).
///
/// Pure CPU — layered alpha shapes on an [`egui::Painter`], no GPU/backend risk —
/// **time-driven** and **deterministic given `(seed, clock)`**, so it is headless-
/// testable exactly like [`ParticleBurst`] / [`RavenSprite`]. It is **off unless
/// [`enabled`](Self::enabled)** (the gate); [`paint_gated`](Self::paint_gated) also
/// follows the active theme's [`EffectsPolicy`](crate::look::EffectsPolicy) so it
/// stays dark under `Reduced`/`None`/Device.
///
/// ```ignore
/// let mut drip = IceDrip::new(12, 0).enabled(true);
/// drip.set_clock(ui.input(|i| i.time));   // time-driven
/// drip.paint_gated(ui, ui.max_rect());    // honours EffectsPolicy
/// ```
#[derive(Clone)]
pub struct IceDrip {
    icicles: Vec<Icicle>,
    /// Icicle body colour.
    ice: Color32,
    /// Frost shimmer + droplet highlight colour.
    glow: Color32,
    /// Seconds since the scene started (set by [`set_clock`](Self::set_clock),
    /// advanced by [`update`](Self::update)).
    elapsed: f32,
    /// Drip cycle length, seconds (one droplet forms + falls per cycle).
    period: f32,
    /// How far a droplet falls (fraction of the rect height) before it fades.
    fall_frac: f32,
    /// The gate — nothing paints and no droplet is "active" until enabled.
    enabled: bool,
}

impl IceDrip {
    /// `count` icicles across the top edge, jittered deterministically by `seed`
    /// (SplitMix64 — no RNG crate). Default icy palette; **disabled by default**
    /// (the gate) — call [`enabled(true)`](Self::enabled) to turn it on.
    pub fn new(count: usize, seed: u64) -> Self {
        let mut h = seed ^ 0x51ED_2701_A17F_C3B9;
        let mut rng = || {
            h = h.wrapping_add(0x9E37_79B9_7F4A_7C15);
            let mut z = h;
            z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
            z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
            ((z ^ (z >> 31)) as f64 / u64::MAX as f64) as f32
        };
        let n = count.max(1);
        let icicles = (0..n)
            .map(|i| {
                // even spread across the edge + a little jitter so it reads natural
                let base = (i as f32 + 0.5) / n as f32;
                let x = (base + (rng() - 0.5) * 0.6 / n as f32).clamp(0.0, 1.0);
                Icicle {
                    x,
                    len: 8.0 + rng() * 16.0,
                    half_w: 3.0 + rng() * 3.0,
                    phase: rng(),
                    rate: 0.7 + rng() * 0.6,
                }
            })
            .collect();
        Self {
            icicles,
            ice: Color32::from_rgb(206, 232, 248),  // pale glacier ice
            glow: Color32::from_rgb(170, 214, 240), // frost bloom
            elapsed: 0.0,
            period: 2.6,
            fall_frac: 0.85,
            enabled: false,
        }
    }

    /// Turn the decor on/off (the gate). Builder form.
    pub fn enabled(mut self, on: bool) -> Self {
        self.enabled = on;
        self
    }

    /// Override the icicle + frost colours.
    pub fn colors(mut self, ice: Color32, glow: Color32) -> Self {
        self.ice = ice;
        self.glow = glow;
        self
    }

    /// Drip cycle length in seconds (one droplet per cycle). Builder form.
    pub fn with_period(mut self, secs: f32) -> Self {
        self.period = secs.max(0.1);
        self
    }

    /// Pin the clock up front (builder form of [`set_clock`](Self::set_clock)) —
    /// the testable, deterministic entry point.
    pub fn at_clock(mut self, secs: f32) -> Self {
        self.elapsed = secs.max(0.0);
        self
    }

    /// Set the absolute scene time (drive from `ui.input(|i| i.time)`).
    pub fn set_clock(&mut self, secs: f32) {
        self.elapsed = secs.max(0.0);
    }

    /// Advance the scene time by `dt` seconds.
    pub fn update(&mut self, dt: f32) {
        self.elapsed += dt.max(0.0);
    }

    /// Is the decor on?
    pub fn is_enabled(&self) -> bool {
        self.enabled
    }

    /// An icicle's current drip-cycle position, `[0,1)`. Pure function of the clock.
    fn cycle(&self, ic: &Icicle) -> f32 {
        ((self.elapsed / self.period) * ic.rate + ic.phase).rem_euclid(1.0)
    }

    /// The droplet's fall distance as a fraction `[0,1]` of `fall_frac·height`, at
    /// cycle `c`. **Accelerating under gravity** (`c²`): 0 at the tip, 1 at the
    /// bottom of its fall — the testable motion core.
    fn droplet_fall_norm(c: f32) -> f32 {
        let c = c.clamp(0.0, 1.0);
        c * c
    }

    /// The droplet's alpha at cycle `c`: a quick bead-up near the tip, full body,
    /// then a fade as it nears the end of its fall.
    fn droplet_alpha(c: f32) -> f32 {
        let c = c.clamp(0.0, 1.0);
        if c < 0.08 {
            c / 0.08
        } else if c < 0.75 {
            1.0
        } else {
            (1.0 - (c - 0.75) / 0.25).max(0.0)
        }
    }

    /// The frost-shimmer sweep phase `[0,1)` along the top edge (pure clock fn).
    fn frost_phase(&self) -> f32 {
        (self.elapsed * 0.2).rem_euclid(1.0)
    }

    /// Paint the icicles + falling melt-droplets + frost shimmer onto `painter`,
    /// hanging from the **top edge** of `rect`. No-op unless [`enabled`](Self::enabled).
    pub fn paint(&self, painter: &Painter, rect: Rect) {
        if !self.enabled || rect.width() <= 0.0 || rect.height() <= 0.0 {
            return;
        }
        // Frost shimmer along the very top edge.
        let band = Rect::from_min_max(rect.left_top(), pos2(rect.right(), rect.top() + 14.0));
        shimmer(painter, band, self.glow, self.frost_phase());

        for ic in &self.icicles {
            let x = rect.left() + ic.x * rect.width();
            let tip = pos2(x, rect.top() + ic.len);
            // The icicle: a downward triangle from the top edge, translucent ice.
            let base_l = pos2(x - ic.half_w, rect.top());
            let base_r = pos2(x + ic.half_w, rect.top());
            let body = Color32::from_rgba_unmultiplied(self.ice.r(), self.ice.g(), self.ice.b(), 150);
            painter.add(egui::Shape::convex_polygon(
                vec![base_l, base_r, tip],
                body,
                Stroke::new(1.0_f32, self.glow),
            ));
            // A bright central ridge so the ice catches light.
            painter.line_segment(
                [pos2(x, rect.top()), tip],
                Stroke::new(1.0_f32, Color32::from_rgba_unmultiplied(255, 255, 255, 90)),
            );

            // The melt-droplet, falling under gravity and fading near the end.
            let c = self.cycle(ic);
            let a = Self::droplet_alpha(c);
            if a > 0.0 {
                let y = tip.y + Self::droplet_fall_norm(c) * self.fall_frac * rect.height();
                let col = Color32::from_rgba_unmultiplied(self.glow.r(), self.glow.g(), self.glow.b(), (a * 220.0) as u8);
                // A little teardrop: a round bead with a small tail above it.
                painter.circle_filled(pos2(x, y), 2.2, col);
                painter.circle_filled(pos2(x, y - 2.6), 1.1, col);
            }
        }
    }

    /// Paint **only if the active theme's [`EffectsPolicy`](crate::look::EffectsPolicy)
    /// allows decorative motion** (i.e. `Full`) — so the decor follows the theme
    /// (dark under `Reduced`/`None`/Device) on top of its own [`enabled`](Self::enabled)
    /// gate. Reads the policy published by [`crate::look::Theme::apply`].
    pub fn paint_gated(&self, ui: &egui::Ui, rect: Rect) {
        if crate::look::effects_policy(ui).allows_decorative_motion() {
            self.paint(ui.painter(), rect);
        }
    }

    /// Observable state for headless / robot tests (the `state_json` hook): the
    /// gate, the icicle count, the live droplets (each `x` + normalised fall +
    /// alpha), the frost phase, and the clock. Asserted as DATA, not pixels.
    pub fn state_json(&self) -> serde_json::Value {
        let droplets: Vec<serde_json::Value> = if self.enabled {
            self.icicles
                .iter()
                .filter_map(|ic| {
                    let c = self.cycle(ic);
                    let a = Self::droplet_alpha(c);
                    (a > 0.0).then(|| {
                        serde_json::json!({
                            "x": ic.x,
                            "y_norm": Self::droplet_fall_norm(c),
                            "alpha": a,
                        })
                    })
                })
                .collect()
        } else {
            Vec::new()
        };
        serde_json::json!({
            "enabled": self.enabled,
            "icicles": self.icicles.len(),
            "active_droplets": droplets.len(),
            "frost_phase": self.frost_phase(),
            "elapsed_s": self.elapsed,
            "droplets": droplets,
        })
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    /// A named easing fn (keeps the test table readable / clippy-clean).
    type NamedEasing = (&'static str, fn(f32) -> f32);

    fn approx(a: f32, b: f32, eps: f32) -> bool {
        (a - b).abs() <= eps
    }

    #[test]
    fn easing_fns_hit_their_endpoints() {
        let fns: [NamedEasing; 5] = [
            ("linear", easing::linear),
            ("cubic", easing::ease_in_out_cubic),
            ("back", easing::ease_out_back),
            ("elastic", easing::elastic),
            ("bounce", easing::bounce),
        ];
        for (name, f) in fns {
            assert!(approx(f(0.0), 0.0, 1e-5), "{name}(0) should be 0, got {}", f(0.0));
            assert!(approx(f(1.0), 1.0, 1e-5), "{name}(1) should be 1, got {}", f(1.0));
            // clamped outside [0,1]
            assert!(approx(f(-1.0), 0.0, 1e-5), "{name}(-1) clamps to 0");
            assert!(approx(f(2.0), 1.0, 1e-5), "{name}(2) clamps to 1");
        }
    }

    #[test]
    fn tween_hits_endpoints_and_clamps() {
        let tw = Tween::new(10.0, 50.0, 0.2, Curve::EaseInOutCubic);
        assert!(approx(tw.value_at(0.0), 10.0, 1e-4), "start = from");
        assert!(approx(tw.value_at(0.2), 50.0, 1e-4), "end = to");
        // Clamps outside [0, duration].
        assert!(approx(tw.value_at(-1.0), 10.0, 1e-4), "before start clamps to from");
        assert!(approx(tw.value_at(99.0), 50.0, 1e-4), "after end clamps to to");
        assert!(!tw.is_done(0.1) && tw.is_done(0.2), "done at/after duration");
        // progress_at is the eased [0,1] fraction.
        assert!(approx(tw.progress_at(0.0), 0.0, 1e-4) && approx(tw.progress_at(0.2), 1.0, 1e-4));
    }

    #[test]
    fn tween_zero_duration_snaps_to_target() {
        // Honours Motion::duration == 0 (reduced-motion / Device): instant, no NaN.
        let tw = Tween::new(0.0, 1.0, 0.0, Curve::EaseInOutCubic);
        assert_eq!(tw.value_at(0.0), 1.0);
        assert_eq!(tw.progress_at(0.0), 1.0);
        assert!(tw.is_done(0.0));
    }

    #[test]
    fn tween_from_motion_uses_theme_durations() {
        let m = crate::look::Motion::default(); // duration 0.18, fast 0.10
        let slow = Tween::from_motion(&m, 0.0, 1.0, Curve::Linear);
        let fast = Tween::from_motion_fast(&m, 0.0, 1.0, Curve::Linear);
        assert!(approx(slow.duration, m.duration, 1e-6));
        assert!(approx(fast.duration, m.fast, 1e-6));
        // Linear half-way checks the durations actually drive the value.
        assert!(approx(slow.value_at(m.duration / 2.0), 0.5, 1e-4));
        assert!(approx(fast.value_at(m.fast / 2.0), 0.5, 1e-4));
    }

    #[test]
    fn tween_rect_lerps_corners_for_slide_over() {
        let off = Rect::from_min_max(pos2(100.0, 0.0), pos2(140.0, 50.0));
        let on = Rect::from_min_max(pos2(0.0, 0.0), pos2(40.0, 50.0));
        assert_eq!(tween_rect(off, on, 0.0), off, "t=0 → off-screen rect");
        assert_eq!(tween_rect(off, on, 1.0), on, "t=1 → on-screen rect");
        let mid = tween_rect(off, on, 0.5);
        assert!(approx(mid.min.x, 50.0, 1e-4), "half-slid x");
    }

    #[test]
    fn fade_track_fades_in_then_out_and_drops_settled_keys() {
        let k = FadeTrack::key("row-7");
        let mut ft = FadeTrack::default();
        // Fade IN: lit every frame ramps the factor toward 1 over `duration`.
        for _ in 0..12 {
            ft.begin();
            ft.lit(k);
            ft.advance(1.0 / 60.0, 0.10);
        }
        assert!(approx(ft.factor(k), 1.0, 1e-3), "lit key reaches 1, got {}", ft.factor(k));
        // Fade OUT: stop lighting it → eases back to 0, then the key is dropped.
        let mut animating = true;
        for _ in 0..40 {
            ft.begin();
            animating = ft.advance(1.0 / 60.0, 0.10);
        }
        assert!(!animating, "settles (no repaint) once faded out");
        assert_eq!(ft.factor(k), 0.0, "faded-out key reads 0 (dropped)");
    }

    #[test]
    fn fade_track_zero_duration_snaps() {
        // Honours Motion::duration == 0 (reduced-motion / Device): instant, no NaN.
        let k = FadeTrack::key(3u64);
        let mut ft = FadeTrack::default();
        ft.begin();
        ft.lit(k);
        let animating = ft.advance(1.0 / 60.0, 0.0);
        assert_eq!(ft.factor(k), 1.0, "zero-duration snaps lit → 1");
        assert!(!animating, "snap is not 'animating'");
    }

    #[test]
    fn fade_track_is_deterministic() {
        let k = FadeTrack::key("r");
        let mut a = FadeTrack::default();
        let mut b = FadeTrack::default();
        for _ in 0..5 {
            a.begin();
            a.lit(k);
            a.advance(1.0 / 60.0, 0.18);
            b.begin();
            b.lit(k);
            b.advance(1.0 / 60.0, 0.18);
        }
        assert!(approx(a.factor(k), b.factor(k), 1e-9), "same inputs → same factor (FC-7)");
    }

    #[test]
    fn ease_out_back_overshoots_before_settling() {
        // The "back" curve should exceed 1.0 somewhere near the end.
        let peak = (60..100).map(|i| easing::ease_out_back(i as f32 / 100.0)).fold(0.0_f32, f32::max);
        assert!(peak > 1.0, "ease_out_back should overshoot, peak={peak}");
    }

    #[test]
    fn raven_starts_at_launch_and_converges_onto_target_rect() {
        let target = Rect::from_min_size(pos2(300.0, 200.0), vec2(180.0, 24.0));
        let mut raven = RavenSprite::new().from(pos2(-40.0, -40.0)).fly_to(target);

        // At t=0 it's at the launch point, not perched.
        raven.advance(0.0);
        assert!(!raven.is_perched());
        assert!(approx(raven.pos().x, -40.0, 0.5) && approx(raven.pos().y, -40.0, 0.5), "starts at launch");

        // Midway it's airborne and somewhere between start and target (and lifted
        // by the arc — above the straight line).
        raven.advance(RAVEN_FLIGHT_SECS * 0.5);
        assert!(!raven.is_perched());

        // After the full flight duration it has perched on the target's top edge,
        // centred, within a tiny bob amplitude.
        raven.advance(RAVEN_FLIGHT_SECS);
        assert!(raven.is_perched(), "perched after flight duration");
        let perch = pos2(target.center().x, target.top());
        let d = (raven.pos() - perch).length();
        assert!(d <= 2.0, "raven converges onto the perch (dist {d} px)");

        // And it stays on the perch (within the bob) for a while after.
        for k in 1..20 {
            raven.advance(RAVEN_FLIGHT_SECS + k as f32 * 0.05);
            assert!(approx(raven.pos().x, perch.x, 0.01), "x stays centred on perch");
            assert!(approx(raven.pos().y, perch.y, 2.0), "y stays within bob of perch");
        }
    }

    #[test]
    fn particle_burst_falls_and_finishes() {
        let mut b = ParticleBurst::new(pos2(100.0, 100.0), 16, Color32::WHITE, 42);
        assert!(!b.finished());
        for _ in 0..120 {
            b.update(1.0 / 60.0);
        }
        assert!(b.finished(), "burst should expire after its lifetime");
        // deterministic given the seed
        let mut a = ParticleBurst::new(pos2(0.0, 0.0), 8, Color32::WHITE, 7);
        let mut c = ParticleBurst::new(pos2(0.0, 0.0), 8, Color32::WHITE, 7);
        a.update(0.1);
        c.update(0.1);
        assert_eq!(a.particles[0].pos, c.particles[0].pos, "same seed → same motion");
    }

    #[test]
    fn ice_drip_is_off_until_enabled() {
        // The gate: a fresh drip is disabled — no active droplets, paints nothing.
        let off = IceDrip::new(12, 3).at_clock(2.0);
        let s = off.state_json();
        assert_eq!(s["enabled"], false);
        assert_eq!(s["active_droplets"], 0, "disabled ⇒ no droplets: {s}");
        assert_eq!(s["icicles"], 12, "icicle count is still reported: {s}");

        // Enabled: the icicles drip.
        let on = IceDrip::new(12, 3).enabled(true).at_clock(2.0);
        let s = on.state_json();
        assert_eq!(s["enabled"], true);
        assert!(s["active_droplets"].as_u64().unwrap() > 0, "enabled ⇒ live droplets: {s}");
    }

    #[test]
    fn ice_drip_is_deterministic_given_seed_and_clock() {
        // Same seed + clock ⇒ byte-identical observable state (LAW: pure clock fn).
        let a = IceDrip::new(10, 7).enabled(true).at_clock(2.0);
        let b = IceDrip::new(10, 7).enabled(true).at_clock(2.0);
        assert_eq!(a.state_json(), b.state_json(), "same (seed,clock) ⇒ identical drip");
        // Advancing the clock changes the scene deterministically.
        let later = IceDrip::new(10, 7).enabled(true).at_clock(2.4);
        assert_ne!(a.state_json(), later.state_json(), "advancing the clock moves the drip");
    }

    #[test]
    fn reveal_highlight_proximity_is_a_cursor_pool() {
        let rect = Rect::from_min_size(pos2(0.0, 0.0), vec2(100.0, 40.0));
        let r = RevealHighlight::new(Color32::from_rgb(80, 160, 255)).with_radius(100.0);
        // Inside the control → fully lit.
        assert!(approx(r.proximity(rect, Some(pos2(50.0, 20.0))), 1.0, 1e-6), "inside → 1");
        // No pointer / beyond the reach radius → dark.
        assert_eq!(r.proximity(rect, None), 0.0, "no pointer → 0");
        assert_eq!(r.proximity(rect, Some(pos2(300.0, 20.0))), 0.0, "far (>radius) → 0");
        // Monotonic decay as the pointer recedes from the edge.
        let near = r.proximity(rect, Some(pos2(120.0, 20.0))); // 20px off the right edge
        let mid = r.proximity(rect, Some(pos2(160.0, 20.0))); // 60px off
        assert!(near > mid && mid > 0.0, "reveal glow decays with distance: {near} > {mid} > 0");
        // Deterministic (FC-1/FC-7): same inputs → same value, no state/clock.
        assert_eq!(r.proximity(rect, Some(pos2(130.0, 10.0))), r.proximity(rect, Some(pos2(130.0, 10.0))));
    }

    #[test]
    fn ice_drip_droplets_fall_under_gravity() {
        // The fall curve is 0 at the tip, 1 at the bottom, monotonic increasing,
        // and ACCELERATING (gravity) — later samples gain more than earlier ones.
        assert!((IceDrip::droplet_fall_norm(0.0)).abs() < 1e-6);
        assert!((IceDrip::droplet_fall_norm(1.0) - 1.0).abs() < 1e-6);
        let mut prev = 0.0;
        let mut last_step = 0.0;
        for i in 1..=10 {
            let c = i as f32 / 10.0;
            let y = IceDrip::droplet_fall_norm(c);
            assert!(y >= prev, "fall is monotonic downward at c={c}: {y} < {prev}");
            let step = y - prev;
            assert!(step >= last_step - 1e-6, "fall accelerates (gravity) at c={c}");
            last_step = step;
            prev = y;
        }
        // Alpha beads up at the tip, holds, then fades out by the end of the fall.
        assert!(IceDrip::droplet_alpha(0.0) < IceDrip::droplet_alpha(0.5));
        assert!((IceDrip::droplet_alpha(0.5) - 1.0).abs() < 1e-6);
        assert!(IceDrip::droplet_alpha(0.99) < 0.2, "the droplet fades near the end");
    }
}