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            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        // A list shorter than its window is not "fully scrolled": a progress rule over
258        // it must read empty, or every short page would claim to be finished.
259        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        // Dragged past the edge, the offset is out of range; the fraction is not.
268        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        // The whole reason `End` is a name and not a number: what it means changes as
290        // the content does, and the request outlives the state it was written in.
291        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        // A region with nothing to scroll takes every request to 0 rather than refusing
301        // it: the request is satisfied, there simply is nowhere else to be.
302        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/// The rules of [`crate::Runtime::scroll_changes`] and [`crate::Runtime::scroll_to`],
324/// driven through a real runtime — the reporting and the commanding, which are the two
325/// halves this module exists for.
326#[cfg(test)]
327mod runtime_tests {
328    use super::tests::area;
329    use super::*;
330    use crate::runtime::{Runtime, ScrollBallistic};
331
332    /// Every region reports every change — the default a widget that declares
333    /// `on_scroll` and nothing else gets.
334    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        // Told on the first frame of every screen, an application would answer, and it
341        // already knew: a list nobody has touched is at the top.
342        let mut runtime = Runtime::default();
343        let regions = [area(0.0, 1000.0)];
344        assert!(runtime.scroll_changes(&regions, every).is_empty());
345        // And having been recorded silently, it does not go off on the next frame either.
346        assert!(runtime.scroll_changes(&regions, every).is_empty());
347    }
348
349    #[test]
350    fn a_region_appearing_somewhere_else_is_news() {
351        // Restored, or opened at a box it was asked to keep in view. The application did
352        // not put it there and has no other way of finding out.
353        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(&regions, 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(&regions, every).len(), 1);
368        assert!(runtime.scroll_changes(&regions, every).is_empty());
369    }
370
371    #[test]
372    fn a_grain_measures_from_the_last_report_and_not_the_last_frame() {
373        // The failure this rule exists to stop: a slow drag creeping the whole way down
374        // a list, three pixels a frame, without ever travelling a whole grain **in one
375        // frame** — and so never reported at all. Measured from what was last said, the
376        // fourth frame crosses ten and the report arrives.
377        let mut runtime = Runtime::default();
378        let regions = [area(0.0, 1000.0)];
379        let id = regions[0].id;
380        // Still under the finger: not at rest, so only the grain can let a report out.
381        runtime.hold_scroll(id);
382        runtime.scroll_changes(&regions, |_| 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(&regions, |_| 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(&regions, |_| 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        // A coarse grain costs frames, never accuracy: the last step is reported however
399        // short it was, or the application would be left believing a list stopped
400        // somewhere it did not.
401        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(&regions, |_| 100.0);
406        runtime.scroll.insert(id, (0.0, 7.0));
407        assert!(runtime.scroll_changes(&regions, |_| 100.0).is_empty());
408        // The finger lets go and nothing is driving it any more.
409        runtime.release_scroll(id);
410        let changes = runtime.scroll_changes(&regions, |_| 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        // Otherwise the map grows by one entry per screen ever opened, and a region
418        // whose identity came round again would be measured against a stale offset.
419        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(&regions, every);
423        assert!(runtime.scroll_reported.contains_key(&regions[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        // An offset has one owner at a time, and while a hand is on the content that
461        // owner is the hand. A list that jumped out from under a finger because a timer
462        // fired would be the framework overruling the reader.
463        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        // A fling is where the list was going; the request is where it is now being
476        // asked to be, and it is the more recent statement.
477        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}