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