Skip to main content

fret_ui_headless/embla/
engine.rs

1use crate::embla::drag_release::{
2    DragReleaseConfig, DragReleaseResult, PointerKind, compute_release,
3};
4use crate::embla::limit::Limit;
5use crate::embla::scroll_body::ScrollBody;
6use crate::embla::scroll_bounds::{ScrollBounds, ScrollBoundsConfig};
7use crate::embla::scroll_limit::scroll_limit;
8use crate::embla::scroll_target::{ScrollTarget, Target};
9use crate::embla::utils::{DIRECTION_NONE, Direction};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub struct SelectEvent {
13    pub target_snap: usize,
14    pub source_snap: usize,
15}
16
17#[derive(Debug, Clone, Copy, PartialEq)]
18pub struct EngineConfig {
19    pub loop_enabled: bool,
20    pub drag_free: bool,
21    pub skip_snaps: bool,
22    pub duration: f32,
23    pub base_friction: f32,
24    pub view_size: f32,
25    pub start_snap: usize,
26}
27
28/// Minimal Embla-style engine state for headless parity work.
29///
30/// This is intentionally not a full port of `Engine.ts`. It is a small, composable core that
31/// supports:
32/// - scroll target selection (`ScrollTarget`)
33/// - scroll integration (`ScrollBody`)
34/// - drag release shaping (`DragHandler.up` math)
35/// - select event emission (index changes)
36///
37/// Upstream references:
38/// - `repo-ref/embla-carousel/packages/embla-carousel/src/components/Engine.ts`
39/// - `repo-ref/embla-carousel/packages/embla-carousel/src/components/ScrollTo.ts`
40/// - `repo-ref/embla-carousel/packages/embla-carousel/src/components/ScrollAnimator.ts`
41#[derive(Debug, Clone, PartialEq)]
42pub struct Engine {
43    pub scroll_body: ScrollBody,
44    pub scroll_target: ScrollTarget,
45    pub index_current: usize,
46    pub index_previous: usize,
47
48    pub scroll_bounds: ScrollBounds,
49
50    config: EngineConfig,
51    content_size: f32,
52    limit: Limit,
53}
54
55impl Engine {
56    pub fn new(scroll_snaps: Vec<f32>, content_size: f32, config: EngineConfig) -> Self {
57        let max_index = scroll_snaps.len().saturating_sub(1);
58        let start_snap = config.start_snap.min(max_index);
59        let start_location = scroll_snaps.get(start_snap).copied().unwrap_or_default();
60
61        let limit = scroll_limit(content_size, &scroll_snaps, config.loop_enabled);
62        let scroll_target = ScrollTarget::new(
63            config.loop_enabled,
64            scroll_snaps,
65            content_size,
66            limit,
67            start_location,
68        );
69        let mut scroll_body =
70            ScrollBody::new(start_location, config.duration, config.base_friction);
71        scroll_body.set_target(start_location);
72        let mut scroll_bounds = ScrollBounds::new(ScrollBoundsConfig {
73            view_size: config.view_size.max(0.0),
74        });
75        scroll_bounds.toggle_active(!config.loop_enabled);
76
77        Self {
78            scroll_body,
79            scroll_target,
80            index_current: start_snap,
81            index_previous: start_snap,
82            scroll_bounds,
83            config,
84            content_size,
85            limit,
86        }
87    }
88
89    pub fn set_options(
90        &mut self,
91        loop_enabled: bool,
92        drag_free: bool,
93        skip_snaps: bool,
94        duration: f32,
95    ) {
96        self.config.loop_enabled = loop_enabled;
97        self.config.drag_free = drag_free;
98        self.config.skip_snaps = skip_snaps;
99
100        let duration = duration.max(0.0);
101        if (self.config.duration - duration).abs() > 0.0001 {
102            self.config.duration = duration;
103            self.scroll_body.set_base_duration(duration);
104        }
105    }
106
107    #[inline]
108    pub fn loop_enabled(&self) -> bool {
109        self.config.loop_enabled
110    }
111
112    pub fn reinit(
113        &mut self,
114        scroll_snaps: Vec<f32>,
115        content_size: f32,
116        view_size: f32,
117    ) -> Option<SelectEvent> {
118        let content_size = content_size.max(0.0);
119        let view_size = view_size.max(0.0);
120
121        self.config.view_size = view_size;
122
123        let mut scroll_snaps = scroll_snaps;
124        if scroll_snaps.is_empty() {
125            scroll_snaps.push(0.0);
126        }
127
128        let limit = scroll_limit(content_size, &scroll_snaps, self.config.loop_enabled);
129
130        // A geometry-driven re-init should keep motion state but ensure the integrator stays within
131        // the updated limits. Otherwise the recipe can end up displaying a clamped offset while the
132        // engine continues integrating from an out-of-bounds location/target.
133        if self.config.loop_enabled {
134            if limit.length != 0.0 {
135                self.scroll_body
136                    .set_location(limit.remove_offset(self.scroll_body.location()));
137                self.scroll_body
138                    .set_target(limit.remove_offset(self.scroll_body.target()));
139            }
140        } else {
141            self.scroll_body
142                .set_location(limit.clamp(self.scroll_body.location()));
143            self.scroll_body
144                .set_target(limit.clamp(self.scroll_body.target()));
145        }
146
147        let scroll_target = ScrollTarget::new(
148            self.config.loop_enabled,
149            scroll_snaps,
150            content_size,
151            limit,
152            self.scroll_body.target(),
153        );
154
155        self.limit = limit;
156        self.content_size = content_size;
157        self.scroll_target = scroll_target;
158        self.scroll_bounds = ScrollBounds::new(ScrollBoundsConfig { view_size });
159        self.scroll_bounds.toggle_active(!self.config.loop_enabled);
160
161        self.sync_target_vector();
162
163        let next = self
164            .scroll_target
165            .by_distance(0.0, true)
166            .index
167            .min(self.scroll_target.max_index());
168        if next != self.index_current {
169            let source_snap = self.index_current;
170            self.index_previous = source_snap;
171            self.index_current = next;
172            Some(SelectEvent {
173                target_snap: next,
174                source_snap,
175            })
176        } else {
177            None
178        }
179    }
180
181    #[inline]
182    fn sync_target_vector(&mut self) {
183        self.scroll_target
184            .set_target_vector(self.scroll_body.target());
185    }
186
187    pub fn constrain_bounds(&mut self, pointer_down: bool) {
188        self.scroll_bounds
189            .constrain(self.limit, &mut self.scroll_body, pointer_down);
190    }
191
192    fn apply_target(&mut self, target: Target) -> Option<SelectEvent> {
193        let source_snap = self.index_current;
194        if target.distance != 0.0 {
195            self.scroll_body.add_target(target.distance);
196        }
197        self.sync_target_vector();
198
199        if target.index != source_snap {
200            self.index_previous = source_snap;
201            self.index_current = target.index;
202            Some(SelectEvent {
203                target_snap: target.index,
204                source_snap,
205            })
206        } else {
207            None
208        }
209    }
210
211    /// One engine step (typically one rendered frame).
212    pub fn tick(&mut self, pointer_down: bool) {
213        self.scroll_body.seek();
214        self.constrain_bounds(pointer_down);
215        self.normalize_loop_entities();
216        self.sync_target_vector();
217    }
218
219    /// Keeps loop-enabled motion values within the scroll limit by applying Embla-style loop distances.
220    ///
221    /// Unlike `ScrollBounds`, this is only active for `loop=true` and does not clamp; it wraps the
222    /// location/target by the loop length while keeping integrator velocity intact.
223    pub fn normalize_loop_entities(&mut self) {
224        if !self.config.loop_enabled {
225            return;
226        }
227        if self.limit.length == 0.0 {
228            return;
229        }
230
231        let location = self.scroll_body.location();
232        let wrapped = self.limit.remove_offset(location);
233        let delta = wrapped - location;
234        if delta == 0.0 {
235            return;
236        }
237
238        self.scroll_body.add_loop_distance(delta);
239        self.sync_target_vector();
240    }
241
242    pub fn scroll_to_distance(
243        &mut self,
244        distance: f32,
245        snap_to_closest: bool,
246    ) -> Option<SelectEvent> {
247        self.sync_target_vector();
248        let target = self.scroll_target.by_distance(distance, snap_to_closest);
249        self.apply_target(target)
250    }
251
252    pub fn scroll_to_index(&mut self, index: usize, direction: Direction) -> Option<SelectEvent> {
253        self.sync_target_vector();
254        let target = self.scroll_target.by_index(index, direction);
255        self.apply_target(target)
256    }
257
258    pub fn scroll_to_next(&mut self) -> Option<SelectEvent> {
259        let max = self.scroll_target.max_index();
260        let next = if self.config.loop_enabled {
261            (self.index_current + 1) % (max + 1).max(1)
262        } else {
263            (self.index_current + 1).min(max)
264        };
265        self.scroll_to_index(next, DIRECTION_NONE)
266    }
267
268    pub fn scroll_to_prev(&mut self) -> Option<SelectEvent> {
269        let max = self.scroll_target.max_index();
270        let prev = if self.config.loop_enabled {
271            if max == 0 {
272                0
273            } else if self.index_current == 0 {
274                max
275            } else {
276                self.index_current - 1
277            }
278        } else {
279            self.index_current.saturating_sub(1)
280        };
281        self.scroll_to_index(prev, DIRECTION_NONE)
282    }
283
284    /// Apply Embla-style drag release shaping and scroll to the resulting target.
285    ///
286    /// `pointer_delta` is the Embla-style flick force in the main axis (px/ms).
287    /// `direction` is the axis direction function (Embla `axis.direction`).
288    pub fn on_drag_release(
289        &mut self,
290        pointer_kind: PointerKind,
291        pointer_delta: f32,
292        direction: impl Fn(f32) -> f32,
293    ) -> (DragReleaseResult, Option<SelectEvent>) {
294        self.sync_target_vector();
295        let cfg = DragReleaseConfig {
296            drag_free: self.config.drag_free,
297            skip_snaps: self.config.skip_snaps,
298            view_size: self.config.view_size,
299            base_friction: self.config.base_friction,
300        };
301
302        let out = compute_release(
303            cfg,
304            pointer_kind,
305            &self.scroll_target,
306            self.index_current,
307            pointer_delta,
308            direction,
309        );
310
311        self.scroll_body
312            .use_duration(out.duration)
313            .use_friction(out.friction);
314        let ev = self.scroll_to_distance(out.force, !self.config.drag_free);
315        (out, ev)
316    }
317}
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322    use crate::embla::drag_release::PointerKind;
323
324    #[test]
325    fn scroll_to_distance_emits_select_when_index_changes() {
326        let snaps = vec![0.0, -100.0, -200.0, -300.0];
327        let mut engine = Engine::new(
328            snaps,
329            300.0,
330            EngineConfig {
331                loop_enabled: false,
332                drag_free: false,
333                skip_snaps: false,
334                duration: 25.0,
335                base_friction: 0.68,
336                view_size: 320.0,
337                start_snap: 0,
338            },
339        );
340
341        let ev = engine
342            .scroll_to_distance(-130.0, true)
343            .expect("select event");
344        assert_eq!(ev.source_snap, 0);
345        assert_eq!(ev.target_snap, 1);
346        assert_eq!(engine.index_current, 1);
347    }
348
349    #[test]
350    fn drag_release_shapes_duration_and_friction() {
351        let snaps = vec![0.0, -100.0, -200.0, -300.0];
352        let mut engine = Engine::new(
353            snaps,
354            300.0,
355            EngineConfig {
356                loop_enabled: false,
357                drag_free: false,
358                skip_snaps: false,
359                duration: 25.0,
360                base_friction: 0.68,
361                view_size: 320.0,
362                start_snap: 0,
363            },
364        );
365
366        let (release, _ev) = engine.on_drag_release(PointerKind::Mouse, -0.25, |v| v);
367        assert!(release.duration <= 25.0);
368        assert!(release.friction >= 0.68);
369    }
370
371    #[test]
372    fn reinit_updates_limit_and_keeps_location() {
373        let snaps = vec![0.0, -100.0, -200.0, -300.0];
374        let mut engine = Engine::new(
375            snaps,
376            300.0,
377            EngineConfig {
378                loop_enabled: false,
379                drag_free: false,
380                skip_snaps: false,
381                duration: 25.0,
382                base_friction: 0.68,
383                view_size: 320.0,
384                start_snap: 0,
385            },
386        );
387
388        engine.scroll_body.set_location(-250.0);
389        engine.scroll_body.set_target(-250.0);
390
391        let ev = engine.reinit(vec![0.0, -120.0, -240.0], 240.0, 320.0);
392        assert!(ev.is_some());
393        assert_eq!(engine.config.view_size, 320.0);
394        assert_eq!(engine.scroll_body.location(), -240.0);
395        assert_eq!(engine.scroll_body.target(), -240.0);
396        assert_eq!(engine.index_current, 2);
397    }
398
399    #[test]
400    fn loop_normalization_wraps_location_without_resetting_motion() {
401        // 5 uniform slides, view size 100 => content size 500, scroll snaps 0..-400.
402        let snaps = vec![0.0, -100.0, -200.0, -300.0, -400.0];
403        let mut engine = Engine::new(
404            snaps,
405            500.0,
406            EngineConfig {
407                loop_enabled: true,
408                drag_free: false,
409                skip_snaps: false,
410                duration: 25.0,
411                base_friction: 0.9,
412                view_size: 100.0,
413                start_snap: 0,
414            },
415        );
416
417        // Simulate a drag past max bound (location > 0).
418        engine.scroll_body.set_location(20.0);
419        engine.scroll_body.set_target(20.0);
420        engine.scroll_target.set_target_vector(20.0);
421
422        engine.normalize_loop_entities();
423
424        assert!(
425            engine.scroll_body.location() <= engine.limit.max,
426            "expected wrapped location within max bound; loc={}",
427            engine.scroll_body.location()
428        );
429        assert!(
430            engine.scroll_body.location() >= engine.limit.min,
431            "expected wrapped location within min bound; loc={}",
432            engine.scroll_body.location()
433        );
434    }
435
436    #[test]
437    fn loop_scroll_to_next_wraps_selection_index() {
438        let snaps = vec![0.0, -100.0, -200.0, -300.0, -400.0];
439        let mut engine = Engine::new(
440            snaps,
441            500.0,
442            EngineConfig {
443                loop_enabled: true,
444                drag_free: false,
445                skip_snaps: false,
446                duration: 0.0,
447                base_friction: 0.9,
448                view_size: 100.0,
449                start_snap: 0,
450            },
451        );
452
453        for _ in 0..5 {
454            let _ = engine.scroll_to_next();
455        }
456
457        assert_eq!(engine.index_current, 0);
458    }
459
460    #[test]
461    fn loop_normalization_wraps_large_offsets_into_limit_range() {
462        let snaps = vec![0.0, -100.0, -200.0, -300.0, -400.0];
463        let mut engine = Engine::new(
464            snaps,
465            500.0,
466            EngineConfig {
467                loop_enabled: true,
468                drag_free: false,
469                skip_snaps: false,
470                duration: 25.0,
471                base_friction: 0.9,
472                view_size: 100.0,
473                start_snap: 0,
474            },
475        );
476
477        // Values far outside the [-content_size, 0] range should wrap deterministically.
478        engine.scroll_body.set_location(1020.0);
479        engine.scroll_body.set_target(980.0);
480        engine.scroll_target.set_target_vector(980.0);
481        let displacement_before = engine.scroll_body.target() - engine.scroll_body.location();
482        engine.normalize_loop_entities();
483
484        let loc = engine.scroll_body.location();
485        assert!(loc <= engine.limit.max && loc >= engine.limit.min);
486        // Embla-style loop normalization uses the location bound crossing as the trigger and applies
487        // the same loop distance to all entities. This preserves the displacement even if the target
488        // ends up outside the wrapped range in the same step.
489        let displacement_after = engine.scroll_body.target() - engine.scroll_body.location();
490        assert!((displacement_before - displacement_after).abs() <= 0.0001);
491    }
492
493    #[test]
494    fn loop_normalization_is_idempotent() {
495        let snaps = vec![0.0, -100.0, -200.0, -300.0, -400.0];
496        let mut engine = Engine::new(
497            snaps,
498            500.0,
499            EngineConfig {
500                loop_enabled: true,
501                drag_free: false,
502                skip_snaps: false,
503                duration: 25.0,
504                base_friction: 0.9,
505                view_size: 100.0,
506                start_snap: 0,
507            },
508        );
509
510        engine.scroll_body.set_location(-980.0);
511        engine.scroll_body.set_target(-980.0);
512        engine.scroll_target.set_target_vector(-980.0);
513        engine.normalize_loop_entities();
514
515        let first = engine.scroll_body.snapshot();
516        engine.normalize_loop_entities();
517        let second = engine.scroll_body.snapshot();
518
519        assert!((first.location - second.location).abs() <= 0.0001);
520        assert!((first.target - second.target).abs() <= 0.0001);
521        assert!((first.previous_location - second.previous_location).abs() <= 0.0001);
522    }
523
524    #[test]
525    fn loop_normalization_preserves_scroll_velocity() {
526        let snaps = vec![0.0, -100.0, -200.0, -300.0, -400.0];
527        let mut engine = Engine::new(
528            snaps,
529            500.0,
530            EngineConfig {
531                loop_enabled: true,
532                drag_free: false,
533                skip_snaps: false,
534                duration: 25.0,
535                base_friction: 0.9,
536                view_size: 100.0,
537                start_snap: 0,
538            },
539        );
540
541        // Create non-zero motion and then normalize while outside bounds.
542        engine.scroll_body.set_target(200.0);
543        engine.scroll_body.seek();
544        engine
545            .scroll_target
546            .set_target_vector(engine.scroll_body.target());
547        let before = engine.scroll_body.snapshot();
548        assert!(before.velocity != 0.0);
549
550        engine.normalize_loop_entities();
551        let after = engine.scroll_body.snapshot();
552
553        assert!((before.velocity - after.velocity).abs() <= 0.0001);
554        assert!(after.location <= engine.limit.max && after.location >= engine.limit.min);
555        assert!(after.target <= engine.limit.max && after.target >= engine.limit.min);
556    }
557}