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
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
//! **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)");
}
}