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