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