eazy_tweener/
position.rs

1//! Timeline positioning system for sequencing animations.
2//!
3//! The [`Position`] enum provides flexible ways to specify when a child
4//! animation should start within a timeline.
5
6use rustc_hash::FxHashMap as HashMap;
7
8/// Specifies when a child animation starts within a timeline.
9///
10/// Similar to GSAP's position parameter, this allows for:
11/// - Sequential animations (one after another)
12/// - Parallel animations (starting together)
13/// - Overlapping animations (one starts before another ends)
14/// - Labeled positions (named time markers)
15///
16/// # Examples
17///
18/// ```rust
19/// use eazy_tweener::Position;
20///
21/// // Add at 2 seconds from timeline start
22/// let pos = Position::Absolute(2.0);
23///
24/// // Add 0.5 seconds after previous animation ends
25/// let pos = Position::Relative(0.5);
26///
27/// // Overlap with previous animation by 0.3 seconds
28/// let pos = Position::Relative(-0.3);
29///
30/// // Start at the same time as previous animation
31/// let pos = Position::WithPrevious;
32/// ```
33#[derive(Debug, Clone, PartialEq, Default)]
34pub enum Position {
35  /// Start at an absolute time from the timeline's beginning.
36  ///
37  /// `Absolute(2.0)` means start at 2 seconds.
38  Absolute(f32),
39
40  /// Start relative to the end of the previous animation.
41  ///
42  /// `Relative(0.5)` means start 0.5 seconds after previous ends.
43  /// `Relative(-0.3)` means start 0.3 seconds before previous ends
44  /// (overlap).
45  Relative(f32),
46
47  /// Start at a named label position.
48  ///
49  /// Labels must be added to the timeline before referencing them.
50  Label(String),
51
52  /// Start at the same time the previous animation ends (sequential).
53  ///
54  /// This is the default behavior - equivalent to `Relative(0.0)`.
55  #[default]
56  AfterPrevious,
57
58  /// Start at the same time as the previous animation (parallel).
59  ///
60  /// Useful for animating multiple properties simultaneously.
61  WithPrevious,
62
63  /// Start at the beginning of the timeline.
64  Start,
65
66  /// Start at the current end of the timeline.
67  End,
68}
69
70impl Position {
71  /// Calculate the absolute start time for this position.
72  ///
73  /// # Parameters
74  ///
75  /// - `previous_end`: End time of the previous child (0.0 if first child)
76  /// - `previous_start`: Start time of the previous child (0.0 if first child)
77  /// - `timeline_end`: Current total duration of the timeline
78  /// - `labels`: Map of label names to their time positions
79  ///
80  /// # Returns
81  ///
82  /// The absolute start time in seconds, or `None` if label not found.
83  pub fn resolve(
84    &self,
85    previous_end: f32,
86    previous_start: f32,
87    timeline_end: f32,
88    labels: &HashMap<String, f32>,
89  ) -> Option<f32> {
90    let time = match self {
91      Self::Absolute(t) => *t,
92      Self::Relative(offset) => previous_end + offset,
93      Self::Label(name) => *labels.get(name)?,
94      Self::AfterPrevious => previous_end,
95      Self::WithPrevious => previous_start,
96      Self::Start => 0.0,
97      Self::End => timeline_end,
98    };
99
100    Some(time.max(0.0))
101  }
102
103  /// Check if this position is absolute (not relative to other animations).
104  pub fn is_absolute(&self) -> bool {
105    matches!(self, Self::Absolute(_) | Self::Label(_) | Self::Start)
106  }
107
108  /// Check if this position is relative to the previous animation.
109  pub fn is_relative(&self) -> bool {
110    matches!(
111      self,
112      Self::Relative(_) | Self::AfterPrevious | Self::WithPrevious
113    )
114  }
115}
116
117impl From<f32> for Position {
118  fn from(time: f32) -> Self {
119    Self::Absolute(time)
120  }
121}
122
123impl From<&str> for Position {
124  fn from(s: &str) -> Self {
125    // Parse GSAP-style position strings
126    if s == "<" {
127      return Self::WithPrevious;
128    }
129
130    if s == ">" {
131      return Self::AfterPrevious;
132    }
133
134    if let Some(offset) = s.strip_prefix("+=")
135      && let Ok(n) = offset.parse::<f32>()
136    {
137      return Self::Relative(n);
138    }
139
140    if let Some(offset) = s.strip_prefix("-=")
141      && let Ok(n) = offset.parse::<f32>()
142    {
143      return Self::Relative(-n);
144    }
145
146    // Try parsing as absolute time
147    if let Ok(n) = s.parse::<f32>() {
148      return Self::Absolute(n);
149    }
150
151    // Otherwise treat as label
152    Self::Label(s.to_string())
153  }
154}
155
156impl From<String> for Position {
157  fn from(s: String) -> Self {
158    Self::from(s.as_str())
159  }
160}
161
162#[cfg(test)]
163mod tests {
164  use super::*;
165
166  use rustc_hash::FxHashMap as HashMap;
167
168  #[test]
169  fn test_absolute_position() {
170    let pos = Position::Absolute(2.0);
171    let labels = HashMap::default();
172
173    assert_eq!(pos.resolve(1.0, 0.0, 3.0, &labels), Some(2.0));
174  }
175
176  #[test]
177  fn test_relative_position() {
178    let labels = HashMap::default();
179
180    let pos = Position::Relative(0.5);
181    assert_eq!(pos.resolve(1.0, 0.0, 3.0, &labels), Some(1.5));
182
183    let pos = Position::Relative(-0.3);
184    assert_eq!(pos.resolve(1.0, 0.0, 3.0, &labels), Some(0.7));
185  }
186
187  #[test]
188  fn test_after_previous() {
189    let pos = Position::AfterPrevious;
190    let labels = HashMap::default();
191
192    assert_eq!(pos.resolve(1.5, 0.5, 3.0, &labels), Some(1.5));
193  }
194
195  #[test]
196  fn test_with_previous() {
197    let pos = Position::WithPrevious;
198    let labels = HashMap::default();
199
200    assert_eq!(pos.resolve(1.5, 0.5, 3.0, &labels), Some(0.5));
201  }
202
203  #[test]
204  fn test_label_position() {
205    let mut labels = HashMap::default();
206
207    labels.insert("intro".to_string(), 1.0);
208
209    let pos = Position::Label("intro".to_string());
210
211    assert_eq!(pos.resolve(0.0, 0.0, 3.0, &labels), Some(1.0));
212
213    let pos = Position::Label("missing".to_string());
214
215    assert_eq!(pos.resolve(0.0, 0.0, 3.0, &labels), None);
216  }
217
218  #[test]
219  fn test_from_string() {
220    assert_eq!(Position::from("<"), Position::WithPrevious);
221    assert_eq!(Position::from(">"), Position::AfterPrevious);
222    assert_eq!(Position::from("+=0.5"), Position::Relative(0.5));
223    assert_eq!(Position::from("-=0.3"), Position::Relative(-0.3));
224    assert_eq!(Position::from("2.0"), Position::Absolute(2.0));
225    assert_eq!(
226      Position::from("myLabel"),
227      Position::Label("myLabel".to_string())
228    );
229  }
230
231  #[test]
232  fn test_clamp_negative() {
233    let pos = Position::Relative(-10.0);
234    let labels = HashMap::default();
235
236    // Should clamp to 0.0
237    assert_eq!(pos.resolve(1.0, 0.0, 3.0, &labels), Some(0.0));
238  }
239}