facett-core/src/scroll_engine.rs
//! **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)
}
/// **Follow** egui's own resolved scroll offset, one frame, eased. The bridge
/// that wires this engine onto egui's stateful `ScrollArea` (SCRL-3): the caller
/// forces `self.offset` as egui's *displayed* offset, then reads back the offset
/// egui *resolved* after this frame's wheel/drag as `resolved` — this re-clamps
/// the extent, takes `resolved` as the new `target`, and `advance`s by `dt`
/// (the injected `stable_dt`, FC-7). Returns whether it is still
/// [`animating`](Self::animating) (the caller requests a repaint while so).
///
/// Pure follower: it uses no momentum of its own (egui owns the flick), so the
/// two never double-animate — this is just the sub-pixel low-pass that turns
/// egui's instant offset jumps into smooth, deterministic motion.
pub fn follow(&mut self, max: f32, resolved: f32, dt: f32) -> bool {
self.set_max(max);
self.scroll_to(resolved);
self.advance(dt);
self.animating()
}
}
/// **Smooth-scroll wrapper** around an [`egui::ScrollArea`] (SCRL-3): render the
/// area at the eased offset(s) carried by the caller's [`SmoothScroll`] state, run
/// the content, then [`follow`](SmoothScroll::follow) egui's resolved offset so the
/// position glides instead of snapping. Keeps egui's virtualization, scrollbars and
/// wheel/flick handling untouched — it only low-passes the *displayed* offset.
///
/// Determinism (FC-7): the eased state lives in the caller's `vert`/`horiz`
/// (component struct, not egui memory) and is advanced by `dt` (pass
/// `ui.input(|i| i.stable_dt)`), so snapshots reproduce. Pass `horiz` for a
/// both-axes area, or `None` for vertical-only. Repaints itself while moving.
pub fn smooth_scroll_area<R>(
ui: &mut egui::Ui,
area: egui::ScrollArea,
dt: f32,
vert: &mut SmoothScroll,
horiz: Option<&mut SmoothScroll>,
content: impl FnOnce(&mut egui::Ui) -> R,
) -> R {
let mut area = area.vertical_scroll_offset(vert.offset);
if let Some(h) = &horiz {
area = area.horizontal_scroll_offset(h.offset);
}
let out = area.show(ui, content);
let max_y = (out.content_size.y - out.inner_rect.height()).max(0.0);
let mut animating = vert.follow(max_y, out.state.offset.y, dt);
if let Some(h) = horiz {
let max_x = (out.content_size.x - out.inner_rect.width()).max(0.0);
animating |= h.follow(max_x, out.state.offset.x, dt);
}
if animating {
ui.ctx().request_repaint();
}
out.inner
}
#[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");
}
#[test]
fn follow_eases_toward_egui_resolved_offset() {
// `follow` low-passes egui's resolved offset: it animates toward the
// jumped-to target and settles there (FC-7: pure function of state + dt).
let mut s = SmoothScroll::default();
let mut animating = false;
for _ in 0..120 {
animating = s.follow(1000.0, 300.0, 1.0 / 60.0);
}
assert!((s.offset - 300.0).abs() < 0.5, "follows the resolved target, got {}", s.offset);
assert!(!animating, "settles (no repaint) once arrived");
// Same state + inputs → identical result.
let mut a = SmoothScroll::default();
let mut b = SmoothScroll::default();
for _ in 0..20 {
a.follow(500.0, 250.0, 1.0 / 60.0);
b.follow(500.0, 250.0, 1.0 / 60.0);
}
assert_eq!(a, b, "deterministic follow (FC-7)");
}
}