Skip to main content

uzor_interactive/
elastic_slider.rs

1//! Elastic slider with exponential decay overflow
2//!
3//! When dragging beyond min/max bounds, the slider applies exponential
4//! decay to create a rubber-band effect. On release, it springs back.
5
6#[cfg(feature = "animation")]
7use uzor_animation::Spring;
8
9/// Elastic slider state
10///
11/// Computes slider value with elastic overflow when dragging beyond bounds.
12#[derive(Debug, Clone)]
13pub struct ElasticSlider {
14    /// Current slider value (clamped to min..max when not dragging)
15    pub value: f32,
16
17    /// Minimum value
18    pub min: f32,
19
20    /// Maximum value
21    pub max: f32,
22
23    /// Step size for discrete values (0.0 = continuous)
24    pub step: f32,
25
26    /// Maximum overflow distance in pixels before full saturation
27    pub max_overflow: f32,
28
29    /// Current overflow amount (positive = right overflow, negative = left)
30    overflow: f32,
31
32    /// Spring state for snap-back animation (displacement from 0)
33    #[cfg(feature = "animation")]
34    spring_state: Option<SpringState>,
35
36    /// Time when spring started (for animation)
37    #[cfg(feature = "animation")]
38    spring_start_time: f64,
39}
40
41#[cfg(feature = "animation")]
42#[derive(Debug, Clone)]
43struct SpringState {
44    spring: Spring,
45}
46
47impl Default for ElasticSlider {
48    fn default() -> Self {
49        Self::new(0.0, 100.0)
50    }
51}
52
53impl ElasticSlider {
54    /// Create a new elastic slider with given min and max values
55    pub fn new(min: f32, max: f32) -> Self {
56        Self {
57            value: (min + max) / 2.0,
58            min,
59            max,
60            step: 0.0,
61            max_overflow: 50.0,
62            overflow: 0.0,
63            #[cfg(feature = "animation")]
64            spring_state: None,
65            #[cfg(feature = "animation")]
66            spring_start_time: 0.0,
67        }
68    }
69
70    /// Set step size for discrete values
71    pub fn with_step(mut self, step: f32) -> Self {
72        self.step = step;
73        self
74    }
75
76    /// Set maximum overflow distance
77    pub fn with_max_overflow(mut self, max_overflow: f32) -> Self {
78        self.max_overflow = max_overflow;
79        self
80    }
81
82    /// Update slider value from pointer position
83    ///
84    /// # Arguments
85    /// * `pointer_x` - Pointer X position relative to slider left edge
86    /// * `slider_width` - Total width of slider in pixels
87    pub fn update_from_pointer(&mut self, pointer_x: f32, slider_width: f32) {
88        let range = self.max - self.min;
89
90        // Calculate raw value from pointer position
91        let mut new_value = self.min + (pointer_x / slider_width) * range;
92
93        // Apply step quantization if enabled
94        if self.step > 0.0 {
95            new_value = (new_value / self.step).round() * self.step;
96        }
97
98        // Calculate overflow if beyond bounds
99        let overflow_distance = if pointer_x < 0.0 {
100            pointer_x
101        } else if pointer_x > slider_width {
102            pointer_x - slider_width
103        } else {
104            0.0
105        };
106
107        // Apply exponential decay to overflow
108        self.overflow = Self::decay(overflow_distance, self.max_overflow);
109
110        // Clamp value to bounds
111        self.value = new_value.clamp(self.min, self.max);
112    }
113
114    /// Release pointer - start spring snap-back animation
115    #[cfg(feature = "animation")]
116    pub fn release(&mut self, current_time: f64) {
117        if self.overflow.abs() > 0.01 {
118            // Start spring animation from current overflow to 0
119            self.spring_state = Some(SpringState {
120                spring: Spring::new()
121                    .stiffness(180.0)
122                    .damping(12.0)
123                    .mass(1.0),
124            });
125            self.spring_start_time = current_time;
126        }
127    }
128
129    /// Release pointer (no-op without animation feature)
130    #[cfg(not(feature = "animation"))]
131    pub fn release(&mut self, _current_time: f64) {
132        self.overflow = 0.0;
133    }
134
135    /// Update spring animation
136    #[cfg(feature = "animation")]
137    pub fn update(&mut self, current_time: f64) {
138        if let Some(ref state) = self.spring_state {
139            let elapsed = current_time - self.spring_start_time;
140            let initial_overflow = self.overflow;
141
142            let (displacement, _velocity) = state.spring.evaluate(elapsed);
143
144            // Spring goes from 1.0 -> 0.0, scale by initial overflow
145            self.overflow = initial_overflow * displacement as f32;
146
147            // Stop animation when at rest
148            if state.spring.is_at_rest(elapsed) {
149                self.overflow = 0.0;
150                self.spring_state = None;
151            }
152        }
153    }
154
155    /// Update spring animation (no-op without animation feature)
156    #[cfg(not(feature = "animation"))]
157    pub fn update(&mut self, _current_time: f64) {}
158
159    /// Get current overflow amount
160    pub fn overflow(&self) -> f32 {
161        self.overflow
162    }
163
164    /// Get overflow region
165    pub fn overflow_region(&self) -> OverflowRegion {
166        if self.overflow < -0.01 {
167            OverflowRegion::Left
168        } else if self.overflow > 0.01 {
169            OverflowRegion::Right
170        } else {
171            OverflowRegion::None
172        }
173    }
174
175    /// Calculate fill percentage (0.0 to 1.0)
176    pub fn fill_percentage(&self) -> f32 {
177        let range = self.max - self.min;
178        if range == 0.0 {
179            0.0
180        } else {
181            ((self.value - self.min) / range).clamp(0.0, 1.0)
182        }
183    }
184
185    /// Exponential decay function for overflow
186    ///
187    /// Uses sigmoid-like curve: 2 * (1 / (1 + e^(-x)) - 0.5)
188    /// This creates elastic resistance that increases with distance.
189    fn decay(value: f32, max: f32) -> f32 {
190        if max == 0.0 {
191            return 0.0;
192        }
193
194        let entry = value / max;
195        let sigmoid = 2.0 * (1.0 / (1.0 + (-entry).exp()) - 0.5);
196        sigmoid * max
197    }
198}
199
200/// Overflow region indicator
201#[derive(Debug, Clone, Copy, PartialEq, Eq)]
202pub enum OverflowRegion {
203    Left,
204    None,
205    Right,
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211
212    #[test]
213    fn test_basic_slider() {
214        let mut slider = ElasticSlider::new(0.0, 100.0);
215
216        // Middle of slider (50px in 100px width)
217        slider.update_from_pointer(50.0, 100.0);
218        assert!((slider.value - 50.0).abs() < 0.1);
219        assert_eq!(slider.overflow_region(), OverflowRegion::None);
220    }
221
222    #[test]
223    fn test_stepped_slider() {
224        let mut slider = ElasticSlider::new(0.0, 100.0).with_step(10.0);
225
226        // 52px should snap to 50
227        slider.update_from_pointer(52.0, 100.0);
228        assert!((slider.value - 50.0).abs() < 0.1);
229    }
230
231    #[test]
232    fn test_overflow_left() {
233        let mut slider = ElasticSlider::new(0.0, 100.0);
234
235        // Drag -20px beyond left edge
236        slider.update_from_pointer(-20.0, 100.0);
237        assert_eq!(slider.overflow_region(), OverflowRegion::Left);
238        assert!(slider.overflow() < 0.0);
239        assert_eq!(slider.value, 0.0); // Value stays clamped
240    }
241
242    #[test]
243    fn test_overflow_right() {
244        let mut slider = ElasticSlider::new(0.0, 100.0);
245
246        // Drag 20px beyond right edge
247        slider.update_from_pointer(120.0, 100.0);
248        assert_eq!(slider.overflow_region(), OverflowRegion::Right);
249        assert!(slider.overflow() > 0.0);
250        assert_eq!(slider.value, 100.0); // Value stays clamped
251    }
252
253    #[test]
254    fn test_decay_function() {
255        // At zero, no decay
256        assert_eq!(ElasticSlider::decay(0.0, 50.0), 0.0);
257
258        // At max, sigmoid produces ~46% of max (due to exponential curve)
259        let result = ElasticSlider::decay(50.0, 50.0);
260        assert!(result > 20.0 && result < 30.0);
261
262        // Small overflow should be proportionally small
263        let small = ElasticSlider::decay(5.0, 50.0);
264        let large = ElasticSlider::decay(25.0, 50.0);
265        assert!(small < large);
266    }
267
268    #[test]
269    fn test_fill_percentage() {
270        let mut slider = ElasticSlider::new(0.0, 100.0);
271
272        slider.update_from_pointer(0.0, 100.0);
273        assert!((slider.fill_percentage() - 0.0).abs() < 0.01);
274
275        slider.update_from_pointer(50.0, 100.0);
276        assert!((slider.fill_percentage() - 0.5).abs() < 0.01);
277
278        slider.update_from_pointer(100.0, 100.0);
279        assert!((slider.fill_percentage() - 1.0).abs() < 0.01);
280    }
281
282    #[cfg(feature = "animation")]
283    #[test]
284    fn test_spring_release() {
285        let mut slider = ElasticSlider::new(0.0, 100.0);
286
287        // Create overflow
288        slider.update_from_pointer(-20.0, 100.0);
289        let initial_overflow = slider.overflow();
290        assert!(initial_overflow < 0.0);
291
292        // Release at t=0
293        slider.release(0.0);
294
295        // Update at t=0.1s - should be moving toward 0
296        slider.update(0.1);
297        assert!(slider.overflow().abs() < initial_overflow.abs());
298
299        // Update at t=2.0s - should be at rest
300        slider.update(2.0);
301        assert!(slider.overflow().abs() < 0.01);
302    }
303}