Skip to main content

cranpose_ui/modifier/
scroll.rs

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