facett-core 0.1.6

facett — visual kernel: render a node/edge Scene into egui (wgpu fast path to come)
Documentation
//! **CygnusEd smooth-scroll engine** (§11) — pixel-by-pixel, sub-pixel soft
//! scrolling with momentum/acceleration, à la the Amiga editor's jerkyless feel.
//! The scroll *offset* is decoupled from row height (renderers paint at a
//! **fractional** pixel offset + clip), and the whole thing is **deterministic
//! under an injected clock** (`advance(dt)`), so snapshots reproduce exactly
//! (FC-7 / P0-5). Reusable by text, console, and the dataframe grid (SCRL-3).

use serde::{Deserialize, Serialize};

/// A 1-D smooth scroll axis. `offset` is the current (fractional) pixel offset;
/// `target` is where we're easing toward; `velocity` carries momentum after a
/// flick. Advance with [`advance`](Self::advance) (injected dt) — never reads
/// wall-clock time.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct SmoothScroll {
    /// Current sub-pixel offset (what the renderer draws at).
    pub offset: f32,
    /// Target offset we ease toward.
    pub target: f32,
    /// Current momentum (px/s).
    pub velocity: f32,
    /// Scrollable extent `[0, max]`.
    pub max: f32,
    /// Easing stiffness (higher = snappier). 1/seconds.
    pub stiffness: f32,
    /// Momentum friction per second (0..1 retained per second-ish).
    pub friction: f32,
    /// If false, jumps instantly to target (smooth-vs-instant toggle, SCRL-1).
    pub smooth: bool,
}

impl Default for SmoothScroll {
    fn default() -> Self {
        Self {
            offset: 0.0,
            target: 0.0,
            velocity: 0.0,
            max: 0.0,
            stiffness: 16.0,
            friction: 6.0,
            smooth: true,
        }
    }
}

impl SmoothScroll {
    pub fn with_max(mut self, max: f32) -> Self {
        self.max = max.max(0.0);
        self
    }

    /// Set the scrollable extent (e.g. total content height − viewport height),
    /// re-clamping the target.
    pub fn set_max(&mut self, max: f32) {
        self.max = max.max(0.0);
        self.target = self.target.clamp(0.0, self.max);
    }

    /// Request a scroll **to** an absolute offset (e.g. a scrollbar drag / key).
    pub fn scroll_to(&mut self, target: f32) {
        self.target = target.clamp(0.0, self.max);
        if !self.smooth {
            self.offset = self.target;
            self.velocity = 0.0;
        }
    }

    /// Scroll **by** a delta (wheel/keys). Adds momentum proportional to the
    /// delta so repeated flicks accelerate (SCRL-1).
    pub fn scroll_by(&mut self, delta: f32) {
        self.target = (self.target + delta).clamp(0.0, self.max);
        self.velocity += delta * 6.0;
        if !self.smooth {
            self.offset = self.target;
            self.velocity = 0.0;
        }
    }

    /// Apply a momentum flick (px/s), e.g. from a fast drag release.
    pub fn flick(&mut self, velocity: f32) {
        self.velocity = velocity;
    }

    /// **Advance** the animation by `dt` seconds (the injected clock). Integrates
    /// momentum + eases the offset toward the target with a critically-ish damped
    /// spring. Deterministic: same state + dt → same result.
    pub fn advance(&mut self, dt: f32) {
        let dt = dt.clamp(0.0, 0.1); // bound a long pause so a frame can't teleport
        if !self.smooth {
            self.offset = self.target;
            self.velocity = 0.0;
            return;
        }

        // Momentum carries the target along, decaying by friction.
        if self.velocity.abs() > 0.01 {
            self.target = (self.target + self.velocity * dt).clamp(0.0, self.max);
            // Exponential friction decay.
            self.velocity *= (1.0 - self.friction * dt).clamp(0.0, 1.0);
            if self.target <= 0.0 || self.target >= self.max {
                self.velocity = 0.0; // hit an edge — stop momentum
            }
        } else {
            self.velocity = 0.0;
        }

        // Ease the visible offset toward the target (sub-pixel, pixel-by-pixel).
        let k = (self.stiffness * dt).clamp(0.0, 1.0);
        self.offset += (self.target - self.offset) * k;
        // Snap when essentially arrived to avoid an asymptotic crawl.
        if (self.target - self.offset).abs() < 0.05 && self.velocity == 0.0 {
            self.offset = self.target;
        }
        self.offset = self.offset.clamp(0.0, self.max);
    }

    /// Whether the animation is still moving (caller requests a repaint while so).
    pub fn animating(&self) -> bool {
        self.smooth && ((self.target - self.offset).abs() > 0.05 || self.velocity.abs() > 0.01)
    }

    /// The integer row index at the current offset given a uniform row height, and
    /// the fractional pixel remainder the renderer draws at (SCRL-2: render at a
    /// fractional offset + clip).
    pub fn first_row_and_frac(&self, row_h: f32) -> (usize, f32) {
        if row_h <= 0.0 {
            return (0, 0.0);
        }
        let row = (self.offset / row_h).floor();
        let frac = self.offset - row * row_h;
        (row.max(0.0) as usize, frac)
    }
}

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

    #[test]
    fn smooth_scroll_converges_to_target_under_injected_clock() {
        let mut s = SmoothScroll::default().with_max(1000.0);
        s.scroll_to(500.0);
        // Advance a second of 60fps frames.
        for _ in 0..120 {
            s.advance(1.0 / 60.0);
        }
        assert!((s.offset - 500.0).abs() < 0.5, "should converge near target, got {}", s.offset);
    }

    #[test]
    fn deterministic_same_input_same_result() {
        let mut a = SmoothScroll::default().with_max(1000.0);
        let mut b = SmoothScroll::default().with_max(1000.0);
        a.scroll_by(120.0);
        b.scroll_by(120.0);
        for _ in 0..30 {
            a.advance(1.0 / 60.0);
            b.advance(1.0 / 60.0);
        }
        assert_eq!(a, b, "identical state + dt sequence → identical result (FC-7)");
    }

    #[test]
    fn instant_mode_jumps_with_no_animation() {
        let mut s = SmoothScroll { smooth: false, ..SmoothScroll::default().with_max(1000.0) };
        s.scroll_to(400.0);
        assert_eq!(s.offset, 400.0, "instant mode snaps");
        assert!(!s.animating());
    }

    #[test]
    fn momentum_decays_and_stops() {
        let mut s = SmoothScroll::default().with_max(10000.0);
        s.flick(2000.0);
        let mut moved = 0.0;
        for _ in 0..300 {
            let before = s.offset;
            s.advance(1.0 / 60.0);
            moved += (s.offset - before).abs();
        }
        assert!(moved > 0.0, "momentum scrolled");
        assert!(!s.animating(), "momentum eventually settles");
    }

    #[test]
    fn fractional_offset_is_decoupled_from_row_height() {
        let mut s = SmoothScroll::default().with_max(10000.0);
        s.offset = 53.0; // not a multiple of row height
        let (row, frac) = s.first_row_and_frac(20.0);
        assert_eq!(row, 2, "53/20 → row 2");
        assert!((frac - 13.0).abs() < 1e-3, "sub-pixel remainder 13px");
    }

    #[test]
    fn clamps_to_extent() {
        let mut s = SmoothScroll::default().with_max(100.0);
        s.scroll_by(9999.0);
        for _ in 0..300 {
            s.advance(1.0 / 60.0);
        }
        assert!(s.offset <= 100.0 + 1e-3, "cannot scroll past max");
        assert!(s.offset >= 99.0, "reaches the bottom");
    }
}