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
//! One-dimensional Lenis-style smooth scroll **core** (no DOM).
//!
//! [`SmoothScroll1D`] keeps a **target** scroll offset and a **current** value that
//! follows it using **frame-rate independent** exponential decay (not a fixed lerp
//! factor per frame).
//!
//! Use this from the `wasm-dom` [`SmoothScroll`](crate::integrations::smooth_scroll::SmoothScroll)
//! integration or any host that applies the resulting [`SmoothScroll1D::current`] to a scroll container.
#[cfg(not(feature = "std"))]
#[allow(unused_imports)]
use num_traits::Float as _;
/// Portable smooth scroll state: `current` eases toward `target` exponentially.
#[derive(Clone, Debug)]
pub struct SmoothScroll1D {
current: f32,
target: f32,
min: f32,
max: f32,
/// Strength of easing toward `target` (higher = snappier). Scaled for a 60fps reference.
lerp_factor: f32,
}
impl SmoothScroll1D {
/// Create a new state with both `current` and `target` at `initial`, clamped to `[min, max]`.
pub fn new(initial: f32, min: f32, max: f32, lerp_factor: f32) -> Self {
let mut s = Self {
current: initial,
target: initial,
min,
max,
lerp_factor,
};
s.clamp_both();
s
}
/// Smoothed scroll position (what to render / pass to `ScrollDriver`).
#[inline]
pub fn current(&self) -> f32 {
self.current
}
/// Target scroll offset (after input and `add_delta`).
#[inline]
pub fn target(&self) -> f32 {
self.target
}
/// Exponential smoothing factor (tuning knob).
#[inline]
pub fn lerp_factor(&self) -> f32 {
self.lerp_factor
}
/// Set exponential smoothing strength (see struct docs).
#[inline]
pub fn set_lerp_factor(&mut self, lerp_factor: f32) {
self.lerp_factor = lerp_factor;
}
/// Scroll range lower bound.
#[inline]
pub fn min(&self) -> f32 {
self.min
}
/// Scroll range upper bound.
#[inline]
pub fn max(&self) -> f32 {
self.max
}
/// Set scroll limits and clamp `current` / `target`.
pub fn set_limits(&mut self, min: f32, max: f32) {
self.min = min;
self.max = max;
self.clamp_both();
}
/// Set the target offset (clamped). Does not move `current` until [`Self::update`].
pub fn set_target(&mut self, target: f32) {
self.target = target.clamp(self.min, self.max);
}
/// Add a delta to the target (e.g. wheel or touch step).
pub fn add_delta(&mut self, delta: f32) {
self.set_target(self.target + delta);
}
/// Advance one timestep: move `current` toward `target` via exponential decay.
///
/// Uses `factor = 1 - exp(-lerp_factor * dt * 60)` so behavior is **frame-rate independent**.
pub fn update(&mut self, dt: f32) {
if dt <= 0.0 {
return;
}
let factor = 1.0 - (-self.lerp_factor * dt * 60.0).exp();
self.current += (self.target - self.current) * factor;
}
/// Snap `current` to `target` (e.g. `prefers-reduced-motion`).
pub fn snap_to_target(&mut self) {
self.current = self.target;
}
/// Align both values to an external scroll position (e.g. browser sync on attach).
pub fn sync_both(&mut self, scroll_y: f32) {
let y = scroll_y.clamp(self.min, self.max);
self.current = y;
self.target = y;
}
fn clamp_both(&mut self) {
self.target = self.target.clamp(self.min, self.max);
self.current = self.current.clamp(self.min, self.max);
}
/// Whether `current` is close to `target` (for optional early-out in hosts).
pub fn is_settled(&self, epsilon: f32) -> bool {
(self.target - self.current).abs() < epsilon
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn exponential_converges_to_target() {
let mut s = SmoothScroll1D::new(0.0, 0.0, 10_000.0, 8.0);
s.set_target(100.0);
for _ in 0..200 {
s.update(1.0 / 60.0);
}
assert!((s.current() - 100.0).abs() < 0.5, "got {}", s.current());
}
#[test]
fn large_dt_small_dt_same_end_state() {
let mut a = SmoothScroll1D::new(0.0, 0.0, 10_000.0, 5.0);
a.set_target(500.0);
a.update(1.0 / 30.0);
a.update(1.0 / 30.0);
let mut b = SmoothScroll1D::new(0.0, 0.0, 10_000.0, 5.0);
b.set_target(500.0);
b.update(2.0 / 30.0);
assert!((a.current() - b.current()).abs() < 1.0);
}
#[test]
fn add_delta_clamps_target() {
let mut s = SmoothScroll1D::new(0.0, 0.0, 100.0, 4.0);
s.add_delta(200.0);
assert!((s.target() - 100.0).abs() < 1e-5);
}
#[test]
fn snap_to_target() {
let mut s = SmoothScroll1D::new(0.0, 0.0, 1000.0, 4.0);
s.set_target(500.0);
s.update(1.0 / 60.0);
assert!((s.current() - 500.0).abs() > 1.0);
s.snap_to_target();
assert!((s.current() - 500.0).abs() < 1e-5);
}
#[test]
fn sync_both() {
let mut s = SmoothScroll1D::new(0.0, 0.0, 200.0, 4.0);
s.set_target(150.0);
s.sync_both(42.0);
assert!((s.current() - 42.0).abs() < 1e-5);
assert!((s.target() - 42.0).abs() < 1e-5);
}
}