gpui_kit/motion/
keyframes.rs1use gpui_kit_theme::Theme;
5
6use super::easing::{CubicBezier, Easing};
7use super::interpolate::Interpolate;
8use super::spec::MotionSpec;
9
10#[derive(Debug, Clone, Copy, PartialEq)]
13pub struct Keyframe<T> {
14 offset: f32,
15 value: T,
16 easing: Option<Easing>,
17}
18
19impl<T> Keyframe<T> {
20 pub fn new(offset: f32, value: T) -> Self {
22 Self {
23 offset: offset.clamp(0.0, 1.0),
24 value,
25 easing: None,
26 }
27 }
28
29 pub fn eased(mut self, easing: impl Into<Easing>) -> Self {
32 self.easing = Some(easing.into());
33 self
34 }
35}
36
37#[derive(Debug, Clone, Copy, PartialEq)]
40struct Stop<T> {
41 offset: f32,
42 value: T,
43 curve: CubicBezier,
44}
45
46#[derive(Debug, Clone, PartialEq)]
53pub struct Keyframes<T: Interpolate> {
54 stops: Vec<Stop<T>>,
55}
56
57impl<T: Interpolate> Keyframes<T> {
58 pub fn new(
63 theme: &Theme,
64 spec: MotionSpec,
65 stops: impl IntoIterator<Item = Keyframe<T>>,
66 ) -> Option<Self> {
67 let mut stops: Vec<Stop<T>> = stops
68 .into_iter()
69 .map(|stop| Stop {
70 offset: stop.offset,
71 value: stop.value,
72 curve: match stop.easing {
73 Some(easing) => easing.curve(theme),
74 None => spec.curve,
75 },
76 })
77 .collect();
78 if stops.is_empty() {
79 return None;
80 }
81 stops.sort_by(|a, b| a.offset.total_cmp(&b.offset));
82 Some(Self { stops })
83 }
84
85 pub fn sample(&self, progress: f32) -> T {
90 let progress = progress.clamp(0.0, 1.0);
91 let first = self.stops.first().expect("a keyframe list is never empty");
92 if progress <= first.offset {
93 return first.value;
94 }
95 let last = self.stops.last().expect("a keyframe list is never empty");
96 if progress >= last.offset {
97 return last.value;
98 }
99 let reached = self
100 .stops
101 .iter()
102 .position(|stop| stop.offset >= progress)
103 .unwrap_or(self.stops.len() - 1)
104 .max(1);
105 let from = &self.stops[reached - 1];
106 let to = &self.stops[reached];
107 let span = to.offset - from.offset;
108 if span <= 0.0 {
109 return to.value;
110 }
111 from.value
112 .lerp(to.value, to.curve.eval((progress - from.offset) / span))
113 }
114
115 pub fn offsets(&self) -> Vec<f32> {
117 self.stops.iter().map(|stop| stop.offset).collect()
118 }
119}
120
121#[cfg(test)]
122mod tests {
123 use super::*;
124
125 fn spec() -> MotionSpec {
126 MotionSpec::new(200, CubicBezier::new(0.0, 0.0, 1.0, 1.0))
127 }
128
129 fn keyframes(stops: Vec<Keyframe<f32>>) -> Keyframes<f32> {
130 Keyframes::new(&Theme::studio_dark(), spec(), stops).expect("stops were given")
131 }
132
133 #[test]
134 fn stops_are_ordered_however_they_were_given() {
135 let path = keyframes(vec![
136 Keyframe::new(1.0, 10.0),
137 Keyframe::new(0.0, 0.0),
138 Keyframe::new(0.5, 4.0),
139 ]);
140 assert_eq!(path.offsets(), vec![0.0, 0.5, 1.0]);
141 assert!((path.sample(0.5) - 4.0).abs() < 1e-4);
142 assert!((path.sample(0.25) - 2.0).abs() < 1e-3);
143 }
144
145 #[test]
146 fn an_empty_path_is_not_a_path() {
147 assert!(Keyframes::<f32>::new(&Theme::studio_dark(), spec(), []).is_none());
148 }
149
150 #[test]
151 fn the_ends_extend_to_the_stops_that_were_written() {
152 let path = keyframes(vec![Keyframe::new(0.25, 2.0), Keyframe::new(0.75, 6.0)]);
153 assert_eq!(path.sample(0.0), 2.0);
154 assert_eq!(path.sample(0.1), 2.0);
155 assert_eq!(path.sample(1.0), 6.0);
156 }
157
158 #[test]
159 fn sampling_outside_the_run_clamps_to_the_ends() {
160 let path = keyframes(vec![Keyframe::new(0.0, 0.0), Keyframe::new(1.0, 10.0)]);
161 assert_eq!(path.sample(-2.0), 0.0);
162 assert_eq!(path.sample(4.0), 10.0);
163 }
164
165 #[test]
166 fn a_stop_travels_on_its_own_curve() {
167 let stops = |easing: Option<Easing>| {
168 let reached = Keyframe::new(1.0, 10.0);
169 vec![
170 Keyframe::new(0.0, 0.0),
171 match easing {
172 Some(easing) => reached.eased(easing),
173 None => reached,
174 },
175 ]
176 };
177 let linear = keyframes(stops(None));
178 let eased = keyframes(stops(Some(Easing::Custom(CubicBezier::new(
179 0.9, 0.0, 1.0, 0.4,
180 )))));
181 assert!(
182 eased.sample(0.5) < linear.sample(0.5),
183 "a slow-starting curve must lag the specification's, {} against {}",
184 eased.sample(0.5),
185 linear.sample(0.5)
186 );
187 }
188
189 #[test]
190 fn a_single_stop_holds_for_the_whole_run() {
191 let path = keyframes(vec![Keyframe::new(0.5, 3.0)]);
192 assert_eq!(path.sample(0.0), 3.0);
193 assert_eq!(path.sample(1.0), 3.0);
194 }
195}