Skip to main content

armas_basic/animation/
momentum.rs

1//! Momentum-based animation for drag-and-release interactions
2//!
3//! Provides physics-based momentum that continues movement after a drag ends,
4//! with configurable friction and optional snapping to boundaries.
5
6/// Behavior trait for momentum animations
7///
8/// Implement this to customize how the position moves after release.
9pub trait MomentumBehavior {
10    /// Called when the drag is released with a velocity (units per second)
11    fn released_with_velocity(&mut self, position: f64, velocity: f64);
12
13    /// Get the next position after elapsed time
14    /// This should also update internal state (like decaying velocity)
15    fn next_position(&mut self, current_position: f64, elapsed_seconds: f64) -> f64;
16
17    /// Check if the animation has stopped
18    fn is_stopped(&self, position: f64) -> bool;
19}
20
21/// Continuous momentum with friction-based deceleration
22///
23/// The position continues moving with the release velocity and gradually
24/// slows down due to friction. Good for free-scrolling content.
25///
26/// # Example
27/// ```ignore
28/// let mut behavior = ContinuousWithMomentum::new();
29/// behavior.set_friction(0.08); // Higher = more friction
30/// ```
31#[derive(Debug, Clone)]
32pub struct ContinuousWithMomentum {
33    velocity: f64,
34    damping: f64,
35    minimum_velocity: f64,
36}
37
38impl Default for ContinuousWithMomentum {
39    fn default() -> Self {
40        Self::new()
41    }
42}
43
44impl ContinuousWithMomentum {
45    /// Create a new continuous momentum behavior
46    #[must_use]
47    pub const fn new() -> Self {
48        Self {
49            velocity: 0.0,
50            damping: 0.92,
51            minimum_velocity: 0.05,
52        }
53    }
54
55    /// Set the friction that damps movement
56    ///
57    /// Typical values are 0.05-0.15. Higher = more friction = stops faster.
58    #[must_use]
59    pub fn friction(mut self, friction: f64) -> Self {
60        self.damping = 1.0 - friction.clamp(0.0, 0.99);
61        self
62    }
63
64    /// Set the minimum velocity threshold
65    ///
66    /// When velocity drops below this, animation stops. Default is 0.05.
67    #[must_use]
68    pub const fn minimum_velocity(mut self, min_vel: f64) -> Self {
69        self.minimum_velocity = min_vel.abs();
70        self
71    }
72
73    /// Get the current velocity
74    #[must_use]
75    pub const fn velocity(&self) -> f64 {
76        self.velocity
77    }
78}
79
80impl MomentumBehavior for ContinuousWithMomentum {
81    fn released_with_velocity(&mut self, _position: f64, velocity: f64) {
82        self.velocity = velocity;
83    }
84
85    fn next_position(&mut self, current_position: f64, elapsed_seconds: f64) -> f64 {
86        let new_pos = current_position + self.velocity * elapsed_seconds;
87        // Decay velocity
88        self.velocity *= self.damping;
89        if self.velocity.abs() < self.minimum_velocity {
90            self.velocity = 0.0;
91        }
92        new_pos
93    }
94
95    fn is_stopped(&self, _position: f64) -> bool {
96        self.velocity.abs() < self.minimum_velocity
97    }
98}
99
100/// Snap-to-page momentum behavior
101///
102/// When released, the position gravitates toward the nearest integer (page) boundary.
103/// Useful for paged content or snapping to grid positions.
104///
105/// # Example
106/// ```ignore
107/// let mut behavior = SnapToPageBoundaries::new();
108/// // Will snap to nearest integer position after release
109/// ```
110#[derive(Debug, Clone)]
111pub struct SnapToPageBoundaries {
112    target_position: f64,
113    snap_speed: f64,
114}
115
116impl Default for SnapToPageBoundaries {
117    fn default() -> Self {
118        Self::new()
119    }
120}
121
122impl SnapToPageBoundaries {
123    /// Create a new snap-to-page behavior
124    #[must_use]
125    pub const fn new() -> Self {
126        Self {
127            target_position: 0.0,
128            snap_speed: 10.0,
129        }
130    }
131
132    /// Set the speed at which it snaps to the target
133    ///
134    /// Higher values = faster snapping. Default is 10.0.
135    #[must_use]
136    pub const fn snap_speed(mut self, speed: f64) -> Self {
137        self.snap_speed = speed.max(1.0);
138        self
139    }
140
141    /// Get the target snap position
142    #[must_use]
143    pub const fn target(&self) -> f64 {
144        self.target_position
145    }
146}
147
148impl MomentumBehavior for SnapToPageBoundaries {
149    fn released_with_velocity(&mut self, position: f64, velocity: f64) {
150        // Start by snapping to nearest integer
151        self.target_position = position.round();
152
153        // If moving fast enough, snap to next/previous page in that direction
154        if velocity > 1.0 && self.target_position < position {
155            self.target_position += 1.0;
156        }
157        if velocity < -1.0 && self.target_position > position {
158            self.target_position -= 1.0;
159        }
160    }
161
162    fn next_position(&mut self, current_position: f64, elapsed_seconds: f64) -> f64 {
163        if self.is_stopped(current_position) {
164            return self.target_position;
165        }
166
167        let velocity = (self.target_position - current_position) * self.snap_speed;
168        let new_pos = current_position + velocity * elapsed_seconds;
169
170        // If we've overshot, clamp to target
171        if (current_position < self.target_position && new_pos > self.target_position)
172            || (current_position > self.target_position && new_pos < self.target_position)
173        {
174            self.target_position
175        } else {
176            new_pos
177        }
178    }
179
180    fn is_stopped(&self, position: f64) -> bool {
181        (self.target_position - position).abs() < 0.001
182    }
183}
184
185/// Animated position with momentum physics
186///
187/// Models a 1D position that can be dragged and released with momentum.
188/// The position continues moving after release based on the configured behavior.
189///
190/// # Example
191/// ```ignore
192/// use armas_basic::animation::{MomentumPosition, ContinuousWithMomentum};
193///
194/// let mut pos = MomentumPosition::new(ContinuousWithMomentum::new().friction(0.08));
195/// pos.set_limits(0.0, 100.0);
196///
197/// // During drag:
198/// pos.begin_drag();
199/// pos.drag(delta_from_start);
200/// pos.end_drag();
201///
202/// // Each frame:
203/// pos.update(dt);
204/// let current = pos.position();
205/// ```
206#[derive(Debug, Clone)]
207pub struct MomentumPosition<B: MomentumBehavior> {
208    position: f64,
209    grabbed_position: f64,
210    release_velocity: f64,
211    limits: (f64, f64),
212    last_drag_time: f64,
213    last_drag_position: f64,
214    is_dragging: bool,
215    is_animating: bool,
216    /// The behavior that controls momentum physics
217    pub behavior: B,
218}
219
220impl<B: MomentumBehavior> MomentumPosition<B> {
221    /// Create a new momentum position with the given behavior
222    pub const fn new(behavior: B) -> Self {
223        Self {
224            position: 0.0,
225            grabbed_position: 0.0,
226            release_velocity: 0.0,
227            limits: (f64::MIN, f64::MAX),
228            last_drag_time: 0.0,
229            last_drag_position: 0.0,
230            is_dragging: false,
231            is_animating: false,
232            behavior,
233        }
234    }
235
236    /// Set the position limits
237    pub const fn set_limits(&mut self, min: f64, max: f64) {
238        self.limits = (min, max);
239        self.position = self.position.clamp(min, max);
240    }
241
242    /// Get current position
243    pub const fn position(&self) -> f64 {
244        self.position
245    }
246
247    /// Set position directly (stops any animation)
248    pub const fn set_position(&mut self, position: f64) {
249        self.position = position.clamp(self.limits.0, self.limits.1);
250        self.is_animating = false;
251        self.is_dragging = false;
252    }
253
254    /// Check if currently being dragged
255    pub const fn is_dragging(&self) -> bool {
256        self.is_dragging
257    }
258
259    /// Check if momentum animation is active
260    pub const fn is_animating(&self) -> bool {
261        self.is_animating
262    }
263
264    /// Begin a drag operation
265    pub const fn begin_drag(&mut self) {
266        self.grabbed_position = self.position;
267        self.release_velocity = 0.0;
268        self.last_drag_time = 0.0;
269        self.last_drag_position = self.position;
270        self.is_dragging = true;
271        self.is_animating = false;
272    }
273
274    /// Update position during drag
275    ///
276    /// `delta` is the total offset from where the drag started
277    /// `elapsed_since_last` is time since last `drag()` call (for velocity calculation)
278    pub fn drag(&mut self, delta: f64, elapsed_since_last: f64) {
279        let new_position = (self.grabbed_position + delta).clamp(self.limits.0, self.limits.1);
280
281        // Calculate velocity for momentum
282        if elapsed_since_last > 0.005 {
283            let velocity = (new_position - self.last_drag_position) / elapsed_since_last;
284            // Only update if significant movement
285            if velocity.abs() > 0.2 {
286                self.release_velocity = velocity;
287            }
288            self.last_drag_position = new_position;
289            self.last_drag_time = elapsed_since_last;
290        }
291
292        self.position = new_position;
293    }
294
295    /// End the drag and start momentum animation
296    pub fn end_drag(&mut self) {
297        if self.is_dragging {
298            self.is_dragging = false;
299            self.behavior
300                .released_with_velocity(self.position, self.release_velocity);
301            self.is_animating = true;
302        }
303    }
304
305    /// Apply a nudge (like from mouse wheel)
306    pub fn nudge(&mut self, delta: f64) {
307        self.position = (self.position + delta).clamp(self.limits.0, self.limits.1);
308        self.behavior.released_with_velocity(self.position, 0.0);
309        self.is_animating = true;
310    }
311
312    /// Update the animation (call each frame)
313    ///
314    /// Returns true if the position changed
315    pub fn update(&mut self, dt: f64) -> bool {
316        if self.is_dragging || !self.is_animating {
317            return false;
318        }
319
320        let new_position = self.behavior.next_position(self.position, dt);
321        let clamped = new_position.clamp(self.limits.0, self.limits.1);
322
323        if self.behavior.is_stopped(clamped) {
324            self.is_animating = false;
325            if (self.position - clamped).abs() > 0.0001 {
326                self.position = clamped;
327                return true;
328            }
329            return false;
330        }
331
332        if (self.position - clamped).abs() > 0.0001 {
333            self.position = clamped;
334            true
335        } else {
336            false
337        }
338    }
339}