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 physics: None,
246 refresh: None,
247 page: None,
248 reverse_x: false,
249 reverse_y: false,
250 keep_visible: None,
251 host: None,
252 }
253 }
254
255 #[test]
256 fn a_fraction_of_nothing_is_zero_and_not_one() {
257 let position = ScrollPosition::of(&area(0.0, 0.0), (0.0, 0.0));
260 assert_eq!(position.fraction_y(), 0.0);
261 assert_eq!(position.fraction_x(), 0.0);
262 assert_eq!(position.remaining_y(), 0.0);
263 }
264
265 #[test]
266 fn a_fraction_is_clamped_through_an_overscroll() {
267 let position = ScrollPosition::of(&area(0.0, 1000.0), (0.0, -80.0));
269 assert_eq!(position.fraction_y(), 0.0);
270 let position = ScrollPosition::of(&area(0.0, 1000.0), (0.0, 1080.0));
271 assert_eq!(position.fraction_y(), 1.0);
272 assert_eq!(
273 position.remaining_y(),
274 0.0,
275 "past the end is not more to go"
276 );
277 }
278
279 #[test]
280 fn remaining_is_measured_in_pixels_the_reader_moves_in() {
281 let position = ScrollPosition::of(&area(0.0, 1000.0), (0.0, 700.0));
282 assert_eq!(position.remaining_y(), 300.0);
283 assert_eq!(position.y().max, 1000.0);
284 assert_eq!(position.y().viewport, 500.0);
285 }
286
287 #[test]
288 fn an_end_is_resolved_against_the_frame_and_not_the_request() {
289 assert_eq!(ScrollTarget::End.resolve(1200.0), 1200.0);
292 assert_eq!(ScrollTarget::End.resolve(2400.0), 2400.0);
293 assert_eq!(ScrollTarget::Start.resolve(2400.0), 0.0);
294 }
295
296 #[test]
297 fn an_offset_is_clamped_to_what_the_content_has() {
298 assert_eq!(ScrollTarget::Offset(9999.0).resolve(300.0), 300.0);
299 assert_eq!(ScrollTarget::Offset(-40.0).resolve(300.0), 0.0);
300 assert_eq!(ScrollTarget::Offset(120.0).resolve(0.0), 0.0);
303 }
304
305 #[test]
306 fn an_unnamed_axis_is_left_alone() {
307 let area = area(400.0, 1000.0);
308 let request = ScrollTo::start();
309 assert_eq!(request.resolve(&area, (250.0, 800.0)), (250.0, 0.0));
310 let both = ScrollTo::start().and_x(ScrollTarget::End);
311 assert_eq!(both.resolve(&area, (250.0, 800.0)), (400.0, 0.0));
312 }
313
314 #[test]
315 fn a_request_animates_unless_it_is_told_not_to() {
316 assert!(ScrollTo::end().animate);
317 assert!(!ScrollTo::end().instant().animate);
318 assert!(ScrollTo::default().is_empty());
319 assert!(!ScrollTo::y(10.0).is_empty());
320 }
321}
322
323#[cfg(test)]
327mod runtime_tests {
328 use super::tests::area;
329 use super::*;
330 use crate::runtime::{Runtime, ScrollBallistic};
331
332 fn every(_: crate::interaction::WidgetId) -> f32 {
335 0.0
336 }
337
338 #[test]
339 fn a_region_appearing_at_the_top_is_not_a_movement() {
340 let mut runtime = Runtime::default();
343 let regions = [area(0.0, 1000.0)];
344 assert!(runtime.scroll_changes(®ions, every).is_empty());
345 assert!(runtime.scroll_changes(®ions, every).is_empty());
347 }
348
349 #[test]
350 fn a_region_appearing_somewhere_else_is_news() {
351 let mut runtime = Runtime::default();
354 let regions = [area(0.0, 1000.0)];
355 runtime.scroll.insert(regions[0].id, (0.0, 320.0));
356 let changes = runtime.scroll_changes(®ions, every);
357 assert_eq!(changes.len(), 1);
358 assert_eq!(changes[0].1.offset, (0.0, 320.0));
359 assert_eq!(changes[0].1.max, (0.0, 1000.0));
360 }
361
362 #[test]
363 fn a_region_that_has_not_moved_says_nothing() {
364 let mut runtime = Runtime::default();
365 let regions = [area(0.0, 1000.0)];
366 runtime.scroll.insert(regions[0].id, (0.0, 320.0));
367 assert_eq!(runtime.scroll_changes(®ions, every).len(), 1);
368 assert!(runtime.scroll_changes(®ions, every).is_empty());
369 }
370
371 #[test]
372 fn a_grain_measures_from_the_last_report_and_not_the_last_frame() {
373 let mut runtime = Runtime::default();
378 let regions = [area(0.0, 1000.0)];
379 let id = regions[0].id;
380 runtime.hold_scroll(id);
382 runtime.scroll_changes(®ions, |_| 10.0);
383 for step in 1..=3 {
384 runtime.scroll.insert(id, (0.0, 3.0 * step as f32));
385 assert!(
386 runtime.scroll_changes(®ions, |_| 10.0).is_empty(),
387 "3 px is under the grain"
388 );
389 }
390 runtime.scroll.insert(id, (0.0, 12.0));
391 let changes = runtime.scroll_changes(®ions, |_| 10.0);
392 assert_eq!(changes.len(), 1, "12 px from the last report is over it");
393 assert_eq!(changes[0].1.offset, (0.0, 12.0));
394 }
395
396 #[test]
397 fn a_grain_never_swallows_where_it_came_to_rest() {
398 let mut runtime = Runtime::default();
402 let regions = [area(0.0, 1000.0)];
403 let id = regions[0].id;
404 runtime.hold_scroll(id);
405 runtime.scroll_changes(®ions, |_| 100.0);
406 runtime.scroll.insert(id, (0.0, 7.0));
407 assert!(runtime.scroll_changes(®ions, |_| 100.0).is_empty());
408 runtime.release_scroll(id);
410 let changes = runtime.scroll_changes(®ions, |_| 100.0);
411 assert_eq!(changes.len(), 1);
412 assert_eq!(changes[0].1.offset, (0.0, 7.0));
413 }
414
415 #[test]
416 fn a_region_that_has_gone_is_forgotten() {
417 let mut runtime = Runtime::default();
420 let regions = [area(0.0, 1000.0)];
421 runtime.scroll.insert(regions[0].id, (0.0, 40.0));
422 runtime.scroll_changes(®ions, every);
423 assert!(runtime.scroll_reported.contains_key(®ions[0].id));
424 runtime.scroll_changes(&[], every);
425 assert!(runtime.scroll_reported.is_empty());
426 }
427
428 #[test]
429 fn an_animated_request_moves_the_target_and_a_jump_moves_the_offset() {
430 let mut runtime = Runtime::default();
431 let area = area(0.0, 1000.0);
432 runtime.scroll.insert(area.id, (0.0, 900.0));
433
434 assert!(runtime.scroll_to(&area, ScrollTo::start()));
435 assert_eq!(runtime.scroll_target.get(&area.id), Some(&(0.0, 0.0)));
436 assert_eq!(
437 runtime.scroll.get(&area.id),
438 Some(&(0.0, 900.0)),
439 "an animated request does not teleport: the spring runs"
440 );
441
442 assert!(runtime.scroll_to(&area, ScrollTo::start().instant()));
443 assert_eq!(runtime.scroll.get(&area.id), Some(&(0.0, 0.0)));
444 assert!(
445 !runtime.scroll_target.contains_key(&area.id),
446 "there is nothing left to spring towards"
447 );
448 }
449
450 #[test]
451 fn an_end_is_resolved_against_the_region_it_reaches() {
452 let mut runtime = Runtime::default();
453 let area = area(0.0, 1240.0);
454 assert!(runtime.scroll_to(&area, ScrollTo::end().instant()));
455 assert_eq!(runtime.scroll.get(&area.id), Some(&(0.0, 1240.0)));
456 }
457
458 #[test]
459 fn a_finger_refuses_a_request() {
460 let mut runtime = Runtime::default();
464 let area = area(0.0, 1000.0);
465 runtime.scroll.insert(area.id, (0.0, 500.0));
466 runtime.hold_scroll(area.id);
467 assert!(!runtime.scroll_to(&area, ScrollTo::start().instant()));
468 assert_eq!(runtime.scroll.get(&area.id), Some(&(0.0, 500.0)));
469 runtime.release_scroll(area.id);
470 assert!(runtime.scroll_to(&area, ScrollTo::start().instant()));
471 }
472
473 #[test]
474 fn a_request_catches_a_fling() {
475 let mut runtime = Runtime::default();
478 let area = area(0.0, 1000.0);
479 runtime
480 .scroll_ballistic
481 .insert(area.id, ScrollBallistic::default());
482 assert!(runtime.scroll_to(&area, ScrollTo::start()));
483 assert!(!runtime.scroll_ballistic.contains_key(&area.id));
484 }
485
486 #[test]
487 fn a_request_naming_no_axis_does_nothing_at_all() {
488 let mut runtime = Runtime::default();
489 let area = area(0.0, 1000.0);
490 assert!(!runtime.scroll_to(&area, ScrollTo::default()));
491 assert!(runtime.scroll_target.is_empty());
492 assert!(runtime.scroll.is_empty());
493 }
494}