1use frus_core::Size;
17
18use crate::physics::ScrollMetrics;
19use crate::ui::Scrollable;
20
21#[derive(Copy, Clone, Debug, PartialEq)]
27pub struct ScrollPosition {
28 pub offset: (f32, f32),
31 pub max: (f32, f32),
34 pub viewport: Size,
36}
37
38impl ScrollPosition {
39 pub fn of(area: &Scrollable, offset: (f32, f32)) -> Self {
41 Self {
42 offset,
43 max: (area.max_x, area.max_y),
44 viewport: Size {
45 width: area.viewport.width,
46 height: area.viewport.height,
47 },
48 }
49 }
50
51 pub fn x(&self) -> ScrollMetrics {
53 ScrollMetrics::new(self.offset.0, self.max.0, self.viewport.width)
54 }
55
56 pub fn y(&self) -> ScrollMetrics {
58 ScrollMetrics::new(self.offset.1, self.max.1, self.viewport.height)
59 }
60
61 pub fn fraction_y(&self) -> f32 {
67 fraction(self.offset.1, self.max.1)
68 }
69
70 pub fn fraction_x(&self) -> f32 {
72 fraction(self.offset.0, self.max.0)
73 }
74
75 pub fn remaining_y(&self) -> f32 {
83 remaining(self.offset.1, self.max.1)
84 }
85
86 pub fn remaining_x(&self) -> f32 {
88 remaining(self.offset.0, self.max.0)
89 }
90}
91
92fn fraction(offset: f32, max: f32) -> f32 {
94 if max <= 0.0 {
95 0.0
96 } else {
97 (offset / max).clamp(0.0, 1.0)
98 }
99}
100
101fn remaining(offset: f32, max: f32) -> f32 {
103 (max - offset).clamp(0.0, max.max(0.0))
104}
105
106#[derive(Copy, Clone, Debug, PartialEq)]
114pub enum ScrollTarget {
115 Start,
118 End,
120 Offset(f32),
122}
123
124impl ScrollTarget {
125 pub fn resolve(self, max: f32) -> f32 {
127 let max = max.max(0.0);
128 match self {
129 ScrollTarget::Start => 0.0,
130 ScrollTarget::End => max,
131 ScrollTarget::Offset(offset) => offset.clamp(0.0, max),
132 }
133 }
134}
135
136#[derive(Copy, Clone, Debug, PartialEq, Default)]
146pub struct ScrollTo {
147 pub x: Option<ScrollTarget>,
149 pub y: Option<ScrollTarget>,
151 pub animate: bool,
153}
154
155impl ScrollTo {
156 pub fn y(offset: f32) -> Self {
158 Self {
159 y: Some(ScrollTarget::Offset(offset)),
160 animate: true,
161 ..Self::default()
162 }
163 }
164
165 pub fn x(offset: f32) -> Self {
167 Self {
168 x: Some(ScrollTarget::Offset(offset)),
169 animate: true,
170 ..Self::default()
171 }
172 }
173
174 pub fn start() -> Self {
176 Self {
177 y: Some(ScrollTarget::Start),
178 animate: true,
179 ..Self::default()
180 }
181 }
182
183 pub fn end() -> Self {
185 Self {
186 y: Some(ScrollTarget::End),
187 animate: true,
188 ..Self::default()
189 }
190 }
191
192 pub fn and_x(mut self, target: ScrollTarget) -> Self {
194 self.x = Some(target);
195 self
196 }
197
198 pub fn and_y(mut self, target: ScrollTarget) -> Self {
200 self.y = Some(target);
201 self
202 }
203
204 pub fn instant(mut self) -> Self {
210 self.animate = false;
211 self
212 }
213
214 pub fn is_empty(&self) -> bool {
216 self.x.is_none() && self.y.is_none()
217 }
218
219 pub fn resolve(&self, area: &Scrollable, current: (f32, f32)) -> (f32, f32) {
221 (
222 self.x.map_or(current.0, |t| t.resolve(area.max_x)),
223 self.y.map_or(current.1, |t| t.resolve(area.max_y)),
224 )
225 }
226}
227
228#[cfg(test)]
229mod tests {
230 use super::*;
231 use crate::interaction::WidgetId;
232 use frus_core::Rect;
233
234 pub(super) fn area(max_x: f32, max_y: f32) -> Scrollable {
235 Scrollable {
236 id: WidgetId::from_u64(1),
237 viewport: Rect {
238 x: 0.0,
239 y: 0.0,
240 width: 300.0,
241 height: 500.0,
242 },
243 max_x,
244 max_y,
245 axis: crate::scroll::Axis::Vertical,
246 physics: crate::ScrollPhysics::CLAMPING,
247 overscroll: Some(crate::OverscrollIndicator::Glow),
248 fling: frus_core::VelocityStrategy::Regression,
249 refresh: None,
250 page: None,
251 reverse_x: false,
252 reverse_y: false,
253 keep_visible: None,
254 host: None,
255 }
256 }
257
258 #[test]
259 fn a_fraction_of_nothing_is_zero_and_not_one() {
260 let position = ScrollPosition::of(&area(0.0, 0.0), (0.0, 0.0));
263 assert_eq!(position.fraction_y(), 0.0);
264 assert_eq!(position.fraction_x(), 0.0);
265 assert_eq!(position.remaining_y(), 0.0);
266 }
267
268 #[test]
269 fn a_fraction_is_clamped_through_an_overscroll() {
270 let position = ScrollPosition::of(&area(0.0, 1000.0), (0.0, -80.0));
272 assert_eq!(position.fraction_y(), 0.0);
273 let position = ScrollPosition::of(&area(0.0, 1000.0), (0.0, 1080.0));
274 assert_eq!(position.fraction_y(), 1.0);
275 assert_eq!(
276 position.remaining_y(),
277 0.0,
278 "past the end is not more to go"
279 );
280 }
281
282 #[test]
283 fn remaining_is_measured_in_pixels_the_reader_moves_in() {
284 let position = ScrollPosition::of(&area(0.0, 1000.0), (0.0, 700.0));
285 assert_eq!(position.remaining_y(), 300.0);
286 assert_eq!(position.y().max, 1000.0);
287 assert_eq!(position.y().viewport, 500.0);
288 }
289
290 #[test]
291 fn an_end_is_resolved_against_the_frame_and_not_the_request() {
292 assert_eq!(ScrollTarget::End.resolve(1200.0), 1200.0);
295 assert_eq!(ScrollTarget::End.resolve(2400.0), 2400.0);
296 assert_eq!(ScrollTarget::Start.resolve(2400.0), 0.0);
297 }
298
299 #[test]
300 fn an_offset_is_clamped_to_what_the_content_has() {
301 assert_eq!(ScrollTarget::Offset(9999.0).resolve(300.0), 300.0);
302 assert_eq!(ScrollTarget::Offset(-40.0).resolve(300.0), 0.0);
303 assert_eq!(ScrollTarget::Offset(120.0).resolve(0.0), 0.0);
306 }
307
308 #[test]
309 fn an_unnamed_axis_is_left_alone() {
310 let area = area(400.0, 1000.0);
311 let request = ScrollTo::start();
312 assert_eq!(request.resolve(&area, (250.0, 800.0)), (250.0, 0.0));
313 let both = ScrollTo::start().and_x(ScrollTarget::End);
314 assert_eq!(both.resolve(&area, (250.0, 800.0)), (400.0, 0.0));
315 }
316
317 #[test]
318 fn a_request_animates_unless_it_is_told_not_to() {
319 assert!(ScrollTo::end().animate);
320 assert!(!ScrollTo::end().instant().animate);
321 assert!(ScrollTo::default().is_empty());
322 assert!(!ScrollTo::y(10.0).is_empty());
323 }
324}
325
326#[cfg(test)]
330mod runtime_tests {
331 use super::tests::area;
332 use super::*;
333 use crate::runtime::{Runtime, ScrollBallistic};
334
335 fn every(_: crate::interaction::WidgetId) -> f32 {
338 0.0
339 }
340
341 #[test]
342 fn a_region_appearing_at_the_top_is_not_a_movement() {
343 let mut runtime = Runtime::default();
346 let regions = [area(0.0, 1000.0)];
347 assert!(runtime.scroll_changes(®ions, every).is_empty());
348 assert!(runtime.scroll_changes(®ions, every).is_empty());
350 }
351
352 #[test]
353 fn a_region_appearing_somewhere_else_is_news() {
354 let mut runtime = Runtime::default();
357 let regions = [area(0.0, 1000.0)];
358 runtime.scroll.insert(regions[0].id, (0.0, 320.0));
359 let changes = runtime.scroll_changes(®ions, every);
360 assert_eq!(changes.len(), 1);
361 assert_eq!(changes[0].1.offset, (0.0, 320.0));
362 assert_eq!(changes[0].1.max, (0.0, 1000.0));
363 }
364
365 #[test]
366 fn a_region_that_has_not_moved_says_nothing() {
367 let mut runtime = Runtime::default();
368 let regions = [area(0.0, 1000.0)];
369 runtime.scroll.insert(regions[0].id, (0.0, 320.0));
370 assert_eq!(runtime.scroll_changes(®ions, every).len(), 1);
371 assert!(runtime.scroll_changes(®ions, every).is_empty());
372 }
373
374 #[test]
375 fn a_grain_measures_from_the_last_report_and_not_the_last_frame() {
376 let mut runtime = Runtime::default();
381 let regions = [area(0.0, 1000.0)];
382 let id = regions[0].id;
383 runtime.hold_scroll(id);
385 runtime.scroll_changes(®ions, |_| 10.0);
386 for step in 1..=3 {
387 runtime.scroll.insert(id, (0.0, 3.0 * step as f32));
388 assert!(
389 runtime.scroll_changes(®ions, |_| 10.0).is_empty(),
390 "3 px is under the grain"
391 );
392 }
393 runtime.scroll.insert(id, (0.0, 12.0));
394 let changes = runtime.scroll_changes(®ions, |_| 10.0);
395 assert_eq!(changes.len(), 1, "12 px from the last report is over it");
396 assert_eq!(changes[0].1.offset, (0.0, 12.0));
397 }
398
399 #[test]
400 fn a_grain_never_swallows_where_it_came_to_rest() {
401 let mut runtime = Runtime::default();
405 let regions = [area(0.0, 1000.0)];
406 let id = regions[0].id;
407 runtime.hold_scroll(id);
408 runtime.scroll_changes(®ions, |_| 100.0);
409 runtime.scroll.insert(id, (0.0, 7.0));
410 assert!(runtime.scroll_changes(®ions, |_| 100.0).is_empty());
411 runtime.release_scroll(id);
413 let changes = runtime.scroll_changes(®ions, |_| 100.0);
414 assert_eq!(changes.len(), 1);
415 assert_eq!(changes[0].1.offset, (0.0, 7.0));
416 }
417
418 #[test]
419 fn a_region_that_has_gone_is_forgotten() {
420 let mut runtime = Runtime::default();
423 let regions = [area(0.0, 1000.0)];
424 runtime.scroll.insert(regions[0].id, (0.0, 40.0));
425 runtime.scroll_changes(®ions, every);
426 assert!(runtime.scroll_reported.contains_key(®ions[0].id));
427 runtime.scroll_changes(&[], every);
428 assert!(runtime.scroll_reported.is_empty());
429 }
430
431 #[test]
432 fn an_animated_request_moves_the_target_and_a_jump_moves_the_offset() {
433 let mut runtime = Runtime::default();
434 let area = area(0.0, 1000.0);
435 runtime.scroll.insert(area.id, (0.0, 900.0));
436
437 assert!(runtime.scroll_to(&area, ScrollTo::start()));
438 assert_eq!(runtime.scroll_target.get(&area.id), Some(&(0.0, 0.0)));
439 assert_eq!(
440 runtime.scroll.get(&area.id),
441 Some(&(0.0, 900.0)),
442 "an animated request does not teleport: the spring runs"
443 );
444
445 assert!(runtime.scroll_to(&area, ScrollTo::start().instant()));
446 assert_eq!(runtime.scroll.get(&area.id), Some(&(0.0, 0.0)));
447 assert!(
448 !runtime.scroll_target.contains_key(&area.id),
449 "there is nothing left to spring towards"
450 );
451 }
452
453 #[test]
454 fn an_end_is_resolved_against_the_region_it_reaches() {
455 let mut runtime = Runtime::default();
456 let area = area(0.0, 1240.0);
457 assert!(runtime.scroll_to(&area, ScrollTo::end().instant()));
458 assert_eq!(runtime.scroll.get(&area.id), Some(&(0.0, 1240.0)));
459 }
460
461 #[test]
462 fn a_finger_refuses_a_request() {
463 let mut runtime = Runtime::default();
467 let area = area(0.0, 1000.0);
468 runtime.scroll.insert(area.id, (0.0, 500.0));
469 runtime.hold_scroll(area.id);
470 assert!(!runtime.scroll_to(&area, ScrollTo::start().instant()));
471 assert_eq!(runtime.scroll.get(&area.id), Some(&(0.0, 500.0)));
472 runtime.release_scroll(area.id);
473 assert!(runtime.scroll_to(&area, ScrollTo::start().instant()));
474 }
475
476 #[test]
477 fn a_request_catches_a_fling() {
478 let mut runtime = Runtime::default();
481 let area = area(0.0, 1000.0);
482 runtime
483 .scroll_ballistic
484 .insert(area.id, ScrollBallistic::default());
485 assert!(runtime.scroll_to(&area, ScrollTo::start()));
486 assert!(!runtime.scroll_ballistic.contains_key(&area.id));
487 }
488
489 #[test]
490 fn a_request_naming_no_axis_does_nothing_at_all() {
491 let mut runtime = Runtime::default();
492 let area = area(0.0, 1000.0);
493 assert!(!runtime.scroll_to(&area, ScrollTo::default()));
494 assert!(runtime.scroll_target.is_empty());
495 assert!(runtime.scroll.is_empty());
496 }
497}