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");
}
}