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    /// **Follow** egui's own resolved scroll offset, one frame, eased. The bridge
135    /// that wires this engine onto egui's stateful `ScrollArea` (SCRL-3): the caller
136    /// forces `self.offset` as egui's *displayed* offset, then reads back the offset
137    /// egui *resolved* after this frame's wheel/drag as `resolved` — this re-clamps
138    /// the extent, takes `resolved` as the new `target`, and `advance`s by `dt`
139    /// (the injected `stable_dt`, FC-7). Returns whether it is still
140    /// [`animating`](Self::animating) (the caller requests a repaint while so).
141    ///
142    /// Pure follower: it uses no momentum of its own (egui owns the flick), so the
143    /// two never double-animate — this is just the sub-pixel low-pass that turns
144    /// egui's instant offset jumps into smooth, deterministic motion.
145    pub fn follow(&mut self, max: f32, resolved: f32, dt: f32) -> bool {
146        self.set_max(max);
147        self.scroll_to(resolved);
148        self.advance(dt);
149        self.animating()
150    }
151}
152
153/// **Smooth-scroll wrapper** around an [`egui::ScrollArea`] (SCRL-3): render the
154/// area at the eased offset(s) carried by the caller's [`SmoothScroll`] state, run
155/// the content, then [`follow`](SmoothScroll::follow) egui's resolved offset so the
156/// position glides instead of snapping. Keeps egui's virtualization, scrollbars and
157/// wheel/flick handling untouched — it only low-passes the *displayed* offset.
158///
159/// Determinism (FC-7): the eased state lives in the caller's `vert`/`horiz`
160/// (component struct, not egui memory) and is advanced by `dt` (pass
161/// `ui.input(|i| i.stable_dt)`), so snapshots reproduce. Pass `horiz` for a
162/// both-axes area, or `None` for vertical-only. Repaints itself while moving.
163pub fn smooth_scroll_area<R>(
164    ui: &mut egui::Ui,
165    area: egui::ScrollArea,
166    dt: f32,
167    vert: &mut SmoothScroll,
168    horiz: Option<&mut SmoothScroll>,
169    content: impl FnOnce(&mut egui::Ui) -> R,
170) -> R {
171    let mut area = area.vertical_scroll_offset(vert.offset);
172    if let Some(h) = &horiz {
173        area = area.horizontal_scroll_offset(h.offset);
174    }
175    let out = area.show(ui, content);
176
177    let max_y = (out.content_size.y - out.inner_rect.height()).max(0.0);
178    let mut animating = vert.follow(max_y, out.state.offset.y, dt);
179    if let Some(h) = horiz {
180        let max_x = (out.content_size.x - out.inner_rect.width()).max(0.0);
181        animating |= h.follow(max_x, out.state.offset.x, dt);
182    }
183    if animating {
184        ui.ctx().request_repaint();
185    }
186    out.inner
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192
193    #[test]
194    fn smooth_scroll_converges_to_target_under_injected_clock() {
195        let mut s = SmoothScroll::default().with_max(1000.0);
196        s.scroll_to(500.0);
197        // Advance a second of 60fps frames.
198        for _ in 0..120 {
199            s.advance(1.0 / 60.0);
200        }
201        assert!((s.offset - 500.0).abs() < 0.5, "should converge near target, got {}", s.offset);
202    }
203
204    #[test]
205    fn deterministic_same_input_same_result() {
206        let mut a = SmoothScroll::default().with_max(1000.0);
207        let mut b = SmoothScroll::default().with_max(1000.0);
208        a.scroll_by(120.0);
209        b.scroll_by(120.0);
210        for _ in 0..30 {
211            a.advance(1.0 / 60.0);
212            b.advance(1.0 / 60.0);
213        }
214        assert_eq!(a, b, "identical state + dt sequence → identical result (FC-7)");
215    }
216
217    #[test]
218    fn instant_mode_jumps_with_no_animation() {
219        let mut s = SmoothScroll { smooth: false, ..SmoothScroll::default().with_max(1000.0) };
220        s.scroll_to(400.0);
221        assert_eq!(s.offset, 400.0, "instant mode snaps");
222        assert!(!s.animating());
223    }
224
225    #[test]
226    fn momentum_decays_and_stops() {
227        let mut s = SmoothScroll::default().with_max(10000.0);
228        s.flick(2000.0);
229        let mut moved = 0.0;
230        for _ in 0..300 {
231            let before = s.offset;
232            s.advance(1.0 / 60.0);
233            moved += (s.offset - before).abs();
234        }
235        assert!(moved > 0.0, "momentum scrolled");
236        assert!(!s.animating(), "momentum eventually settles");
237    }
238
239    #[test]
240    fn fractional_offset_is_decoupled_from_row_height() {
241        let mut s = SmoothScroll::default().with_max(10000.0);
242        s.offset = 53.0; // not a multiple of row height
243        let (row, frac) = s.first_row_and_frac(20.0);
244        assert_eq!(row, 2, "53/20 → row 2");
245        assert!((frac - 13.0).abs() < 1e-3, "sub-pixel remainder 13px");
246    }
247
248    #[test]
249    fn clamps_to_extent() {
250        let mut s = SmoothScroll::default().with_max(100.0);
251        s.scroll_by(9999.0);
252        for _ in 0..300 {
253            s.advance(1.0 / 60.0);
254        }
255        assert!(s.offset <= 100.0 + 1e-3, "cannot scroll past max");
256        assert!(s.offset >= 99.0, "reaches the bottom");
257    }
258
259    #[test]
260    fn follow_eases_toward_egui_resolved_offset() {
261        // `follow` low-passes egui's resolved offset: it animates toward the
262        // jumped-to target and settles there (FC-7: pure function of state + dt).
263        let mut s = SmoothScroll::default();
264        let mut animating = false;
265        for _ in 0..120 {
266            animating = s.follow(1000.0, 300.0, 1.0 / 60.0);
267        }
268        assert!((s.offset - 300.0).abs() < 0.5, "follows the resolved target, got {}", s.offset);
269        assert!(!animating, "settles (no repaint) once arrived");
270        // Same state + inputs → identical result.
271        let mut a = SmoothScroll::default();
272        let mut b = SmoothScroll::default();
273        for _ in 0..20 {
274            a.follow(500.0, 250.0, 1.0 / 60.0);
275            b.follow(500.0, 250.0, 1.0 / 60.0);
276        }
277        assert_eq!(a, b, "deterministic follow (FC-7)");
278    }
279}