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 movement along the scroll axis exceeds
13//!    `DRAG_THRESHOLD` (8dp of touch slop — pointer positions arrive in
14//!    logical, density-independent pixels on every platform)
15//!    - The drag is captured only when the scroll axis DOMINATES the total
16//!      movement (`|main| >= |cross|`, Compose-style axis locking). A drag
17//!      that decisively belongs to the other axis locks this detector out
18//!      for the rest of the gesture, so a horizontal scrollable nested in a
19//!      vertical one (chips row in a screen list) wins mostly-horizontal
20//!      drags and never steals mostly-vertical ones — and vice versa.
21//!    - Once captured: start consuming events, apply scroll delta. This
22//!      prevents child click handlers from firing during scrolls, and makes
23//!      enclosing scrollables abandon the gesture (they see consumed moves).
24//! 3. **Up/Cancel**: Clean up state, consume if was dragging
25
26use super::{inspector_metadata, Modifier, Point, PointerEventKind};
27use crate::current_density;
28use crate::fling_animation::{
29    fling_rest_position, FlingAnimation, SettleAnimation, MIN_FLING_VELOCITY,
30};
31use crate::render_state::schedule_modifier_slices_repass;
32use crate::scroll::{
33    scroll_motion_context_for_key, ScrollElement, ScrollMotionContext, ScrollMotionContextKey,
34    ScrollSettlePolicy, ScrollState,
35};
36use cranpose_core::internal::FrameCallbackRegistration;
37use cranpose_core::{current_runtime_handle, NodeId};
38use cranpose_foundation::{
39    velocity_tracker::ASSUME_STOPPED_MS, DelegatableNode, ModifierNode, ModifierNodeElement,
40    NodeCapabilities, NodeState, PointerButton, PointerButtons, VelocityTracker1D, DRAG_THRESHOLD,
41    MAX_FLING_VELOCITY,
42};
43use std::cell::{Cell, RefCell};
44use std::rc::Rc;
45use web_time::Instant;
46
47#[cfg(feature = "test-helpers")]
48pub fn last_fling_velocity() -> f32 {
49    crate::render_state::debug_last_fling_velocity()
50}
51
52#[cfg(feature = "test-helpers")]
53pub fn reset_last_fling_velocity() {
54    crate::render_state::debug_reset_last_fling_velocity();
55}
56
57#[inline]
58fn set_last_fling_velocity(velocity: f32) {
59    crate::render_state::record_last_fling_velocity(velocity);
60}
61
62/// Local gesture state for scroll drag handling.
63///
64/// This is NOT part of `ScrollState` to keep the scroll model pure.
65/// Each scroll modifier instance has its own gesture state, which enables
66/// multiple independent scroll regions without state interference.
67struct ScrollGestureState {
68    /// Position where pointer was pressed down.
69    /// Used to calculate total drag distance for threshold detection.
70    drag_down_position: Option<Point>,
71
72    /// Last known pointer position during drag.
73    /// Used to calculate incremental delta for each move event.
74    last_position: Option<Point>,
75
76    /// Whether we've crossed the drag threshold and are actively scrolling.
77    /// Once true, we consume all events until Up/Cancel to prevent child
78    /// handlers from receiving drag events.
79    is_dragging: bool,
80
81    /// Whether this gesture was decided to belong to the cross axis
82    /// (its cross-axis movement crossed the touch slop while dominating the
83    /// main axis). A locked-out detector never captures for the rest of the
84    /// gesture, so e.g. a horizontal chips row cannot steal a vertical
85    /// screen scroll that happens to drift sideways later on.
86    axis_locked_out: bool,
87
88    /// Velocity tracker for fling gesture detection.
89    velocity_tracker: VelocityTracker1D,
90
91    /// Time when gesture down started (for velocity calculation).
92    gesture_start_time: Option<Instant>,
93
94    /// Platform timestamp (ms) of the Down event, when the platform provides
95    /// input timestamps. Preferred over `gesture_start_time` because batched
96    /// input delivery (Android) makes delivery-time deltas meaningless.
97    gesture_start_event_time_ms: Option<i64>,
98
99    /// Last time a velocity sample was recorded (milliseconds since gesture start).
100    last_velocity_sample_ms: Option<i64>,
101
102    /// Current fling animation (if any).
103    fling_animation: Option<FlingAnimation>,
104
105    /// Current settle animation driving toward a policy target (if any).
106    settle_animation: Option<SettleAnimation>,
107
108    /// Frame loop watching for wheel-scroll idleness to run the settle policy
109    /// (wheel gestures have no end event).
110    wheel_settle_watcher: Option<WheelSettleWatcher>,
111}
112
113impl Default for ScrollGestureState {
114    fn default() -> Self {
115        Self {
116            drag_down_position: None,
117            last_position: None,
118            is_dragging: false,
119            axis_locked_out: false,
120            velocity_tracker: VelocityTracker1D::new(),
121            gesture_start_time: None,
122            gesture_start_event_time_ms: None,
123            last_velocity_sample_ms: None,
124            fling_animation: None,
125            settle_animation: None,
126            wheel_settle_watcher: None,
127        }
128    }
129}
130
131// ============================================================================
132// Helper Functions
133// ============================================================================
134
135/// Calculates the total movement distance from the original down position.
136///
137/// This is used to determine if we've crossed the drag threshold. Returns
138/// the distance along the requested axis (Y for `is_vertical`, X otherwise);
139/// callers pass `!is_vertical` to read the cross-axis component.
140#[inline]
141fn calculate_total_delta(from: Point, to: Point, is_vertical: bool) -> f32 {
142    if is_vertical {
143        to.y - from.y
144    } else {
145        to.x - from.x
146    }
147}
148
149/// Calculates the incremental movement delta from the previous position.
150///
151/// This is used to update the scroll offset incrementally during drag.
152/// Returns the distance in the scroll axis direction (Y for vertical, X for horizontal).
153#[inline]
154fn calculate_incremental_delta(from: Point, to: Point, is_vertical: bool) -> f32 {
155    if is_vertical {
156        to.y - from.y
157    } else {
158        to.x - from.x
159    }
160}
161
162// ============================================================================
163// Scroll Gesture Detector (Generic Implementation)
164// ============================================================================
165
166/// Trait for scroll targets that can receive scroll deltas.
167///
168/// Implemented by both `ScrollState` (regular scroll) and `LazyListState` (lazy lists).
169trait ScrollTarget: Clone {
170    /// Apply a gesture delta. Returns the consumed amount in gesture coordinates.
171    fn apply_delta(&self, delta: f32) -> f32;
172
173    /// Apply a wheel/trackpad event delta. Returns the consumed amount.
174    fn apply_wheel_delta(&self, delta: f32) -> f32 {
175        self.apply_delta(delta)
176    }
177
178    /// Apply a scroll delta during fling. Returns consumed delta in scroll coordinates.
179    fn apply_fling_delta(&self, delta: f32) -> f32;
180
181    /// Called after scroll to trigger any necessary invalidation.
182    fn invalidate(&self);
183
184    /// Get the current scroll offset.
185    fn current_offset(&self) -> f32;
186
187    /// Whether the target can currently scroll in either direction.
188    ///
189    /// When this is `false` the gesture detector must not capture drags:
190    /// a non-scrollable target (e.g. a lazy list realized in full inside an
191    /// unbounded parent, or a scroll container whose content fits its
192    /// viewport) would otherwise consume the move events that an enclosing
193    /// scrollable needs to receive.
194    fn can_scroll(&self) -> bool {
195        true
196    }
197
198    /// Whether the target can consume a gesture moving in the direction of
199    /// `gesture_delta` RIGHT NOW. A target pinned at one end must yield the
200    /// drag to its enclosing scrollable instead of capturing a gesture it
201    /// cannot consume — otherwise an exhausted inner list swallows every
202    /// event and the page around it goes dead.
203    fn can_consume(&self, gesture_delta: f32) -> bool {
204        let _ = gesture_delta;
205        self.can_scroll()
206    }
207
208    /// Settle policy remapping the post-interaction rest offset (see
209    /// [`ScrollSettlePolicy`]). `None` keeps natural rest positions.
210    fn settle_policy(&self) -> Option<ScrollSettlePolicy> {
211        None
212    }
213}
214
215impl ScrollTarget for ScrollState {
216    fn apply_delta(&self, delta: f32) -> f32 {
217        // Regular scroll uses negative delta (natural scrolling)
218        self.dispatch_raw_delta(-delta)
219    }
220
221    fn apply_fling_delta(&self, delta: f32) -> f32 {
222        self.dispatch_raw_delta(delta)
223    }
224
225    fn invalidate(&self) {
226        // ScrollState triggers invalidation internally
227    }
228
229    fn current_offset(&self) -> f32 {
230        self.value()
231    }
232
233    fn can_scroll(&self) -> bool {
234        self.max_value() > 0.0
235    }
236
237    fn can_consume(&self, gesture_delta: f32) -> bool {
238        // apply_delta maps a gesture delta to dispatch_raw_delta(-delta):
239        // finger up (negative) raises the offset toward max_value.
240        let raw = -gesture_delta;
241        if raw > 0.0 {
242            self.value_non_reactive() < self.max_value()
243        } else {
244            self.value_non_reactive() > 0.0
245        }
246    }
247
248    fn settle_policy(&self) -> Option<ScrollSettlePolicy> {
249        ScrollState::settle_policy(self)
250    }
251}
252
253impl ScrollTarget for LazyListState {
254    fn apply_delta(&self, delta: f32) -> f32 {
255        // LazyListState uses positive delta directly
256        // dispatch_scroll_delta already calls self.invalidate() which triggers the
257        // layout invalidation callback registered in lazy_scroll_impl
258        self.dispatch_scroll_delta(delta)
259    }
260
261    fn apply_wheel_delta(&self, delta: f32) -> f32 {
262        if delta.abs() <= 0.001 {
263            0.0
264        } else {
265            self.dispatch_scroll_delta(delta)
266        }
267    }
268
269    fn apply_fling_delta(&self, delta: f32) -> f32 {
270        -self.dispatch_scroll_delta(-delta)
271    }
272
273    fn invalidate(&self) {
274        // dispatch_scroll_delta already handles invalidation internally via callback.
275        // The registered callback uses schedule_layout_repass for scoped layout work.
276    }
277
278    fn current_offset(&self) -> f32 {
279        // LazyListState doesn't have a simple offset - use first visible item offset
280        self.first_visible_item_scroll_offset()
281    }
282
283    fn can_scroll(&self) -> bool {
284        // Before the first measure pass no bounds are known; stay permissive
285        // so gestures that race the first layout are not dropped.
286        self.layout_info().total_items_count == 0
287            || self.can_scroll_forward_non_reactive()
288            || self.can_scroll_backward_non_reactive()
289    }
290
291    fn can_consume(&self, gesture_delta: f32) -> bool {
292        // The list scrolls FORWARD on a NEGATIVE dispatch_scroll_delta
293        // (`pushing_forward = delta < 0`), and apply_delta passes the
294        // gesture delta straight through.
295        if self.layout_info().total_items_count == 0 {
296            return true;
297        }
298        if gesture_delta < 0.0 {
299            self.can_scroll_forward_non_reactive()
300        } else {
301            self.can_scroll_backward_non_reactive()
302        }
303    }
304}
305
306/// Generic scroll gesture detector that works with any ScrollTarget.
307///
308/// This struct provides a clean interface for processing pointer events
309/// and managing scroll interactions. The generic parameter S determines
310/// how scroll deltas are applied.
311/// Wheel/trackpad frame-time idleness after which the settle policy runs
312/// (wheel gestures have no end event to hook).
313const WHEEL_SETTLE_IDLE_NANOS: u64 = 180_000_000;
314
315/// Frame loop that waits for the scroll offset to sit still after wheel input
316/// and then runs the settle policy. Cancelled by any new gesture.
317struct WheelSettleWatcher {
318    is_running: Rc<Cell<bool>>,
319    registration: Rc<RefCell<Option<FrameCallbackRegistration>>>,
320}
321
322impl WheelSettleWatcher {
323    fn cancel(&self) {
324        self.is_running.set(false);
325        self.registration.borrow_mut().take();
326    }
327}
328
329struct ScrollGestureDetector<S: ScrollTarget> {
330    /// Shared gesture state (position tracking, drag status).
331    gesture_state: Rc<RefCell<ScrollGestureState>>,
332
333    /// The scroll target to update when drag is detected.
334    scroll_target: S,
335
336    /// Whether this is vertical or horizontal scroll.
337    is_vertical: bool,
338
339    /// Whether to reverse the scroll direction (flip delta).
340    reverse_scrolling: bool,
341
342    /// Active motion state for renderer policy selection.
343    motion_context: ScrollMotionContext,
344}
345
346impl<S: ScrollTarget + 'static> ScrollGestureDetector<S> {
347    /// Creates a new detector for the given scroll configuration.
348    fn new(
349        gesture_state: Rc<RefCell<ScrollGestureState>>,
350        scroll_target: S,
351        is_vertical: bool,
352        reverse_scrolling: bool,
353        motion_context: ScrollMotionContext,
354    ) -> Self {
355        Self {
356            gesture_state,
357            scroll_target,
358            is_vertical,
359            reverse_scrolling,
360            motion_context,
361        }
362    }
363
364    /// Handles pointer down event.
365    ///
366    /// Records the initial position for threshold calculation and
367    /// resets drag state. We don't consume Down events because we
368    /// don't know yet if this will become a drag or a click.
369    ///
370    /// Returns `false` - Down events are never consumed to allow
371    /// potential child click handlers to receive the initial press.
372    fn on_down(&self, position: Point, time_ms: Option<i64>) -> bool {
373        let mut gs = self.gesture_state.borrow_mut();
374
375        // Cancel any running fling/settle animation and wheel-settle watcher
376        if let Some(fling) = gs.fling_animation.take() {
377            fling.cancel();
378        }
379        if let Some(settle) = gs.settle_animation.take() {
380            settle.cancel();
381        }
382        if let Some(watcher) = gs.wheel_settle_watcher.take() {
383            watcher.cancel();
384        }
385        self.motion_context.set_active(false);
386
387        gs.drag_down_position = Some(position);
388        gs.last_position = Some(position);
389        gs.is_dragging = false;
390        gs.axis_locked_out = false;
391        gs.velocity_tracker.reset();
392        gs.gesture_start_time = Some(Instant::now());
393        gs.gesture_start_event_time_ms = time_ms;
394
395        // Add initial position to velocity tracker
396        let pos = if self.is_vertical {
397            position.y
398        } else {
399            position.x
400        };
401        gs.velocity_tracker.add_data_point(0, pos);
402        gs.last_velocity_sample_ms = Some(0);
403
404        // Never consume Down - we don't know if this is a drag yet
405        false
406    }
407
408    /// Handles pointer move event.
409    ///
410    /// This is the core gesture detection logic:
411    /// 1. Safety check: if no primary button is pressed but we think we're
412    ///    tracking, we missed an Up event - reset state.
413    /// 2. Calculate total movement from down position on BOTH axes.
414    /// 3. Axis-locked slop: start dragging once the scroll-axis movement
415    ///    exceeds `DRAG_THRESHOLD` (8dp) AND dominates the cross-axis
416    ///    movement; a decisively cross-axis drag locks this detector out
417    ///    for the rest of the gesture.
418    /// 4. While dragging, apply scroll delta and consume events.
419    ///
420    /// Returns `true` if event should be consumed (we're actively dragging).
421    fn on_move(&self, position: Point, buttons: PointerButtons, time_ms: Option<i64>) -> bool {
422        let mut gs = self.gesture_state.borrow_mut();
423
424        // Safety: detect missed Up events (hit test delivered to wrong target)
425        if !buttons.contains(PointerButton::Primary) && gs.drag_down_position.is_some() {
426            gs.drag_down_position = None;
427            gs.last_position = None;
428            gs.is_dragging = false;
429            gs.axis_locked_out = false;
430            gs.gesture_start_time = None;
431            gs.gesture_start_event_time_ms = None;
432            gs.last_velocity_sample_ms = None;
433            gs.velocity_tracker.reset();
434            self.motion_context.set_active(false);
435            return false;
436        }
437
438        let Some(down_pos) = gs.drag_down_position else {
439            return false;
440        };
441
442        let Some(last_pos) = gs.last_position else {
443            gs.last_position = Some(position);
444            return false;
445        };
446
447        let incremental_delta = calculate_incremental_delta(last_pos, position, self.is_vertical);
448
449        // Axis-locked touch slop (Compose-style): capture only when the
450        // movement along the scroll axis crosses the slop AND dominates the
451        // cross-axis movement, so of two nested scrollables the one whose
452        // axis matches the drag wins. Ties go to the innermost handler
453        // (children are dispatched before their ancestors). A drag that
454        // decisively belongs to the cross axis locks this detector out for
455        // the rest of the gesture. Targets that cannot scroll in either
456        // direction never capture, so enclosing scrollables receive the
457        // gesture instead.
458        if !gs.is_dragging && !gs.axis_locked_out {
459            let signed_main_delta = calculate_total_delta(down_pos, position, self.is_vertical);
460            let main_delta = signed_main_delta.abs();
461            let cross_delta = calculate_total_delta(down_pos, position, !self.is_vertical).abs();
462            if main_delta > DRAG_THRESHOLD && main_delta >= cross_delta {
463                // Direction-aware capture: a target pinned at one end yields
464                // gestures it cannot consume to its enclosing scrollable.
465                if self.scroll_target.can_consume(signed_main_delta) {
466                    gs.is_dragging = true;
467                    self.motion_context.set_active(true);
468                }
469            } else if cross_delta > DRAG_THRESHOLD && cross_delta > main_delta {
470                gs.axis_locked_out = true;
471            }
472        }
473
474        gs.last_position = Some(position);
475
476        // Track velocity for fling
477        let pos = if self.is_vertical {
478            position.y
479        } else {
480            position.x
481        };
482        let event_sample_ms = gs
483            .gesture_start_event_time_ms
484            .zip(time_ms)
485            .map(|(start_ms, now_ms)| now_ms - start_ms);
486        let sample_ms = if let Some(event_sample_ms) = event_sample_ms {
487            // The platform supplied real input timestamps: trust them.
488            // Android delivers touch samples batched/frame-aligned, so several
489            // moves are processed back-to-back here; only the event's own
490            // timestamp yields the real dt between finger positions. Real
491            // pauses must also stay real so a stop-then-release does not fling
492            // (the tracker treats gaps > ASSUME_STOPPED_MS as stopped).
493            Some(match gs.last_velocity_sample_ms {
494                Some(last_sample_ms) => event_sample_ms.max(last_sample_ms),
495                None => event_sample_ms.max(0),
496            })
497        } else if let Some(start_time) = gs.gesture_start_time {
498            // Fallback: delivery-time stamping for platforms without input
499            // timestamps (desktop mouse, web).
500            let elapsed_ms = start_time.elapsed().as_millis() as i64;
501            // Keep sample times strictly increasing so velocity stays stable when
502            // multiple move events land in the same millisecond.
503            Some(match gs.last_velocity_sample_ms {
504                Some(last_sample_ms) => {
505                    let mut sample_ms = if elapsed_ms <= last_sample_ms {
506                        last_sample_ms + 1
507                    } else {
508                        elapsed_ms
509                    };
510                    // Clamp large processing gaps so frame stalls don't erase fling velocity.
511                    if sample_ms - last_sample_ms > ASSUME_STOPPED_MS {
512                        sample_ms = last_sample_ms + ASSUME_STOPPED_MS;
513                    }
514                    sample_ms
515                }
516                None => elapsed_ms,
517            })
518        } else {
519            None
520        };
521        if let Some(sample_ms) = sample_ms {
522            log::trace!(
523                target: "cranpose::velocity",
524                "sample t={sample_ms}ms pos={pos:.2} event_time={time_ms:?}"
525            );
526            gs.velocity_tracker.add_data_point(sample_ms, pos);
527            gs.last_velocity_sample_ms = Some(sample_ms);
528        }
529
530        if gs.is_dragging {
531            drop(gs); // Release borrow before calling scroll target
532            let delta = if self.reverse_scrolling {
533                -incremental_delta
534            } else {
535                incremental_delta
536            };
537            let _ = self.scroll_target.apply_delta(delta);
538            self.scroll_target.invalidate();
539            true // Consume event while dragging
540        } else {
541            false
542        }
543    }
544
545    /// Handles pointer up event.
546    ///
547    /// Cleans up drag state. If we were actively dragging, calculates fling
548    /// velocity and starts fling animation if velocity is above threshold.
549    ///
550    /// Returns `true` if we were dragging (event should be consumed).
551    fn finish_gesture(&self, allow_fling: bool, release_time_ms: Option<i64>) -> bool {
552        let (was_dragging, velocity, start_fling, existing_fling) = {
553            let mut gs = self.gesture_state.borrow_mut();
554            let was_dragging = gs.is_dragging;
555            let mut velocity = 0.0;
556
557            if allow_fling && was_dragging && gs.gesture_start_time.is_some() {
558                // A finger that rested before lifting must not fling: the
559                // tracker only sees inter-SAMPLE gaps, so a release long
560                // after the last move would otherwise replay the stale
561                // pre-hold velocity (hold-then-release phantom fling).
562                let release_sample_ms = release_time_ms
563                    .zip(gs.gesture_start_event_time_ms)
564                    .map(|(release_ms, start_ms)| release_ms - start_ms)
565                    .or_else(|| {
566                        gs.gesture_start_time
567                            .map(|start| start.elapsed().as_millis() as i64)
568                    });
569                let rested_before_release = release_sample_ms
570                    .zip(gs.last_velocity_sample_ms)
571                    .is_some_and(|(release_ms, last_sample_ms)| {
572                        release_ms - last_sample_ms > ASSUME_STOPPED_MS
573                    });
574                if !rested_before_release {
575                    velocity = gs
576                        .velocity_tracker
577                        .calculate_velocity_with_max(MAX_FLING_VELOCITY);
578                }
579            }
580
581            let start_fling = allow_fling && was_dragging && velocity.abs() > MIN_FLING_VELOCITY;
582            let existing_fling = if start_fling {
583                gs.fling_animation.take()
584            } else {
585                None
586            };
587
588            gs.drag_down_position = None;
589            gs.last_position = None;
590            gs.is_dragging = false;
591            gs.axis_locked_out = false;
592            gs.gesture_start_time = None;
593            gs.gesture_start_event_time_ms = None;
594            gs.last_velocity_sample_ms = None;
595
596            (was_dragging, velocity, start_fling, existing_fling)
597        };
598
599        // Always record velocity for test accessibility (even if below fling threshold)
600        if allow_fling && was_dragging {
601            log::debug!(
602                target: "cranpose::velocity",
603                "gesture finished: fling velocity={velocity:.2} dp/s start_fling={start_fling}"
604            );
605            set_last_fling_velocity(velocity);
606        }
607
608        // Convert gesture velocity to scroll-offset velocity (offset units/s).
609        let adjusted_velocity = if self.reverse_scrolling {
610            -velocity
611        } else {
612            velocity
613        };
614        let fling_velocity = -adjusted_velocity;
615
616        // Settle policy: remap where this interaction comes to rest (the
617        // `targetContentOffset` analog). When it moves the rest position, a
618        // spring seeded with the release velocity replaces the decay so the
619        // adjustment still reads as one continuous deceleration.
620        let settle_target = if was_dragging {
621            self.scroll_target.settle_policy().and_then(|policy| {
622                let current = self.scroll_target.current_offset();
623                let proposed = if start_fling {
624                    fling_rest_position(current, fling_velocity, current_density())
625                } else {
626                    current
627                };
628                let target = policy(proposed, fling_velocity);
629                ((target - proposed).abs() > 0.5).then_some(target)
630            })
631        } else {
632            None
633        };
634
635        if let Some(target) = settle_target {
636            if let Some(old_fling) = existing_fling {
637                old_fling.cancel();
638            }
639            self.start_settle_animation(target, fling_velocity);
640        } else if start_fling {
641            if let Some(old_fling) = existing_fling {
642                old_fling.cancel();
643            }
644
645            // Get runtime handle for frame callbacks
646            if let Some(runtime) = current_runtime_handle() {
647                self.motion_context.set_active(true);
648                let scroll_target = self.scroll_target.clone();
649                let fling = FlingAnimation::new(runtime);
650                let motion_context = self.motion_context.clone();
651
652                // Get current scroll position for fling start
653                let initial_value = scroll_target.current_offset();
654
655                let scroll_target_for_fling = scroll_target.clone();
656                let scroll_target_for_end = scroll_target.clone();
657
658                fling.start_fling(
659                    initial_value,
660                    fling_velocity,
661                    current_density(),
662                    move |delta| {
663                        // Apply scroll delta during fling, return consumed amount
664                        let consumed = scroll_target_for_fling.apply_fling_delta(delta);
665                        scroll_target_for_fling.invalidate();
666                        consumed
667                    },
668                    move || {
669                        // Animation complete - invalidate to ensure final render
670                        scroll_target_for_end.invalidate();
671                        motion_context.set_active(false);
672                    },
673                );
674
675                let mut gs = self.gesture_state.borrow_mut();
676                gs.fling_animation = Some(fling);
677            }
678        } else {
679            self.motion_context.set_active(false);
680        }
681
682        was_dragging
683    }
684
685    /// Springs the scroll offset to `target` (offset units), seeded with the
686    /// release velocity so policy-adjusted rests feel like one deceleration.
687    fn start_settle_animation(&self, target: f32, initial_velocity: f32) {
688        let Some(runtime) = current_runtime_handle() else {
689            self.motion_context.set_active(false);
690            return;
691        };
692        self.motion_context.set_active(true);
693        let settle = SettleAnimation::new(runtime);
694        let scroll_target_for_settle = self.scroll_target.clone();
695        let scroll_target_for_end = self.scroll_target.clone();
696        let motion_context = self.motion_context.clone();
697        settle.start_settle(
698            self.scroll_target.current_offset(),
699            initial_velocity,
700            target,
701            move |delta| {
702                let consumed = scroll_target_for_settle.apply_fling_delta(delta);
703                scroll_target_for_settle.invalidate();
704                consumed
705            },
706            move || {
707                scroll_target_for_end.invalidate();
708                motion_context.set_active(false);
709            },
710        );
711        let mut gs = self.gesture_state.borrow_mut();
712        gs.settle_animation = Some(settle);
713    }
714
715    /// Handles pointer up event.
716    ///
717    /// Cleans up drag state. If we were actively dragging, calculates fling
718    /// velocity and starts fling animation if velocity is above threshold.
719    ///
720    /// Returns `true` if we were dragging (event should be consumed).
721    fn on_up(&self, time_ms: Option<i64>) -> bool {
722        self.finish_gesture(true, time_ms)
723    }
724
725    /// Handles pointer cancel event.
726    ///
727    /// Cleans up state without starting a fling. Returns `true` if we were dragging.
728    fn on_cancel(&self) -> bool {
729        self.finish_gesture(false, None)
730    }
731
732    /// Handles mouse wheel / trackpad scroll event.
733    ///
734    /// Returns `true` when the target consumed any delta.
735    fn on_scroll(&self, axis_delta: f32) -> bool {
736        if axis_delta.abs() <= f32::EPSILON {
737            return false;
738        }
739
740        {
741            // Wheel scroll should take over immediately and stop any active drag/fling state.
742            let mut gs = self.gesture_state.borrow_mut();
743            if let Some(fling) = gs.fling_animation.take() {
744                fling.cancel();
745            }
746            if let Some(settle) = gs.settle_animation.take() {
747                settle.cancel();
748            }
749            gs.drag_down_position = None;
750            gs.last_position = None;
751            gs.is_dragging = false;
752            gs.axis_locked_out = false;
753            gs.gesture_start_time = None;
754            gs.gesture_start_event_time_ms = None;
755            gs.last_velocity_sample_ms = None;
756            gs.velocity_tracker.reset();
757        }
758
759        self.motion_context.activate_for_current_frame();
760
761        let delta = if self.reverse_scrolling {
762            -axis_delta
763        } else {
764            axis_delta
765        };
766        let consumed = self.scroll_target.apply_wheel_delta(delta);
767        if consumed.abs() > 0.001 {
768            self.scroll_target.invalidate();
769            self.ensure_wheel_settle_watcher();
770            true
771        } else {
772            false
773        }
774    }
775
776    /// Arms (once) a frame loop that runs the settle policy after the wheel
777    /// goes idle. Wheel input has no end event, so idleness — the offset
778    /// sitting still for [`WHEEL_SETTLE_IDLE_NANOS`] of frame time — is the
779    /// gesture end.
780    fn ensure_wheel_settle_watcher(&self) {
781        if self.scroll_target.settle_policy().is_none() {
782            return;
783        }
784        {
785            let gs = self.gesture_state.borrow();
786            if gs
787                .wheel_settle_watcher
788                .as_ref()
789                .is_some_and(|watcher| watcher.is_running.get())
790            {
791                return;
792            }
793        }
794        let Some(runtime) = current_runtime_handle() else {
795            return;
796        };
797
798        let is_running = Rc::new(Cell::new(true));
799        let registration = Rc::new(RefCell::new(None));
800
801        struct WheelSettleLoop<S: ScrollTarget> {
802            detector: ScrollGestureDetector<S>,
803            gesture_state: Rc<RefCell<ScrollGestureState>>,
804            frame_clock: cranpose_core::internal::FrameClock,
805            is_running: Rc<Cell<bool>>,
806            registration: Rc<RefCell<Option<FrameCallbackRegistration>>>,
807            last_offset: Rc<Cell<f32>>,
808            idle_nanos: Rc<Cell<u64>>,
809            last_frame: Rc<Cell<Option<u64>>>,
810        }
811
812        impl<S: ScrollTarget + 'static> WheelSettleLoop<S> {
813            fn next(&self) -> Self {
814                Self {
815                    detector: self.detector.clone_for_watcher(),
816                    gesture_state: Rc::clone(&self.gesture_state),
817                    frame_clock: self.frame_clock.clone(),
818                    is_running: Rc::clone(&self.is_running),
819                    registration: Rc::clone(&self.registration),
820                    last_offset: Rc::clone(&self.last_offset),
821                    idle_nanos: Rc::clone(&self.idle_nanos),
822                    last_frame: Rc::clone(&self.last_frame),
823                }
824            }
825
826            fn schedule(self) {
827                let continuation = self.next();
828                let registration_slot = Rc::clone(&self.registration);
829                let new_registration = self.frame_clock.with_frame_nanos(move |frame_time_nanos| {
830                    let this = &continuation;
831                    if !this.is_running.get() {
832                        return;
833                    }
834                    // A live drag or fling owns the settle decision now.
835                    {
836                        let gs = this.gesture_state.borrow();
837                        let animating = gs.is_dragging
838                            || gs
839                                .fling_animation
840                                .as_ref()
841                                .is_some_and(FlingAnimation::is_running)
842                            || gs
843                                .settle_animation
844                                .as_ref()
845                                .is_some_and(SettleAnimation::is_running);
846                        if animating {
847                            this.is_running.set(false);
848                            return;
849                        }
850                    }
851
852                    let offset = this.detector.scroll_target.current_offset();
853                    let dt = this
854                        .last_frame
855                        .get()
856                        .map_or(0, |last| frame_time_nanos.saturating_sub(last));
857                    this.last_frame.set(Some(frame_time_nanos));
858                    if (offset - this.last_offset.get()).abs() > 0.01 {
859                        this.last_offset.set(offset);
860                        this.idle_nanos.set(0);
861                    } else {
862                        this.idle_nanos.set(this.idle_nanos.get() + dt);
863                    }
864
865                    if this.idle_nanos.get() >= WHEEL_SETTLE_IDLE_NANOS {
866                        this.is_running.set(false);
867                        if let Some(policy) = this.detector.scroll_target.settle_policy() {
868                            let target = policy(offset, 0.0);
869                            if (target - offset).abs() > 0.5 {
870                                this.detector.start_settle_animation(target, 0.0);
871                            }
872                        }
873                        return;
874                    }
875
876                    continuation.next().schedule();
877                });
878                *registration_slot.borrow_mut() = Some(new_registration);
879            }
880        }
881
882        WheelSettleLoop {
883            detector: self.clone_for_watcher(),
884            gesture_state: Rc::clone(&self.gesture_state),
885            frame_clock: runtime.frame_clock(),
886            is_running: Rc::clone(&is_running),
887            registration: Rc::clone(&registration),
888            last_offset: Rc::new(Cell::new(self.scroll_target.current_offset())),
889            idle_nanos: Rc::new(Cell::new(0u64)),
890            last_frame: Rc::new(Cell::new(None::<u64>)),
891        }
892        .schedule();
893
894        self.gesture_state.borrow_mut().wheel_settle_watcher = Some(WheelSettleWatcher {
895            is_running,
896            registration,
897        });
898    }
899
900    fn clone_for_watcher(&self) -> ScrollGestureDetector<S> {
901        ScrollGestureDetector {
902            gesture_state: Rc::clone(&self.gesture_state),
903            scroll_target: self.scroll_target.clone(),
904            is_vertical: self.is_vertical,
905            reverse_scrolling: self.reverse_scrolling,
906            motion_context: self.motion_context.clone(),
907        }
908    }
909}
910
911pub(crate) struct MotionContextAnimatedNode {
912    state: NodeState,
913    motion_context: ScrollMotionContext,
914    invalidation_callback_id: Option<u64>,
915    node_id: Option<NodeId>,
916}
917
918impl MotionContextAnimatedNode {
919    fn new(motion_context: ScrollMotionContext) -> Self {
920        Self {
921            state: NodeState::new(),
922            motion_context,
923            invalidation_callback_id: None,
924            node_id: None,
925        }
926    }
927
928    pub(crate) fn is_active(&self) -> bool {
929        self.motion_context.is_active()
930    }
931}
932
933pub(crate) struct TranslatedContentContextNode {
934    state: NodeState,
935    identity: usize,
936    offset_source: TranslatedContentOffsetSource,
937}
938
939impl TranslatedContentContextNode {
940    fn new(identity: usize, offset_source: TranslatedContentOffsetSource) -> Self {
941        Self {
942            state: NodeState::new(),
943            identity,
944            offset_source,
945        }
946    }
947
948    pub(crate) fn is_active(&self) -> bool {
949        true
950    }
951
952    pub(crate) fn identity(&self) -> usize {
953        self.identity
954    }
955
956    pub(crate) fn content_offset_reader(&self) -> Option<Rc<dyn Fn() -> Point>> {
957        self.offset_source.content_offset_reader()
958    }
959}
960
961impl DelegatableNode for TranslatedContentContextNode {
962    fn node_state(&self) -> &NodeState {
963        &self.state
964    }
965}
966
967impl ModifierNode for TranslatedContentContextNode {}
968
969impl DelegatableNode for MotionContextAnimatedNode {
970    fn node_state(&self) -> &NodeState {
971        &self.state
972    }
973}
974
975impl ModifierNode for MotionContextAnimatedNode {
976    fn on_attach(&mut self, context: &mut dyn cranpose_foundation::ModifierNodeContext) {
977        let node_id = context.node_id();
978        self.node_id = node_id;
979        if let Some(node_id) = node_id {
980            let callback_id = self
981                .motion_context
982                .add_invalidate_callback(Box::new(move || {
983                    schedule_modifier_slices_repass(node_id);
984                }));
985            self.invalidation_callback_id = Some(callback_id);
986        }
987    }
988
989    fn on_detach(&mut self) {
990        if let Some(id) = self.invalidation_callback_id.take() {
991            self.motion_context.remove_invalidate_callback(id);
992        }
993        self.node_id = None;
994    }
995}
996
997#[derive(Clone)]
998struct MotionContextAnimatedElement {
999    motion_context: ScrollMotionContext,
1000}
1001
1002impl MotionContextAnimatedElement {
1003    fn new(motion_context: ScrollMotionContext) -> Self {
1004        Self { motion_context }
1005    }
1006}
1007
1008impl std::fmt::Debug for MotionContextAnimatedElement {
1009    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1010        f.debug_struct("MotionContextAnimatedElement").finish()
1011    }
1012}
1013
1014impl PartialEq for MotionContextAnimatedElement {
1015    fn eq(&self, other: &Self) -> bool {
1016        self.motion_context.ptr_eq(&other.motion_context)
1017    }
1018}
1019
1020impl Eq for MotionContextAnimatedElement {}
1021
1022impl std::hash::Hash for MotionContextAnimatedElement {
1023    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
1024        self.motion_context.stable_key().hash(state);
1025    }
1026}
1027
1028impl ModifierNodeElement for MotionContextAnimatedElement {
1029    type Node = MotionContextAnimatedNode;
1030
1031    fn create(&self) -> Self::Node {
1032        MotionContextAnimatedNode::new(self.motion_context.clone())
1033    }
1034
1035    fn update(&self, node: &mut Self::Node) {
1036        if node.motion_context.ptr_eq(&self.motion_context) {
1037            return;
1038        }
1039        if let Some(id) = node.invalidation_callback_id.take() {
1040            node.motion_context.remove_invalidate_callback(id);
1041        }
1042        node.motion_context = self.motion_context.clone();
1043        if let Some(node_id) = node.node_id {
1044            let callback_id = node
1045                .motion_context
1046                .add_invalidate_callback(Box::new(move || {
1047                    schedule_modifier_slices_repass(node_id);
1048                }));
1049            node.invalidation_callback_id = Some(callback_id);
1050        }
1051    }
1052
1053    fn capabilities(&self) -> NodeCapabilities {
1054        NodeCapabilities::LAYOUT
1055    }
1056}
1057
1058#[derive(Clone)]
1059enum TranslatedContentOffsetSource {
1060    LayoutContentOffset,
1061    LazyList {
1062        state: LazyListState,
1063        is_vertical: bool,
1064        reverse_scrolling: bool,
1065    },
1066}
1067
1068impl TranslatedContentOffsetSource {
1069    fn content_offset_reader(&self) -> Option<Rc<dyn Fn() -> Point>> {
1070        match self {
1071            Self::LayoutContentOffset => None,
1072            Self::LazyList {
1073                state, is_vertical, ..
1074            } => Some(Rc::new(lazy_list_content_offset_reader(
1075                *state,
1076                *is_vertical,
1077            ))),
1078        }
1079    }
1080
1081    fn is_vertical(&self) -> Option<bool> {
1082        match self {
1083            Self::LayoutContentOffset => None,
1084            Self::LazyList { is_vertical, .. } => Some(*is_vertical),
1085        }
1086    }
1087
1088    fn reverse_scrolling(&self) -> Option<bool> {
1089        match self {
1090            Self::LayoutContentOffset => None,
1091            Self::LazyList {
1092                reverse_scrolling, ..
1093            } => Some(*reverse_scrolling),
1094        }
1095    }
1096}
1097
1098fn lazy_list_content_offset_reader(state: LazyListState, is_vertical: bool) -> impl Fn() -> Point {
1099    move || {
1100        let info = state.layout_info();
1101        if info.visible_items_info.is_empty() {
1102            return Point::default();
1103        };
1104        let main_offset = info.snap_anchor_offset;
1105        if is_vertical {
1106            Point::new(0.0, main_offset)
1107        } else {
1108            Point::new(main_offset, 0.0)
1109        }
1110    }
1111}
1112
1113#[derive(Clone)]
1114struct TranslatedContentContextElement {
1115    identity: usize,
1116    offset_source: TranslatedContentOffsetSource,
1117}
1118
1119impl TranslatedContentContextElement {
1120    fn new(identity: usize, offset_source: TranslatedContentOffsetSource) -> Self {
1121        Self {
1122            identity,
1123            offset_source,
1124        }
1125    }
1126}
1127
1128impl std::fmt::Debug for TranslatedContentContextElement {
1129    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1130        let offset_source = match &self.offset_source {
1131            TranslatedContentOffsetSource::LayoutContentOffset => "layout",
1132            TranslatedContentOffsetSource::LazyList { .. } => "lazy_list",
1133        };
1134        f.debug_struct("TranslatedContentContextElement")
1135            .field("identity", &self.identity)
1136            .field("offset_source", &offset_source)
1137            .finish()
1138    }
1139}
1140
1141impl PartialEq for TranslatedContentContextElement {
1142    fn eq(&self, other: &Self) -> bool {
1143        self.identity == other.identity
1144            && self.offset_source.is_vertical() == other.offset_source.is_vertical()
1145            && self.offset_source.reverse_scrolling() == other.offset_source.reverse_scrolling()
1146    }
1147}
1148
1149impl Eq for TranslatedContentContextElement {}
1150
1151impl std::hash::Hash for TranslatedContentContextElement {
1152    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
1153        self.identity.hash(state);
1154        self.offset_source.is_vertical().hash(state);
1155        self.offset_source.reverse_scrolling().hash(state);
1156    }
1157}
1158
1159impl ModifierNodeElement for TranslatedContentContextElement {
1160    type Node = TranslatedContentContextNode;
1161
1162    fn create(&self) -> Self::Node {
1163        TranslatedContentContextNode::new(self.identity, self.offset_source.clone())
1164    }
1165
1166    fn update(&self, node: &mut Self::Node) {
1167        node.identity = self.identity;
1168        node.offset_source = self.offset_source.clone();
1169    }
1170
1171    fn capabilities(&self) -> NodeCapabilities {
1172        NodeCapabilities::LAYOUT
1173    }
1174}
1175
1176// ============================================================================
1177// Modifier Extensions
1178// ============================================================================
1179
1180impl Modifier {
1181    /// Creates a horizontally scrollable modifier.
1182    ///
1183    /// # Arguments
1184    /// * `state` - The ScrollState to control scroll position
1185    /// * `reverse_scrolling` - If true, reverses the scroll direction in layout.
1186    ///   Note: This affects how scroll offset is applied to content (via `ScrollNode`),
1187    ///   NOT the drag direction. Drag gestures always follow natural touch semantics:
1188    ///   drag right = scroll left (content moves right under finger).
1189    ///
1190    /// # Example
1191    /// ```text
1192    /// let scroll_state = ScrollState::new(0.0);
1193    /// Row(
1194    ///     Modifier::empty().horizontal_scroll(scroll_state, false),
1195    ///     // ... content
1196    /// );
1197    /// ```
1198    pub fn horizontal_scroll(self, state: ScrollState, reverse_scrolling: bool) -> Self {
1199        self.then(scroll_impl(state, false, reverse_scrolling, None))
1200    }
1201
1202    /// Creates a vertically scrollable modifier.
1203    ///
1204    /// # Arguments
1205    /// * `state` - The ScrollState to control scroll position
1206    /// * `reverse_scrolling` - If true, reverses the scroll direction in layout.
1207    ///   Note: This affects how scroll offset is applied to content (via `ScrollNode`),
1208    ///   NOT the drag direction. Drag gestures always follow natural touch semantics:
1209    ///   drag down = scroll up (content moves down under finger).
1210    pub fn vertical_scroll(self, state: ScrollState, reverse_scrolling: bool) -> Self {
1211        self.then(scroll_impl(state, true, reverse_scrolling, None))
1212    }
1213
1214    /// Creates a horizontally scrollable modifier with a guard that can disable scrolling.
1215    pub fn horizontal_scroll_guarded(
1216        self,
1217        state: ScrollState,
1218        reverse_scrolling: bool,
1219        guard: impl Fn() -> bool + 'static,
1220    ) -> Self {
1221        self.then(scroll_impl(
1222            state,
1223            false,
1224            reverse_scrolling,
1225            Some(Rc::new(guard)),
1226        ))
1227    }
1228
1229    /// Creates a vertically scrollable modifier with a guard that can disable scrolling.
1230    pub fn vertical_scroll_guarded(
1231        self,
1232        state: ScrollState,
1233        reverse_scrolling: bool,
1234        guard: impl Fn() -> bool + 'static,
1235    ) -> Self {
1236        self.then(scroll_impl(
1237            state,
1238            true,
1239            reverse_scrolling,
1240            Some(Rc::new(guard)),
1241        ))
1242    }
1243}
1244
1245/// Internal implementation for scroll modifiers.
1246///
1247/// Creates a combined modifier consisting of:
1248/// 1. Pointer input handler (for gesture detection)
1249/// 2. Layout modifier (for applying scroll offset)
1250///
1251/// The pointer input is added FIRST so it appears earlier in the modifier
1252/// chain, allowing it to intercept events before layout-related handlers.
1253fn scroll_impl(
1254    state: ScrollState,
1255    is_vertical: bool,
1256    reverse_scrolling: bool,
1257    guard: Option<Rc<dyn Fn() -> bool>>,
1258) -> Modifier {
1259    // Create local gesture state - each scroll modifier instance is independent
1260    let gesture_state = Rc::new(RefCell::new(ScrollGestureState::default()));
1261    let motion_context = scroll_motion_context_for_key(ScrollMotionContextKey::ScrollState {
1262        state_id: state.id(),
1263        is_vertical,
1264        reverse_scrolling,
1265    });
1266
1267    // Set up pointer input handler
1268    let scroll_state = state.clone();
1269    let pointer_motion_context = motion_context.clone();
1270    let key = (state.id(), is_vertical);
1271    let pointer_input = Modifier::empty().pointer_input(key, move |scope| {
1272        // Create detector inside the async closure to capture the cloned state
1273        let detector = ScrollGestureDetector::new(
1274            gesture_state.clone(),
1275            scroll_state.clone(),
1276            is_vertical,
1277            false, // ScrollState handles reversing in layout, not input
1278            pointer_motion_context.clone(),
1279        );
1280        let guard = guard.clone();
1281
1282        async move {
1283            scope
1284                .await_pointer_event_scope(|await_scope| async move {
1285                    // Main event loop - processes events until scope is cancelled
1286                    loop {
1287                        let event = await_scope.await_pointer_event().await;
1288
1289                        // Scroll drags track the primary pointer only;
1290                        // secondary pointers belong to multi-touch gestures
1291                        // (pinch/zoom) handled by other modifiers.
1292                        if event.id != 0 {
1293                            continue;
1294                        }
1295
1296                        if event.is_consumed() {
1297                            if matches!(
1298                                event.kind,
1299                                PointerEventKind::Down
1300                                    | PointerEventKind::Move
1301                                    | PointerEventKind::Up
1302                                    | PointerEventKind::Cancel
1303                            ) {
1304                                detector.on_cancel();
1305                            }
1306                            continue;
1307                        }
1308
1309                        if let Some(ref guard) = guard {
1310                            if !guard() {
1311                                if matches!(
1312                                    event.kind,
1313                                    PointerEventKind::Up | PointerEventKind::Cancel
1314                                ) {
1315                                    detector.on_cancel();
1316                                }
1317                                continue;
1318                            }
1319                        }
1320
1321                        // Delegate to detector's lifecycle methods
1322                        let should_consume = match event.kind {
1323                            PointerEventKind::Down => {
1324                                detector.on_down(event.position, event.time_ms)
1325                            }
1326                            PointerEventKind::Move => {
1327                                detector.on_move(event.position, event.buttons, event.time_ms)
1328                            }
1329                            PointerEventKind::Up => detector.on_up(event.time_ms),
1330                            PointerEventKind::Cancel => detector.on_cancel(),
1331                            PointerEventKind::Scroll => detector.on_scroll(if is_vertical {
1332                                event.scroll_delta.y
1333                            } else {
1334                                event.scroll_delta.x
1335                            }),
1336                            PointerEventKind::Zoom
1337                            | PointerEventKind::Enter
1338                            | PointerEventKind::Exit => false,
1339                        };
1340
1341                        if should_consume {
1342                            event.consume();
1343                        }
1344                    }
1345                })
1346                .await;
1347        }
1348    });
1349
1350    // Create layout modifier for applying scroll offset to content
1351    let element = ScrollElement::new(state.clone(), is_vertical, reverse_scrolling);
1352    let layout_modifier =
1353        Modifier::with_element(element).with_inspector_metadata(inspector_metadata(
1354            if is_vertical {
1355                "verticalScroll"
1356            } else {
1357                "horizontalScroll"
1358            },
1359            move |info| {
1360                info.add_property("isVertical", is_vertical.to_string());
1361                info.add_property("reverseScrolling", reverse_scrolling.to_string());
1362            },
1363        ));
1364    let motion_modifier =
1365        Modifier::with_element(MotionContextAnimatedElement::new(motion_context.clone()));
1366    let translated_content_modifier = Modifier::with_element(TranslatedContentContextElement::new(
1367        state.id() as usize,
1368        TranslatedContentOffsetSource::LayoutContentOffset,
1369    ));
1370
1371    // Combine: pointer input THEN layout modifier, clip to bounds by default
1372    pointer_input
1373        .then(motion_modifier)
1374        .then(translated_content_modifier)
1375        .then(layout_modifier)
1376        .clip_to_bounds()
1377}
1378
1379// ============================================================================
1380// Lazy Scroll Support for LazyListState
1381// ============================================================================
1382
1383use cranpose_foundation::lazy::LazyListState;
1384
1385impl Modifier {
1386    /// Creates a vertically scrollable modifier for lazy lists.
1387    ///
1388    /// This connects pointer gestures to LazyListState for scroll handling.
1389    /// Unlike regular vertical_scroll, no layout offset is applied here
1390    /// since LazyListState manages item positioning internally.
1391    /// Creates a vertically scrollable modifier for lazy lists.
1392    ///
1393    /// This connects pointer gestures to LazyListState for scroll handling.
1394    /// Unlike regular vertical_scroll, no layout offset is applied here
1395    /// since LazyListState manages item positioning internally.
1396    pub fn lazy_vertical_scroll(self, state: LazyListState, reverse_scrolling: bool) -> Self {
1397        self.then(lazy_scroll_impl(state, true, reverse_scrolling))
1398    }
1399
1400    /// Creates a horizontally scrollable modifier for lazy lists.
1401    pub fn lazy_horizontal_scroll(self, state: LazyListState, reverse_scrolling: bool) -> Self {
1402        self.then(lazy_scroll_impl(state, false, reverse_scrolling))
1403    }
1404}
1405
1406/// Internal implementation for lazy scroll modifiers.
1407fn lazy_scroll_impl(state: LazyListState, is_vertical: bool, reverse_scrolling: bool) -> Modifier {
1408    let gesture_state = Rc::new(RefCell::new(ScrollGestureState::default()));
1409    let list_state = state;
1410    let state_id = state.inner_ptr() as usize;
1411    let motion_context = scroll_motion_context_for_key(ScrollMotionContextKey::LazyList {
1412        state_identity: state_id,
1413        is_vertical,
1414        reverse_scrolling,
1415    });
1416    let key = (state_id, is_vertical, reverse_scrolling);
1417    let translated_content_modifier = Modifier::with_element(TranslatedContentContextElement::new(
1418        state_id,
1419        TranslatedContentOffsetSource::LazyList {
1420            state,
1421            is_vertical,
1422            reverse_scrolling,
1423        },
1424    ));
1425
1426    Modifier::with_element(MotionContextAnimatedElement::new(motion_context.clone()))
1427        .then(translated_content_modifier)
1428        .pointer_input(key, move |scope| {
1429            // Use the same generic detector with LazyListState
1430            let detector = ScrollGestureDetector::new(
1431                gesture_state.clone(),
1432                list_state,
1433                is_vertical,
1434                reverse_scrolling,
1435                motion_context.clone(),
1436            );
1437
1438            async move {
1439                scope
1440                    .await_pointer_event_scope(|await_scope| async move {
1441                        loop {
1442                            let event = await_scope.await_pointer_event().await;
1443
1444                            // Scroll drags track the primary pointer only;
1445                            // secondary pointers belong to multi-touch
1446                            // gestures handled by other modifiers.
1447                            if event.id != 0 {
1448                                continue;
1449                            }
1450
1451                            if event.is_consumed() {
1452                                if matches!(
1453                                    event.kind,
1454                                    PointerEventKind::Down
1455                                        | PointerEventKind::Move
1456                                        | PointerEventKind::Up
1457                                        | PointerEventKind::Cancel
1458                                ) {
1459                                    detector.on_cancel();
1460                                }
1461                                continue;
1462                            }
1463
1464                            // Delegate to detector's lifecycle methods
1465                            let should_consume = match event.kind {
1466                                PointerEventKind::Down => {
1467                                    detector.on_down(event.position, event.time_ms)
1468                                }
1469                                PointerEventKind::Move => {
1470                                    detector.on_move(event.position, event.buttons, event.time_ms)
1471                                }
1472                                PointerEventKind::Up => detector.on_up(event.time_ms),
1473                                PointerEventKind::Cancel => detector.on_cancel(),
1474                                PointerEventKind::Scroll => detector.on_scroll(if is_vertical {
1475                                    event.scroll_delta.y
1476                                } else {
1477                                    event.scroll_delta.x
1478                                }),
1479                                PointerEventKind::Zoom
1480                                | PointerEventKind::Enter
1481                                | PointerEventKind::Exit => false,
1482                            };
1483
1484                            if should_consume {
1485                                event.consume();
1486                            }
1487                        }
1488                    })
1489                    .await;
1490            }
1491        })
1492}