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