1#[derive(Debug, Clone, Copy, PartialEq)]
3pub enum ScrollDirection {
4 Vertical,
5 Horizontal,
6 Both,
7}
8
9#[derive(Debug, Clone, Copy)]
11pub enum ScrollPhysics {
12 Momentum { friction: f32 },
14 Clamped,
16 Paged { page_size: f32 },
18 Bounce { friction: f32, spring_stiffness: f32 },
27}
28
29impl Default for ScrollPhysics {
30 fn default() -> Self {
31 ScrollPhysics::Momentum { friction: 0.92 }
32 }
33}
34
35#[derive(Debug, Clone, Copy)]
42pub struct ScrollStyle {
43 pub physics: ScrollPhysics,
44}
45
46impl ScrollStyle {
47 pub fn default_for_platform(platform: rosace_core::Platform) -> ScrollPhysics {
52 match platform {
59 rosace_core::Platform::Ios | rosace_core::Platform::MacOs => {
60 ScrollPhysics::Bounce { friction: 0.88, spring_stiffness: 12.0 }
61 }
62 _ => ScrollPhysics::Momentum { friction: 0.88 },
63 }
64 }
65}
66
67pub struct MomentumState {
69 pub velocity_x: f32,
70 pub velocity_y: f32,
71}
72
73impl MomentumState {
74 pub fn new() -> Self {
75 Self {
76 velocity_x: 0.0,
77 velocity_y: 0.0,
78 }
79 }
80
81 pub fn push(&mut self, dx: f32, dy: f32) {
83 self.velocity_x = dx;
84 self.velocity_y = dy;
85 }
86
87 pub fn tick(&mut self, physics: ScrollPhysics) -> (f32, f32) {
89 match physics {
90 ScrollPhysics::Clamped => {
91 let out = (self.velocity_x, self.velocity_y);
92 self.velocity_x = 0.0;
93 self.velocity_y = 0.0;
94 out
95 }
96 ScrollPhysics::Momentum { friction } | ScrollPhysics::Bounce { friction, .. } => {
97 let out = (self.velocity_x, self.velocity_y);
98 self.velocity_x *= friction;
99 self.velocity_y *= friction;
100 if self.velocity_x.abs() < 0.5 {
102 self.velocity_x = 0.0;
103 }
104 if self.velocity_y.abs() < 0.5 {
105 self.velocity_y = 0.0;
106 }
107 out
108 }
109 ScrollPhysics::Paged { page_size: _ } => {
110 let out = (self.velocity_x, self.velocity_y);
112 self.velocity_x = 0.0;
113 self.velocity_y = 0.0;
114 out
115 }
116 }
117 }
118
119 pub fn is_settled(&self) -> bool {
121 self.velocity_x.abs() < 0.5 && self.velocity_y.abs() < 0.5
122 }
123}
124
125impl Default for MomentumState {
126 fn default() -> Self {
127 Self::new()
128 }
129}
130
131pub fn clamp_offset(offset: [f32; 2], content: [f32; 2], viewport: [f32; 2]) -> [f32; 2] {
133 let max_x = (content[0] - viewport[0]).max(0.0);
134 let max_y = (content[1] - viewport[1]).max(0.0);
135 [offset[0].clamp(0.0, max_x), offset[1].clamp(0.0, max_y)]
136}
137
138pub fn snap_to_page(offset: f32, page_size: f32) -> f32 {
140 (offset / page_size).round() * page_size
141}
142
143#[cfg(test)]
147mod tests {
148 use super::*;
149
150 #[test]
151 fn clamp_offset_returns_zero_when_content_fits_viewport() {
152 let result = clamp_offset([5.0, 10.0], [100.0, 200.0], [150.0, 250.0]);
153 assert_eq!(result, [0.0, 0.0]);
154 }
155
156 #[test]
157 fn clamp_offset_keeps_offset_within_bounds() {
158 let result = clamp_offset([250.0, 450.0], [500.0, 800.0], [300.0, 400.0]);
160 assert_eq!(result[0], 200.0);
161 assert_eq!(result[1], 400.0);
162 }
163
164 #[test]
165 fn clamp_offset_allows_valid_offset() {
166 let result = clamp_offset([50.0, 100.0], [500.0, 800.0], [300.0, 400.0]);
167 assert_eq!(result[0], 50.0);
168 assert_eq!(result[1], 100.0);
169 }
170
171 #[test]
172 fn snap_to_page_rounds_to_nearest_boundary() {
173 assert_eq!(snap_to_page(260.0, 200.0), 200.0);
174 assert_eq!(snap_to_page(350.0, 200.0), 400.0);
175 assert_eq!(snap_to_page(0.0, 200.0), 0.0);
176 }
177
178 #[test]
179 fn momentum_state_tick_clamped_zeroes_velocity() {
180 let mut state = MomentumState::new();
181 state.push(50.0, 30.0);
182 let (dx, dy) = state.tick(ScrollPhysics::Clamped);
183 assert_eq!(dx, 50.0);
184 assert_eq!(dy, 30.0);
185 assert_eq!(state.velocity_x, 0.0);
186 assert_eq!(state.velocity_y, 0.0);
187 }
188
189 #[test]
190 fn momentum_state_tick_momentum_decays_velocity() {
191 let mut state = MomentumState::new();
192 state.push(100.0, 80.0);
193 state.tick(ScrollPhysics::Momentum { friction: 0.92 });
194 assert!((state.velocity_x - 92.0).abs() < 0.01);
195 assert!((state.velocity_y - 73.6).abs() < 0.01);
196 }
197
198 #[test]
199 fn momentum_state_tick_momentum_stops_tiny_residual() {
200 let mut state = MomentumState::new();
201 state.velocity_x = 0.3;
202 state.velocity_y = 0.4;
203 state.tick(ScrollPhysics::Momentum { friction: 0.92 });
204 assert_eq!(state.velocity_x, 0.0);
205 assert_eq!(state.velocity_y, 0.0);
206 }
207
208 #[test]
209 fn momentum_state_is_settled_when_both_below_threshold() {
210 let mut state = MomentumState::new();
211 assert!(state.is_settled());
212 state.push(10.0, 5.0);
213 assert!(!state.is_settled());
214 state.velocity_x = 0.4;
215 state.velocity_y = 0.4;
216 assert!(state.is_settled());
217 }
218
219 #[test]
220 fn momentum_state_tick_bounce_decays_velocity_same_as_momentum() {
221 let mut state = MomentumState::new();
222 state.push(100.0, 80.0);
223 state.tick(ScrollPhysics::Bounce { friction: 0.92, spring_stiffness: 12.0 });
224 assert!((state.velocity_x - 92.0).abs() < 0.01);
225 assert!((state.velocity_y - 73.6).abs() < 0.01);
226 }
227
228 #[test]
229 fn default_for_platform_is_bounce_on_ios_and_macos() {
230 assert!(matches!(
231 ScrollStyle::default_for_platform(rosace_core::Platform::Ios),
232 ScrollPhysics::Bounce { .. }
233 ));
234 assert!(matches!(
235 ScrollStyle::default_for_platform(rosace_core::Platform::MacOs),
236 ScrollPhysics::Bounce { .. }
237 ));
238 }
239
240 #[test]
241 fn default_for_platform_is_momentum_elsewhere() {
242 for p in [
243 rosace_core::Platform::Android,
244 rosace_core::Platform::Windows,
245 rosace_core::Platform::Linux,
246 rosace_core::Platform::Web,
247 ] {
248 assert!(matches!(ScrollStyle::default_for_platform(p), ScrollPhysics::Momentum { .. }));
249 }
250 }
251}