Skip to main content

facett_core/
effects.rs

1//! **effects** — motion + bloom for the facett look. Pure egui, **glow backend
2//! only**: everything here paints with layered alpha shapes on an
3//! [`egui::Painter`], so it works without a custom GPU shader.
4//!
5//! Three layers:
6//! 1. [`easing`] — `t∈[0,1] → [0,1]` tweens (cubic, back, elastic, bounce).
7//! 2. **bloom helpers** — [`glow_rect`], [`glow_text`], [`shimmer`], and a tiny
8//!    [`ParticleBurst`].
9//! 3. [`RavenSprite`] — the signature effect: a raven flies in along a bezier
10//!    arc with ease-out and *perches* on a target [`egui::Rect`], then idle-bobs.
11//!
12//! # Where the GPU path lands later
13//! True bloom (a bright-pass + separable Gaussian on a HDR target) and the raven
14//! as a textured/instanced sprite belong in the **wgpu kernel** (see
15//! `.nornir/wgpu-plan.md`): they'd replace the layered-alpha [`glow_rect`] /
16//! shape-painted raven here behind the same call sites. Until then this CPU path
17//! is the reference and is fully headless-testable.
18
19use egui::{Color32, Painter, Pos2, Rect, Stroke, Vec2, pos2, vec2};
20use serde::{Deserialize, Serialize};
21
22use crate::look::Motion;
23
24/// `t∈[0,1] → [0,1]` tweening curves. Each is clamped at the ends so
25/// `f(0.0) == 0.0` and `f(1.0) == 1.0` exactly (the demo + tests rely on it).
26pub mod easing {
27    /// Identity ramp.
28    pub fn linear(t: f32) -> f32 {
29        t.clamp(0.0, 1.0)
30    }
31
32    /// Smooth accelerate-then-decelerate (the workhorse).
33    pub fn ease_in_out_cubic(t: f32) -> f32 {
34        let t = t.clamp(0.0, 1.0);
35        if t < 0.5 {
36            4.0 * t * t * t
37        } else {
38            let f = -2.0 * t + 2.0;
39            1.0 - f * f * f / 2.0
40        }
41    }
42
43    /// Overshoots slightly past 1.0 near the end, then settles — a confident
44    /// "snap into place". Endpoints are still exactly 0 and 1.
45    pub fn ease_out_back(t: f32) -> f32 {
46        let t = t.clamp(0.0, 1.0);
47        const C1: f32 = 1.70158;
48        const C3: f32 = C1 + 1.0;
49        let f = t - 1.0;
50        1.0 + C3 * f * f * f + C1 * f * f
51    }
52
53    /// Springy elastic settle (decaying sine). `f(0)=0`, `f(1)=1`.
54    pub fn elastic(t: f32) -> f32 {
55        let t = t.clamp(0.0, 1.0);
56        if t == 0.0 || t == 1.0 {
57            return t;
58        }
59        const C4: f32 = std::f32::consts::TAU / 3.0;
60        2.0_f32.powf(-10.0 * t) * ((t * 10.0 - 0.75) * C4).sin() + 1.0
61    }
62
63    /// Gravity bounce-to-rest. `f(0)=0`, `f(1)=1`.
64    pub fn bounce(t: f32) -> f32 {
65        let t = t.clamp(0.0, 1.0);
66        const N1: f32 = 7.5625;
67        const D1: f32 = 2.75;
68        if t < 1.0 / D1 {
69            N1 * t * t
70        } else if t < 2.0 / D1 {
71            let t = t - 1.5 / D1;
72            N1 * t * t + 0.75
73        } else if t < 2.5 / D1 {
74            let t = t - 2.25 / D1;
75            N1 * t * t + 0.9375
76        } else {
77            let t = t - 2.625 / D1;
78            N1 * t * t + 0.984375
79        }
80    }
81}
82
83/// A named easing curve — lets a [`Tween`] (or a theme [`Motion`]) carry *which*
84/// curve to use, instead of every call site hard-coding an [`easing`] fn.
85///
86/// [`Motion`]: crate::look::Motion
87#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
88pub enum Curve {
89    /// Identity ramp.
90    Linear,
91    /// Smooth accelerate-then-decelerate — the workhorse (default).
92    #[default]
93    EaseInOutCubic,
94    /// Confident overshoot-and-settle.
95    EaseOutBack,
96    /// Springy elastic settle.
97    Elastic,
98    /// Gravity bounce-to-rest.
99    Bounce,
100}
101
102impl Curve {
103    /// Apply the curve: `t∈[0,1] → [0,1]` (endpoints exact, clamped).
104    pub fn apply(self, t: f32) -> f32 {
105        match self {
106            Curve::Linear => easing::linear(t),
107            Curve::EaseInOutCubic => easing::ease_in_out_cubic(t),
108            Curve::EaseOutBack => easing::ease_out_back(t),
109            Curve::Elastic => easing::elastic(t),
110            Curve::Bounce => easing::bounce(t),
111        }
112    }
113}
114
115/// **Injected-clock tween** of a scalar from `from`→`to` over `duration` seconds,
116/// eased by a [`Curve`]. The single "animate a value over a duration" primitive the
117/// form facets were missing (hover/selection/expand, drawer slide-ins).
118///
119/// Determinism (FC-7): a `Tween` holds **no egui state and no wall-clock** — you
120/// evaluate it at the `elapsed` seconds *you* own (the same `dt`/clock model as
121/// [`SmoothScroll`](crate::scroll_engine) and [`focus::motion_progress`](crate::focus::motion_progress)),
122/// so snapshots stay reproducible. It is a pure value, `Copy`, cheap to rebuild each frame.
123///
124/// ```
125/// use facett_core::effects::{Tween, Curve};
126/// let t = Tween::new(0.0, 100.0, 0.2, Curve::EaseInOutCubic);
127/// assert_eq!(t.value_at(0.0), 0.0);     // start
128/// assert_eq!(t.value_at(0.2), 100.0);   // end
129/// assert!(t.is_done(0.2));
130/// ```
131#[derive(Clone, Copy, Debug, PartialEq)]
132pub struct Tween {
133    /// Value at `elapsed <= 0`.
134    pub from: f32,
135    /// Value at `elapsed >= duration`.
136    pub to: f32,
137    /// Tween length in seconds. `<= 0` snaps instantly to `to` (honours `Motion::duration == 0`,
138    /// the reduced-motion / Device setting).
139    pub duration: f32,
140    /// Easing curve.
141    pub curve: Curve,
142}
143
144impl Tween {
145    /// A tween `from`→`to` over `duration` seconds with `curve`.
146    pub fn new(from: f32, to: f32, duration: f32, curve: Curve) -> Self {
147        Self { from, to, duration, curve }
148    }
149
150    /// A standard-duration tween from the theme's [`Motion`](crate::look::Motion) token.
151    pub fn from_motion(motion: &Motion, from: f32, to: f32, curve: Curve) -> Self {
152        Self::new(from, to, motion.duration, curve)
153    }
154
155    /// A faster tween using [`Motion::fast`](crate::look::Motion) — for small/cheap transitions.
156    pub fn from_motion_fast(motion: &Motion, from: f32, to: f32, curve: Curve) -> Self {
157        Self::new(from, to, motion.fast, curve)
158    }
159
160    /// Eased value at `elapsed` seconds since the tween started.
161    /// `value_at(<=0) == from`, `value_at(>=duration) == to`; `duration <= 0` ⇒ `to`.
162    pub fn value_at(&self, elapsed: f32) -> f32 {
163        if self.duration <= 0.0 {
164            return self.to;
165        }
166        let t = (elapsed / self.duration).clamp(0.0, 1.0);
167        self.from + (self.to - self.from) * self.curve.apply(t)
168    }
169
170    /// Eased progress `∈[0,1]` (the `to`-fraction) at `elapsed` — the lerp factor
171    /// to drive geometry/colour interpolation yourself.
172    pub fn progress_at(&self, elapsed: f32) -> f32 {
173        if self.duration <= 0.0 {
174            return 1.0;
175        }
176        self.curve.apply((elapsed / self.duration).clamp(0.0, 1.0))
177    }
178
179    /// Has the tween reached `to` by `elapsed`?
180    pub fn is_done(&self, elapsed: f32) -> bool {
181        self.duration <= 0.0 || elapsed >= self.duration
182    }
183}
184
185/// Eased interpolation between two rectangles — the slide-over / drawer primitive
186/// (interpolate an off-screen `from` rect to an on-screen `to` rect by the tween's
187/// eased progress). Pairs with [`Tween`]/[`Curve`] to make a "half-over sliding
188/// drawer" a one-call affordance over [`crate::overlay`].
189pub fn tween_rect(from: Rect, to: Rect, eased_t: f32) -> Rect {
190    let t = eased_t.clamp(0.0, 1.0);
191    let lerp = |a: f32, b: f32| a + (b - a) * t;
192    Rect::from_min_max(
193        pos2(lerp(from.min.x, to.min.x), lerp(from.min.y, to.min.y)),
194        pos2(lerp(from.max.x, to.max.x), lerp(from.max.y, to.max.y)),
195    )
196}
197
198/// Injected-clock **fade tracker** keyed by a small `u64` id — the per-element
199/// hover / selection *progress* the immediate-mode form facets were missing. Each
200/// key holds a factor in `[0,1]` that eases toward `1` while the key is "lit" this
201/// frame (hovered / selected) and toward `0` once it isn't; keys that settle back
202/// at `0` are dropped, so the map only ever holds the handful of elements that are
203/// currently mid-fade.
204///
205/// The per-frame protocol is three calls: [`begin`](Self::begin) (mark all keys
206/// un-lit), [`lit`](Self::lit) for each element that should glow this frame, then
207/// [`advance`](Self::advance) with your own `dt` once the frame is laid out. Read
208/// the eased value with [`factor`](Self::factor) when you paint each element.
209///
210/// Determinism (FC-7): no egui state, no wall-clock — the factors live in the
211/// caller's component struct and advance by the `dt` *you* own (pass
212/// `ui.input(|i| i.stable_dt)`), so snapshots reproduce. `duration <= 0` snaps
213/// instantly (honours `Motion::duration == 0`, the reduced-motion / Device setting).
214#[derive(Clone, Debug, Default)]
215pub struct FadeTrack {
216    fades: std::collections::HashMap<u64, FadeState>,
217}
218
219#[derive(Clone, Copy, Debug)]
220struct FadeState {
221    factor: f32,
222    lit: bool,
223}
224
225impl FadeTrack {
226    /// Start a frame: mark every tracked key as *not* lit. Re-light the ones that
227    /// should glow with [`lit`](Self::lit) before [`advance`](Self::advance).
228    pub fn begin(&mut self) {
229        for f in self.fades.values_mut() {
230            f.lit = false;
231        }
232    }
233
234    /// Mark `key` lit this frame (hovered / selected). Creates it at factor `0`
235    /// (so it fades *in*) if it wasn't tracked yet.
236    pub fn lit(&mut self, key: u64) {
237        self.fades.entry(key).or_insert(FadeState { factor: 0.0, lit: false }).lit = true;
238    }
239
240    /// The current eased factor `∈[0,1]` for `key` (`0` if untracked) — multiply
241    /// your highlight alpha by this when painting the element.
242    pub fn factor(&self, key: u64) -> f32 {
243        self.fades.get(&key).map(|f| f.factor).unwrap_or(0.0)
244    }
245
246    /// How many keys are currently tracked (lit or still fading out) — a
247    /// host-readable "is motion live right now" signal for `state_json` proofs.
248    pub fn active(&self) -> usize {
249        self.fades.len()
250    }
251
252    /// Whether any tracked key is mid-fade (factor strictly between fully-off and
253    /// fully-on) — i.e. a cross-fade is visibly animating this frame.
254    pub fn is_animating(&self) -> bool {
255        self.fades.values().any(|f| f.factor > 1e-3 && f.factor < 1.0 - 1e-3)
256    }
257
258    /// Advance every key toward its target (`lit → 1`, else `→ 0`) over `duration`
259    /// seconds, dropping keys that have faded fully back to `0`. Returns whether any
260    /// key is still mid-fade (the caller requests a repaint while so). `duration <= 0`
261    /// snaps. Deterministic given the same factors + `dt`.
262    pub fn advance(&mut self, dt: f32, duration: f32) -> bool {
263        let step = if duration <= 0.0 { 1.0 } else { (dt.max(0.0) / duration).clamp(0.0, 1.0) };
264        let mut animating = false;
265        self.fades.retain(|_, f| {
266            let target = if f.lit { 1.0 } else { 0.0 };
267            let dist = target - f.factor;
268            if dist.abs() <= step {
269                f.factor = target;
270            } else {
271                f.factor += step * dist.signum();
272                animating = true;
273            }
274            // Keep a key while it's lit or still visibly fading out.
275            f.lit || f.factor > 1e-3
276        });
277        animating
278    }
279
280    /// Hash any `Hash` id (a row `&str`, a row index, a repo name) into the `u64`
281    /// key space — the convenience bridge for facets whose element ids aren't
282    /// already `u64`. Stable within a run (uses the std hasher).
283    pub fn key(id: impl std::hash::Hash) -> u64 {
284        use std::hash::Hasher as _;
285        let mut h = std::collections::hash_map::DefaultHasher::new();
286        id.hash(&mut h);
287        h.finish()
288    }
289}
290
291/// Linear interpolation between two colours in straight (unmultiplied) space.
292fn lerp_color(a: Color32, b: Color32, t: f32) -> Color32 {
293    let t = t.clamp(0.0, 1.0);
294    let l = |x: u8, y: u8| (x as f32 + (y as f32 - x as f32) * t).round() as u8;
295    Color32::from_rgba_unmultiplied(l(a.r(), b.r()), l(a.g(), b.g()), l(a.b(), b.b()), l(a.a(), b.a()))
296}
297
298/// Paint a soft **bloom** around `rect`: `layers` concentric rounded strokes that
299/// fade out as they expand. `intensity∈[0,1]` scales the alpha (e.g. animate it
300/// with a [`easing`] curve for a pulse). Cheap stand-in for true HDR bloom.
301pub fn glow_rect(painter: &Painter, rect: Rect, color: Color32, intensity: f32, layers: u32) {
302    let intensity = intensity.clamp(0.0, 1.0);
303    let layers = layers.max(1);
304    for i in 0..layers {
305        let f = i as f32 / layers as f32; // 0 (inner) .. ~1 (outer)
306        let grow = 1.0 + f * 7.0;
307        let alpha = ((1.0 - f) * (1.0 - f) * 90.0 * intensity) as u8;
308        if alpha == 0 {
309            continue;
310        }
311        let c = Color32::from_rgba_unmultiplied(color.r(), color.g(), color.b(), alpha);
312        painter.rect_stroke(
313            rect.expand(grow),
314            4.0 + grow,
315            Stroke::new(1.5 + f * 2.0, c),
316            egui::StrokeKind::Outside,
317        );
318    }
319}
320
321/// **Windows 11 "reveal highlight"** — the signature Fluent hover cue: a
322/// cursor-follow edge glow that lights a control's border in proportion to how
323/// near the pointer is, fading to dark beyond [`radius`](RevealHighlight::radius).
324///
325/// Built on the same layered-alpha edge approach as [`glow_rect`] (glow backend,
326/// no GPU shader). Determinism (FC-1/FC-7): it holds **no egui state and no
327/// wall-clock** — you pass the pointer position + rect each frame, so the same
328/// inputs paint identically. It's the Windows-preset counterpart to macOS's
329/// quieter highlight; gate it on [`NativeFeel::reveal_highlight`](crate::look::NativeFeel::reveal_highlight).
330///
331/// ```
332/// use facett_core::effects::RevealHighlight;
333/// use egui::{pos2, Rect, vec2, Color32};
334/// let r = Rect::from_min_size(pos2(0.0, 0.0), vec2(100.0, 40.0));
335/// let reveal = RevealHighlight::new(Color32::from_rgb(80, 160, 255));
336/// assert_eq!(reveal.proximity(r, Some(pos2(50.0, 20.0))), 1.0); // pointer inside → full
337/// assert_eq!(reveal.proximity(r, Some(pos2(9999.0, 9999.0))), 0.0); // far away → dark
338/// assert_eq!(reveal.proximity(r, None), 0.0); // no pointer → dark
339/// ```
340#[derive(Clone, Copy, Debug, PartialEq)]
341pub struct RevealHighlight {
342    /// Edge-glow colour (usually the theme `accent` or `glow` role).
343    pub color: Color32,
344    /// Distance (px) within which the pointer lights the edge; beyond it, dark.
345    pub radius: f32,
346    /// Peak edge intensity `∈[0,1]` when the pointer is on the control.
347    pub intensity: f32,
348    /// Number of glow layers (softer/heavier edge).
349    pub layers: u32,
350}
351
352impl RevealHighlight {
353    /// A reveal glow in `color` with Fluent-ish defaults (≈120px reach, soft).
354    pub fn new(color: Color32) -> Self {
355        Self { color, radius: 120.0, intensity: 0.8, layers: 4 }
356    }
357
358    /// Reach radius in px (builder).
359    pub fn with_radius(mut self, radius: f32) -> Self {
360        self.radius = radius.max(1.0);
361        self
362    }
363
364    /// Peak intensity `∈[0,1]` (builder).
365    pub fn with_intensity(mut self, intensity: f32) -> Self {
366        self.intensity = intensity.clamp(0.0, 1.0);
367        self
368    }
369
370    /// **Pointer proximity** `∈[0,1]` for `rect`: `1` when the pointer is on the
371    /// control, decaying (quadratic falloff) to `0` at [`radius`](Self::radius) and
372    /// for `None`/far pointers. The pure, testable core of the effect.
373    pub fn proximity(&self, rect: Rect, pointer: Option<Pos2>) -> f32 {
374        let Some(p) = pointer else { return 0.0 };
375        // Distance from the pointer to the nearest point on the rect (0 if inside).
376        let nx = p.x.clamp(rect.left(), rect.right());
377        let ny = p.y.clamp(rect.top(), rect.bottom());
378        let dist = (p - pos2(nx, ny)).length();
379        if dist >= self.radius {
380            return 0.0;
381        }
382        let t = 1.0 - dist / self.radius; // 1 at the edge, 0 at radius
383        t * t // quadratic falloff — a tight, glowing pool around the cursor
384    }
385
386    /// Paint the reveal edge glow on `rect` for the current `pointer`, scaled by
387    /// [`proximity`](Self::proximity)·[`intensity`](Self::intensity). No-op when the
388    /// pointer is absent/far (proximity `0`). Pure painter work — no state, no clock.
389    pub fn paint(&self, painter: &Painter, rect: Rect, pointer: Option<Pos2>, corner_radius: f32) {
390        let lit = self.proximity(rect, pointer) * self.intensity;
391        if lit <= 0.0 {
392            return;
393        }
394        let layers = self.layers.max(1);
395        for i in 0..layers {
396            let f = i as f32 / layers as f32; // 0 (inner) .. ~1 (outer)
397            let grow = f * 4.0;
398            let alpha = ((1.0 - f) * 150.0 * lit) as u8;
399            if alpha == 0 {
400                continue;
401            }
402            let c = Color32::from_rgba_unmultiplied(self.color.r(), self.color.g(), self.color.b(), alpha);
403            painter.rect_stroke(
404                rect.expand(grow),
405                corner_radius + grow,
406                Stroke::new(1.0 + f, c),
407                egui::StrokeKind::Outside,
408            );
409        }
410    }
411
412    /// Convenience: paint the reveal glow **gated** on `enabled` (pass
413    /// `theme.native.reveal_highlight`) AND the active
414    /// [`EffectsPolicy`](crate::look::EffectsPolicy) allowing decorative motion,
415    /// reading the live pointer from `ui`. The one-call hover affordance for a
416    /// Windows-preset control. No-op on the macOS/Neutral/Device presets.
417    pub fn paint_in(&self, ui: &egui::Ui, rect: Rect, corner_radius: f32, enabled: bool) {
418        if !enabled || !crate::look::effects_policy(ui).allows_decorative_motion() {
419            return;
420        }
421        let pointer = ui.input(|i| i.pointer.hover_pos());
422        self.paint(ui.painter(), rect, pointer, corner_radius);
423    }
424}
425
426/// Draw `text` at `pos` with a coloured **glow halo** behind it (offset copies
427/// in a ring), then the crisp text on top. `glow` is usually `theme.glow`.
428#[allow(clippy::too_many_arguments)]
429pub fn glow_text(
430    painter: &Painter,
431    pos: Pos2,
432    anchor: egui::Align2,
433    text: &str,
434    font: egui::FontId,
435    text_color: Color32,
436    glow: Color32,
437    intensity: f32,
438) {
439    let intensity = intensity.clamp(0.0, 1.0);
440    let halo = Color32::from_rgba_unmultiplied(glow.r(), glow.g(), glow.b(), (70.0 * intensity) as u8);
441    for r in [3.0_f32, 2.0, 1.0] {
442        for k in 0..8 {
443            let a = std::f32::consts::TAU * k as f32 / 8.0;
444            let off = vec2(a.cos(), a.sin()) * r;
445            painter.text(pos + off, anchor, text, font.clone(), halo);
446        }
447    }
448    painter.text(pos, anchor, text, font, text_color);
449}
450
451/// Paint an animated **shimmer** sweep across `rect`: a diagonal highlight band
452/// at phase `t∈[0,1]` (wrap `t` yourself, e.g. `(time * speed).fract()`). Built
453/// from a handful of vertical alpha bars, brightest at the band centre.
454pub fn shimmer(painter: &Painter, rect: Rect, color: Color32, t: f32) {
455    let bars = 24;
456    let band = 0.18; // half-width of the highlight, in [0,1] of the sweep
457    let center = t.rem_euclid(1.0) * (1.0 + 2.0 * band) - band;
458    for i in 0..bars {
459        let x = (i as f32 + 0.5) / bars as f32; // 0..1 across the rect
460        let d = (x - center).abs() / band;
461        if d >= 1.0 {
462            continue;
463        }
464        let g = (1.0 - d) * (1.0 - d); // brightness, 0 at edges → 1 at band centre
465        // Brighten toward white at the band centre for a glinting highlight,
466        // then carry that as a translucent overlay.
467        let bright = lerp_color(color, Color32::WHITE, g * 0.6);
468        let c = Color32::from_rgba_unmultiplied(bright.r(), bright.g(), bright.b(), (g * 130.0) as u8);
469        let bx0 = rect.left() + x * rect.width();
470        let bw = rect.width() / bars as f32 + 1.0;
471        // a gentle diagonal: shear the bar top by the band offset
472        let shear = (x - 0.5) * rect.height() * 0.25;
473        let seg = Rect::from_min_max(pos2(bx0, rect.top() + shear), pos2(bx0 + bw, rect.bottom() + shear))
474            .intersect(rect);
475        painter.rect_filled(seg, 0.0, c);
476    }
477}
478
479/// One particle: position, velocity, age. Internal to [`ParticleBurst`].
480#[derive(Clone, Copy)]
481struct Particle {
482    pos: Pos2,
483    vel: Vec2,
484    /// Seconds lived.
485    age: f32,
486}
487
488/// A tiny **particle burst** — circles fired outward from a point that fall under
489/// gravity and fade out. Deterministic given the same `seed`, so a test can pin
490/// it. Advance with [`ParticleBurst::update`], render with
491/// [`ParticleBurst::paint`].
492#[derive(Clone)]
493pub struct ParticleBurst {
494    particles: Vec<Particle>,
495    color: Color32,
496    /// px/s² downward.
497    gravity: f32,
498    /// Total lifetime of a particle, seconds.
499    lifetime: f32,
500    elapsed: f32,
501}
502
503impl ParticleBurst {
504    /// Fire `count` particles from `origin` outward in a ring, with speeds
505    /// jittered by `seed` (deterministic — no RNG crate).
506    pub fn new(origin: Pos2, count: usize, color: Color32, seed: u64) -> Self {
507        let mut h = seed ^ 0x9E37_79B9_7F4A_7C15;
508        let mut rng = || {
509            // SplitMix64 — deterministic, dependency-free.
510            h = h.wrapping_add(0x9E37_79B9_7F4A_7C15);
511            let mut z = h;
512            z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
513            z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
514            ((z ^ (z >> 31)) as f64 / u64::MAX as f64) as f32
515        };
516        let particles = (0..count)
517            .map(|i| {
518                let a = std::f32::consts::TAU * i as f32 / count.max(1) as f32;
519                let speed = 90.0 + rng() * 140.0;
520                let up = 0.6 + rng() * 0.4; // bias upward so they arc nicely
521                Particle { pos: origin, vel: vec2(a.cos() * speed, a.sin() * speed - 120.0 * up), age: 0.0 }
522            })
523            .collect();
524        Self { particles, color, gravity: 520.0, lifetime: 1.1, elapsed: 0.0 }
525    }
526
527    /// Advance every particle by `dt` seconds (integrate velocity + gravity).
528    pub fn update(&mut self, dt: f32) {
529        let dt = dt.max(0.0);
530        self.elapsed += dt;
531        for p in &mut self.particles {
532            p.vel.y += self.gravity * dt;
533            p.pos += p.vel * dt;
534            p.age += dt;
535        }
536    }
537
538    /// All particles have aged past their lifetime — the burst is done and can
539    /// be dropped.
540    pub fn finished(&self) -> bool {
541        self.elapsed >= self.lifetime
542    }
543
544    /// Paint each live particle as a fading circle.
545    pub fn paint(&self, painter: &Painter) {
546        for p in &self.particles {
547            let life = (1.0 - p.age / self.lifetime).clamp(0.0, 1.0);
548            if life <= 0.0 {
549                continue;
550            }
551            let a = (life * 220.0) as u8;
552            let c = Color32::from_rgba_unmultiplied(self.color.r(), self.color.g(), self.color.b(), a);
553            painter.circle_filled(p.pos, 1.0 + life * 2.5, c);
554        }
555    }
556}
557
558/// Quadratic bezier point at `t∈[0,1]` for control points `p0,p1,p2`.
559fn bezier2(p0: Pos2, p1: Pos2, p2: Pos2, t: f32) -> Pos2 {
560    let u = 1.0 - t;
561    let x = u * u * p0.x + 2.0 * u * t * p1.x + t * t * p2.x;
562    let y = u * u * p0.y + 2.0 * u * t * p1.y + t * t * p2.y;
563    pos2(x, y)
564}
565
566/// The flight duration (seconds) of a [`RavenSprite`] from launch to perch.
567pub const RAVEN_FLIGHT_SECS: f32 = 1.4;
568
569/// **The signature effect.** A stylized raven 🐦 (painted from shapes — no asset
570/// dependency, so it never goes missing) that **flies in along a bezier arc with
571/// ease-out and perches** on a target [`egui::Rect`], then idle-bobs.
572///
573/// Motion is **deterministic given `(start, target, elapsed)`** — that's what
574/// makes [`RavenSprite::pos_at`] unit-testable. The egui-driven loop is:
575///
576/// ```ignore
577/// raven.update(ctx);       // reads ctx.input(i.time), requests a repaint while flying
578/// raven.paint(&painter);   // draws the bird at its current pos
579/// ```
580#[derive(Clone)]
581pub struct RavenSprite {
582    start: Pos2,
583    target: Pos2,
584    /// `None` until the first [`update`](Self::update) pins the launch time.
585    launch_time: Option<f64>,
586    /// Filled by [`update`](Self::update) each frame.
587    current: Pos2,
588    /// 0 while flying, 1 once perched.
589    perched: bool,
590    /// Seconds since launch (drives the idle bob once perched).
591    elapsed: f32,
592    color: Color32,
593    /// Face left (-1) or right (+1) — set from the flight direction.
594    facing: f32,
595    scale: f32,
596}
597
598impl Default for RavenSprite {
599    fn default() -> Self {
600        Self::new()
601    }
602}
603
604impl RavenSprite {
605    /// A raven waiting off the top-left, ready to be aimed with [`fly_to`].
606    pub fn new() -> Self {
607        Self {
608            start: pos2(-40.0, -40.0),
609            target: pos2(0.0, 0.0),
610            launch_time: None,
611            current: pos2(-40.0, -40.0),
612            perched: false,
613            elapsed: 0.0,
614            color: Color32::from_rgb(18, 18, 22),
615            facing: 1.0,
616            scale: 1.0,
617        }
618    }
619
620    /// Launch point of the flight (defaults to off-screen top-left).
621    pub fn from(mut self, start: Pos2) -> Self {
622        self.start = start;
623        self.current = start;
624        self
625    }
626
627    /// Raven body colour (override the default near-black, e.g. for `hugin_noir`
628    /// you might tint the beak/eye via `accent` separately).
629    pub fn color(mut self, color: Color32) -> Self {
630        self.color = color;
631        self
632    }
633
634    /// Overall sprite scale (1.0 ≈ a ~14px-tall bird body).
635    pub fn scale(mut self, scale: f32) -> Self {
636        self.scale = scale.max(0.1);
637        self
638    }
639
640    /// **Aim the raven** at `target`: it will perch centred on the top edge of
641    /// the rect (like landing on a table row). Resets the flight clock.
642    pub fn fly_to(mut self, target: Rect) -> Self {
643        // Perch on the top edge, centred — feet just above the row.
644        self.target = pos2(target.center().x, target.top());
645        self.facing = if self.target.x >= self.start.x { 1.0 } else { -1.0 };
646        self.launch_time = None;
647        self.perched = false;
648        self
649    }
650
651    /// The flight path control point — a high arc above the midpoint, so the
652    /// raven swoops down onto the perch. Pure function of `start`/`target`.
653    fn control(&self) -> Pos2 {
654        let mid = pos2((self.start.x + self.target.x) * 0.5, (self.start.y + self.target.y) * 0.5);
655        let span = (self.target - self.start).length().max(1.0);
656        pos2(mid.x, mid.y - span * 0.45) // lift the arc
657    }
658
659    /// **Deterministic position** at `elapsed` seconds since launch — the testable
660    /// core. Eases along the bezier arc with [`easing::ease_out_back`] (a
661    /// confident landing), holds the perch afterwards. Adds a subtle idle bob
662    /// once perched.
663    pub fn pos_at(&self, elapsed: f32) -> Pos2 {
664        if elapsed >= RAVEN_FLIGHT_SECS {
665            // Perched: tiny vertical bob (±1.5px, ~0.6Hz).
666            let bob = ((elapsed - RAVEN_FLIGHT_SECS) * std::f32::consts::TAU * 0.6).sin() * 1.5;
667            return pos2(self.target.x, self.target.y + bob);
668        }
669        let lin = (elapsed / RAVEN_FLIGHT_SECS).clamp(0.0, 1.0);
670        let t = easing::ease_out_back(lin);
671        bezier2(self.start, self.control(), self.target, t)
672    }
673
674    /// True once the flight is over (raven is perched, idle-bobbing).
675    pub fn is_perched(&self) -> bool {
676        self.perched
677    }
678
679    /// Current sprite position (set by the last [`update`](Self::update)).
680    pub fn pos(&self) -> Pos2 {
681        self.current
682    }
683
684    /// Drive the animation from the egui clock. Pins the launch time on first
685    /// call, advances `current`, and `request_repaint`s while still in flight so
686    /// the animation keeps ticking. Call once per frame before [`paint`].
687    pub fn update(&mut self, ctx: &egui::Context) {
688        let now = ctx.input(|i| i.time);
689        let launch = *self.launch_time.get_or_insert(now);
690        let elapsed = (now - launch) as f32;
691        self.advance(elapsed);
692        if !self.perched {
693            ctx.request_repaint();
694        }
695    }
696
697    /// Time-driven core shared by [`update`] and the headless test: set the
698    /// current pos + facing + perched flag for `elapsed` seconds since launch.
699    pub fn advance(&mut self, elapsed: f32) {
700        self.elapsed = elapsed;
701        self.current = self.pos_at(elapsed);
702        self.perched = elapsed >= RAVEN_FLIGHT_SECS;
703    }
704
705    /// Paint the raven at its current position. A stylized corvid built from a
706    /// body ellipse-ish blob, swept wings, a wedge tail, a head, and a beak —
707    /// wings flap while flying and fold once perched.
708    pub fn paint(&self, painter: &Painter) {
709        let c = self.current;
710        let s = self.scale;
711        let f = self.facing;
712        let body = self.color;
713        let stroke = Stroke::new(1.0 * s, body);
714
715        // Wing phase: flapping in flight, near-folded when perched.
716        let flap = if self.perched {
717            0.15
718        } else {
719            // 0..1..0 flap, ~5Hz
720            (self.elapsed * std::f32::consts::TAU * 5.0).sin() * 0.5 + 0.5
721        };
722        let wing_lift = (flap - 0.5) * 9.0 * s; // up/down sweep of the wingtip
723
724        // Body: a rounded blob (overlapping circles read as a corvid body).
725        painter.circle_filled(c, 5.0 * s, body);
726        painter.circle_filled(c + vec2(-3.5 * s * f, 1.0 * s), 3.5 * s, body);
727
728        // Tail: a wedge trailing behind (opposite the facing direction).
729        let tail_root = c + vec2(-5.0 * s * f, 0.5 * s);
730        painter.add(egui::Shape::convex_polygon(
731            vec![
732                tail_root,
733                tail_root + vec2(-7.0 * s * f, -2.5 * s),
734                tail_root + vec2(-7.5 * s * f, 1.0 * s),
735                tail_root + vec2(-6.0 * s * f, 3.0 * s),
736            ],
737            body,
738            stroke,
739        ));
740
741        // Wings: two swept triangles from the back, lifting with the flap.
742        let shoulder = c + vec2(-s * f, -1.5 * s);
743        let tip_far = shoulder + vec2(-9.0 * s * f, -wing_lift - 2.0 * s);
744        let tip_near = shoulder + vec2(-4.0 * s * f, -wing_lift * 0.5 + 4.0 * s);
745        painter.add(egui::Shape::convex_polygon(
746            vec![shoulder, tip_far, tip_near],
747            body,
748            stroke,
749        ));
750
751        // Head + beak, leading the body in the facing direction.
752        let head = c + vec2(4.5 * s * f, -2.5 * s);
753        painter.circle_filled(head, 3.0 * s, body);
754        let beak_color = Color32::from_rgb(40, 30, 18); // charcoal beak
755        painter.add(egui::Shape::convex_polygon(
756            vec![
757                head + vec2(2.5 * s * f, -0.5 * s),
758                head + vec2(6.5 * s * f, 0.5 * s),
759                head + vec2(2.5 * s * f, 1.5 * s),
760            ],
761            beak_color,
762            Stroke::NONE,
763        ));
764        // Eye: a tiny bright glint so it reads as alive.
765        painter.circle_filled(head + vec2(1.2 * s * f, -0.8 * s), 0.8 * s, Color32::from_rgb(230, 220, 210));
766    }
767}
768
769// ── ice drip (the Skaði "Skade Vinter" winter decor) ─────────────────────────
770
771/// One icicle hanging from the top edge. Internal to [`IceDrip`].
772#[derive(Clone, Copy)]
773struct Icicle {
774    /// Horizontal position across the top edge, fraction in `[0,1]`.
775    x: f32,
776    /// Hanging length, px.
777    len: f32,
778    /// Base half-width, px.
779    half_w: f32,
780    /// Per-icicle phase offset into the drip cycle, `[0,1)`.
781    phase: f32,
782    /// Per-icicle cycle-rate multiplier (so they don't all drip in lockstep).
783    rate: f32,
784}
785
786/// **Ice-drip decor** — a row of icicles along the top edge of a rect with
787/// **melt-droplets that fall under gravity, fade out, and recur**, plus a faint
788/// **frost shimmer** along the edge. The signature decor of the Skaði *"Skade
789/// Vinter"* winter scene (pairs with [`crate::look::Theme::skade_vinter`]).
790///
791/// Pure CPU — layered alpha shapes on an [`egui::Painter`], no GPU/backend risk —
792/// **time-driven** and **deterministic given `(seed, clock)`**, so it is headless-
793/// testable exactly like [`ParticleBurst`] / [`RavenSprite`]. It is **off unless
794/// [`enabled`](Self::enabled)** (the gate); [`paint_gated`](Self::paint_gated) also
795/// follows the active theme's [`EffectsPolicy`](crate::look::EffectsPolicy) so it
796/// stays dark under `Reduced`/`None`/Device.
797///
798/// ```ignore
799/// let mut drip = IceDrip::new(12, 0).enabled(true);
800/// drip.set_clock(ui.input(|i| i.time));   // time-driven
801/// drip.paint_gated(ui, ui.max_rect());    // honours EffectsPolicy
802/// ```
803#[derive(Clone)]
804pub struct IceDrip {
805    icicles: Vec<Icicle>,
806    /// Icicle body colour.
807    ice: Color32,
808    /// Frost shimmer + droplet highlight colour.
809    glow: Color32,
810    /// Seconds since the scene started (set by [`set_clock`](Self::set_clock),
811    /// advanced by [`update`](Self::update)).
812    elapsed: f32,
813    /// Drip cycle length, seconds (one droplet forms + falls per cycle).
814    period: f32,
815    /// How far a droplet falls (fraction of the rect height) before it fades.
816    fall_frac: f32,
817    /// The gate — nothing paints and no droplet is "active" until enabled.
818    enabled: bool,
819}
820
821impl IceDrip {
822    /// `count` icicles across the top edge, jittered deterministically by `seed`
823    /// (SplitMix64 — no RNG crate). Default icy palette; **disabled by default**
824    /// (the gate) — call [`enabled(true)`](Self::enabled) to turn it on.
825    pub fn new(count: usize, seed: u64) -> Self {
826        let mut h = seed ^ 0x51ED_2701_A17F_C3B9;
827        let mut rng = || {
828            h = h.wrapping_add(0x9E37_79B9_7F4A_7C15);
829            let mut z = h;
830            z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
831            z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
832            ((z ^ (z >> 31)) as f64 / u64::MAX as f64) as f32
833        };
834        let n = count.max(1);
835        let icicles = (0..n)
836            .map(|i| {
837                // even spread across the edge + a little jitter so it reads natural
838                let base = (i as f32 + 0.5) / n as f32;
839                let x = (base + (rng() - 0.5) * 0.6 / n as f32).clamp(0.0, 1.0);
840                Icicle {
841                    x,
842                    len: 8.0 + rng() * 16.0,
843                    half_w: 3.0 + rng() * 3.0,
844                    phase: rng(),
845                    rate: 0.7 + rng() * 0.6,
846                }
847            })
848            .collect();
849        Self {
850            icicles,
851            ice: Color32::from_rgb(206, 232, 248),  // pale glacier ice
852            glow: Color32::from_rgb(170, 214, 240), // frost bloom
853            elapsed: 0.0,
854            period: 2.6,
855            fall_frac: 0.85,
856            enabled: false,
857        }
858    }
859
860    /// Turn the decor on/off (the gate). Builder form.
861    pub fn enabled(mut self, on: bool) -> Self {
862        self.enabled = on;
863        self
864    }
865
866    /// Override the icicle + frost colours.
867    pub fn colors(mut self, ice: Color32, glow: Color32) -> Self {
868        self.ice = ice;
869        self.glow = glow;
870        self
871    }
872
873    /// Drip cycle length in seconds (one droplet per cycle). Builder form.
874    pub fn with_period(mut self, secs: f32) -> Self {
875        self.period = secs.max(0.1);
876        self
877    }
878
879    /// Pin the clock up front (builder form of [`set_clock`](Self::set_clock)) —
880    /// the testable, deterministic entry point.
881    pub fn at_clock(mut self, secs: f32) -> Self {
882        self.elapsed = secs.max(0.0);
883        self
884    }
885
886    /// Set the absolute scene time (drive from `ui.input(|i| i.time)`).
887    pub fn set_clock(&mut self, secs: f32) {
888        self.elapsed = secs.max(0.0);
889    }
890
891    /// Advance the scene time by `dt` seconds.
892    pub fn update(&mut self, dt: f32) {
893        self.elapsed += dt.max(0.0);
894    }
895
896    /// Is the decor on?
897    pub fn is_enabled(&self) -> bool {
898        self.enabled
899    }
900
901    /// An icicle's current drip-cycle position, `[0,1)`. Pure function of the clock.
902    fn cycle(&self, ic: &Icicle) -> f32 {
903        ((self.elapsed / self.period) * ic.rate + ic.phase).rem_euclid(1.0)
904    }
905
906    /// The droplet's fall distance as a fraction `[0,1]` of `fall_frac·height`, at
907    /// cycle `c`. **Accelerating under gravity** (`c²`): 0 at the tip, 1 at the
908    /// bottom of its fall — the testable motion core.
909    fn droplet_fall_norm(c: f32) -> f32 {
910        let c = c.clamp(0.0, 1.0);
911        c * c
912    }
913
914    /// The droplet's alpha at cycle `c`: a quick bead-up near the tip, full body,
915    /// then a fade as it nears the end of its fall.
916    fn droplet_alpha(c: f32) -> f32 {
917        let c = c.clamp(0.0, 1.0);
918        if c < 0.08 {
919            c / 0.08
920        } else if c < 0.75 {
921            1.0
922        } else {
923            (1.0 - (c - 0.75) / 0.25).max(0.0)
924        }
925    }
926
927    /// The frost-shimmer sweep phase `[0,1)` along the top edge (pure clock fn).
928    fn frost_phase(&self) -> f32 {
929        (self.elapsed * 0.2).rem_euclid(1.0)
930    }
931
932    /// Paint the icicles + falling melt-droplets + frost shimmer onto `painter`,
933    /// hanging from the **top edge** of `rect`. No-op unless [`enabled`](Self::enabled).
934    pub fn paint(&self, painter: &Painter, rect: Rect) {
935        if !self.enabled || rect.width() <= 0.0 || rect.height() <= 0.0 {
936            return;
937        }
938        // Frost shimmer along the very top edge.
939        let band = Rect::from_min_max(rect.left_top(), pos2(rect.right(), rect.top() + 14.0));
940        shimmer(painter, band, self.glow, self.frost_phase());
941
942        for ic in &self.icicles {
943            let x = rect.left() + ic.x * rect.width();
944            let tip = pos2(x, rect.top() + ic.len);
945            // The icicle: a downward triangle from the top edge, translucent ice.
946            let base_l = pos2(x - ic.half_w, rect.top());
947            let base_r = pos2(x + ic.half_w, rect.top());
948            let body = Color32::from_rgba_unmultiplied(self.ice.r(), self.ice.g(), self.ice.b(), 150);
949            painter.add(egui::Shape::convex_polygon(
950                vec![base_l, base_r, tip],
951                body,
952                Stroke::new(1.0, self.glow),
953            ));
954            // A bright central ridge so the ice catches light.
955            painter.line_segment(
956                [pos2(x, rect.top()), tip],
957                Stroke::new(1.0, Color32::from_rgba_unmultiplied(255, 255, 255, 90)),
958            );
959
960            // The melt-droplet, falling under gravity and fading near the end.
961            let c = self.cycle(ic);
962            let a = Self::droplet_alpha(c);
963            if a > 0.0 {
964                let y = tip.y + Self::droplet_fall_norm(c) * self.fall_frac * rect.height();
965                let col = Color32::from_rgba_unmultiplied(self.glow.r(), self.glow.g(), self.glow.b(), (a * 220.0) as u8);
966                // A little teardrop: a round bead with a small tail above it.
967                painter.circle_filled(pos2(x, y), 2.2, col);
968                painter.circle_filled(pos2(x, y - 2.6), 1.1, col);
969            }
970        }
971    }
972
973    /// Paint **only if the active theme's [`EffectsPolicy`](crate::look::EffectsPolicy)
974    /// allows decorative motion** (i.e. `Full`) — so the decor follows the theme
975    /// (dark under `Reduced`/`None`/Device) on top of its own [`enabled`](Self::enabled)
976    /// gate. Reads the policy published by [`crate::look::Theme::apply`].
977    pub fn paint_gated(&self, ui: &egui::Ui, rect: Rect) {
978        if crate::look::effects_policy(ui).allows_decorative_motion() {
979            self.paint(ui.painter(), rect);
980        }
981    }
982
983    /// Observable state for headless / robot tests (the `state_json` hook): the
984    /// gate, the icicle count, the live droplets (each `x` + normalised fall +
985    /// alpha), the frost phase, and the clock. Asserted as DATA, not pixels.
986    pub fn state_json(&self) -> serde_json::Value {
987        let droplets: Vec<serde_json::Value> = if self.enabled {
988            self.icicles
989                .iter()
990                .filter_map(|ic| {
991                    let c = self.cycle(ic);
992                    let a = Self::droplet_alpha(c);
993                    (a > 0.0).then(|| {
994                        serde_json::json!({
995                            "x": ic.x,
996                            "y_norm": Self::droplet_fall_norm(c),
997                            "alpha": a,
998                        })
999                    })
1000                })
1001                .collect()
1002        } else {
1003            Vec::new()
1004        };
1005        serde_json::json!({
1006            "enabled": self.enabled,
1007            "icicles": self.icicles.len(),
1008            "active_droplets": droplets.len(),
1009            "frost_phase": self.frost_phase(),
1010            "elapsed_s": self.elapsed,
1011            "droplets": droplets,
1012        })
1013    }
1014}
1015
1016#[cfg(test)]
1017mod tests {
1018    use super::*;
1019
1020    /// A named easing fn (keeps the test table readable / clippy-clean).
1021    type NamedEasing = (&'static str, fn(f32) -> f32);
1022
1023    fn approx(a: f32, b: f32, eps: f32) -> bool {
1024        (a - b).abs() <= eps
1025    }
1026
1027    #[test]
1028    fn easing_fns_hit_their_endpoints() {
1029        let fns: [NamedEasing; 5] = [
1030            ("linear", easing::linear),
1031            ("cubic", easing::ease_in_out_cubic),
1032            ("back", easing::ease_out_back),
1033            ("elastic", easing::elastic),
1034            ("bounce", easing::bounce),
1035        ];
1036        for (name, f) in fns {
1037            assert!(approx(f(0.0), 0.0, 1e-5), "{name}(0) should be 0, got {}", f(0.0));
1038            assert!(approx(f(1.0), 1.0, 1e-5), "{name}(1) should be 1, got {}", f(1.0));
1039            // clamped outside [0,1]
1040            assert!(approx(f(-1.0), 0.0, 1e-5), "{name}(-1) clamps to 0");
1041            assert!(approx(f(2.0), 1.0, 1e-5), "{name}(2) clamps to 1");
1042        }
1043    }
1044
1045    #[test]
1046    fn tween_hits_endpoints_and_clamps() {
1047        let tw = Tween::new(10.0, 50.0, 0.2, Curve::EaseInOutCubic);
1048        assert!(approx(tw.value_at(0.0), 10.0, 1e-4), "start = from");
1049        assert!(approx(tw.value_at(0.2), 50.0, 1e-4), "end = to");
1050        // Clamps outside [0, duration].
1051        assert!(approx(tw.value_at(-1.0), 10.0, 1e-4), "before start clamps to from");
1052        assert!(approx(tw.value_at(99.0), 50.0, 1e-4), "after end clamps to to");
1053        assert!(!tw.is_done(0.1) && tw.is_done(0.2), "done at/after duration");
1054        // progress_at is the eased [0,1] fraction.
1055        assert!(approx(tw.progress_at(0.0), 0.0, 1e-4) && approx(tw.progress_at(0.2), 1.0, 1e-4));
1056    }
1057
1058    #[test]
1059    fn tween_zero_duration_snaps_to_target() {
1060        // Honours Motion::duration == 0 (reduced-motion / Device): instant, no NaN.
1061        let tw = Tween::new(0.0, 1.0, 0.0, Curve::EaseInOutCubic);
1062        assert_eq!(tw.value_at(0.0), 1.0);
1063        assert_eq!(tw.progress_at(0.0), 1.0);
1064        assert!(tw.is_done(0.0));
1065    }
1066
1067    #[test]
1068    fn tween_from_motion_uses_theme_durations() {
1069        let m = crate::look::Motion::default(); // duration 0.18, fast 0.10
1070        let slow = Tween::from_motion(&m, 0.0, 1.0, Curve::Linear);
1071        let fast = Tween::from_motion_fast(&m, 0.0, 1.0, Curve::Linear);
1072        assert!(approx(slow.duration, m.duration, 1e-6));
1073        assert!(approx(fast.duration, m.fast, 1e-6));
1074        // Linear half-way checks the durations actually drive the value.
1075        assert!(approx(slow.value_at(m.duration / 2.0), 0.5, 1e-4));
1076        assert!(approx(fast.value_at(m.fast / 2.0), 0.5, 1e-4));
1077    }
1078
1079    #[test]
1080    fn tween_rect_lerps_corners_for_slide_over() {
1081        let off = Rect::from_min_max(pos2(100.0, 0.0), pos2(140.0, 50.0));
1082        let on = Rect::from_min_max(pos2(0.0, 0.0), pos2(40.0, 50.0));
1083        assert_eq!(tween_rect(off, on, 0.0), off, "t=0 → off-screen rect");
1084        assert_eq!(tween_rect(off, on, 1.0), on, "t=1 → on-screen rect");
1085        let mid = tween_rect(off, on, 0.5);
1086        assert!(approx(mid.min.x, 50.0, 1e-4), "half-slid x");
1087    }
1088
1089    #[test]
1090    fn fade_track_fades_in_then_out_and_drops_settled_keys() {
1091        let k = FadeTrack::key("row-7");
1092        let mut ft = FadeTrack::default();
1093        // Fade IN: lit every frame ramps the factor toward 1 over `duration`.
1094        for _ in 0..12 {
1095            ft.begin();
1096            ft.lit(k);
1097            ft.advance(1.0 / 60.0, 0.10);
1098        }
1099        assert!(approx(ft.factor(k), 1.0, 1e-3), "lit key reaches 1, got {}", ft.factor(k));
1100        // Fade OUT: stop lighting it → eases back to 0, then the key is dropped.
1101        let mut animating = true;
1102        for _ in 0..40 {
1103            ft.begin();
1104            animating = ft.advance(1.0 / 60.0, 0.10);
1105        }
1106        assert!(!animating, "settles (no repaint) once faded out");
1107        assert_eq!(ft.factor(k), 0.0, "faded-out key reads 0 (dropped)");
1108    }
1109
1110    #[test]
1111    fn fade_track_zero_duration_snaps() {
1112        // Honours Motion::duration == 0 (reduced-motion / Device): instant, no NaN.
1113        let k = FadeTrack::key(3u64);
1114        let mut ft = FadeTrack::default();
1115        ft.begin();
1116        ft.lit(k);
1117        let animating = ft.advance(1.0 / 60.0, 0.0);
1118        assert_eq!(ft.factor(k), 1.0, "zero-duration snaps lit → 1");
1119        assert!(!animating, "snap is not 'animating'");
1120    }
1121
1122    #[test]
1123    fn fade_track_is_deterministic() {
1124        let k = FadeTrack::key("r");
1125        let mut a = FadeTrack::default();
1126        let mut b = FadeTrack::default();
1127        for _ in 0..5 {
1128            a.begin();
1129            a.lit(k);
1130            a.advance(1.0 / 60.0, 0.18);
1131            b.begin();
1132            b.lit(k);
1133            b.advance(1.0 / 60.0, 0.18);
1134        }
1135        assert!(approx(a.factor(k), b.factor(k), 1e-9), "same inputs → same factor (FC-7)");
1136    }
1137
1138    #[test]
1139    fn ease_out_back_overshoots_before_settling() {
1140        // The "back" curve should exceed 1.0 somewhere near the end.
1141        let peak = (60..100).map(|i| easing::ease_out_back(i as f32 / 100.0)).fold(0.0_f32, f32::max);
1142        assert!(peak > 1.0, "ease_out_back should overshoot, peak={peak}");
1143    }
1144
1145    #[test]
1146    fn raven_starts_at_launch_and_converges_onto_target_rect() {
1147        let target = Rect::from_min_size(pos2(300.0, 200.0), vec2(180.0, 24.0));
1148        let mut raven = RavenSprite::new().from(pos2(-40.0, -40.0)).fly_to(target);
1149
1150        // At t=0 it's at the launch point, not perched.
1151        raven.advance(0.0);
1152        assert!(!raven.is_perched());
1153        assert!(approx(raven.pos().x, -40.0, 0.5) && approx(raven.pos().y, -40.0, 0.5), "starts at launch");
1154
1155        // Midway it's airborne and somewhere between start and target (and lifted
1156        // by the arc — above the straight line).
1157        raven.advance(RAVEN_FLIGHT_SECS * 0.5);
1158        assert!(!raven.is_perched());
1159
1160        // After the full flight duration it has perched on the target's top edge,
1161        // centred, within a tiny bob amplitude.
1162        raven.advance(RAVEN_FLIGHT_SECS);
1163        assert!(raven.is_perched(), "perched after flight duration");
1164        let perch = pos2(target.center().x, target.top());
1165        let d = (raven.pos() - perch).length();
1166        assert!(d <= 2.0, "raven converges onto the perch (dist {d} px)");
1167
1168        // And it stays on the perch (within the bob) for a while after.
1169        for k in 1..20 {
1170            raven.advance(RAVEN_FLIGHT_SECS + k as f32 * 0.05);
1171            assert!(approx(raven.pos().x, perch.x, 0.01), "x stays centred on perch");
1172            assert!(approx(raven.pos().y, perch.y, 2.0), "y stays within bob of perch");
1173        }
1174    }
1175
1176    #[test]
1177    fn particle_burst_falls_and_finishes() {
1178        let mut b = ParticleBurst::new(pos2(100.0, 100.0), 16, Color32::WHITE, 42);
1179        assert!(!b.finished());
1180        for _ in 0..120 {
1181            b.update(1.0 / 60.0);
1182        }
1183        assert!(b.finished(), "burst should expire after its lifetime");
1184        // deterministic given the seed
1185        let mut a = ParticleBurst::new(pos2(0.0, 0.0), 8, Color32::WHITE, 7);
1186        let mut c = ParticleBurst::new(pos2(0.0, 0.0), 8, Color32::WHITE, 7);
1187        a.update(0.1);
1188        c.update(0.1);
1189        assert_eq!(a.particles[0].pos, c.particles[0].pos, "same seed → same motion");
1190    }
1191
1192    #[test]
1193    fn ice_drip_is_off_until_enabled() {
1194        // The gate: a fresh drip is disabled — no active droplets, paints nothing.
1195        let off = IceDrip::new(12, 3).at_clock(2.0);
1196        let s = off.state_json();
1197        assert_eq!(s["enabled"], false);
1198        assert_eq!(s["active_droplets"], 0, "disabled ⇒ no droplets: {s}");
1199        assert_eq!(s["icicles"], 12, "icicle count is still reported: {s}");
1200
1201        // Enabled: the icicles drip.
1202        let on = IceDrip::new(12, 3).enabled(true).at_clock(2.0);
1203        let s = on.state_json();
1204        assert_eq!(s["enabled"], true);
1205        assert!(s["active_droplets"].as_u64().unwrap() > 0, "enabled ⇒ live droplets: {s}");
1206    }
1207
1208    #[test]
1209    fn ice_drip_is_deterministic_given_seed_and_clock() {
1210        // Same seed + clock ⇒ byte-identical observable state (LAW: pure clock fn).
1211        let a = IceDrip::new(10, 7).enabled(true).at_clock(2.0);
1212        let b = IceDrip::new(10, 7).enabled(true).at_clock(2.0);
1213        assert_eq!(a.state_json(), b.state_json(), "same (seed,clock) ⇒ identical drip");
1214        // Advancing the clock changes the scene deterministically.
1215        let later = IceDrip::new(10, 7).enabled(true).at_clock(2.4);
1216        assert_ne!(a.state_json(), later.state_json(), "advancing the clock moves the drip");
1217    }
1218
1219    #[test]
1220    fn reveal_highlight_proximity_is_a_cursor_pool() {
1221        let rect = Rect::from_min_size(pos2(0.0, 0.0), vec2(100.0, 40.0));
1222        let r = RevealHighlight::new(Color32::from_rgb(80, 160, 255)).with_radius(100.0);
1223        // Inside the control → fully lit.
1224        assert!(approx(r.proximity(rect, Some(pos2(50.0, 20.0))), 1.0, 1e-6), "inside → 1");
1225        // No pointer / beyond the reach radius → dark.
1226        assert_eq!(r.proximity(rect, None), 0.0, "no pointer → 0");
1227        assert_eq!(r.proximity(rect, Some(pos2(300.0, 20.0))), 0.0, "far (>radius) → 0");
1228        // Monotonic decay as the pointer recedes from the edge.
1229        let near = r.proximity(rect, Some(pos2(120.0, 20.0))); // 20px off the right edge
1230        let mid = r.proximity(rect, Some(pos2(160.0, 20.0))); // 60px off
1231        assert!(near > mid && mid > 0.0, "reveal glow decays with distance: {near} > {mid} > 0");
1232        // Deterministic (FC-1/FC-7): same inputs → same value, no state/clock.
1233        assert_eq!(r.proximity(rect, Some(pos2(130.0, 10.0))), r.proximity(rect, Some(pos2(130.0, 10.0))));
1234    }
1235
1236    #[test]
1237    fn ice_drip_droplets_fall_under_gravity() {
1238        // The fall curve is 0 at the tip, 1 at the bottom, monotonic increasing,
1239        // and ACCELERATING (gravity) — later samples gain more than earlier ones.
1240        assert!((IceDrip::droplet_fall_norm(0.0)).abs() < 1e-6);
1241        assert!((IceDrip::droplet_fall_norm(1.0) - 1.0).abs() < 1e-6);
1242        let mut prev = 0.0;
1243        let mut last_step = 0.0;
1244        for i in 1..=10 {
1245            let c = i as f32 / 10.0;
1246            let y = IceDrip::droplet_fall_norm(c);
1247            assert!(y >= prev, "fall is monotonic downward at c={c}: {y} < {prev}");
1248            let step = y - prev;
1249            assert!(step >= last_step - 1e-6, "fall accelerates (gravity) at c={c}");
1250            last_step = step;
1251            prev = y;
1252        }
1253        // Alpha beads up at the tip, holds, then fades out by the end of the fall.
1254        assert!(IceDrip::droplet_alpha(0.0) < IceDrip::droplet_alpha(0.5));
1255        assert!((IceDrip::droplet_alpha(0.5) - 1.0).abs() < 1e-6);
1256        assert!(IceDrip::droplet_alpha(0.99) < 0.2, "the droplet fades near the end");
1257    }
1258}