Skip to main content

frus_widgets/
scrollposition.rs

1//! **Where a scroll region is**, and **where an application is asking it to go**.
2//!
3//! Two halves of one gap. A scroll offset lives in the [`Runtime`](crate::Runtime),
4//! written by the finger, the fling and the spring; until this module there was no way
5//! for an application to read one and no way to move one. So a "back to top" button, a
6//! chat that opens at its newest message, a bar that fades in as the page goes down and
7//! a list that loads more when its end comes into view were all inexpressible.
8//!
9//! Neither half is a controller. The reference hands the application an object that
10//! *owns* the offset and is asked for it; here the offset belongs to the runtime, a view
11//! is rebuilt from state every frame, and an application speaks in messages. So the
12//! reading half is a **notification** — [`Widget::on_scroll`](crate::Widget::on_scroll),
13//! a message when the offset changes — and the commanding half is a **request** —
14//! [`ScrollTo`], carried by a command and named by the same key a focus request uses.
15
16use frus_core::Size;
17
18use crate::physics::ScrollMetrics;
19use crate::ui::Scrollable;
20
21/// Where a scroll region is, and how much room it has, at the moment it is reported.
22///
23/// Handed to [`Widget::on_scroll`](crate::Widget::on_scroll). Everything an application
24/// could want to know about a position is derivable from these three, and they are the
25/// three the layout already knows — no measurement is done to produce one.
26#[derive(Copy, Clone, Debug, PartialEq)]
27pub struct ScrollPosition {
28    /// The offsets `(x, y)` the region is resting or moving at. Measured from the end
29    /// its axis starts at, so a reversed list at 0 is at its newest end.
30    pub offset: (f32, f32),
31    /// The largest offsets the content may rest at, per axis. `0` = that axis has
32    /// nothing to scroll.
33    pub max: (f32, f32),
34    /// The window the content is seen through.
35    pub viewport: Size,
36}
37
38impl ScrollPosition {
39    /// The position of `area`, resting at `offset`.
40    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    /// The horizontal axis, as the physics states it.
52    pub fn x(&self) -> ScrollMetrics {
53        ScrollMetrics::new(self.offset.0, self.max.0, self.viewport.width)
54    }
55
56    /// The vertical axis, as the physics states it.
57    pub fn y(&self) -> ScrollMetrics {
58        ScrollMetrics::new(self.offset.1, self.max.1, self.viewport.height)
59    }
60
61    /// How far down the content is, `0.0..=1.0`.
62    ///
63    /// **Zero when there is nothing to scroll**, which is the only answer that is not a
64    /// lie: a list shorter than its window is neither at its beginning nor at its end,
65    /// and a progress rule over it should read empty rather than full.
66    pub fn fraction_y(&self) -> f32 {
67        fraction(self.offset.1, self.max.1)
68    }
69
70    /// How far across the content is, `0.0..=1.0`. See [`ScrollPosition::fraction_y`].
71    pub fn fraction_x(&self) -> f32 {
72        fraction(self.offset.0, self.max.0)
73    }
74
75    /// Pixels of content left below the window; `0` at the end, and `0` when there is
76    /// nothing to scroll.
77    ///
78    /// **This is what a load-more asks**: `remaining_y() < 400.0` is "the end is
79    /// coming", said in the unit the reader is moving in rather than as a percentage,
80    /// which means something quite different in a list of ten and a list of ten
81    /// thousand.
82    pub fn remaining_y(&self) -> f32 {
83        remaining(self.offset.1, self.max.1)
84    }
85
86    /// Pixels of content left past the trailing edge. See [`ScrollPosition::remaining_y`].
87    pub fn remaining_x(&self) -> f32 {
88        remaining(self.offset.0, self.max.0)
89    }
90}
91
92/// `offset / max`, clamped, and 0 where there is no room to move.
93fn 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
101/// What is left of an axis past `offset`, never negative and never more than the axis.
102fn remaining(offset: f32, max: f32) -> f32 {
103    (max - offset).clamp(0.0, max.max(0.0))
104}
105
106/// One axis of a [`ScrollTo`]: an offset, or an end named rather than measured.
107///
108/// `End` exists because the number it stands for is not knowable where the request is
109/// written: `update` has the state, and the largest offset a region may rest at is a
110/// fact about a layout that has not happened yet. Naming the end and letting the frame
111/// resolve it is the difference between "the bottom" and a number that was the bottom
112/// one frame ago.
113#[derive(Copy, Clone, Debug, PartialEq)]
114pub enum ScrollTarget {
115    /// Offset 0 — the end the axis **starts** at. On a reversed list that is the end it
116    /// opened at, which is the point of reversing it.
117    Start,
118    /// As far as the content goes.
119    End,
120    /// This offset exactly, clamped to what the content actually has.
121    Offset(f32),
122}
123
124impl ScrollTarget {
125    /// The offset this names, on an axis whose largest is `max`.
126    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/// **A request to move a scroll region**, addressed to one axis or both.
137///
138/// `None` on an axis leaves that axis where it is, which is what lets one type serve a
139/// vertical page, a sideways strip and the rare surface that scrolls both ways without a
140/// caller ever writing a coordinate it did not mean.
141///
142/// Carried by a command, resolved against the frame that follows it, and **refused by a
143/// region a finger is holding** — an offset has one owner at a time, and the finger is
144/// the one the reader can see.
145#[derive(Copy, Clone, Debug, PartialEq, Default)]
146pub struct ScrollTo {
147    /// Where the horizontal axis is asked to go; `None` leaves it alone.
148    pub x: Option<ScrollTarget>,
149    /// Where the vertical axis is asked to go; `None` leaves it alone.
150    pub y: Option<ScrollTarget>,
151    /// Spring to it (what every constructor here sets) rather than arrive there.
152    pub animate: bool,
153}
154
155impl ScrollTo {
156    /// To offset `offset` down the page, with an animation.
157    pub fn y(offset: f32) -> Self {
158        Self {
159            y: Some(ScrollTarget::Offset(offset)),
160            animate: true,
161            ..Self::default()
162        }
163    }
164
165    /// To offset `offset` across, with an animation.
166    pub fn x(offset: f32) -> Self {
167        Self {
168            x: Some(ScrollTarget::Offset(offset)),
169            animate: true,
170            ..Self::default()
171        }
172    }
173
174    /// Back to where the vertical axis starts — the "back to top" button.
175    pub fn start() -> Self {
176        Self {
177            y: Some(ScrollTarget::Start),
178            animate: true,
179            ..Self::default()
180        }
181    }
182
183    /// To the far end of the vertical axis, wherever the content has got to.
184    pub fn end() -> Self {
185        Self {
186            y: Some(ScrollTarget::End),
187            animate: true,
188            ..Self::default()
189        }
190    }
191
192    /// Also moves the horizontal axis to `target`.
193    pub fn and_x(mut self, target: ScrollTarget) -> Self {
194        self.x = Some(target);
195        self
196    }
197
198    /// Also moves the vertical axis to `target`.
199    pub fn and_y(mut self, target: ScrollTarget) -> Self {
200        self.y = Some(target);
201        self
202    }
203
204    /// **Arrive** rather than travel: no animation, the offset is simply there.
205    ///
206    /// What restoring a reader's place wants. An animation there would be a list
207    /// scrolling itself in front of somebody who has just come back and is looking for
208    /// where they left off, and the reference draws the same line between its two verbs.
209    pub fn instant(mut self) -> Self {
210        self.animate = false;
211        self
212    }
213
214    /// Nothing to do: neither axis was named.
215    pub fn is_empty(&self) -> bool {
216        self.x.is_none() && self.y.is_none()
217    }
218
219    /// The offsets this request means for `area`, starting from `current`.
220    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        // A list shorter than its window is not "fully scrolled": a progress rule over
261        // it must read empty, or every short page would claim to be finished.
262        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        // Dragged past the edge, the offset is out of range; the fraction is not.
271        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        // The whole reason `End` is a name and not a number: what it means changes as
293        // the content does, and the request outlives the state it was written in.
294        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        // A region with nothing to scroll takes every request to 0 rather than refusing
304        // it: the request is satisfied, there simply is nowhere else to be.
305        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/// The rules of [`crate::Runtime::scroll_changes`] and [`crate::Runtime::scroll_to`],
327/// driven through a real runtime — the reporting and the commanding, which are the two
328/// halves this module exists for.
329#[cfg(test)]
330mod runtime_tests {
331    use super::tests::area;
332    use super::*;
333    use crate::runtime::{Runtime, ScrollBallistic};
334
335    /// Every region reports every change — the default a widget that declares
336    /// `on_scroll` and nothing else gets.
337    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        // Told on the first frame of every screen, an application would answer, and it
344        // already knew: a list nobody has touched is at the top.
345        let mut runtime = Runtime::default();
346        let regions = [area(0.0, 1000.0)];
347        assert!(runtime.scroll_changes(&regions, every).is_empty());
348        // And having been recorded silently, it does not go off on the next frame either.
349        assert!(runtime.scroll_changes(&regions, every).is_empty());
350    }
351
352    #[test]
353    fn a_region_appearing_somewhere_else_is_news() {
354        // Restored, or opened at a box it was asked to keep in view. The application did
355        // not put it there and has no other way of finding out.
356        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(&regions, 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(&regions, every).len(), 1);
371        assert!(runtime.scroll_changes(&regions, every).is_empty());
372    }
373
374    #[test]
375    fn a_grain_measures_from_the_last_report_and_not_the_last_frame() {
376        // The failure this rule exists to stop: a slow drag creeping the whole way down
377        // a list, three pixels a frame, without ever travelling a whole grain **in one
378        // frame** — and so never reported at all. Measured from what was last said, the
379        // fourth frame crosses ten and the report arrives.
380        let mut runtime = Runtime::default();
381        let regions = [area(0.0, 1000.0)];
382        let id = regions[0].id;
383        // Still under the finger: not at rest, so only the grain can let a report out.
384        runtime.hold_scroll(id);
385        runtime.scroll_changes(&regions, |_| 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(&regions, |_| 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(&regions, |_| 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        // A coarse grain costs frames, never accuracy: the last step is reported however
402        // short it was, or the application would be left believing a list stopped
403        // somewhere it did not.
404        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(&regions, |_| 100.0);
409        runtime.scroll.insert(id, (0.0, 7.0));
410        assert!(runtime.scroll_changes(&regions, |_| 100.0).is_empty());
411        // The finger lets go and nothing is driving it any more.
412        runtime.release_scroll(id);
413        let changes = runtime.scroll_changes(&regions, |_| 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        // Otherwise the map grows by one entry per screen ever opened, and a region
421        // whose identity came round again would be measured against a stale offset.
422        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(&regions, every);
426        assert!(runtime.scroll_reported.contains_key(&regions[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        // An offset has one owner at a time, and while a hand is on the content that
464        // owner is the hand. A list that jumped out from under a finger because a timer
465        // fired would be the framework overruling the reader.
466        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        // A fling is where the list was going; the request is where it is now being
479        // asked to be, and it is the more recent statement.
480        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}