Skip to main content

cranpose_ui/modifier/
scroll.rs

1//! Scroll modifier extensions for Modifier.
2//!
3//! # Overview
4//! This module implements scrollable containers with gesture-based interaction.
5//! It follows the pattern of separating:
6//! - **State management** (`ScrollGestureState`) - tracks pointer/drag state
7//! - **Event handling** (`ScrollGestureDetector`) - processes events and updates state
8//! - **Layout** (`ScrollElement`/`ScrollNode` in `scroll.rs`) - applies scroll offset
9//!
10//! # Gesture Flow
11//! 1. **Down**: Record initial position, reset drag state
12//! 2. **Move**: Check if total movement exceeds `DRAG_THRESHOLD` (8px)
13//!    - If threshold crossed: start consuming events, apply scroll delta
14//!    - This prevents child click handlers from firing during scrolls
15//! 3. **Up/Cancel**: Clean up state, consume if was dragging
16
17use super::{inspector_metadata, Modifier, Point, PointerEventKind};
18use crate::current_density;
19use crate::fling_animation::FlingAnimation;
20use crate::fling_animation::MIN_FLING_VELOCITY;
21use crate::scroll::{ScrollElement, ScrollState};
22use cranpose_core::current_runtime_handle;
23use cranpose_foundation::{
24    velocity_tracker::ASSUME_STOPPED_MS, PointerButton, PointerButtons, VelocityTracker1D,
25    DRAG_THRESHOLD, MAX_FLING_VELOCITY,
26};
27use std::cell::RefCell;
28use std::rc::Rc;
29use web_time::Instant;
30
31// ============================================================================
32// Test Accessibility: Last Fling Velocity (only in test-helpers builds)
33// ============================================================================
34
35#[cfg(feature = "test-helpers")]
36mod test_velocity_tracking {
37    use std::sync::atomic::{AtomicU32, Ordering};
38
39    /// Stores the last fling velocity calculated for test verification.
40    ///
41    /// Uses `AtomicU32` (not thread_local) because the test driver runs on a separate
42    /// thread from the UI thread, and the test needs to read values written by the UI.
43    ///
44    /// # Parallel Test Safety
45    /// This global state means parallel tests could interfere with each other.
46    /// For test isolation, run robot tests sequentially (cargo test -- --test-threads=1)
47    /// or use test harnesses that reset state between runs.
48    static LAST_FLING_VELOCITY: AtomicU32 = AtomicU32::new(0);
49
50    /// Returns the last fling velocity calculated (in px/sec).
51    ///
52    /// This is primarily for testing - allows robot tests to verify that
53    /// velocity detection is working correctly instead of relying on log output.
54    pub fn last_fling_velocity() -> f32 {
55        f32::from_bits(LAST_FLING_VELOCITY.load(Ordering::SeqCst))
56    }
57
58    /// Resets the last fling velocity to 0.0.
59    ///
60    /// Call this at the start of a test to ensure clean state.
61    pub fn reset_last_fling_velocity() {
62        LAST_FLING_VELOCITY.store(0.0f32.to_bits(), Ordering::SeqCst);
63    }
64
65    /// Internal: Set the last fling velocity (called from gesture detection).
66    pub(super) fn set_last_fling_velocity(velocity: f32) {
67        LAST_FLING_VELOCITY.store(velocity.to_bits(), Ordering::SeqCst);
68    }
69}
70
71#[cfg(feature = "test-helpers")]
72pub use test_velocity_tracking::{last_fling_velocity, reset_last_fling_velocity};
73
74/// Internal: Set the last fling velocity (called from gesture detection).
75/// No-op in production builds without test-helpers feature.
76#[inline]
77fn set_last_fling_velocity(velocity: f32) {
78    #[cfg(feature = "test-helpers")]
79    test_velocity_tracking::set_last_fling_velocity(velocity);
80    #[cfg(not(feature = "test-helpers"))]
81    let _ = velocity; // Silence unused variable warning
82}
83
84/// Local gesture state for scroll drag handling.
85///
86/// This is NOT part of `ScrollState` to keep the scroll model pure.
87/// Each scroll modifier instance has its own gesture state, which enables
88/// multiple independent scroll regions without state interference.
89struct ScrollGestureState {
90    /// Position where pointer was pressed down.
91    /// Used to calculate total drag distance for threshold detection.
92    drag_down_position: Option<Point>,
93
94    /// Last known pointer position during drag.
95    /// Used to calculate incremental delta for each move event.
96    last_position: Option<Point>,
97
98    /// Whether we've crossed the drag threshold and are actively scrolling.
99    /// Once true, we consume all events until Up/Cancel to prevent child
100    /// handlers from receiving drag events.
101    is_dragging: bool,
102
103    /// Velocity tracker for fling gesture detection.
104    velocity_tracker: VelocityTracker1D,
105
106    /// Time when gesture down started (for velocity calculation).
107    gesture_start_time: Option<Instant>,
108
109    /// Last time a velocity sample was recorded (milliseconds since gesture start).
110    last_velocity_sample_ms: Option<i64>,
111
112    /// Current fling animation (if any).
113    fling_animation: Option<FlingAnimation>,
114}
115
116impl Default for ScrollGestureState {
117    fn default() -> Self {
118        Self {
119            drag_down_position: None,
120            last_position: None,
121            is_dragging: false,
122            velocity_tracker: VelocityTracker1D::new(),
123            gesture_start_time: None,
124            last_velocity_sample_ms: None,
125            fling_animation: None,
126        }
127    }
128}
129
130// ============================================================================
131// Helper Functions
132// ============================================================================
133
134/// Calculates the total movement distance from the original down position.
135///
136/// This is used to determine if we've crossed the drag threshold.
137/// Returns the distance in the scroll axis direction (Y for vertical, X for horizontal).
138#[inline]
139fn calculate_total_delta(from: Point, to: Point, is_vertical: bool) -> f32 {
140    if is_vertical {
141        to.y - from.y
142    } else {
143        to.x - from.x
144    }
145}
146
147/// Calculates the incremental movement delta from the previous position.
148///
149/// This is used to update the scroll offset incrementally during drag.
150/// Returns the distance in the scroll axis direction (Y for vertical, X for horizontal).
151#[inline]
152fn calculate_incremental_delta(from: Point, to: Point, is_vertical: bool) -> f32 {
153    if is_vertical {
154        to.y - from.y
155    } else {
156        to.x - from.x
157    }
158}
159
160// ============================================================================
161// Scroll Gesture Detector (Generic Implementation)
162// ============================================================================
163
164/// Trait for scroll targets that can receive scroll deltas.
165///
166/// Implemented by both `ScrollState` (regular scroll) and `LazyListState` (lazy lists).
167trait ScrollTarget: Clone {
168    /// Apply a gesture delta. Returns the consumed amount in gesture coordinates.
169    fn apply_delta(&self, delta: f32) -> f32;
170
171    /// Apply a scroll delta during fling. Returns consumed delta in scroll coordinates.
172    fn apply_fling_delta(&self, delta: f32) -> f32;
173
174    /// Called after scroll to trigger any necessary invalidation.
175    fn invalidate(&self);
176
177    /// Get the current scroll offset.
178    fn current_offset(&self) -> f32;
179}
180
181impl ScrollTarget for ScrollState {
182    fn apply_delta(&self, delta: f32) -> f32 {
183        // Regular scroll uses negative delta (natural scrolling)
184        self.dispatch_raw_delta(-delta)
185    }
186
187    fn apply_fling_delta(&self, delta: f32) -> f32 {
188        self.dispatch_raw_delta(delta)
189    }
190
191    fn invalidate(&self) {
192        // ScrollState triggers invalidation internally
193    }
194
195    fn current_offset(&self) -> f32 {
196        self.value()
197    }
198}
199
200impl ScrollTarget for LazyListState {
201    fn apply_delta(&self, delta: f32) -> f32 {
202        // LazyListState uses positive delta directly
203        // dispatch_scroll_delta already calls self.invalidate() which triggers the
204        // layout invalidation callback registered in lazy_scroll_impl
205        self.dispatch_scroll_delta(delta)
206    }
207
208    fn apply_fling_delta(&self, delta: f32) -> f32 {
209        -self.dispatch_scroll_delta(-delta)
210    }
211
212    fn invalidate(&self) {
213        // dispatch_scroll_delta already handles invalidation internally via callback.
214        // We do NOT call request_layout_invalidation() here - that's the global
215        // nuclear option that invalidates ALL layout caches app-wide.
216        // The registered callback uses schedule_layout_repass for scoped invalidation.
217    }
218
219    fn current_offset(&self) -> f32 {
220        // LazyListState doesn't have a simple offset - use first visible item offset
221        self.first_visible_item_scroll_offset()
222    }
223}
224
225/// Generic scroll gesture detector that works with any ScrollTarget.
226///
227/// This struct provides a clean interface for processing pointer events
228/// and managing scroll interactions. The generic parameter S determines
229/// how scroll deltas are applied.
230struct ScrollGestureDetector<S: ScrollTarget> {
231    /// Shared gesture state (position tracking, drag status).
232    gesture_state: Rc<RefCell<ScrollGestureState>>,
233
234    /// The scroll target to update when drag is detected.
235    scroll_target: S,
236
237    /// Whether this is vertical or horizontal scroll.
238    is_vertical: bool,
239
240    /// Whether to reverse the scroll direction (flip delta).
241    reverse_scrolling: bool,
242}
243
244impl<S: ScrollTarget + 'static> ScrollGestureDetector<S> {
245    /// Creates a new detector for the given scroll configuration.
246    fn new(
247        gesture_state: Rc<RefCell<ScrollGestureState>>,
248        scroll_target: S,
249        is_vertical: bool,
250        reverse_scrolling: bool,
251    ) -> Self {
252        Self {
253            gesture_state,
254            scroll_target,
255            is_vertical,
256            reverse_scrolling,
257        }
258    }
259
260    /// Handles pointer down event.
261    ///
262    /// Records the initial position for threshold calculation and
263    /// resets drag state. We don't consume Down events because we
264    /// don't know yet if this will become a drag or a click.
265    ///
266    /// Returns `false` - Down events are never consumed to allow
267    /// potential child click handlers to receive the initial press.
268    fn on_down(&self, position: Point) -> bool {
269        let mut gs = self.gesture_state.borrow_mut();
270
271        // Cancel any running fling animation
272        if let Some(fling) = gs.fling_animation.take() {
273            fling.cancel();
274        }
275
276        gs.drag_down_position = Some(position);
277        gs.last_position = Some(position);
278        gs.is_dragging = false;
279        gs.velocity_tracker.reset();
280        gs.gesture_start_time = Some(Instant::now());
281
282        // Add initial position to velocity tracker
283        let pos = if self.is_vertical {
284            position.y
285        } else {
286            position.x
287        };
288        gs.velocity_tracker.add_data_point(0, pos);
289        gs.last_velocity_sample_ms = Some(0);
290
291        // Never consume Down - we don't know if this is a drag yet
292        false
293    }
294
295    /// Handles pointer move event.
296    ///
297    /// This is the core gesture detection logic:
298    /// 1. Safety check: if no primary button is pressed but we think we're
299    ///    tracking, we missed an Up event - reset state.
300    /// 2. Calculate total movement from down position.
301    /// 3. If total movement exceeds `DRAG_THRESHOLD` (8px), start dragging.
302    /// 4. While dragging, apply scroll delta and consume events.
303    ///
304    /// Returns `true` if event should be consumed (we're actively dragging).
305    fn on_move(&self, position: Point, buttons: PointerButtons) -> bool {
306        let mut gs = self.gesture_state.borrow_mut();
307
308        // Safety: detect missed Up events (hit test delivered to wrong target)
309        if !buttons.contains(PointerButton::Primary) && gs.drag_down_position.is_some() {
310            gs.drag_down_position = None;
311            gs.last_position = None;
312            gs.is_dragging = false;
313            gs.gesture_start_time = None;
314            gs.last_velocity_sample_ms = None;
315            gs.velocity_tracker.reset();
316            return false;
317        }
318
319        let Some(down_pos) = gs.drag_down_position else {
320            return false;
321        };
322
323        let Some(last_pos) = gs.last_position else {
324            gs.last_position = Some(position);
325            return false;
326        };
327
328        let total_delta = calculate_total_delta(down_pos, position, self.is_vertical);
329        let incremental_delta = calculate_incremental_delta(last_pos, position, self.is_vertical);
330
331        // Threshold check: start dragging only after moving 8px from down position.
332        if !gs.is_dragging && total_delta.abs() > DRAG_THRESHOLD {
333            gs.is_dragging = true;
334        }
335
336        gs.last_position = Some(position);
337
338        // Track velocity for fling
339        if let Some(start_time) = gs.gesture_start_time {
340            let elapsed_ms = start_time.elapsed().as_millis() as i64;
341            let pos = if self.is_vertical {
342                position.y
343            } else {
344                position.x
345            };
346            // Keep sample times strictly increasing so velocity stays stable when
347            // multiple move events land in the same millisecond.
348            let sample_ms = match gs.last_velocity_sample_ms {
349                Some(last_sample_ms) => {
350                    let mut sample_ms = if elapsed_ms <= last_sample_ms {
351                        last_sample_ms + 1
352                    } else {
353                        elapsed_ms
354                    };
355                    // Clamp large processing gaps so frame stalls don't erase fling velocity.
356                    if sample_ms - last_sample_ms > ASSUME_STOPPED_MS {
357                        sample_ms = last_sample_ms + ASSUME_STOPPED_MS;
358                    }
359                    sample_ms
360                }
361                None => elapsed_ms,
362            };
363            gs.velocity_tracker.add_data_point(sample_ms, pos);
364            gs.last_velocity_sample_ms = Some(sample_ms);
365        }
366
367        if gs.is_dragging {
368            drop(gs); // Release borrow before calling scroll target
369            let delta = if self.reverse_scrolling {
370                -incremental_delta
371            } else {
372                incremental_delta
373            };
374            let _ = self.scroll_target.apply_delta(delta);
375            self.scroll_target.invalidate();
376            true // Consume event while dragging
377        } else {
378            false
379        }
380    }
381
382    /// Handles pointer up event.
383    ///
384    /// Cleans up drag state. If we were actively dragging, calculates fling
385    /// velocity and starts fling animation if velocity is above threshold.
386    ///
387    /// Returns `true` if we were dragging (event should be consumed).
388    fn finish_gesture(&self, allow_fling: bool) -> bool {
389        let (was_dragging, velocity, start_fling, existing_fling) = {
390            let mut gs = self.gesture_state.borrow_mut();
391            let was_dragging = gs.is_dragging;
392            let mut velocity = 0.0;
393
394            if allow_fling && was_dragging && gs.gesture_start_time.is_some() {
395                velocity = gs
396                    .velocity_tracker
397                    .calculate_velocity_with_max(MAX_FLING_VELOCITY);
398            }
399
400            let start_fling = allow_fling && was_dragging && velocity.abs() > MIN_FLING_VELOCITY;
401            let existing_fling = if start_fling {
402                gs.fling_animation.take()
403            } else {
404                None
405            };
406
407            gs.drag_down_position = None;
408            gs.last_position = None;
409            gs.is_dragging = false;
410            gs.gesture_start_time = None;
411            gs.last_velocity_sample_ms = None;
412
413            (was_dragging, velocity, start_fling, existing_fling)
414        };
415
416        // Always record velocity for test accessibility (even if below fling threshold)
417        if allow_fling && was_dragging {
418            set_last_fling_velocity(velocity);
419        }
420
421        // Start fling animation if velocity is significant
422        if start_fling {
423            if let Some(old_fling) = existing_fling {
424                old_fling.cancel();
425            }
426
427            // Get runtime handle for frame callbacks
428            if let Some(runtime) = current_runtime_handle() {
429                let scroll_target = self.scroll_target.clone();
430                let reverse = self.reverse_scrolling;
431                let fling = FlingAnimation::new(runtime);
432
433                // Get current scroll position for fling start
434                let initial_value = scroll_target.current_offset();
435
436                // Convert gesture velocity to scroll velocity.
437                let adjusted_velocity = if reverse { -velocity } else { velocity };
438                let fling_velocity = -adjusted_velocity;
439
440                let scroll_target_for_fling = scroll_target.clone();
441                let scroll_target_for_end = scroll_target.clone();
442
443                fling.start_fling(
444                    initial_value,
445                    fling_velocity,
446                    current_density(),
447                    move |delta| {
448                        // Apply scroll delta during fling, return consumed amount
449                        let consumed = scroll_target_for_fling.apply_fling_delta(delta);
450                        scroll_target_for_fling.invalidate();
451                        consumed
452                    },
453                    move || {
454                        // Animation complete - invalidate to ensure final render
455                        scroll_target_for_end.invalidate();
456                    },
457                );
458
459                let mut gs = self.gesture_state.borrow_mut();
460                gs.fling_animation = Some(fling);
461            }
462        }
463
464        was_dragging
465    }
466
467    /// Handles pointer up event.
468    ///
469    /// Cleans up drag state. If we were actively dragging, calculates fling
470    /// velocity and starts fling animation if velocity is above threshold.
471    ///
472    /// Returns `true` if we were dragging (event should be consumed).
473    fn on_up(&self) -> bool {
474        self.finish_gesture(true)
475    }
476
477    /// Handles pointer cancel event.
478    ///
479    /// Cleans up state without starting a fling. Returns `true` if we were dragging.
480    fn on_cancel(&self) -> bool {
481        self.finish_gesture(false)
482    }
483}
484
485// ============================================================================
486// Modifier Extensions
487// ============================================================================
488
489impl Modifier {
490    /// Creates a horizontally scrollable modifier.
491    ///
492    /// # Arguments
493    /// * `state` - The ScrollState to control scroll position
494    /// * `reverse_scrolling` - If true, reverses the scroll direction in layout.
495    ///   Note: This affects how scroll offset is applied to content (via `ScrollNode`),
496    ///   NOT the drag direction. Drag gestures always follow natural touch semantics:
497    ///   drag right = scroll left (content moves right under finger).
498    ///
499    /// # Example
500    /// ```text
501    /// let scroll_state = ScrollState::new(0.0);
502    /// Row(
503    ///     Modifier::empty().horizontal_scroll(scroll_state, false),
504    ///     // ... content
505    /// );
506    /// ```
507    pub fn horizontal_scroll(self, state: ScrollState, reverse_scrolling: bool) -> Self {
508        self.then(scroll_impl(state, false, reverse_scrolling))
509    }
510
511    /// Creates a vertically scrollable modifier.
512    ///
513    /// # Arguments
514    /// * `state` - The ScrollState to control scroll position
515    /// * `reverse_scrolling` - If true, reverses the scroll direction in layout.
516    ///   Note: This affects how scroll offset is applied to content (via `ScrollNode`),
517    ///   NOT the drag direction. Drag gestures always follow natural touch semantics:
518    ///   drag down = scroll up (content moves down under finger).
519    pub fn vertical_scroll(self, state: ScrollState, reverse_scrolling: bool) -> Self {
520        self.then(scroll_impl(state, true, reverse_scrolling))
521    }
522}
523
524/// Internal implementation for scroll modifiers.
525///
526/// Creates a combined modifier consisting of:
527/// 1. Pointer input handler (for gesture detection)
528/// 2. Layout modifier (for applying scroll offset)
529///
530/// The pointer input is added FIRST so it appears earlier in the modifier
531/// chain, allowing it to intercept events before layout-related handlers.
532fn scroll_impl(state: ScrollState, is_vertical: bool, reverse_scrolling: bool) -> Modifier {
533    // Create local gesture state - each scroll modifier instance is independent
534    let gesture_state = Rc::new(RefCell::new(ScrollGestureState::default()));
535
536    // Set up pointer input handler
537    let scroll_state = state.clone();
538    let key = (state.id(), is_vertical);
539    let pointer_input = Modifier::empty().pointer_input(key, move |scope| {
540        // Create detector inside the async closure to capture the cloned state
541        let detector = ScrollGestureDetector::new(
542            gesture_state.clone(),
543            scroll_state.clone(),
544            is_vertical,
545            false, // ScrollState handles reversing in layout, not input
546        );
547
548        async move {
549            scope
550                .await_pointer_event_scope(|await_scope| async move {
551                    // Main event loop - processes events until scope is cancelled
552                    loop {
553                        let event = await_scope.await_pointer_event().await;
554
555                        // Delegate to detector's lifecycle methods
556                        let should_consume = match event.kind {
557                            PointerEventKind::Down => detector.on_down(event.position),
558                            PointerEventKind::Move => {
559                                detector.on_move(event.position, event.buttons)
560                            }
561                            PointerEventKind::Up => detector.on_up(),
562                            PointerEventKind::Cancel => detector.on_cancel(),
563                        };
564
565                        if should_consume {
566                            event.consume();
567                        }
568                    }
569                })
570                .await;
571        }
572    });
573
574    // Create layout modifier for applying scroll offset to content
575    let element = ScrollElement::new(state.clone(), is_vertical, reverse_scrolling);
576    let layout_modifier =
577        Modifier::with_element(element).with_inspector_metadata(inspector_metadata(
578            if is_vertical {
579                "verticalScroll"
580            } else {
581                "horizontalScroll"
582            },
583            move |info| {
584                info.add_property("isVertical", is_vertical.to_string());
585                info.add_property("reverseScrolling", reverse_scrolling.to_string());
586            },
587        ));
588
589    // Combine: pointer input THEN layout modifier
590    pointer_input.then(layout_modifier)
591}
592
593// ============================================================================
594// Lazy Scroll Support for LazyListState
595// ============================================================================
596
597use cranpose_foundation::lazy::LazyListState;
598
599impl Modifier {
600    /// Creates a vertically scrollable modifier for lazy lists.
601    ///
602    /// This connects pointer gestures to LazyListState for scroll handling.
603    /// Unlike regular vertical_scroll, no layout offset is applied here
604    /// since LazyListState manages item positioning internally.
605    /// Creates a vertically scrollable modifier for lazy lists.
606    ///
607    /// This connects pointer gestures to LazyListState for scroll handling.
608    /// Unlike regular vertical_scroll, no layout offset is applied here
609    /// since LazyListState manages item positioning internally.
610    pub fn lazy_vertical_scroll(self, state: LazyListState, reverse_scrolling: bool) -> Self {
611        self.then(lazy_scroll_impl(state, true, reverse_scrolling))
612    }
613
614    /// Creates a horizontally scrollable modifier for lazy lists.
615    pub fn lazy_horizontal_scroll(self, state: LazyListState, reverse_scrolling: bool) -> Self {
616        self.then(lazy_scroll_impl(state, false, reverse_scrolling))
617    }
618}
619
620/// Internal implementation for lazy scroll modifiers.
621fn lazy_scroll_impl(state: LazyListState, is_vertical: bool, reverse_scrolling: bool) -> Modifier {
622    let gesture_state = Rc::new(RefCell::new(ScrollGestureState::default()));
623    let list_state = state;
624
625    // Note: Layout invalidation callback is registered in LazyColumnImpl/LazyRowImpl
626    // after the node is created, using schedule_layout_repass(node_id) for O(subtree)
627    // performance instead of request_layout_invalidation() which is O(entire app).
628
629    // Use a unique key per LazyListState
630    let state_id = std::ptr::addr_of!(*state.inner_ptr()) as usize;
631    let key = (state_id, is_vertical, reverse_scrolling);
632
633    Modifier::empty().pointer_input(key, move |scope| {
634        // Use the same generic detector with LazyListState
635        let detector = ScrollGestureDetector::new(
636            gesture_state.clone(),
637            list_state,
638            is_vertical,
639            reverse_scrolling,
640        );
641
642        async move {
643            scope
644                .await_pointer_event_scope(|await_scope| async move {
645                    loop {
646                        let event = await_scope.await_pointer_event().await;
647
648                        // Delegate to detector's lifecycle methods
649                        let should_consume = match event.kind {
650                            PointerEventKind::Down => detector.on_down(event.position),
651                            PointerEventKind::Move => {
652                                detector.on_move(event.position, event.buttons)
653                            }
654                            PointerEventKind::Up => detector.on_up(),
655                            PointerEventKind::Cancel => detector.on_cancel(),
656                        };
657
658                        if should_consume {
659                            event.consume();
660                        }
661                    }
662                })
663                .await;
664        }
665    })
666}