Skip to main content

gpui_kit/motion/
scroll_link.rs

1//! A value read off a scroll offset rather than off a clock.
2//!
3//! Everything else in [`motion`](crate::motion) is a function of time: it is
4//! started, it runs, it settles, and while it runs it asks for the next frame.
5//! A scroll-linked value is a function of where the content is. It has no
6//! duration, no start and no end, it never requests an animation frame, and
7//! there is no such thing as interrupting it — scrolling back up runs it
8//! backwards because the offset went backwards.
9//!
10//! That is why this is a plain value with no `animate` and no `Window`: there
11//! is nothing to drive. A caller reads the offset it already has and asks what
12//! the progress is.
13//!
14//! ```
15//! # use gpui::px;
16//! # use gpui_kit::motion::ScrollLink;
17//! let header = ScrollLink::new(px(0.0), px(64.0));
18//! let height = header.sample(px(32.0), px(96.0), px(40.0));
19//! ```
20
21use gpui::{Pixels, px};
22
23use super::Interpolate;
24
25/// Scroll offsets mapped onto progress from 0 to 1.
26///
27/// # Reduced motion
28///
29/// A link makes no decision of its own, and that is deliberate rather than
30/// lazy. A header that collapses as the content scrolls under it, or a shadow
31/// that appears once there is something above the fold, is not gratuitous
32/// motion: it is a direct, one-to-one response to a movement the user is
33/// making with their own hand, and suppressing it would remove information
34/// rather than calm. A decorative parallax — a background drifting at a
35/// different rate to say nothing at all — is the opposite, and under reduced
36/// motion it should not drift.
37///
38/// Only the caller knows which of those it is building, so the caller says so
39/// with [`ScrollLink::decorative`]. A decorative link marked under reduced
40/// motion reports 0 at every offset, which is the resting end of the effect:
41/// the parallax layer simply sits where it belongs.
42#[derive(Debug, Clone, Copy, PartialEq)]
43pub struct ScrollLink {
44    start: f32,
45    end: f32,
46    suppressed: bool,
47}
48
49impl ScrollLink {
50    /// Over the offsets `start..end`, measured the way a reader thinks about
51    /// scrolling: 0 is the top of the content and the number grows as the
52    /// content moves up.
53    ///
54    /// A range with no length is a threshold rather than a ramp: progress is 0
55    /// below it and 1 from it on.
56    pub fn new(start: Pixels, end: Pixels) -> Self {
57        Self {
58            start: f32::from(start),
59            end: f32::from(end),
60            suppressed: false,
61        }
62    }
63
64    /// Over the first `distance` of scrolling.
65    pub fn over(distance: Pixels) -> Self {
66        Self::new(px(0.0), distance)
67    }
68
69    /// Marks the effect decorative, and suppresses it when the user has asked
70    /// for less motion.
71    ///
72    /// Pass [`reduce_motion`](super::reduce_motion). A link left unmarked
73    /// always reports the offset, because a response to the user's own
74    /// scrolling is not the motion the preference is about.
75    pub fn decorative(mut self, reduce_motion: bool) -> Self {
76        self.suppressed = reduce_motion;
77        self
78    }
79
80    /// Where `offset` sits in the range: 0 before it, 1 after it, and
81    /// monotonically between.
82    pub fn progress(self, offset: Pixels) -> f32 {
83        if self.suppressed {
84            return 0.0;
85        }
86        let offset = f32::from(offset);
87        let span = self.end - self.start;
88        if span <= 0.0 {
89            return if offset >= self.start { 1.0 } else { 0.0 };
90        }
91        ((offset - self.start) / span).clamp(0.0, 1.0)
92    }
93
94    /// Anything interpolable, read off the offset.
95    ///
96    /// Anything that takes a progress can be driven from here, including
97    /// [`Keyframes::sample`](super::Keyframes::sample) for a value that passes
98    /// through stops on the way.
99    pub fn sample<T: Interpolate>(self, offset: Pixels, from: T, to: T) -> T {
100        from.lerp(to, self.progress(offset))
101    }
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107    use gpui::px;
108
109    fn link() -> ScrollLink {
110        ScrollLink::new(px(20.0), px(120.0))
111    }
112
113    #[test]
114    fn progress_is_nothing_before_the_range_and_all_of_it_after() {
115        assert_eq!(link().progress(px(0.0)), 0.0);
116        assert_eq!(link().progress(px(20.0)), 0.0);
117        assert_eq!(link().progress(px(120.0)), 1.0);
118        assert_eq!(link().progress(px(4_000.0)), 1.0);
119    }
120
121    #[test]
122    fn progress_only_ever_grows_within_the_range() {
123        let mut previous = 0.0;
124        for step in 0..=200 {
125            let progress = link().progress(px(step as f32));
126            assert!(progress >= previous, "progress went backwards at {step}");
127            previous = progress;
128        }
129        assert_eq!(previous, 1.0);
130    }
131
132    #[test]
133    fn a_range_with_no_length_is_a_threshold() {
134        let threshold = ScrollLink::new(px(50.0), px(50.0));
135        assert_eq!(threshold.progress(px(49.9)), 0.0);
136        assert_eq!(threshold.progress(px(50.0)), 1.0);
137    }
138
139    #[test]
140    fn a_sampled_value_travels_across_the_range() {
141        assert_eq!(ScrollLink::over(px(100.0)).sample(px(50.0), 0.0, 10.0), 5.0);
142        assert_eq!(
143            ScrollLink::over(px(100.0)).sample(px(400.0), px(80.0), px(40.0)),
144            px(40.0)
145        );
146    }
147
148    #[test]
149    fn a_decorative_effect_rests_under_reduced_motion() {
150        let parallax = link().decorative(true);
151        assert_eq!(parallax.progress(px(80.0)), 0.0);
152        assert_eq!(parallax.progress(px(400.0)), 0.0);
153        // The same link, when it is answering the user's own scrolling.
154        assert!(link().decorative(false).progress(px(80.0)) > 0.0);
155    }
156}