1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
//! **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");
}
}