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