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, PointerEventKind};
27use crate::current_density;
28use crate::fling_animation::FlingAnimation;
29use crate::fling_animation::MIN_FLING_VELOCITY;
30use crate::render_state::schedule_modifier_slices_repass;
31use crate::scroll::{
32    scroll_motion_context_for_key, ScrollElement, ScrollMotionContext, ScrollMotionContextKey,
33    ScrollState,
34};
35use cranpose_core::{current_runtime_handle, NodeId};
36use cranpose_foundation::{
37    velocity_tracker::ASSUME_STOPPED_MS, DelegatableNode, ModifierNode, ModifierNodeElement,
38    NodeCapabilities, NodeState, PointerButton, PointerButtons, VelocityTracker1D, DRAG_THRESHOLD,
39    MAX_FLING_VELOCITY,
40};
41use std::cell::RefCell;
42use std::rc::Rc;
43use web_time::Instant;
44
45#[cfg(feature = "test-helpers")]
46pub fn last_fling_velocity() -> f32 {
47    crate::render_state::debug_last_fling_velocity()
48}
49
50#[cfg(feature = "test-helpers")]
51pub fn reset_last_fling_velocity() {
52    crate::render_state::debug_reset_last_fling_velocity();
53}
54
55#[inline]
56fn set_last_fling_velocity(velocity: f32) {
57    crate::render_state::record_last_fling_velocity(velocity);
58}
59
60/// Local gesture state for scroll drag handling.
61///
62/// This is NOT part of `ScrollState` to keep the scroll model pure.
63/// Each scroll modifier instance has its own gesture state, which enables
64/// multiple independent scroll regions without state interference.
65struct ScrollGestureState {
66    /// Position where pointer was pressed down.
67    /// Used to calculate total drag distance for threshold detection.
68    drag_down_position: Option<Point>,
69
70    /// Last known pointer position during drag.
71    /// Used to calculate incremental delta for each move event.
72    last_position: Option<Point>,
73
74    /// Whether we've crossed the drag threshold and are actively scrolling.
75    /// Once true, we consume all events until Up/Cancel to prevent child
76    /// handlers from receiving drag events.
77    is_dragging: bool,
78
79    /// Whether this gesture was decided to belong to the cross axis
80    /// (its cross-axis movement crossed the touch slop while dominating the
81    /// main axis). A locked-out detector never captures for the rest of the
82    /// gesture, so e.g. a horizontal chips row cannot steal a vertical
83    /// screen scroll that happens to drift sideways later on.
84    axis_locked_out: bool,
85
86    /// Velocity tracker for fling gesture detection.
87    velocity_tracker: VelocityTracker1D,
88
89    /// Time when gesture down started (for velocity calculation).
90    gesture_start_time: Option<Instant>,
91
92    /// Platform timestamp (ms) of the Down event, when the platform provides
93    /// input timestamps. Preferred over `gesture_start_time` because batched
94    /// input delivery (Android) makes delivery-time deltas meaningless.
95    gesture_start_event_time_ms: Option<i64>,
96
97    /// Last time a velocity sample was recorded (milliseconds since gesture start).
98    last_velocity_sample_ms: Option<i64>,
99
100    /// Current fling animation (if any).
101    fling_animation: Option<FlingAnimation>,
102}
103
104impl Default for ScrollGestureState {
105    fn default() -> Self {
106        Self {
107            drag_down_position: None,
108            last_position: None,
109            is_dragging: false,
110            axis_locked_out: false,
111            velocity_tracker: VelocityTracker1D::new(),
112            gesture_start_time: None,
113            gesture_start_event_time_ms: None,
114            last_velocity_sample_ms: None,
115            fling_animation: None,
116        }
117    }
118}
119
120// ============================================================================
121// Helper Functions
122// ============================================================================
123
124/// Calculates the total movement distance from the original down position.
125///
126/// This is used to determine if we've crossed the drag threshold. Returns
127/// the distance along the requested axis (Y for `is_vertical`, X otherwise);
128/// callers pass `!is_vertical` to read the cross-axis component.
129#[inline]
130fn calculate_total_delta(from: Point, to: Point, is_vertical: bool) -> f32 {
131    if is_vertical {
132        to.y - from.y
133    } else {
134        to.x - from.x
135    }
136}
137
138/// Calculates the incremental movement delta from the previous position.
139///
140/// This is used to update the scroll offset incrementally during drag.
141/// Returns the distance in the scroll axis direction (Y for vertical, X for horizontal).
142#[inline]
143fn calculate_incremental_delta(from: Point, to: Point, is_vertical: bool) -> f32 {
144    if is_vertical {
145        to.y - from.y
146    } else {
147        to.x - from.x
148    }
149}
150
151// ============================================================================
152// Scroll Gesture Detector (Generic Implementation)
153// ============================================================================
154
155/// Trait for scroll targets that can receive scroll deltas.
156///
157/// Implemented by both `ScrollState` (regular scroll) and `LazyListState` (lazy lists).
158trait ScrollTarget: Clone {
159    /// Apply a gesture delta. Returns the consumed amount in gesture coordinates.
160    fn apply_delta(&self, delta: f32) -> f32;
161
162    /// Apply a wheel/trackpad event delta. Returns the consumed amount.
163    fn apply_wheel_delta(&self, delta: f32) -> f32 {
164        self.apply_delta(delta)
165    }
166
167    /// Apply a scroll delta during fling. Returns consumed delta in scroll coordinates.
168    fn apply_fling_delta(&self, delta: f32) -> f32;
169
170    /// Called after scroll to trigger any necessary invalidation.
171    fn invalidate(&self);
172
173    /// Get the current scroll offset.
174    fn current_offset(&self) -> f32;
175
176    /// Whether the target can currently scroll in either direction.
177    ///
178    /// When this is `false` the gesture detector must not capture drags:
179    /// a non-scrollable target (e.g. a lazy list realized in full inside an
180    /// unbounded parent, or a scroll container whose content fits its
181    /// viewport) would otherwise consume the move events that an enclosing
182    /// scrollable needs to receive.
183    fn can_scroll(&self) -> bool {
184        true
185    }
186}
187
188impl ScrollTarget for ScrollState {
189    fn apply_delta(&self, delta: f32) -> f32 {
190        // Regular scroll uses negative delta (natural scrolling)
191        self.dispatch_raw_delta(-delta)
192    }
193
194    fn apply_fling_delta(&self, delta: f32) -> f32 {
195        self.dispatch_raw_delta(delta)
196    }
197
198    fn invalidate(&self) {
199        // ScrollState triggers invalidation internally
200    }
201
202    fn current_offset(&self) -> f32 {
203        self.value()
204    }
205
206    fn can_scroll(&self) -> bool {
207        self.max_value() > 0.0
208    }
209}
210
211impl ScrollTarget for LazyListState {
212    fn apply_delta(&self, delta: f32) -> f32 {
213        // LazyListState uses positive delta directly
214        // dispatch_scroll_delta already calls self.invalidate() which triggers the
215        // layout invalidation callback registered in lazy_scroll_impl
216        self.dispatch_scroll_delta(delta)
217    }
218
219    fn apply_wheel_delta(&self, delta: f32) -> f32 {
220        if delta.abs() <= 0.001 {
221            0.0
222        } else {
223            self.dispatch_scroll_delta(delta)
224        }
225    }
226
227    fn apply_fling_delta(&self, delta: f32) -> f32 {
228        -self.dispatch_scroll_delta(-delta)
229    }
230
231    fn invalidate(&self) {
232        // dispatch_scroll_delta already handles invalidation internally via callback.
233        // The registered callback uses schedule_layout_repass for scoped layout work.
234    }
235
236    fn current_offset(&self) -> f32 {
237        // LazyListState doesn't have a simple offset - use first visible item offset
238        self.first_visible_item_scroll_offset()
239    }
240
241    fn can_scroll(&self) -> bool {
242        // Before the first measure pass no bounds are known; stay permissive
243        // so gestures that race the first layout are not dropped.
244        self.layout_info().total_items_count == 0
245            || self.can_scroll_forward_non_reactive()
246            || self.can_scroll_backward_non_reactive()
247    }
248}
249
250/// Generic scroll gesture detector that works with any ScrollTarget.
251///
252/// This struct provides a clean interface for processing pointer events
253/// and managing scroll interactions. The generic parameter S determines
254/// how scroll deltas are applied.
255struct ScrollGestureDetector<S: ScrollTarget> {
256    /// Shared gesture state (position tracking, drag status).
257    gesture_state: Rc<RefCell<ScrollGestureState>>,
258
259    /// The scroll target to update when drag is detected.
260    scroll_target: S,
261
262    /// Whether this is vertical or horizontal scroll.
263    is_vertical: bool,
264
265    /// Whether to reverse the scroll direction (flip delta).
266    reverse_scrolling: bool,
267
268    /// Active motion state for renderer policy selection.
269    motion_context: ScrollMotionContext,
270}
271
272impl<S: ScrollTarget + 'static> ScrollGestureDetector<S> {
273    /// Creates a new detector for the given scroll configuration.
274    fn new(
275        gesture_state: Rc<RefCell<ScrollGestureState>>,
276        scroll_target: S,
277        is_vertical: bool,
278        reverse_scrolling: bool,
279        motion_context: ScrollMotionContext,
280    ) -> Self {
281        Self {
282            gesture_state,
283            scroll_target,
284            is_vertical,
285            reverse_scrolling,
286            motion_context,
287        }
288    }
289
290    /// Handles pointer down event.
291    ///
292    /// Records the initial position for threshold calculation and
293    /// resets drag state. We don't consume Down events because we
294    /// don't know yet if this will become a drag or a click.
295    ///
296    /// Returns `false` - Down events are never consumed to allow
297    /// potential child click handlers to receive the initial press.
298    fn on_down(&self, position: Point, time_ms: Option<i64>) -> bool {
299        let mut gs = self.gesture_state.borrow_mut();
300
301        // Cancel any running fling animation
302        if let Some(fling) = gs.fling_animation.take() {
303            fling.cancel();
304        }
305        self.motion_context.set_active(false);
306
307        gs.drag_down_position = Some(position);
308        gs.last_position = Some(position);
309        gs.is_dragging = false;
310        gs.axis_locked_out = false;
311        gs.velocity_tracker.reset();
312        gs.gesture_start_time = Some(Instant::now());
313        gs.gesture_start_event_time_ms = time_ms;
314
315        // Add initial position to velocity tracker
316        let pos = if self.is_vertical {
317            position.y
318        } else {
319            position.x
320        };
321        gs.velocity_tracker.add_data_point(0, pos);
322        gs.last_velocity_sample_ms = Some(0);
323
324        // Never consume Down - we don't know if this is a drag yet
325        false
326    }
327
328    /// Handles pointer move event.
329    ///
330    /// This is the core gesture detection logic:
331    /// 1. Safety check: if no primary button is pressed but we think we're
332    ///    tracking, we missed an Up event - reset state.
333    /// 2. Calculate total movement from down position on BOTH axes.
334    /// 3. Axis-locked slop: start dragging once the scroll-axis movement
335    ///    exceeds `DRAG_THRESHOLD` (8dp) AND dominates the cross-axis
336    ///    movement; a decisively cross-axis drag locks this detector out
337    ///    for the rest of the gesture.
338    /// 4. While dragging, apply scroll delta and consume events.
339    ///
340    /// Returns `true` if event should be consumed (we're actively dragging).
341    fn on_move(&self, position: Point, buttons: PointerButtons, time_ms: Option<i64>) -> bool {
342        let mut gs = self.gesture_state.borrow_mut();
343
344        // Safety: detect missed Up events (hit test delivered to wrong target)
345        if !buttons.contains(PointerButton::Primary) && gs.drag_down_position.is_some() {
346            gs.drag_down_position = None;
347            gs.last_position = None;
348            gs.is_dragging = false;
349            gs.axis_locked_out = false;
350            gs.gesture_start_time = None;
351            gs.gesture_start_event_time_ms = None;
352            gs.last_velocity_sample_ms = None;
353            gs.velocity_tracker.reset();
354            self.motion_context.set_active(false);
355            return false;
356        }
357
358        let Some(down_pos) = gs.drag_down_position else {
359            return false;
360        };
361
362        let Some(last_pos) = gs.last_position else {
363            gs.last_position = Some(position);
364            return false;
365        };
366
367        let incremental_delta = calculate_incremental_delta(last_pos, position, self.is_vertical);
368
369        // Axis-locked touch slop (Compose-style): capture only when the
370        // movement along the scroll axis crosses the slop AND dominates the
371        // cross-axis movement, so of two nested scrollables the one whose
372        // axis matches the drag wins. Ties go to the innermost handler
373        // (children are dispatched before their ancestors). A drag that
374        // decisively belongs to the cross axis locks this detector out for
375        // the rest of the gesture. Targets that cannot scroll in either
376        // direction never capture, so enclosing scrollables receive the
377        // gesture instead.
378        if !gs.is_dragging && !gs.axis_locked_out {
379            let main_delta = calculate_total_delta(down_pos, position, self.is_vertical).abs();
380            let cross_delta = calculate_total_delta(down_pos, position, !self.is_vertical).abs();
381            if main_delta > DRAG_THRESHOLD && main_delta >= cross_delta {
382                if self.scroll_target.can_scroll() {
383                    gs.is_dragging = true;
384                    self.motion_context.set_active(true);
385                }
386            } else if cross_delta > DRAG_THRESHOLD && cross_delta > main_delta {
387                gs.axis_locked_out = true;
388            }
389        }
390
391        gs.last_position = Some(position);
392
393        // Track velocity for fling
394        let pos = if self.is_vertical {
395            position.y
396        } else {
397            position.x
398        };
399        let event_sample_ms = gs
400            .gesture_start_event_time_ms
401            .zip(time_ms)
402            .map(|(start_ms, now_ms)| now_ms - start_ms);
403        let sample_ms = if let Some(event_sample_ms) = event_sample_ms {
404            // The platform supplied real input timestamps: trust them.
405            // Android delivers touch samples batched/frame-aligned, so several
406            // moves are processed back-to-back here; only the event's own
407            // timestamp yields the real dt between finger positions. Real
408            // pauses must also stay real so a stop-then-release does not fling
409            // (the tracker treats gaps > ASSUME_STOPPED_MS as stopped).
410            Some(match gs.last_velocity_sample_ms {
411                Some(last_sample_ms) => event_sample_ms.max(last_sample_ms),
412                None => event_sample_ms.max(0),
413            })
414        } else if let Some(start_time) = gs.gesture_start_time {
415            // Fallback: delivery-time stamping for platforms without input
416            // timestamps (desktop mouse, web).
417            let elapsed_ms = start_time.elapsed().as_millis() as i64;
418            // Keep sample times strictly increasing so velocity stays stable when
419            // multiple move events land in the same millisecond.
420            Some(match gs.last_velocity_sample_ms {
421                Some(last_sample_ms) => {
422                    let mut sample_ms = if elapsed_ms <= last_sample_ms {
423                        last_sample_ms + 1
424                    } else {
425                        elapsed_ms
426                    };
427                    // Clamp large processing gaps so frame stalls don't erase fling velocity.
428                    if sample_ms - last_sample_ms > ASSUME_STOPPED_MS {
429                        sample_ms = last_sample_ms + ASSUME_STOPPED_MS;
430                    }
431                    sample_ms
432                }
433                None => elapsed_ms,
434            })
435        } else {
436            None
437        };
438        if let Some(sample_ms) = sample_ms {
439            log::trace!(
440                target: "cranpose::velocity",
441                "sample t={sample_ms}ms pos={pos:.2} event_time={time_ms:?}"
442            );
443            gs.velocity_tracker.add_data_point(sample_ms, pos);
444            gs.last_velocity_sample_ms = Some(sample_ms);
445        }
446
447        if gs.is_dragging {
448            drop(gs); // Release borrow before calling scroll target
449            let delta = if self.reverse_scrolling {
450                -incremental_delta
451            } else {
452                incremental_delta
453            };
454            let _ = self.scroll_target.apply_delta(delta);
455            self.scroll_target.invalidate();
456            true // Consume event while dragging
457        } else {
458            false
459        }
460    }
461
462    /// Handles pointer up event.
463    ///
464    /// Cleans up drag state. If we were actively dragging, calculates fling
465    /// velocity and starts fling animation if velocity is above threshold.
466    ///
467    /// Returns `true` if we were dragging (event should be consumed).
468    fn finish_gesture(&self, allow_fling: bool) -> bool {
469        let (was_dragging, velocity, start_fling, existing_fling) = {
470            let mut gs = self.gesture_state.borrow_mut();
471            let was_dragging = gs.is_dragging;
472            let mut velocity = 0.0;
473
474            if allow_fling && was_dragging && gs.gesture_start_time.is_some() {
475                velocity = gs
476                    .velocity_tracker
477                    .calculate_velocity_with_max(MAX_FLING_VELOCITY);
478            }
479
480            let start_fling = allow_fling && was_dragging && velocity.abs() > MIN_FLING_VELOCITY;
481            let existing_fling = if start_fling {
482                gs.fling_animation.take()
483            } else {
484                None
485            };
486
487            gs.drag_down_position = None;
488            gs.last_position = None;
489            gs.is_dragging = false;
490            gs.axis_locked_out = false;
491            gs.gesture_start_time = None;
492            gs.gesture_start_event_time_ms = None;
493            gs.last_velocity_sample_ms = None;
494
495            (was_dragging, velocity, start_fling, existing_fling)
496        };
497
498        // Always record velocity for test accessibility (even if below fling threshold)
499        if allow_fling && was_dragging {
500            log::debug!(
501                target: "cranpose::velocity",
502                "gesture finished: fling velocity={velocity:.2} dp/s start_fling={start_fling}"
503            );
504            set_last_fling_velocity(velocity);
505        }
506
507        // Start fling animation if velocity is significant
508        if start_fling {
509            if let Some(old_fling) = existing_fling {
510                old_fling.cancel();
511            }
512
513            // Get runtime handle for frame callbacks
514            if let Some(runtime) = current_runtime_handle() {
515                self.motion_context.set_active(true);
516                let scroll_target = self.scroll_target.clone();
517                let reverse = self.reverse_scrolling;
518                let fling = FlingAnimation::new(runtime);
519                let motion_context = self.motion_context.clone();
520
521                // Get current scroll position for fling start
522                let initial_value = scroll_target.current_offset();
523
524                // Convert gesture velocity to scroll velocity.
525                let adjusted_velocity = if reverse { -velocity } else { velocity };
526                let fling_velocity = -adjusted_velocity;
527
528                let scroll_target_for_fling = scroll_target.clone();
529                let scroll_target_for_end = scroll_target.clone();
530
531                fling.start_fling(
532                    initial_value,
533                    fling_velocity,
534                    current_density(),
535                    move |delta| {
536                        // Apply scroll delta during fling, return consumed amount
537                        let consumed = scroll_target_for_fling.apply_fling_delta(delta);
538                        scroll_target_for_fling.invalidate();
539                        consumed
540                    },
541                    move || {
542                        // Animation complete - invalidate to ensure final render
543                        scroll_target_for_end.invalidate();
544                        motion_context.set_active(false);
545                    },
546                );
547
548                let mut gs = self.gesture_state.borrow_mut();
549                gs.fling_animation = Some(fling);
550            }
551        } else {
552            self.motion_context.set_active(false);
553        }
554
555        was_dragging
556    }
557
558    /// Handles pointer up event.
559    ///
560    /// Cleans up drag state. If we were actively dragging, calculates fling
561    /// velocity and starts fling animation if velocity is above threshold.
562    ///
563    /// Returns `true` if we were dragging (event should be consumed).
564    fn on_up(&self) -> bool {
565        self.finish_gesture(true)
566    }
567
568    /// Handles pointer cancel event.
569    ///
570    /// Cleans up state without starting a fling. Returns `true` if we were dragging.
571    fn on_cancel(&self) -> bool {
572        self.finish_gesture(false)
573    }
574
575    /// Handles mouse wheel / trackpad scroll event.
576    ///
577    /// Returns `true` when the target consumed any delta.
578    fn on_scroll(&self, axis_delta: f32) -> bool {
579        if axis_delta.abs() <= f32::EPSILON {
580            return false;
581        }
582
583        {
584            // Wheel scroll should take over immediately and stop any active drag/fling state.
585            let mut gs = self.gesture_state.borrow_mut();
586            if let Some(fling) = gs.fling_animation.take() {
587                fling.cancel();
588            }
589            gs.drag_down_position = None;
590            gs.last_position = None;
591            gs.is_dragging = false;
592            gs.axis_locked_out = false;
593            gs.gesture_start_time = None;
594            gs.gesture_start_event_time_ms = None;
595            gs.last_velocity_sample_ms = None;
596            gs.velocity_tracker.reset();
597        }
598
599        self.motion_context.activate_for_current_frame();
600
601        let delta = if self.reverse_scrolling {
602            -axis_delta
603        } else {
604            axis_delta
605        };
606        let consumed = self.scroll_target.apply_wheel_delta(delta);
607        if consumed.abs() > 0.001 {
608            self.scroll_target.invalidate();
609            true
610        } else {
611            false
612        }
613    }
614}
615
616pub(crate) struct MotionContextAnimatedNode {
617    state: NodeState,
618    motion_context: ScrollMotionContext,
619    invalidation_callback_id: Option<u64>,
620    node_id: Option<NodeId>,
621}
622
623impl MotionContextAnimatedNode {
624    fn new(motion_context: ScrollMotionContext) -> Self {
625        Self {
626            state: NodeState::new(),
627            motion_context,
628            invalidation_callback_id: None,
629            node_id: None,
630        }
631    }
632
633    pub(crate) fn is_active(&self) -> bool {
634        self.motion_context.is_active()
635    }
636}
637
638pub(crate) struct TranslatedContentContextNode {
639    state: NodeState,
640    identity: usize,
641    offset_source: TranslatedContentOffsetSource,
642}
643
644impl TranslatedContentContextNode {
645    fn new(identity: usize, offset_source: TranslatedContentOffsetSource) -> Self {
646        Self {
647            state: NodeState::new(),
648            identity,
649            offset_source,
650        }
651    }
652
653    pub(crate) fn is_active(&self) -> bool {
654        true
655    }
656
657    pub(crate) fn identity(&self) -> usize {
658        self.identity
659    }
660
661    pub(crate) fn content_offset_reader(&self) -> Option<Rc<dyn Fn() -> Point>> {
662        self.offset_source.content_offset_reader()
663    }
664}
665
666impl DelegatableNode for TranslatedContentContextNode {
667    fn node_state(&self) -> &NodeState {
668        &self.state
669    }
670}
671
672impl ModifierNode for TranslatedContentContextNode {}
673
674impl DelegatableNode for MotionContextAnimatedNode {
675    fn node_state(&self) -> &NodeState {
676        &self.state
677    }
678}
679
680impl ModifierNode for MotionContextAnimatedNode {
681    fn on_attach(&mut self, context: &mut dyn cranpose_foundation::ModifierNodeContext) {
682        let node_id = context.node_id();
683        self.node_id = node_id;
684        if let Some(node_id) = node_id {
685            let callback_id = self
686                .motion_context
687                .add_invalidate_callback(Box::new(move || {
688                    schedule_modifier_slices_repass(node_id);
689                }));
690            self.invalidation_callback_id = Some(callback_id);
691        }
692    }
693
694    fn on_detach(&mut self) {
695        if let Some(id) = self.invalidation_callback_id.take() {
696            self.motion_context.remove_invalidate_callback(id);
697        }
698        self.node_id = None;
699    }
700}
701
702#[derive(Clone)]
703struct MotionContextAnimatedElement {
704    motion_context: ScrollMotionContext,
705}
706
707impl MotionContextAnimatedElement {
708    fn new(motion_context: ScrollMotionContext) -> Self {
709        Self { motion_context }
710    }
711}
712
713impl std::fmt::Debug for MotionContextAnimatedElement {
714    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
715        f.debug_struct("MotionContextAnimatedElement").finish()
716    }
717}
718
719impl PartialEq for MotionContextAnimatedElement {
720    fn eq(&self, other: &Self) -> bool {
721        self.motion_context.ptr_eq(&other.motion_context)
722    }
723}
724
725impl Eq for MotionContextAnimatedElement {}
726
727impl std::hash::Hash for MotionContextAnimatedElement {
728    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
729        self.motion_context.stable_key().hash(state);
730    }
731}
732
733impl ModifierNodeElement for MotionContextAnimatedElement {
734    type Node = MotionContextAnimatedNode;
735
736    fn create(&self) -> Self::Node {
737        MotionContextAnimatedNode::new(self.motion_context.clone())
738    }
739
740    fn update(&self, node: &mut Self::Node) {
741        if node.motion_context.ptr_eq(&self.motion_context) {
742            return;
743        }
744        if let Some(id) = node.invalidation_callback_id.take() {
745            node.motion_context.remove_invalidate_callback(id);
746        }
747        node.motion_context = self.motion_context.clone();
748        if let Some(node_id) = node.node_id {
749            let callback_id = node
750                .motion_context
751                .add_invalidate_callback(Box::new(move || {
752                    schedule_modifier_slices_repass(node_id);
753                }));
754            node.invalidation_callback_id = Some(callback_id);
755        }
756    }
757
758    fn capabilities(&self) -> NodeCapabilities {
759        NodeCapabilities::LAYOUT
760    }
761}
762
763#[derive(Clone)]
764enum TranslatedContentOffsetSource {
765    LayoutContentOffset,
766    LazyList {
767        state: LazyListState,
768        is_vertical: bool,
769        reverse_scrolling: bool,
770    },
771}
772
773impl TranslatedContentOffsetSource {
774    fn content_offset_reader(&self) -> Option<Rc<dyn Fn() -> Point>> {
775        match self {
776            Self::LayoutContentOffset => None,
777            Self::LazyList {
778                state, is_vertical, ..
779            } => Some(Rc::new(lazy_list_content_offset_reader(
780                *state,
781                *is_vertical,
782            ))),
783        }
784    }
785
786    fn is_vertical(&self) -> Option<bool> {
787        match self {
788            Self::LayoutContentOffset => None,
789            Self::LazyList { is_vertical, .. } => Some(*is_vertical),
790        }
791    }
792
793    fn reverse_scrolling(&self) -> Option<bool> {
794        match self {
795            Self::LayoutContentOffset => None,
796            Self::LazyList {
797                reverse_scrolling, ..
798            } => Some(*reverse_scrolling),
799        }
800    }
801}
802
803fn lazy_list_content_offset_reader(state: LazyListState, is_vertical: bool) -> impl Fn() -> Point {
804    move || {
805        let info = state.layout_info();
806        if info.visible_items_info.is_empty() {
807            return Point::default();
808        };
809        let main_offset = info.snap_anchor_offset;
810        if is_vertical {
811            Point::new(0.0, main_offset)
812        } else {
813            Point::new(main_offset, 0.0)
814        }
815    }
816}
817
818#[derive(Clone)]
819struct TranslatedContentContextElement {
820    identity: usize,
821    offset_source: TranslatedContentOffsetSource,
822}
823
824impl TranslatedContentContextElement {
825    fn new(identity: usize, offset_source: TranslatedContentOffsetSource) -> Self {
826        Self {
827            identity,
828            offset_source,
829        }
830    }
831}
832
833impl std::fmt::Debug for TranslatedContentContextElement {
834    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
835        let offset_source = match &self.offset_source {
836            TranslatedContentOffsetSource::LayoutContentOffset => "layout",
837            TranslatedContentOffsetSource::LazyList { .. } => "lazy_list",
838        };
839        f.debug_struct("TranslatedContentContextElement")
840            .field("identity", &self.identity)
841            .field("offset_source", &offset_source)
842            .finish()
843    }
844}
845
846impl PartialEq for TranslatedContentContextElement {
847    fn eq(&self, other: &Self) -> bool {
848        self.identity == other.identity
849            && self.offset_source.is_vertical() == other.offset_source.is_vertical()
850            && self.offset_source.reverse_scrolling() == other.offset_source.reverse_scrolling()
851    }
852}
853
854impl Eq for TranslatedContentContextElement {}
855
856impl std::hash::Hash for TranslatedContentContextElement {
857    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
858        self.identity.hash(state);
859        self.offset_source.is_vertical().hash(state);
860        self.offset_source.reverse_scrolling().hash(state);
861    }
862}
863
864impl ModifierNodeElement for TranslatedContentContextElement {
865    type Node = TranslatedContentContextNode;
866
867    fn create(&self) -> Self::Node {
868        TranslatedContentContextNode::new(self.identity, self.offset_source.clone())
869    }
870
871    fn update(&self, node: &mut Self::Node) {
872        node.identity = self.identity;
873        node.offset_source = self.offset_source.clone();
874    }
875
876    fn capabilities(&self) -> NodeCapabilities {
877        NodeCapabilities::LAYOUT
878    }
879}
880
881// ============================================================================
882// Modifier Extensions
883// ============================================================================
884
885impl Modifier {
886    /// Creates a horizontally scrollable modifier.
887    ///
888    /// # Arguments
889    /// * `state` - The ScrollState to control scroll position
890    /// * `reverse_scrolling` - If true, reverses the scroll direction in layout.
891    ///   Note: This affects how scroll offset is applied to content (via `ScrollNode`),
892    ///   NOT the drag direction. Drag gestures always follow natural touch semantics:
893    ///   drag right = scroll left (content moves right under finger).
894    ///
895    /// # Example
896    /// ```text
897    /// let scroll_state = ScrollState::new(0.0);
898    /// Row(
899    ///     Modifier::empty().horizontal_scroll(scroll_state, false),
900    ///     // ... content
901    /// );
902    /// ```
903    pub fn horizontal_scroll(self, state: ScrollState, reverse_scrolling: bool) -> Self {
904        self.then(scroll_impl(state, false, reverse_scrolling, None))
905    }
906
907    /// Creates a vertically scrollable modifier.
908    ///
909    /// # Arguments
910    /// * `state` - The ScrollState to control scroll position
911    /// * `reverse_scrolling` - If true, reverses the scroll direction in layout.
912    ///   Note: This affects how scroll offset is applied to content (via `ScrollNode`),
913    ///   NOT the drag direction. Drag gestures always follow natural touch semantics:
914    ///   drag down = scroll up (content moves down under finger).
915    pub fn vertical_scroll(self, state: ScrollState, reverse_scrolling: bool) -> Self {
916        self.then(scroll_impl(state, true, reverse_scrolling, None))
917    }
918
919    /// Creates a horizontally scrollable modifier with a guard that can disable scrolling.
920    pub fn horizontal_scroll_guarded(
921        self,
922        state: ScrollState,
923        reverse_scrolling: bool,
924        guard: impl Fn() -> bool + 'static,
925    ) -> Self {
926        self.then(scroll_impl(
927            state,
928            false,
929            reverse_scrolling,
930            Some(Rc::new(guard)),
931        ))
932    }
933
934    /// Creates a vertically scrollable modifier with a guard that can disable scrolling.
935    pub fn vertical_scroll_guarded(
936        self,
937        state: ScrollState,
938        reverse_scrolling: bool,
939        guard: impl Fn() -> bool + 'static,
940    ) -> Self {
941        self.then(scroll_impl(
942            state,
943            true,
944            reverse_scrolling,
945            Some(Rc::new(guard)),
946        ))
947    }
948}
949
950/// Internal implementation for scroll modifiers.
951///
952/// Creates a combined modifier consisting of:
953/// 1. Pointer input handler (for gesture detection)
954/// 2. Layout modifier (for applying scroll offset)
955///
956/// The pointer input is added FIRST so it appears earlier in the modifier
957/// chain, allowing it to intercept events before layout-related handlers.
958fn scroll_impl(
959    state: ScrollState,
960    is_vertical: bool,
961    reverse_scrolling: bool,
962    guard: Option<Rc<dyn Fn() -> bool>>,
963) -> Modifier {
964    // Create local gesture state - each scroll modifier instance is independent
965    let gesture_state = Rc::new(RefCell::new(ScrollGestureState::default()));
966    let motion_context = scroll_motion_context_for_key(ScrollMotionContextKey::ScrollState {
967        state_id: state.id(),
968        is_vertical,
969        reverse_scrolling,
970    });
971
972    // Set up pointer input handler
973    let scroll_state = state.clone();
974    let pointer_motion_context = motion_context.clone();
975    let key = (state.id(), is_vertical);
976    let pointer_input = Modifier::empty().pointer_input(key, move |scope| {
977        // Create detector inside the async closure to capture the cloned state
978        let detector = ScrollGestureDetector::new(
979            gesture_state.clone(),
980            scroll_state.clone(),
981            is_vertical,
982            false, // ScrollState handles reversing in layout, not input
983            pointer_motion_context.clone(),
984        );
985        let guard = guard.clone();
986
987        async move {
988            scope
989                .await_pointer_event_scope(|await_scope| async move {
990                    // Main event loop - processes events until scope is cancelled
991                    loop {
992                        let event = await_scope.await_pointer_event().await;
993
994                        // Scroll drags track the primary pointer only;
995                        // secondary pointers belong to multi-touch gestures
996                        // (pinch/zoom) handled by other modifiers.
997                        if event.id != 0 {
998                            continue;
999                        }
1000
1001                        if event.is_consumed() {
1002                            if matches!(
1003                                event.kind,
1004                                PointerEventKind::Down
1005                                    | PointerEventKind::Move
1006                                    | PointerEventKind::Up
1007                                    | PointerEventKind::Cancel
1008                            ) {
1009                                detector.on_cancel();
1010                            }
1011                            continue;
1012                        }
1013
1014                        if let Some(ref guard) = guard {
1015                            if !guard() {
1016                                if matches!(
1017                                    event.kind,
1018                                    PointerEventKind::Up | PointerEventKind::Cancel
1019                                ) {
1020                                    detector.on_cancel();
1021                                }
1022                                continue;
1023                            }
1024                        }
1025
1026                        // Delegate to detector's lifecycle methods
1027                        let should_consume = match event.kind {
1028                            PointerEventKind::Down => {
1029                                detector.on_down(event.position, event.time_ms)
1030                            }
1031                            PointerEventKind::Move => {
1032                                detector.on_move(event.position, event.buttons, event.time_ms)
1033                            }
1034                            PointerEventKind::Up => detector.on_up(),
1035                            PointerEventKind::Cancel => detector.on_cancel(),
1036                            PointerEventKind::Scroll => detector.on_scroll(if is_vertical {
1037                                event.scroll_delta.y
1038                            } else {
1039                                event.scroll_delta.x
1040                            }),
1041                            PointerEventKind::Zoom
1042                            | PointerEventKind::Enter
1043                            | PointerEventKind::Exit => false,
1044                        };
1045
1046                        if should_consume {
1047                            event.consume();
1048                        }
1049                    }
1050                })
1051                .await;
1052        }
1053    });
1054
1055    // Create layout modifier for applying scroll offset to content
1056    let element = ScrollElement::new(state.clone(), is_vertical, reverse_scrolling);
1057    let layout_modifier =
1058        Modifier::with_element(element).with_inspector_metadata(inspector_metadata(
1059            if is_vertical {
1060                "verticalScroll"
1061            } else {
1062                "horizontalScroll"
1063            },
1064            move |info| {
1065                info.add_property("isVertical", is_vertical.to_string());
1066                info.add_property("reverseScrolling", reverse_scrolling.to_string());
1067            },
1068        ));
1069    let motion_modifier =
1070        Modifier::with_element(MotionContextAnimatedElement::new(motion_context.clone()));
1071    let translated_content_modifier = Modifier::with_element(TranslatedContentContextElement::new(
1072        state.id() as usize,
1073        TranslatedContentOffsetSource::LayoutContentOffset,
1074    ));
1075
1076    // Combine: pointer input THEN layout modifier, clip to bounds by default
1077    pointer_input
1078        .then(motion_modifier)
1079        .then(translated_content_modifier)
1080        .then(layout_modifier)
1081        .clip_to_bounds()
1082}
1083
1084// ============================================================================
1085// Lazy Scroll Support for LazyListState
1086// ============================================================================
1087
1088use cranpose_foundation::lazy::LazyListState;
1089
1090impl Modifier {
1091    /// Creates a vertically scrollable modifier for lazy lists.
1092    ///
1093    /// This connects pointer gestures to LazyListState for scroll handling.
1094    /// Unlike regular vertical_scroll, no layout offset is applied here
1095    /// since LazyListState manages item positioning internally.
1096    /// Creates a vertically scrollable modifier for lazy lists.
1097    ///
1098    /// This connects pointer gestures to LazyListState for scroll handling.
1099    /// Unlike regular vertical_scroll, no layout offset is applied here
1100    /// since LazyListState manages item positioning internally.
1101    pub fn lazy_vertical_scroll(self, state: LazyListState, reverse_scrolling: bool) -> Self {
1102        self.then(lazy_scroll_impl(state, true, reverse_scrolling))
1103    }
1104
1105    /// Creates a horizontally scrollable modifier for lazy lists.
1106    pub fn lazy_horizontal_scroll(self, state: LazyListState, reverse_scrolling: bool) -> Self {
1107        self.then(lazy_scroll_impl(state, false, reverse_scrolling))
1108    }
1109}
1110
1111/// Internal implementation for lazy scroll modifiers.
1112fn lazy_scroll_impl(state: LazyListState, is_vertical: bool, reverse_scrolling: bool) -> Modifier {
1113    let gesture_state = Rc::new(RefCell::new(ScrollGestureState::default()));
1114    let list_state = state;
1115    let state_id = state.inner_ptr() as usize;
1116    let motion_context = scroll_motion_context_for_key(ScrollMotionContextKey::LazyList {
1117        state_identity: state_id,
1118        is_vertical,
1119        reverse_scrolling,
1120    });
1121    let key = (state_id, is_vertical, reverse_scrolling);
1122    let translated_content_modifier = Modifier::with_element(TranslatedContentContextElement::new(
1123        state_id,
1124        TranslatedContentOffsetSource::LazyList {
1125            state,
1126            is_vertical,
1127            reverse_scrolling,
1128        },
1129    ));
1130
1131    Modifier::with_element(MotionContextAnimatedElement::new(motion_context.clone()))
1132        .then(translated_content_modifier)
1133        .pointer_input(key, move |scope| {
1134            // Use the same generic detector with LazyListState
1135            let detector = ScrollGestureDetector::new(
1136                gesture_state.clone(),
1137                list_state,
1138                is_vertical,
1139                reverse_scrolling,
1140                motion_context.clone(),
1141            );
1142
1143            async move {
1144                scope
1145                    .await_pointer_event_scope(|await_scope| async move {
1146                        loop {
1147                            let event = await_scope.await_pointer_event().await;
1148
1149                            // Scroll drags track the primary pointer only;
1150                            // secondary pointers belong to multi-touch
1151                            // gestures handled by other modifiers.
1152                            if event.id != 0 {
1153                                continue;
1154                            }
1155
1156                            if event.is_consumed() {
1157                                if matches!(
1158                                    event.kind,
1159                                    PointerEventKind::Down
1160                                        | PointerEventKind::Move
1161                                        | PointerEventKind::Up
1162                                        | PointerEventKind::Cancel
1163                                ) {
1164                                    detector.on_cancel();
1165                                }
1166                                continue;
1167                            }
1168
1169                            // Delegate to detector's lifecycle methods
1170                            let should_consume = match event.kind {
1171                                PointerEventKind::Down => {
1172                                    detector.on_down(event.position, event.time_ms)
1173                                }
1174                                PointerEventKind::Move => {
1175                                    detector.on_move(event.position, event.buttons, event.time_ms)
1176                                }
1177                                PointerEventKind::Up => detector.on_up(),
1178                                PointerEventKind::Cancel => detector.on_cancel(),
1179                                PointerEventKind::Scroll => detector.on_scroll(if is_vertical {
1180                                    event.scroll_delta.y
1181                                } else {
1182                                    event.scroll_delta.x
1183                                }),
1184                                PointerEventKind::Zoom
1185                                | PointerEventKind::Enter
1186                                | PointerEventKind::Exit => false,
1187                            };
1188
1189                            if should_consume {
1190                                event.consume();
1191                            }
1192                        }
1193                    })
1194                    .await;
1195            }
1196        })
1197}