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