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    current_density,
43    draggable::DraggableState,
44    fling_animation::{FlingAnimation, MIN_FLING_VELOCITY, SettleAnimation, fling_rest_position},
45    render_state::schedule_modifier_slices_repass,
46    scroll::{
47        ScrollElement, ScrollMotionContext, ScrollMotionContextKey, ScrollSettlePolicy,
48        ScrollState, scroll_motion_context_for_key,
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                            && !guard()
406                        {
407                            if matches!(event.kind, PointerEventKind::Up | PointerEventKind::Cancel)
408                            {
409                                detector.on_cancel();
410                            }
411                            continue;
412                        }
413
414                        let should_consume = match event.kind {
415                            PointerEventKind::Down => {
416                                detector.on_down(event.position, event.time_ms)
417                            }
418                            PointerEventKind::Move => detector.on_move(
419                                event.position,
420                                event.buttons,
421                                event.time_ms,
422                                &event,
423                            ),
424                            PointerEventKind::Up => detector.on_up(event.time_ms),
425                            PointerEventKind::Cancel => detector.on_cancel(),
426                            PointerEventKind::Scroll => detector.on_scroll(
427                                if is_vertical {
428                                    event.scroll_delta.y
429                                } else {
430                                    event.scroll_delta.x
431                                },
432                                &event,
433                            ),
434                            // Rotary is opt-in via
435                            // `Modifier::on_rotary_scroll_event`; drag surfaces
436                            // ignore it.
437                            PointerEventKind::Zoom
438                            | PointerEventKind::RotaryScrollPre
439                            | PointerEventKind::RotaryScroll
440                            | PointerEventKind::Enter
441                            | PointerEventKind::Exit => false,
442                        };
443
444                        if should_consume {
445                            event.consume();
446                        }
447                    }
448                })
449                .await;
450        }
451    })
452}
453
454impl ScrollTarget for DraggableState {
455    /// A drag surface has no bounds of its own: whatever the caller does with
456    /// the delta is the answer, so the whole gesture is consumed.
457    fn apply_delta(&self, delta: f32) -> f32 {
458        self.drag_by(delta);
459        delta
460    }
461
462    fn apply_fling_delta(&self, delta: f32) -> f32 {
463        self.drag_by(delta);
464        delta
465    }
466
467    fn invalidate(&self) {
468        // Whatever the delta handler writes to invalidates on its own.
469    }
470
471    fn current_offset(&self) -> f32 {
472        self.offset()
473    }
474
475    /// A placed control stops where it is let go.
476    fn allows_fling(&self) -> bool {
477        false
478    }
479
480    fn set_dragging(&self, dragging: bool) {
481        DraggableState::set_dragging(self, dragging);
482    }
483}
484
485/// Generic scroll gesture detector that works with any ScrollTarget.
486///
487/// This struct provides a clean interface for processing pointer events
488/// and managing scroll interactions. The generic parameter S determines
489/// how scroll deltas are applied.
490/// Wheel/trackpad frame-time idleness after which the settle policy runs
491/// (wheel gestures have no end event to hook).
492const WHEEL_SETTLE_IDLE_NANOS: u64 = 180_000_000;
493
494/// Frame loop that waits for the scroll offset to sit still after wheel input
495/// and then runs the settle policy. Cancelled by any new gesture.
496struct WheelSettleWatcher {
497    is_running: Rc<Cell<bool>>,
498    registration: Rc<RefCell<Option<FrameCallbackRegistration>>>,
499}
500
501impl WheelSettleWatcher {
502    fn cancel(&self) {
503        self.is_running.set(false);
504        self.registration.borrow_mut().take();
505    }
506}
507
508struct ScrollGestureDetector<S: ScrollTarget> {
509    /// Shared gesture state (position tracking, drag status).
510    gesture_state: Rc<RefCell<ScrollGestureState>>,
511
512    /// The scroll target to update when drag is detected.
513    scroll_target: S,
514
515    /// Whether this is vertical or horizontal scroll.
516    is_vertical: bool,
517
518    /// Whether to reverse the scroll direction (flip delta).
519    reverse_scrolling: bool,
520
521    overscroll: crate::scroll::OverscrollEffect,
522
523    /// Active motion state for renderer policy selection.
524    motion_context: ScrollMotionContext,
525}
526
527impl<S: ScrollTarget + 'static> ScrollGestureDetector<S> {
528    /// Creates a new detector for the given scroll configuration.
529    fn new(
530        gesture_state: Rc<RefCell<ScrollGestureState>>,
531        scroll_target: S,
532        is_vertical: bool,
533        reverse_scrolling: bool,
534        overscroll: crate::scroll::OverscrollEffect,
535        motion_context: ScrollMotionContext,
536    ) -> Self {
537        Self {
538            gesture_state,
539            scroll_target,
540            is_vertical,
541            reverse_scrolling,
542            overscroll,
543            motion_context,
544        }
545    }
546
547    /// Handles pointer down event.
548    ///
549    /// Records the initial position for threshold calculation and
550    /// resets drag state. We don't consume Down events because we
551    /// don't know yet if this will become a drag or a click.
552    ///
553    /// Returns `false` - Down events are never consumed to allow
554    /// potential child click handlers to receive the initial press.
555    fn on_down(&self, position: Point, time_ms: Option<i64>) -> bool {
556        let mut gs = self.gesture_state.borrow_mut();
557
558        // Cancel any running fling/settle animation and wheel-settle watcher
559        if let Some(fling) = gs.fling_animation.take() {
560            fling.cancel();
561        }
562        if let Some(settle) = gs.settle_animation.take() {
563            settle.cancel();
564        }
565        if let Some(watcher) = gs.wheel_settle_watcher.take() {
566            watcher.cancel();
567        }
568        self.motion_context.set_active(false);
569
570        gs.drag_down_position = Some(position);
571        gs.last_position = Some(position);
572        gs.is_dragging = false;
573        gs.axis_locked_out = false;
574        gs.velocity_tracker.reset();
575        gs.gesture_start_time = Some(Instant::now());
576        gs.gesture_start_event_time_ms = time_ms;
577        gs.is_overscrolling = self.overscroll.offset().abs() > 0.001;
578
579        // Add initial position to velocity tracker
580        let pos = if self.is_vertical {
581            position.y
582        } else {
583            position.x
584        };
585        gs.velocity_tracker.add_data_point(0, pos);
586        gs.last_velocity_sample_ms = Some(0);
587
588        // Never consume Down - we don't know if this is a drag yet
589        false
590    }
591
592    /// Handles pointer move event.
593    ///
594    /// This is the core gesture detection logic:
595    /// 1. Safety check: if no primary button is pressed but we think we're
596    ///    tracking, we missed an Up event - reset state.
597    /// 2. Calculate total movement from down position on BOTH axes.
598    /// 3. Axis-locked slop: start dragging once the scroll-axis movement
599    ///    exceeds `DRAG_THRESHOLD` (8dp) AND dominates the cross-axis
600    ///    movement; a decisively cross-axis drag locks this detector out
601    ///    for the rest of the gesture.
602    /// 4. While dragging, apply scroll delta and consume events.
603    ///
604    /// Returns `true` if event should be consumed (we're actively dragging).
605    fn on_move(
606        &self,
607        position: Point,
608        buttons: PointerButtons,
609        time_ms: Option<i64>,
610        event: &PointerEvent,
611    ) -> bool {
612        let mut gs = self.gesture_state.borrow_mut();
613
614        // Safety: detect missed Up events (hit test delivered to wrong target)
615        if !buttons.contains(PointerButton::Primary) && gs.drag_down_position.is_some() {
616            if gs.is_dragging {
617                self.scroll_target.set_dragging(false);
618            }
619            gs.drag_down_position = None;
620            gs.last_position = None;
621            gs.is_dragging = false;
622            gs.axis_locked_out = false;
623            gs.gesture_start_time = None;
624            gs.gesture_start_event_time_ms = None;
625            gs.last_velocity_sample_ms = None;
626            gs.velocity_tracker.reset();
627            self.motion_context.set_active(false);
628            return false;
629        }
630
631        let Some(down_pos) = gs.drag_down_position else {
632            return false;
633        };
634
635        let Some(last_pos) = gs.last_position else {
636            gs.last_position = Some(position);
637            return false;
638        };
639
640        let incremental_delta = calculate_incremental_delta(last_pos, position, self.is_vertical);
641
642        // Axis-locked touch slop (Compose-style): capture only when the
643        // movement along the scroll axis crosses the slop AND dominates the
644        // cross-axis movement, so of two nested scrollables the one whose
645        // axis matches the drag wins. Ties go to the innermost handler
646        // (children are dispatched before their ancestors). A drag that
647        // decisively belongs to the cross axis locks this detector out for
648        // the rest of the gesture. Targets that cannot scroll in either
649        // direction never capture, so enclosing scrollables receive the
650        // gesture instead.
651        let mut edge_candidate = false;
652        if !gs.is_dragging && !gs.axis_locked_out {
653            let signed_main_delta = calculate_total_delta(down_pos, position, self.is_vertical);
654            let main_delta = signed_main_delta.abs();
655            let cross_delta = calculate_total_delta(down_pos, position, !self.is_vertical).abs();
656            if main_delta > DRAG_THRESHOLD && main_delta >= cross_delta {
657                // Direction-aware capture: a target pinned at one end yields
658                // gestures it cannot consume to its enclosing scrollable.
659                if self.scroll_target.can_consume(signed_main_delta) {
660                    gs.is_dragging = true;
661                    self.scroll_target.set_dragging(true);
662                    self.motion_context.set_active(true);
663                } else if self.scroll_target.can_scroll() {
664                    edge_candidate = true;
665                }
666            } else if cross_delta > DRAG_THRESHOLD && cross_delta > main_delta {
667                gs.axis_locked_out = true;
668            }
669        }
670
671        gs.last_position = Some(position);
672
673        // Track velocity for fling
674        let pos = if self.is_vertical {
675            position.y
676        } else {
677            position.x
678        };
679        let event_sample_ms = gs
680            .gesture_start_event_time_ms
681            .zip(time_ms)
682            .map(|(start_ms, now_ms)| now_ms - start_ms);
683        let sample_ms = if let Some(event_sample_ms) = event_sample_ms {
684            // The platform supplied real input timestamps: trust them.
685            // Android delivers touch samples batched/frame-aligned, so several
686            // moves are processed back-to-back here; only the event's own
687            // timestamp yields the real dt between finger positions. Real
688            // pauses must also stay real so a stop-then-release does not fling
689            // (the tracker treats gaps > ASSUME_STOPPED_MS as stopped).
690            Some(match gs.last_velocity_sample_ms {
691                Some(last_sample_ms) => event_sample_ms.max(last_sample_ms),
692                None => event_sample_ms.max(0),
693            })
694        } else if let Some(start_time) = gs.gesture_start_time {
695            // Fallback: delivery-time stamping for platforms without input
696            // timestamps (desktop mouse, web).
697            let elapsed_ms = start_time.elapsed().as_millis() as i64;
698            // Keep sample times strictly increasing so velocity stays stable when
699            // multiple move events land in the same millisecond.
700            Some(match gs.last_velocity_sample_ms {
701                Some(last_sample_ms) => {
702                    let mut sample_ms = if elapsed_ms <= last_sample_ms {
703                        last_sample_ms + 1
704                    } else {
705                        elapsed_ms
706                    };
707                    // Clamp large processing gaps so frame stalls don't erase fling velocity.
708                    if sample_ms - last_sample_ms > ASSUME_STOPPED_MS {
709                        sample_ms = last_sample_ms + ASSUME_STOPPED_MS;
710                    }
711                    sample_ms
712                }
713                None => elapsed_ms,
714            })
715        } else {
716            None
717        };
718        if let Some(sample_ms) = sample_ms {
719            log::trace!(
720                target: "cranpose::velocity",
721                "sample t={sample_ms}ms pos={pos:.2} event_time={time_ms:?}"
722            );
723            gs.velocity_tracker.add_data_point(sample_ms, pos);
724            gs.last_velocity_sample_ms = Some(sample_ms);
725        }
726
727        if gs.is_dragging {
728            drop(gs); // Release borrow before calling scroll target
729            let delta = if self.reverse_scrolling {
730                -incremental_delta
731            } else {
732                incremental_delta
733            };
734            let overscroll = self.overscroll.clone();
735            overscroll.apply_to_scroll(delta, |delta| self.scroll_target.apply_delta(delta));
736            self.scroll_target.invalidate();
737            true // Consume event while dragging
738        } else if gs.is_overscrolling {
739            drop(gs);
740            let delta = if self.reverse_scrolling {
741                -incremental_delta
742            } else {
743                incremental_delta
744            };
745            self.apply_overscroll_delta(delta)
746        } else if edge_candidate {
747            drop(gs);
748            let delta = if self.reverse_scrolling {
749                -incremental_delta
750            } else {
751                incremental_delta
752            };
753            let detector = self.clone_for_watcher();
754            event.defer_post_dispatch_action(move || detector.apply_overscroll_candidate(delta));
755            false
756        } else {
757            false
758        }
759    }
760
761    fn apply_overscroll_delta(&self, delta: f32) -> bool {
762        self.motion_context.set_active(true);
763        let overscroll = self.overscroll.clone();
764        let target_consumed = Cell::new(0.0);
765        let before_overscroll = overscroll.offset();
766        overscroll.apply_to_scroll(delta, |delta| {
767            let consumed = self.scroll_target.apply_delta(delta);
768            target_consumed.set(consumed);
769            consumed
770        });
771        self.scroll_target.invalidate();
772        let consumed = target_consumed.get().abs() > 0.001
773            || (overscroll.offset() - before_overscroll).abs() > 0.001;
774        if !consumed {
775            self.motion_context.set_active(false);
776        }
777        consumed
778    }
779
780    fn apply_overscroll_candidate(&self, delta: f32) -> bool {
781        let consumed = self.apply_overscroll_delta(delta);
782        if consumed {
783            self.gesture_state.borrow_mut().is_overscrolling = true;
784        }
785        consumed
786    }
787
788    /// Handles pointer up event.
789    ///
790    /// Cleans up drag state. If we were actively dragging, calculates fling
791    /// velocity and starts fling animation if velocity is above threshold.
792    ///
793    /// Returns `true` if we were dragging (event should be consumed).
794    fn finish_gesture(&self, allow_fling: bool, release_time_ms: Option<i64>) -> bool {
795        let (was_dragging, gesture_owned, velocity, start_fling, existing_fling) = {
796            let mut gs = self.gesture_state.borrow_mut();
797            let was_dragging = gs.is_dragging;
798            let gesture_owned = was_dragging || gs.is_overscrolling;
799            let mut velocity = 0.0;
800
801            if allow_fling && gesture_owned && gs.gesture_start_time.is_some() {
802                // A finger that rested before lifting must not fling: the
803                // tracker only sees inter-SAMPLE gaps, so a release long
804                // after the last move would otherwise replay the stale
805                // pre-hold velocity (hold-then-release phantom fling).
806                let release_sample_ms = release_time_ms
807                    .zip(gs.gesture_start_event_time_ms)
808                    .map(|(release_ms, start_ms)| release_ms - start_ms)
809                    .or_else(|| {
810                        gs.gesture_start_time
811                            .map(|start| start.elapsed().as_millis() as i64)
812                    });
813                let rested_before_release = release_sample_ms
814                    .zip(gs.last_velocity_sample_ms)
815                    .is_some_and(|(release_ms, last_sample_ms)| {
816                        release_ms - last_sample_ms > ASSUME_STOPPED_MS
817                    });
818                if !rested_before_release {
819                    velocity = gs
820                        .velocity_tracker
821                        .calculate_velocity_with_max(MAX_FLING_VELOCITY);
822                }
823            }
824
825            let start_fling = allow_fling && was_dragging && velocity.abs() > MIN_FLING_VELOCITY;
826            let existing_fling = if start_fling {
827                gs.fling_animation.take()
828            } else {
829                None
830            };
831
832            if was_dragging {
833                self.scroll_target.set_dragging(false);
834            }
835            gs.drag_down_position = None;
836            gs.last_position = None;
837            gs.is_dragging = false;
838            gs.is_overscrolling = false;
839            gs.axis_locked_out = false;
840            gs.gesture_start_time = None;
841            gs.gesture_start_event_time_ms = None;
842            gs.last_velocity_sample_ms = None;
843
844            (
845                was_dragging,
846                gesture_owned,
847                velocity,
848                start_fling,
849                existing_fling,
850            )
851        };
852
853        // Always record velocity for test accessibility (even if below fling threshold)
854        if allow_fling && gesture_owned {
855            log::debug!(
856                target: "cranpose::velocity",
857                "gesture finished: fling velocity={velocity:.2} dp/s start_fling={start_fling}"
858            );
859            set_last_fling_velocity(velocity);
860        }
861
862        // Convert gesture velocity to scroll-offset velocity (offset units/s).
863        let adjusted_velocity = if self.reverse_scrolling {
864            -velocity
865        } else {
866            velocity
867        };
868        let fling_velocity = -adjusted_velocity;
869        let has_overscroll = self.overscroll.offset().abs() > 0.001;
870
871        // Settle policy: remap where this interaction comes to rest (the
872        // `targetContentOffset` analog). When it moves the rest position, a
873        // spring seeded with the release velocity replaces the decay so the
874        // adjustment still reads as one continuous deceleration.
875        let settle_target = if was_dragging {
876            self.scroll_target.settle_policy().and_then(|policy| {
877                let current = self.scroll_target.current_offset();
878                let proposed = if start_fling {
879                    fling_rest_position(current, fling_velocity, current_density())
880                } else {
881                    current
882                };
883                let target = policy(proposed, fling_velocity);
884                ((target - proposed).abs() > 0.5).then_some(target)
885            })
886        } else {
887            None
888        };
889
890        if has_overscroll {
891            if let Some(old_fling) = existing_fling {
892                old_fling.cancel();
893            }
894            self.start_overscroll_settle(-fling_velocity);
895        } else if let Some(target) = settle_target {
896            if let Some(old_fling) = existing_fling {
897                old_fling.cancel();
898            }
899            self.start_settle_animation(target, fling_velocity);
900        } else if start_fling {
901            if let Some(old_fling) = existing_fling {
902                old_fling.cancel();
903            }
904            self.start_fling_animation(fling_velocity);
905        } else {
906            self.motion_context.set_active(false);
907        }
908
909        gesture_owned
910    }
911
912    fn start_fling_animation(&self, fling_velocity: f32) {
913        let Some(runtime) = current_runtime_handle() else {
914            self.motion_context.set_active(false);
915            return;
916        };
917        self.motion_context.set_active(true);
918        let scroll_target = self.scroll_target.clone();
919        let fling = FlingAnimation::new(runtime);
920        let motion_context = self.motion_context.clone();
921        let initial_value = scroll_target.current_offset();
922        let scroll_target_for_fling = scroll_target.clone();
923        let scroll_target_for_end = scroll_target.clone();
924        let detector_for_end = self.clone_for_watcher();
925        let overscroll_for_fling = self.overscroll.clone();
926
927        fling.start_fling(
928            initial_value,
929            fling_velocity,
930            current_density(),
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);
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);
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_limit(info.viewport_size * 0.5);
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}