Skip to main content

facett_core/
scroll_engine.rs

1//! **CygnusEd smooth-scroll engine** (§11) — pixel-by-pixel, sub-pixel soft
2//! scrolling with momentum/acceleration, à la the Amiga editor's jerkyless feel.
3//! The scroll *offset* is decoupled from row height (renderers paint at a
4//! **fractional** pixel offset + clip), and the whole thing is **deterministic
5//! under an injected clock** (`advance(dt)`), so snapshots reproduce exactly
6//! (FC-7 / P0-5). Reusable by text, console, and the dataframe grid (SCRL-3).
7
8use serde::{Deserialize, Serialize};
9
10/// A 1-D smooth scroll axis. `offset` is the current (fractional) pixel offset;
11/// `target` is where we're easing toward; `velocity` carries momentum after a
12/// flick. Advance with [`advance`](Self::advance) (injected dt) — never reads
13/// wall-clock time.
14#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
15pub struct SmoothScroll {
16    /// Current sub-pixel offset (what the renderer draws at).
17    pub offset: f32,
18    /// Target offset we ease toward.
19    pub target: f32,
20    /// Current momentum (px/s).
21    pub velocity: f32,
22    /// Scrollable extent `[0, max]`.
23    pub max: f32,
24    /// Easing stiffness (higher = snappier). 1/seconds.
25    pub stiffness: f32,
26    /// Momentum friction per second (0..1 retained per second-ish).
27    pub friction: f32,
28    /// If false, jumps instantly to target (smooth-vs-instant toggle, SCRL-1).
29    pub smooth: bool,
30}
31
32impl Default for SmoothScroll {
33    fn default() -> Self {
34        Self {
35            offset: 0.0,
36            target: 0.0,
37            velocity: 0.0,
38            max: 0.0,
39            stiffness: 16.0,
40            friction: 6.0,
41            smooth: true,
42        }
43    }
44}
45
46impl SmoothScroll {
47    pub fn with_max(mut self, max: f32) -> Self {
48        self.max = max.max(0.0);
49        self
50    }
51
52    /// Set the scrollable extent (e.g. total content height − viewport height),
53    /// re-clamping the target.
54    pub fn set_max(&mut self, max: f32) {
55        self.max = max.max(0.0);
56        self.target = self.target.clamp(0.0, self.max);
57    }
58
59    /// Request a scroll **to** an absolute offset (e.g. a scrollbar drag / key).
60    pub fn scroll_to(&mut self, target: f32) {
61        self.target = target.clamp(0.0, self.max);
62        if !self.smooth {
63            self.offset = self.target;
64            self.velocity = 0.0;
65        }
66    }
67
68    /// Scroll **by** a delta (wheel/keys). Adds momentum proportional to the
69    /// delta so repeated flicks accelerate (SCRL-1).
70    pub fn scroll_by(&mut self, delta: f32) {
71        self.target = (self.target + delta).clamp(0.0, self.max);
72        self.velocity += delta * 6.0;
73        if !self.smooth {
74            self.offset = self.target;
75            self.velocity = 0.0;
76        }
77    }
78
79    /// Apply a momentum flick (px/s), e.g. from a fast drag release.
80    pub fn flick(&mut self, velocity: f32) {
81        self.velocity = velocity;
82    }
83
84    /// **Advance** the animation by `dt` seconds (the injected clock). Integrates
85    /// momentum + eases the offset toward the target with a critically-ish damped
86    /// spring. Deterministic: same state + dt → same result.
87    pub fn advance(&mut self, dt: f32) {
88        let dt = dt.clamp(0.0, 0.1); // bound a long pause so a frame can't teleport
89        if !self.smooth {
90            self.offset = self.target;
91            self.velocity = 0.0;
92            return;
93        }
94
95        // Momentum carries the target along, decaying by friction.
96        if self.velocity.abs() > 0.01 {
97            self.target = (self.target + self.velocity * dt).clamp(0.0, self.max);
98            // Exponential friction decay.
99            self.velocity *= (1.0 - self.friction * dt).clamp(0.0, 1.0);
100            if self.target <= 0.0 || self.target >= self.max {
101                self.velocity = 0.0; // hit an edge — stop momentum
102            }
103        } else {
104            self.velocity = 0.0;
105        }
106
107        // Ease the visible offset toward the target (sub-pixel, pixel-by-pixel).
108        let k = (self.stiffness * dt).clamp(0.0, 1.0);
109        self.offset += (self.target - self.offset) * k;
110        // Snap when essentially arrived to avoid an asymptotic crawl.
111        if (self.target - self.offset).abs() < 0.05 && self.velocity == 0.0 {
112            self.offset = self.target;
113        }
114        self.offset = self.offset.clamp(0.0, self.max);
115    }
116
117    /// Whether the animation is still moving (caller requests a repaint while so).
118    pub fn animating(&self) -> bool {
119        self.smooth && ((self.target - self.offset).abs() > 0.05 || self.velocity.abs() > 0.01)
120    }
121
122    /// The integer row index at the current offset given a uniform row height, and
123    /// the fractional pixel remainder the renderer draws at (SCRL-2: render at a
124    /// fractional offset + clip).
125    pub fn first_row_and_frac(&self, row_h: f32) -> (usize, f32) {
126        if row_h <= 0.0 {
127            return (0, 0.0);
128        }
129        let row = (self.offset / row_h).floor();
130        let frac = self.offset - row * row_h;
131        (row.max(0.0) as usize, frac)
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    #[test]
140    fn smooth_scroll_converges_to_target_under_injected_clock() {
141        let mut s = SmoothScroll::default().with_max(1000.0);
142        s.scroll_to(500.0);
143        // Advance a second of 60fps frames.
144        for _ in 0..120 {
145            s.advance(1.0 / 60.0);
146        }
147        assert!((s.offset - 500.0).abs() < 0.5, "should converge near target, got {}", s.offset);
148    }
149
150    #[test]
151    fn deterministic_same_input_same_result() {
152        let mut a = SmoothScroll::default().with_max(1000.0);
153        let mut b = SmoothScroll::default().with_max(1000.0);
154        a.scroll_by(120.0);
155        b.scroll_by(120.0);
156        for _ in 0..30 {
157            a.advance(1.0 / 60.0);
158            b.advance(1.0 / 60.0);
159        }
160        assert_eq!(a, b, "identical state + dt sequence → identical result (FC-7)");
161    }
162
163    #[test]
164    fn instant_mode_jumps_with_no_animation() {
165        let mut s = SmoothScroll { smooth: false, ..SmoothScroll::default().with_max(1000.0) };
166        s.scroll_to(400.0);
167        assert_eq!(s.offset, 400.0, "instant mode snaps");
168        assert!(!s.animating());
169    }
170
171    #[test]
172    fn momentum_decays_and_stops() {
173        let mut s = SmoothScroll::default().with_max(10000.0);
174        s.flick(2000.0);
175        let mut moved = 0.0;
176        for _ in 0..300 {
177            let before = s.offset;
178            s.advance(1.0 / 60.0);
179            moved += (s.offset - before).abs();
180        }
181        assert!(moved > 0.0, "momentum scrolled");
182        assert!(!s.animating(), "momentum eventually settles");
183    }
184
185    #[test]
186    fn fractional_offset_is_decoupled_from_row_height() {
187        let mut s = SmoothScroll::default().with_max(10000.0);
188        s.offset = 53.0; // not a multiple of row height
189        let (row, frac) = s.first_row_and_frac(20.0);
190        assert_eq!(row, 2, "53/20 → row 2");
191        assert!((frac - 13.0).abs() < 1e-3, "sub-pixel remainder 13px");
192    }
193
194    #[test]
195    fn clamps_to_extent() {
196        let mut s = SmoothScroll::default().with_max(100.0);
197        s.scroll_by(9999.0);
198        for _ in 0..300 {
199            s.advance(1.0 / 60.0);
200        }
201        assert!(s.offset <= 100.0 + 1e-3, "cannot scroll past max");
202        assert!(s.offset >= 99.0, "reaches the bottom");
203    }
204}