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