pane_ui 0.1.0

A RON-driven, hot-reloadable wgpu UI library with spring animations and consistent scaling
Documentation
use crate::widgets::WidgetState;
use std::collections::VecDeque;

// ── Spring Parameters ─────────────────────────────────────────────────────────

/// Controls the feel of the spring animation.
/// stiffness — how fast it moves toward target (higher = snappier)
/// damping   — how quickly it settles (higher = less bounce)
#[derive(Clone, Copy)]
pub struct Spring {
    pub stiffness: f32,
    pub damping: f32,
}

impl Spring {
    /// Bouncy — more playful, slight overshoot.
    pub const BOUNCY: Self = Self {
        stiffness: 300.0,
        damping: 18.0,
    };
}

// ── SpringValue ───────────────────────────────────────────────────────────────

/// A single f32 that chases a target value each frame using spring physics.
/// Unlike Anim (which transitions between two `WidgetStates`), `SpringValue` is for
/// continuously-driven values like popout position (0.0 = closed, 1.0 = open).
pub struct SpringValue {
    value: f32,
    velocity: f32,
    spring: Spring,
}

impl SpringValue {
    pub const fn new(initial: f32, spring: Spring) -> Self {
        Self {
            value: initial,
            velocity: 0.0,
            spring,
        }
    }

    /// Drive toward `target` each frame. Call every frame regardless of whether target changed.
    pub fn update(&mut self, target: f32, dt: f32) {
        let force = (-self.spring.stiffness)
            .mul_add(self.value - target, -(self.spring.damping * self.velocity));
        self.velocity += force * dt;
        self.value += self.velocity * dt;
        if (self.value - target).abs() < 0.001 && self.velocity.abs() < 0.001 {
            self.value = target;
            self.velocity = 0.0;
        }
    }

    /// Clamped 0.0..1.5 — allows slight overshoot for bounciness.
    pub const fn t(&self) -> f32 {
        self.value.clamp(0.0, 1.5)
    }
}

// ── TrailBuffer ───────────────────────────────────────────────────────────────
//
// A fixed-duration ring buffer of timestamped mouse positions.
// Used by Actor's FollowCursor action: instead of chasing the cursor's current
// position, the actor chases where the cursor *was* `trail` seconds ago —
// giving a smooth, lagging follow effect.
//
// capacity_secs should be set to the largest trail value any behaviour on the
// actor uses, plus a small margin. Entries older than capacity_secs are dropped.

pub struct TrailBuffer {
    entries: VecDeque<(f32, f32, f32)>, // (time, x, y)
    capacity_secs: f32,
}

impl TrailBuffer {
    pub const fn new(capacity_secs: f32) -> Self {
        Self {
            entries: VecDeque::new(),
            capacity_secs,
        }
    }

    /// Record the current cursor position. Call once per frame before ticking actors.
    pub fn push(&mut self, now: f32, x: f32, y: f32) {
        self.entries.push_back((now, x, y));
        // Evict entries older than our required history window.
        while let Some(&(t, _, _)) = self.entries.front() {
            if now - t > self.capacity_secs + 0.1 {
                self.entries.pop_front();
            } else {
                break;
            }
        }
    }

    /// Return the interpolated cursor position from `trail` seconds ago.
    /// Falls back to the oldest known position if the buffer doesn't reach that far back.
    pub fn sample(&self, now: f32, trail: f32) -> (f32, f32) {
        let target_t = now - trail;

        // Find the two entries that bracket target_t.
        let mut before = None;
        let mut after = None;
        for &entry in &self.entries {
            let (t, _, _) = entry;
            if t <= target_t {
                before = Some(entry);
            } else if after.is_none() {
                after = Some(entry);
                break;
            }
        }

        match (before, after) {
            // Perfect bracket — lerp between the two.
            (Some((t0, x0, y0)), Some((t1, x1, y1))) => {
                let span = (t1 - t0).max(f32::EPSILON);
                let f = ((target_t - t0) / span).clamp(0.0, 1.0);
                ((x1 - x0).mul_add(f, x0), (y1 - y0).mul_add(f, y0))
            }
            // target_t is ahead of all entries — use the most recent.
            // Buffer is empty or target_t is before all entries — use oldest.
            (Some((_, x, y)), None) | (None, Some((_, x, y))) => (x, y),
            (None, None) => (0.0, 0.0),
        }
    }
}

// ── Anim ──────────────────────────────────────────────────────────────────────

/// Tracks the animation state for a single component.
pub struct Anim {
    from: WidgetState,
    to: WidgetState,
    spring: Spring,
    position: f32, // current interpolated value 0.0 → 1.0
    velocity: f32, // current velocity
    done: bool,    // true when settled
}

impl Anim {
    pub fn new(spring: Spring) -> Self {
        Self {
            from: WidgetState::default(),
            to: WidgetState::default(),
            spring,
            position: 1.0,
            velocity: 0.0,
            done: true,
        }
    }

    /// Trigger a transition to a new `WidgetState`.
    pub fn transition(&mut self, to: WidgetState) {
        if self.to == to {
            return;
        }
        // Freeze from/to at the current visual position so momentum carries through.
        // Without this, interrupting press→release resets position=0 and loses velocity,
        // killing the bounce before it can overshoot.
        self.from = self.to;
        self.to = to;
        self.position = 1.0 - self.position.clamp(0.0, 1.5); // mirror into new [0..1] range
        self.velocity = -self.velocity; // velocity direction flips with the lerp
        self.done = false;
    }

    /// Advance the animation by `dt` seconds. Call every frame.
    pub fn update(&mut self, dt: f32) {
        if self.done {
            return;
        }

        // Spring physics: F = -stiffness * displacement - damping * velocity
        let target = 1.0_f32;
        let displacement = self.position - target;
        let force =
            (-self.spring.stiffness).mul_add(displacement, -(self.spring.damping * self.velocity));

        self.velocity += force * dt;
        self.position += self.velocity * dt;

        // Settle check — close enough to target with near zero velocity
        if (self.position - target).abs() < 0.001 && self.velocity.abs() < 0.001 {
            self.position = target;
            self.velocity = 0.0;
            self.done = true;
        }
    }

    /// Current interpolation value to pass into `styles::push_component`.
    pub const fn t(&self) -> f32 {
        self.position.clamp(0.0, 1.5) // allow slight overshoot for bounciness
    }

    /// The state being transitioned FROM — for lerp start point.
    pub const fn prev_state(&self) -> WidgetState {
        self.from
    }
}