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