Skip to main content

cotis_interactivity/
scroll.rs

1//! Wheel and drag scrolling with optional momentum.
2//!
3//! Low-level scroll algorithms ([`MomentumScroller`], [`HardScroller`]) accumulate
4//! per-axis offsets. Element managers combine wheel input, optional mouse drag, and
5//! layout bounds to produce clamped scroll positions.
6//!
7//! Scroll positions use `0.0` when content is aligned to the top-left of the clip
8//! region. Scrolling content up or left produces **negative** values. Valid range
9//! per axis is `[−limits, 0]` where `limits = (internal_size − bound_size).max(0)`.
10//!
11//! Layout bounds come from [`cotis_utils::element_state::ElementBoundingBox`] and
12//! internal content size from [`cotis_utils::element_state::ElementClipInternalSize`].
13
14use crate::mouse::{FullElementCursorState, MouseButtonManager, MouseClickingStrategy};
15use cotis::cotis_app::CotisApp;
16use cotis::layout::LayoutManagerCompatible;
17use cotis::renders::RenderCompatibleWith;
18use cotis::utils::ElementId;
19use cotis_utils::element_state::{ElementBoundingBox, ElementClipInternalSize};
20use cotis_utils::interactivity::mouse::{MouseButton, MouseProvider};
21use cotis_utils::math::{BoundingBox, Vector2};
22
23/// Per-axis scroll algorithm wrapper.
24pub enum ScrollAlgorithm {
25    /// Inertial scrolling with friction decay.
26    Momentum(MomentumScroller),
27    /// Direct delta accumulation without momentum.
28    HardScroll(HardScroller),
29}
30
31impl ScrollAlgorithm {
32    /// Returns the current scroll offset for this axis.
33    pub fn get_position(&self) -> f32 {
34        match self {
35            ScrollAlgorithm::Momentum(momentum) => momentum.get_position(),
36            ScrollAlgorithm::HardScroll(hard) => hard.get_position(),
37        }
38    }
39}
40
41/// Scroll offset with velocity-based momentum and frame-rate-independent friction.
42#[derive(Debug, Clone, Copy)]
43pub struct MomentumScroller {
44    /// Current scroll offset on this axis.
45    pub position: f32,
46    /// Multiplier applied to scroll deltas.
47    pub scale: f32,
48    /// Current scroll velocity in pixels per second.
49    pub velocity: f32,
50    /// Stored friction factor (`1.0 − friction` passed to [`new`](Self::new)).
51    ///
52    /// Momentum decay uses `friction.powf(dt * 60.0)` each frame.
53    pub friction: f32,
54    /// Velocity magnitudes below this threshold are zeroed out.
55    pub min_velocity: f32,
56}
57
58impl MomentumScroller {
59    /// Creates a momentum scroller at position zero.
60    ///
61    /// * `scale` — multiplier for scroll deltas.
62    /// * `friction` — per-60-fps-step decay factor; must satisfy `0.0 < friction < 1.0`.
63    ///   Stored internally as `1.0 - friction`.
64    /// * `min_velocity` — stop threshold; must be positive.
65    ///
66    /// # Panics
67    ///
68    /// Panics if `friction` is not in `(0.0, 1.0)` or if `min_velocity <= 0.0`.
69    pub fn new(scale: f32, friction: f32, min_velocity: f32) -> Self {
70        assert!(friction > 0.0 && friction < 1.0);
71        assert!(min_velocity > 0.0);
72        Self {
73            position: 0.0,
74            scale,
75            velocity: 0.0,
76            friction: 1.0 - friction,
77            min_velocity,
78        }
79    }
80
81    /// Sets the scroll position and clears velocity.
82    pub fn direct_set_position(&mut self, position: f32) {
83        self.position = position;
84        self.velocity = 0.0;
85    }
86
87    /// Advances scroll state for one frame.
88    ///
89    /// When `d_scroll != 0`, applies the delta and sets velocity from `d_scroll / dt`.
90    /// When `d_scroll == 0`, applies momentum with friction decay.
91    ///
92    /// # Examples
93    ///
94    /// ```
95    /// use cotis_interactivity::scroll::MomentumScroller;
96    ///
97    /// let mut scroller = MomentumScroller::new(1.0, 0.1, 0.5);
98    /// scroller.update(10.0, 1.0 / 60.0);
99    /// assert!(scroller.get_position() > 0.0);
100    ///
101    /// // Momentum continues when no new delta is applied
102    /// scroller.update(0.0, 1.0 / 60.0);
103    /// assert!(scroller.velocity.abs() > 0.0 || scroller.get_position() > 10.0);
104    /// ```
105    pub fn update(&mut self, d_scroll: f32, dt: f32) {
106        if d_scroll != 0.0 {
107            self.position += d_scroll * self.scale;
108            if dt > 0.0 {
109                self.velocity = d_scroll * self.scale / dt;
110            }
111        } else {
112            self.position += self.velocity * dt;
113
114            // Apply friction (frame-rate independent; friction is per 60fps step)
115            let friction_per_step = self.friction.powf(dt * 60.0);
116            self.velocity *= friction_per_step;
117
118            if self.velocity.abs() < self.min_velocity {
119                self.velocity = 0.0;
120            }
121        }
122    }
123
124    /// Returns the current scroll offset.
125    pub fn get_position(&self) -> f32 {
126        self.position
127    }
128}
129
130/// Direct scroll offset without momentum.
131#[derive(Debug, Clone, Copy)]
132pub struct HardScroller {
133    /// Current scroll offset on this axis.
134    pub position: f32,
135    /// Multiplier applied to scroll deltas.
136    pub scale: f32,
137}
138
139impl HardScroller {
140    /// Creates a hard scroller at position zero.
141    pub fn new(scale: f32) -> Self {
142        Self {
143            position: 0.0,
144            scale,
145        }
146    }
147
148    /// Sets the scroll position directly.
149    pub fn direct_set_position(&mut self, position: f32) {
150        self.position = position;
151    }
152
153    /// Applies a scroll delta for one frame.
154    ///
155    /// # Examples
156    ///
157    /// ```
158    /// use cotis_interactivity::scroll::HardScroller;
159    ///
160    /// let mut scroller = HardScroller::new(1.0);
161    /// scroller.update(5.0, 0.0);
162    /// assert_eq!(scroller.get_position(), 5.0);
163    /// ```
164    pub fn update(&mut self, d_scroll: f32, _dt: f32) {
165        self.position += d_scroll * self.scale;
166    }
167
168    /// Returns the current scroll offset.
169    pub fn get_position(&self) -> f32 {
170        self.position
171    }
172}
173
174/// Independent scroll algorithms for horizontal (`[0]`) and vertical (`[1]`) axes.
175pub struct DoubleAxisScrollManager {
176    scroll: [ScrollAlgorithm; 2],
177}
178
179impl DoubleAxisScrollManager {
180    /// Creates a manager from per-axis algorithms.
181    ///
182    /// Index `0` is the horizontal axis; index `1` is the vertical axis.
183    pub fn new(scroll: [ScrollAlgorithm; 2]) -> Self {
184        Self { scroll }
185    }
186
187    /// Sets scroll position on both axes and clears momentum velocity.
188    pub fn direct_set_position(&mut self, position: Vector2) {
189        match &mut self.scroll[0] {
190            ScrollAlgorithm::Momentum(m) => {
191                m.direct_set_position(position.x);
192            }
193            ScrollAlgorithm::HardScroll(h) => {
194                h.direct_set_position(position.x);
195            }
196        }
197        match &mut self.scroll[1] {
198            ScrollAlgorithm::Momentum(m) => {
199                m.direct_set_position(position.y);
200            }
201            ScrollAlgorithm::HardScroll(h) => {
202                h.direct_set_position(position.y);
203            }
204        }
205    }
206
207    /// Applies per-axis scroll deltas for one frame.
208    pub fn update(&mut self, d_scroll_x: f32, d_scroll_y: f32, dt: f32) {
209        match &mut self.scroll[0] {
210            ScrollAlgorithm::Momentum(m) => {
211                m.update(d_scroll_x, dt);
212            }
213            ScrollAlgorithm::HardScroll(h) => {
214                h.update(d_scroll_x, dt);
215            }
216        }
217        match &mut self.scroll[1] {
218            ScrollAlgorithm::Momentum(m) => {
219                m.update(d_scroll_y, dt);
220            }
221            ScrollAlgorithm::HardScroll(h) => {
222                h.update(d_scroll_y, dt);
223            }
224        }
225    }
226
227    /// Returns the current scroll offset as a [`Vector2`].
228    pub fn get_position(&self) -> Vector2 {
229        Vector2 {
230            x: self.scroll[0].get_position(),
231            y: self.scroll[1].get_position(),
232        }
233    }
234}
235
236/// Scroll manager for a single axis.
237pub enum SingleAxisScrollManager {
238    /// Horizontal scrolling; vertical component of [`get_position`](Self::get_position) is zero.
239    Horizontal(ScrollAlgorithm),
240    /// Vertical scrolling; horizontal component of [`get_position`](Self::get_position) is zero.
241    Vertical(ScrollAlgorithm),
242}
243
244impl SingleAxisScrollManager {
245    /// Sets scroll position on the active axis.
246    pub fn direct_set_position(&mut self, position: f32) {
247        match self {
248            SingleAxisScrollManager::Horizontal(ScrollAlgorithm::Momentum(m)) => {
249                m.direct_set_position(position);
250            }
251            SingleAxisScrollManager::Horizontal(ScrollAlgorithm::HardScroll(h)) => {
252                h.direct_set_position(position);
253            }
254            SingleAxisScrollManager::Vertical(ScrollAlgorithm::Momentum(m)) => {
255                m.direct_set_position(position);
256            }
257            SingleAxisScrollManager::Vertical(ScrollAlgorithm::HardScroll(h)) => {
258                h.direct_set_position(position);
259            }
260        }
261    }
262
263    /// Applies a scroll delta on the active axis for one frame.
264    pub fn update(&mut self, d_scroll: f32, dt: f32) {
265        match self {
266            SingleAxisScrollManager::Horizontal(ScrollAlgorithm::Momentum(m)) => {
267                m.update(d_scroll, dt);
268            }
269            SingleAxisScrollManager::Horizontal(ScrollAlgorithm::HardScroll(h)) => {
270                h.update(d_scroll, dt);
271            }
272            SingleAxisScrollManager::Vertical(ScrollAlgorithm::Momentum(m)) => {
273                m.update(d_scroll, dt);
274            }
275            SingleAxisScrollManager::Vertical(ScrollAlgorithm::HardScroll(h)) => {
276                h.update(d_scroll, dt);
277            }
278        }
279    }
280
281    /// Returns the current scroll offset; the inactive axis is `0.0`.
282    pub fn get_position(&self) -> Vector2 {
283        match self {
284            SingleAxisScrollManager::Horizontal(s) => Vector2 {
285                x: s.get_position(),
286                y: 0.0,
287            },
288            SingleAxisScrollManager::Vertical(s) => Vector2 {
289                x: 0.0,
290                y: s.get_position(),
291            },
292        }
293    }
294}
295
296/// Combines cursor state, wheel input, and layout bounds for element scrolling.
297///
298/// Implementors supply mouse position, wheel delta, bounding box, and internal
299/// clip size for scroll clamping.
300///
301/// # Blanket implementation
302///
303/// [`CotisApp`] implements this trait when:
304///
305/// - `Renderer: RenderCompatibleWith<Manager> + MouseProvider`
306/// - `Manager: LayoutManagerCompatible<Renderer> + ElementBoundingBox + ElementClipInternalSize`
307pub trait FullElementScrollState: FullElementCursorState {
308    /// Current cursor position in pixels.
309    fn get_mouse_pos(&self) -> Vector2;
310    /// Wheel delta; `x` is horizontal, `y` is vertical per [`MouseProvider`].
311    fn get_mouse_wheel_move_v(&self) -> Vector2;
312    /// Layout bounding box for the element.
313    fn get_bound_box(&self, id: ElementId) -> Option<BoundingBox>;
314    /// Internal clipped content size for scroll limit calculation.
315    fn internal_size(&self, id: ElementId) -> Option<Vector2>;
316}
317
318impl<
319    Renderer: RenderCompatibleWith<Manager> + MouseProvider,
320    Manager: LayoutManagerCompatible<Renderer> + ElementBoundingBox + ElementClipInternalSize,
321    Pipe,
322> FullElementScrollState for CotisApp<Renderer, Manager, Pipe>
323{
324    fn get_mouse_pos(&self) -> Vector2 {
325        self.render_borrow().get_mouse_position()
326    }
327
328    fn get_mouse_wheel_move_v(&self) -> Vector2 {
329        self.render_borrow().get_mouse_wheel_move_v()
330    }
331
332    fn get_bound_box(&self, id: ElementId) -> Option<BoundingBox> {
333        self.layout_manager_borrow().bounding_box(id)
334    }
335
336    fn internal_size(&self, id: ElementId) -> Option<Vector2> {
337        self.layout_manager_borrow().clip_internal_size(id)
338    }
339}
340
341/// Per-element two-axis scroll manager with wheel, optional drag, and clamping.
342///
343/// Combines wheel deltas from [`FullElementScrollState::get_mouse_wheel_move_v`]
344/// with optional mouse-drag scrolling when `button` is `Some`. Drag delta is
345/// `current_mouse_pos − previous_mouse_pos`; no delta is produced on the first
346/// frame the button is held.
347///
348/// When the parent element is hovered but not yet pressed, hover on any
349/// `child_elements` suppresses parent drag capture so children receive priority.
350///
351/// Scroll position is clamped to `[−limits, 0]` per axis after each update.
352pub struct DoubleAxisScrollElementManager {
353    id: ElementId,
354    exclusive_mouse: MouseButtonManager,
355    mouse_prev_pos: Option<Vector2>,
356    manager: DoubleAxisScrollManager,
357    button: Option<MouseButton>,
358    child_elements: Vec<ElementId>,
359}
360
361impl DoubleAxisScrollElementManager {
362    /// Creates a scroll manager for `id`.
363    ///
364    /// * `algorithms` — per-axis scroll algorithms; index `0` horizontal, `1` vertical.
365    /// * `child_elements` — child IDs that take hover priority over parent drag.
366    /// * `button` — mouse button for drag scrolling; `None` disables drag.
367    pub fn new(
368        algorithms: [ScrollAlgorithm; 2],
369        id: ElementId,
370        child_elements: Vec<ElementId>,
371        button: Option<MouseButton>,
372    ) -> Self {
373        Self {
374            id,
375            exclusive_mouse: MouseButtonManager::new(MouseClickingStrategy::Pressed, 0.0),
376            mouse_prev_pos: None,
377            manager: DoubleAxisScrollManager::new(algorithms),
378            button,
379            child_elements,
380        }
381    }
382
383    fn update_mouse(&mut self, provider: &dyn FullElementScrollState, dt: f32) {
384        let mouse_button = {
385            if let Some(button) = self.button {
386                button
387            } else {
388                return;
389            }
390        };
391        let mut hovered = provider.is_hovered(self.id);
392        if hovered && !self.exclusive_mouse.is_clicked() {
393            for child in self.child_elements.iter() {
394                if provider.is_hovered(*child) {
395                    hovered = false;
396                    break;
397                }
398            }
399        }
400        self.exclusive_mouse
401            .update(provider.mouse_button_down(mouse_button), dt, hovered);
402    }
403
404    fn get_mouse_drag_delta(
405        &mut self,
406        provider: &dyn FullElementScrollState,
407        dt: f32,
408    ) -> Option<Vector2> {
409        self.update_mouse(provider, dt);
410        let clicked = self.exclusive_mouse.is_clicked();
411        if !clicked {
412            self.mouse_prev_pos = None;
413            return None;
414        }
415        let mouse_pos = provider.get_mouse_pos();
416
417        let res = self.mouse_prev_pos.map(|mouse_prev_pos| {
418            Vector2::new(
419                mouse_pos.x - mouse_prev_pos.x,
420                mouse_pos.y - mouse_prev_pos.y,
421            )
422        });
423        self.mouse_prev_pos = Some(mouse_pos);
424
425        res
426    }
427
428    /// Advances scroll state for one frame.
429    ///
430    /// No-op when layout bounds or internal size are unavailable for `id`.
431    pub fn update(&mut self, provider: &dyn FullElementScrollState, dt: f32) {
432        let wheel_scroll = provider.get_mouse_wheel_move_v();
433        let mouse_scroll = self.get_mouse_drag_delta(provider, dt);
434        let internal = provider.internal_size(self.id);
435        let bound = provider.get_bound_box(self.id);
436        if internal.is_none() || bound.is_none() {
437            return;
438        }
439        let internal = internal.unwrap();
440        let bound = bound.unwrap();
441        let limits = Vector2 {
442            x: (internal.x - bound.width).max(0.),
443            y: (internal.y - bound.height).max(0.),
444        };
445
446        let scroll = if let Some(mouse_scroll) = mouse_scroll {
447            Vector2 {
448                x: wheel_scroll.x + mouse_scroll.x,
449                y: wheel_scroll.y + mouse_scroll.y,
450            }
451        } else {
452            wheel_scroll
453        };
454        self.manager.update(scroll.x, scroll.y, dt);
455        let pos = self.manager.get_position();
456        if pos.x < 0. || pos.x > -limits.x || pos.y < 0. || pos.y > -limits.y {
457            self.manager.direct_set_position(Vector2::new(
458                pos.x.min(0.0).max(-limits.x),
459                pos.y.min(0.0).max(-limits.y),
460            ));
461        }
462    }
463
464    /// Returns the current clamped scroll offset.
465    pub fn get_position(&self) -> Vector2 {
466        self.manager.get_position()
467    }
468}
469
470/// Per-element single-axis scroll manager with wheel, optional drag, and clamping.
471///
472/// Same wheel/drag/clamp behavior as [`DoubleAxisScrollElementManager`], but
473/// scrolls only the axis selected by `manager`.
474pub struct SingleAxisScrollElementManager {
475    id: ElementId,
476    exclusive_mouse: MouseButtonManager,
477    mouse_prev_pos: Option<Vector2>,
478    manager: SingleAxisScrollManager,
479    button: Option<MouseButton>,
480    child_elements: Vec<ElementId>,
481}
482
483impl SingleAxisScrollElementManager {
484    /// Creates a single-axis scroll manager for `id`.
485    pub fn new(
486        manager: SingleAxisScrollManager,
487        id: ElementId,
488        child_elements: Vec<ElementId>,
489        button: Option<MouseButton>,
490    ) -> Self {
491        Self {
492            id,
493            exclusive_mouse: MouseButtonManager::new(MouseClickingStrategy::Pressed, 0.0),
494            mouse_prev_pos: None,
495            manager,
496            button,
497            child_elements,
498        }
499    }
500
501    fn update_mouse(&mut self, provider: &dyn FullElementScrollState, dt: f32) {
502        let mouse_button = {
503            if let Some(button) = self.button {
504                button
505            } else {
506                return;
507            }
508        };
509        let mut hovered = provider.is_hovered(self.id);
510        if hovered && !self.exclusive_mouse.is_clicked() {
511            for child in self.child_elements.iter() {
512                if provider.is_hovered(*child) {
513                    hovered = false;
514                    break;
515                }
516            }
517        }
518        self.exclusive_mouse
519            .update(provider.mouse_button_down(mouse_button), dt, hovered);
520    }
521
522    fn get_mouse_drag_delta(
523        &mut self,
524        provider: &dyn FullElementScrollState,
525        dt: f32,
526    ) -> Option<Vector2> {
527        self.update_mouse(provider, dt);
528        let clicked = self.exclusive_mouse.is_clicked();
529        if !clicked {
530            self.mouse_prev_pos = None;
531            return None;
532        }
533        let mouse_pos = provider.get_mouse_pos();
534
535        let res = self.mouse_prev_pos.map(|mouse_prev_pos| {
536            Vector2::new(
537                mouse_pos.x - mouse_prev_pos.x,
538                mouse_pos.y - mouse_prev_pos.y,
539            )
540        });
541        self.mouse_prev_pos = Some(mouse_pos);
542
543        res
544    }
545
546    /// Advances scroll state for one frame.
547    ///
548    /// No-op when layout bounds or internal size are unavailable for `id`.
549    pub fn update(&mut self, provider: &dyn FullElementScrollState, dt: f32) {
550        let wheel_scroll = provider.get_mouse_wheel_move_v();
551        let mouse_scroll = self.get_mouse_drag_delta(provider, dt);
552        let internal = provider.internal_size(self.id);
553        let bound = provider.get_bound_box(self.id);
554        if internal.is_none() || bound.is_none() {
555            return;
556        }
557        let internal = internal.unwrap();
558        let bound = bound.unwrap();
559        let limits = Vector2 {
560            x: (internal.x - bound.width).max(0.),
561            y: (internal.y - bound.height).max(0.),
562        };
563
564        let scroll = if let Some(mouse_scroll) = mouse_scroll {
565            Vector2 {
566                x: wheel_scroll.x + mouse_scroll.x,
567                y: wheel_scroll.y + mouse_scroll.y,
568            }
569        } else {
570            wheel_scroll
571        };
572
573        self.manager.update(
574            match &self.manager {
575                SingleAxisScrollManager::Horizontal(_) => scroll.x,
576                SingleAxisScrollManager::Vertical(_) => scroll.y,
577            },
578            dt,
579        );
580        let pos = self.manager.get_position();
581
582        if pos.x < 0. || pos.x > -limits.x || pos.y < 0. || pos.y > -limits.y {
583            self.manager.direct_set_position(match &self.manager {
584                SingleAxisScrollManager::Horizontal(_) => pos.x.min(0.0).max(-limits.x),
585                SingleAxisScrollManager::Vertical(_) => pos.y.min(0.0).max(-limits.y),
586            });
587        }
588    }
589
590    /// Returns the current clamped scroll offset.
591    pub fn get_position(&self) -> Vector2 {
592        self.manager.get_position()
593    }
594}