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