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}