Skip to main content

cranpose_ui/modifier/
scroll.rs

1use std::{
2    cell::{Cell, RefCell},
3    rc::Rc,
4};
5
6use cranpose_core::{NodeId, current_runtime_handle, internal::FrameCallbackRegistration};
7use cranpose_foundation::{
8    DRAG_THRESHOLD, DelegatableNode, MAX_FLING_VELOCITY, ModifierNode, ModifierNodeElement,
9    NodeCapabilities, NodeState, PointerButton, PointerButtons, VelocityTracker1D,
10    velocity_tracker::ASSUME_STOPPED_MS,
11};
12use cranpose_ui_layout::Axis;
13use web_time::Instant;
14
15use super::{Modifier, Point, PointerEvent, PointerEventKind, inspector_metadata};
16use crate::{
17    draggable::{DraggableState, ScrollableState},
18    fling_animation::{
19        FlingAnimation, MIN_FLING_VELOCITY, SettleAnimation, SpringParams, fling_rest_position,
20    },
21    nested_scroll::{NestedScrollSource, ScrollAxis},
22    render_state::schedule_modifier_slices_repass,
23    scroll::{
24        ScrollElement, ScrollMotionContext, ScrollMotionContextKey, ScrollSettlePolicy,
25        ScrollState, scroll_motion_context_for_key,
26    },
27};
28
29/// Returns the velocity of the last fling that started in the current app context, in dp per
30/// second.
31#[cfg(feature = "test-helpers")]
32pub fn last_fling_velocity() -> f32 {
33    crate::render_state::debug_last_fling_velocity()
34}
35
36/// Sets the value that [`last_fling_velocity`] returns to zero.
37#[cfg(feature = "test-helpers")]
38pub fn reset_last_fling_velocity() {
39    crate::render_state::debug_reset_last_fling_velocity();
40}
41
42#[inline]
43fn set_last_fling_velocity(velocity: f32) {
44    crate::render_state::record_last_fling_velocity(velocity);
45}
46
47struct ScrollGestureState {
48    disposed: bool,
49    drag_down_position: Option<Point>,
50
51    last_position: Option<Point>,
52
53    is_dragging: bool,
54
55    /// Whether the press stopped a scroll animation the app started, so
56    /// letting go settles even without a drag.
57    interrupted_animation: bool,
58
59    axis_locked_out: bool,
60
61    velocity_tracker: VelocityTracker1D,
62
63    gesture_start_time: Option<Instant>,
64
65    gesture_start_event_time_ms: Option<i64>,
66
67    last_velocity_sample_ms: Option<i64>,
68
69    fling_animation: Option<FlingAnimation>,
70
71    is_overscrolling: bool,
72
73    settle_animation: Option<SettleAnimation>,
74
75    wheel_settle_watcher: Option<WheelSettleWatcher>,
76}
77
78impl Default for ScrollGestureState {
79    fn default() -> Self {
80        Self {
81            disposed: false,
82            drag_down_position: None,
83            last_position: None,
84            is_dragging: false,
85            interrupted_animation: false,
86            axis_locked_out: false,
87            velocity_tracker: VelocityTracker1D::new(),
88            gesture_start_time: None,
89            gesture_start_event_time_ms: None,
90            last_velocity_sample_ms: None,
91            fling_animation: None,
92            is_overscrolling: false,
93            settle_animation: None,
94            wheel_settle_watcher: None,
95        }
96    }
97}
98
99impl ScrollGestureState {
100    fn is_active(&self) -> bool {
101        self.is_dragging
102            || self.is_overscrolling
103            || self
104                .fling_animation
105                .as_ref()
106                .is_some_and(FlingAnimation::is_running)
107            || self
108                .settle_animation
109                .as_ref()
110                .is_some_and(SettleAnimation::is_running)
111            || self
112                .wheel_settle_watcher
113                .as_ref()
114                .is_some_and(|watcher| watcher.is_running.get())
115    }
116
117    /// The velocity a gesture ends with, zero when the finger rested before
118    /// it lifted.
119    fn release_velocity(&mut self, release_time_ms: Option<i64>) -> f32 {
120        let Some(start) = self.gesture_start_time else {
121            return 0.0;
122        };
123        let release_sample_ms = release_time_ms
124            .zip(self.gesture_start_event_time_ms)
125            .map_or_else(
126                || start.elapsed().as_millis() as i64,
127                |(release_ms, start_ms)| release_ms - start_ms,
128            );
129        let rested_before_release = self
130            .last_velocity_sample_ms
131            .is_some_and(|last_sample_ms| release_sample_ms - last_sample_ms > ASSUME_STOPPED_MS);
132        if rested_before_release {
133            return 0.0;
134        }
135        self.velocity_tracker
136            .calculate_velocity_with_max(MAX_FLING_VELOCITY)
137    }
138}
139
140#[inline]
141fn calculate_total_delta(from: Point, to: Point, is_vertical: bool) -> f32 {
142    if is_vertical {
143        to.y - from.y
144    } else {
145        to.x - from.x
146    }
147}
148
149#[inline]
150fn calculate_incremental_delta(from: Point, to: Point, is_vertical: bool) -> f32 {
151    if is_vertical {
152        to.y - from.y
153    } else {
154        to.x - from.x
155    }
156}
157
158trait ScrollTarget: Clone {
159    fn apply_delta(&self, delta: f32) -> f32;
160
161    fn apply_wheel_delta(&self, delta: f32) -> f32 {
162        self.apply_delta(delta)
163    }
164
165    fn apply_fling_delta(&self, delta: f32) -> f32;
166
167    fn invalidate(&self);
168
169    fn current_offset(&self) -> f32;
170
171    fn can_scroll(&self) -> bool {
172        true
173    }
174
175    fn can_consume(&self, gesture_delta: f32) -> bool {
176        let _ = gesture_delta;
177        self.can_scroll()
178    }
179
180    fn settle_policy(&self) -> Option<ScrollSettlePolicy> {
181        None
182    }
183
184    fn allows_fling(&self) -> bool {
185        true
186    }
187
188    fn set_dragging(&self, dragging: bool) {
189        let _ = dragging;
190    }
191
192    /// Stops a scroll animation the app started, which a press takes over,
193    /// and returns whether one ran.
194    fn stop_animation(&self) -> bool {
195        false
196    }
197
198    /// The part of a release's `velocity`, in content units per second, the
199    /// containers around the scroller take before it flings.
200    fn pre_fling(&self, velocity: f32) -> f32 {
201        let _ = velocity;
202        0.0
203    }
204}
205
206/// A scroller inside [`crate::NestedScroll`] containers: every move goes
207/// through them, before and after the scroller takes its part.
208#[derive(Clone)]
209struct NestedTarget<S> {
210    target: S,
211    parent: crate::nested_scroll::NestedScrollParent,
212    axis: ScrollAxis,
213}
214
215impl<S: ScrollTarget> ScrollTarget for NestedTarget<S> {
216    fn apply_delta(&self, delta: f32) -> f32 {
217        self.parent
218            .dispatch(self.axis, delta, NestedScrollSource::Drag, |left| {
219                self.target.apply_delta(left)
220            })
221    }
222
223    fn apply_wheel_delta(&self, delta: f32) -> f32 {
224        self.parent
225            .dispatch(self.axis, delta, NestedScrollSource::Wheel, |left| {
226                self.target.apply_wheel_delta(left)
227            })
228    }
229
230    /// A fling moves the scroll value, the opposite way to the content.
231    fn apply_fling_delta(&self, delta: f32) -> f32 {
232        -self
233            .parent
234            .dispatch(self.axis, -delta, NestedScrollSource::Fling, |left| {
235                -self.target.apply_fling_delta(-left)
236            })
237    }
238
239    fn invalidate(&self) {
240        self.target.invalidate();
241    }
242
243    fn current_offset(&self) -> f32 {
244        self.target.current_offset()
245    }
246
247    fn can_scroll(&self) -> bool {
248        self.target.can_scroll()
249    }
250
251    fn can_consume(&self, gesture_delta: f32) -> bool {
252        self.target.can_consume(gesture_delta)
253    }
254
255    fn settle_policy(&self) -> Option<ScrollSettlePolicy> {
256        self.target.settle_policy()
257    }
258
259    fn allows_fling(&self) -> bool {
260        self.target.allows_fling()
261    }
262
263    fn set_dragging(&self, dragging: bool) {
264        self.target.set_dragging(dragging);
265    }
266
267    fn stop_animation(&self) -> bool {
268        self.target.stop_animation()
269    }
270
271    fn pre_fling(&self, velocity: f32) -> f32 {
272        self.parent.pre_fling(self.axis, velocity)
273    }
274}
275
276impl ScrollTarget for ScrollState {
277    fn apply_delta(&self, delta: f32) -> f32 {
278        -self.dispatch_raw_delta(-delta)
279    }
280
281    fn apply_fling_delta(&self, delta: f32) -> f32 {
282        self.dispatch_raw_delta(delta)
283    }
284
285    fn invalidate(&self) {}
286
287    fn current_offset(&self) -> f32 {
288        self.value()
289    }
290
291    fn can_scroll(&self) -> bool {
292        self.max_value() > 0.0
293    }
294
295    fn can_consume(&self, gesture_delta: f32) -> bool {
296        let raw = -gesture_delta;
297        if raw > 0.0 {
298            self.value_non_reactive() < self.max_value()
299        } else {
300            self.value_non_reactive() > 0.0
301        }
302    }
303
304    fn settle_policy(&self) -> Option<ScrollSettlePolicy> {
305        ScrollState::settle_policy(self)
306    }
307}
308
309impl ScrollTarget for LazyListState {
310    fn apply_delta(&self, delta: f32) -> f32 {
311        self.dispatch_scroll_delta(delta)
312    }
313
314    fn apply_wheel_delta(&self, delta: f32) -> f32 {
315        if delta.abs() <= 0.001 {
316            0.0
317        } else {
318            self.dispatch_scroll_delta(delta)
319        }
320    }
321
322    fn apply_fling_delta(&self, delta: f32) -> f32 {
323        -self.dispatch_scroll_delta(-delta)
324    }
325
326    fn invalidate(&self) {}
327
328    fn current_offset(&self) -> f32 {
329        self.first_visible_item_scroll_offset()
330    }
331
332    fn can_scroll(&self) -> bool {
333        self.total_items_count() == 0
334            || self.can_scroll_forward_non_reactive()
335            || self.can_scroll_backward_non_reactive()
336    }
337
338    fn can_consume(&self, gesture_delta: f32) -> bool {
339        if self.total_items_count() == 0 {
340            return true;
341        }
342        if gesture_delta < 0.0 {
343            self.can_scroll_forward_non_reactive()
344        } else {
345            self.can_scroll_backward_non_reactive()
346        }
347    }
348
349    fn settle_policy(&self) -> Option<ScrollSettlePolicy> {
350        LazyListState::settle_policy(self)
351    }
352
353    fn set_dragging(&self, dragging: bool) {
354        self.set_dragged(dragging);
355    }
356
357    fn stop_animation(&self) -> bool {
358        self.stop_scroll_animation()
359    }
360}
361
362struct DragGesture<S: ScrollTarget> {
363    target: S,
364    is_vertical: bool,
365    reverse_input: bool,
366    motion_context: ScrollMotionContext,
367    guard: Option<Rc<dyn Fn() -> bool>>,
368}
369
370struct ScrollGestureLifetime {
371    gesture_state: Rc<RefCell<ScrollGestureState>>,
372    motion_context: ScrollMotionContext,
373}
374
375impl Drop for ScrollGestureLifetime {
376    fn drop(&mut self) {
377        let state = std::mem::replace(
378            &mut *self.gesture_state.borrow_mut(),
379            ScrollGestureState {
380                disposed: true,
381                ..ScrollGestureState::default()
382            },
383        );
384        let was_active = state.is_active();
385        if let Some(fling) = state.fling_animation {
386            fling.cancel();
387        }
388        if let Some(settle) = state.settle_animation {
389            settle.cancel();
390        }
391        if let Some(watcher) = state.wheel_settle_watcher {
392            watcher.cancel();
393        }
394        if was_active {
395            self.motion_context.set_active(false);
396        }
397    }
398}
399
400/// The drag gesture of a scroller, inside the [`crate::NestedScroll`]
401/// containers of the composition it is built in.
402fn nested_drag_gesture_input<K, S>(key: K, gesture: DragGesture<S>) -> Modifier
403where
404    K: std::hash::Hash + 'static,
405    S: ScrollTarget + 'static,
406{
407    let Some(parent) = crate::nested_scroll::current_nested_scroll_parent() else {
408        return drag_gesture_input(key, gesture);
409    };
410    let DragGesture {
411        target,
412        is_vertical,
413        reverse_input,
414        motion_context,
415        guard,
416    } = gesture;
417    let identity = parent.identity();
418    let axis = if is_vertical {
419        ScrollAxis::Vertical
420    } else {
421        ScrollAxis::Horizontal
422    };
423    drag_gesture_input(
424        (key, identity),
425        DragGesture {
426            target: NestedTarget {
427                target,
428                parent,
429                axis,
430            },
431            is_vertical,
432            reverse_input,
433            motion_context,
434            guard,
435        },
436    )
437}
438
439fn drag_gesture_input<K, S>(key: K, gesture: DragGesture<S>) -> Modifier
440where
441    K: std::hash::Hash + 'static,
442    S: ScrollTarget + 'static,
443{
444    let DragGesture {
445        target,
446        is_vertical,
447        reverse_input,
448        motion_context,
449        guard,
450    } = gesture;
451
452    Modifier::empty().pointer_input(key, move |scope| {
453        let gesture_state = Rc::new(RefCell::new(ScrollGestureState::default()));
454        let detector = ScrollGestureDetector::new(
455            gesture_state.clone(),
456            target.clone(),
457            is_vertical,
458            reverse_input,
459            motion_context.overscroll(),
460            motion_context.clone(),
461        );
462        let guard = guard.clone();
463        let lifetime = ScrollGestureLifetime {
464            gesture_state: Rc::clone(&gesture_state),
465            motion_context: motion_context.clone(),
466        };
467
468        async move {
469            let _lifetime = lifetime;
470            scope
471                .await_pointer_event_scope(|await_scope| async move {
472                    loop {
473                        let event = await_scope.await_pointer_event().await;
474
475                        if event.id != 0 {
476                            continue;
477                        }
478
479                        if event.is_consumed() {
480                            if matches!(
481                                event.kind,
482                                PointerEventKind::Down
483                                    | PointerEventKind::Move
484                                    | PointerEventKind::Up
485                                    | PointerEventKind::Cancel
486                            ) {
487                                detector.on_cancel();
488                            }
489                            continue;
490                        }
491
492                        if let Some(ref guard) = guard
493                            && !guard()
494                        {
495                            if matches!(event.kind, PointerEventKind::Up | PointerEventKind::Cancel)
496                            {
497                                detector.on_cancel();
498                            }
499                            continue;
500                        }
501
502                        let should_consume = match event.kind {
503                            PointerEventKind::Down => {
504                                detector.on_down(event.position, event.time_ms)
505                            }
506                            PointerEventKind::Move => detector.on_move(
507                                event.position,
508                                event.buttons,
509                                event.time_ms,
510                                &event,
511                            ),
512                            PointerEventKind::Up => detector.on_up(event.time_ms),
513                            PointerEventKind::Cancel => detector.on_cancel(),
514                            PointerEventKind::Scroll => detector.on_scroll(
515                                if is_vertical {
516                                    event.scroll_delta.y
517                                } else {
518                                    event.scroll_delta.x
519                                },
520                                &event,
521                            ),
522                            PointerEventKind::Zoom
523                            | PointerEventKind::RotaryScrollPre
524                            | PointerEventKind::RotaryScroll
525                            | PointerEventKind::Enter
526                            | PointerEventKind::Exit
527                            | PointerEventKind::ButtonDown(_)
528                            | PointerEventKind::ButtonUp(_) => false,
529                        };
530
531                        if should_consume {
532                            event.consume();
533                        }
534                    }
535                })
536                .await;
537        }
538    })
539}
540
541impl ScrollTarget for DraggableState {
542    fn apply_delta(&self, delta: f32) -> f32 {
543        self.drag_by(delta);
544        delta
545    }
546
547    fn apply_fling_delta(&self, delta: f32) -> f32 {
548        self.drag_by(delta);
549        delta
550    }
551
552    fn invalidate(&self) {}
553
554    fn current_offset(&self) -> f32 {
555        self.offset()
556    }
557
558    fn allows_fling(&self) -> bool {
559        false
560    }
561
562    fn set_dragging(&self, dragging: bool) {
563        DraggableState::set_dragging(self, dragging);
564    }
565}
566
567impl ScrollTarget for ScrollableState {
568    fn apply_delta(&self, delta: f32) -> f32 {
569        self.dispatch_raw_delta(delta)
570    }
571
572    fn apply_fling_delta(&self, delta: f32) -> f32 {
573        -self.dispatch_raw_delta(-delta)
574    }
575
576    fn invalidate(&self) {}
577
578    fn current_offset(&self) -> f32 {
579        -self.offset()
580    }
581
582    fn set_dragging(&self, dragging: bool) {
583        ScrollableState::set_dragging(self, dragging);
584    }
585}
586
587const WHEEL_SETTLE_IDLE_NANOS: u64 = 180_000_000;
588
589struct WheelSettleWatcher {
590    is_running: Rc<Cell<bool>>,
591    registration: Rc<RefCell<Option<FrameCallbackRegistration>>>,
592}
593
594impl WheelSettleWatcher {
595    fn cancel(&self) {
596        self.is_running.set(false);
597        self.registration.borrow_mut().take();
598    }
599}
600
601struct ScrollGestureDetector<S: ScrollTarget> {
602    gesture_state: Rc<RefCell<ScrollGestureState>>,
603
604    scroll_target: S,
605
606    is_vertical: bool,
607
608    reverse_scrolling: bool,
609
610    overscroll: crate::scroll::OverscrollEffect,
611
612    motion_context: ScrollMotionContext,
613}
614
615impl<S: ScrollTarget + 'static> ScrollGestureDetector<S> {
616    fn new(
617        gesture_state: Rc<RefCell<ScrollGestureState>>,
618        scroll_target: S,
619        is_vertical: bool,
620        reverse_scrolling: bool,
621        overscroll: crate::scroll::OverscrollEffect,
622        motion_context: ScrollMotionContext,
623    ) -> Self {
624        Self {
625            gesture_state,
626            scroll_target,
627            is_vertical,
628            reverse_scrolling,
629            overscroll,
630            motion_context,
631        }
632    }
633
634    fn on_down(&self, position: Point, time_ms: Option<i64>) -> bool {
635        let interrupted = self.scroll_target.stop_animation();
636        let mut gs = self.gesture_state.borrow_mut();
637        gs.interrupted_animation = interrupted;
638
639        if let Some(fling) = gs.fling_animation.take() {
640            fling.cancel();
641        }
642        if let Some(settle) = gs.settle_animation.take() {
643            settle.cancel();
644        }
645        if let Some(watcher) = gs.wheel_settle_watcher.take() {
646            watcher.cancel();
647        }
648        self.motion_context.set_active(false);
649
650        gs.drag_down_position = Some(position);
651        gs.last_position = Some(position);
652        gs.is_dragging = false;
653        gs.axis_locked_out = false;
654        gs.velocity_tracker.reset();
655        gs.gesture_start_time = Some(Instant::now());
656        gs.gesture_start_event_time_ms = time_ms;
657        gs.is_overscrolling = self.overscroll.offset().abs() > 0.001;
658
659        let pos = if self.is_vertical {
660            position.y
661        } else {
662            position.x
663        };
664        gs.velocity_tracker.add_data_point(0, pos);
665        gs.last_velocity_sample_ms = Some(0);
666
667        false
668    }
669
670    fn on_move(
671        &self,
672        position: Point,
673        buttons: PointerButtons,
674        time_ms: Option<i64>,
675        event: &PointerEvent,
676    ) -> bool {
677        let mut gs = self.gesture_state.borrow_mut();
678
679        if !buttons.contains(PointerButton::Primary) && gs.drag_down_position.is_some() {
680            if gs.is_dragging {
681                self.scroll_target.set_dragging(false);
682            }
683            gs.drag_down_position = None;
684            gs.last_position = None;
685            gs.is_dragging = false;
686            gs.axis_locked_out = false;
687            gs.gesture_start_time = None;
688            gs.gesture_start_event_time_ms = None;
689            gs.last_velocity_sample_ms = None;
690            gs.velocity_tracker.reset();
691            self.motion_context.set_active(false);
692            return false;
693        }
694
695        let Some(down_pos) = gs.drag_down_position else {
696            return false;
697        };
698
699        let Some(last_pos) = gs.last_position else {
700            gs.last_position = Some(position);
701            return false;
702        };
703
704        let incremental_delta = calculate_incremental_delta(last_pos, position, self.is_vertical);
705
706        let mut edge_candidate = false;
707        if !gs.is_dragging && !gs.axis_locked_out {
708            let signed_main_delta = calculate_total_delta(down_pos, position, self.is_vertical);
709            let main_delta = signed_main_delta.abs();
710            let cross_delta = calculate_total_delta(down_pos, position, !self.is_vertical).abs();
711            if main_delta > DRAG_THRESHOLD && main_delta >= cross_delta {
712                if self.scroll_target.can_consume(signed_main_delta) {
713                    gs.is_dragging = true;
714                    self.scroll_target.set_dragging(true);
715                    self.motion_context.set_active(true);
716                } else if self.scroll_target.can_scroll() {
717                    edge_candidate = true;
718                }
719            } else if cross_delta > DRAG_THRESHOLD && cross_delta > main_delta {
720                gs.axis_locked_out = true;
721            }
722        }
723
724        gs.last_position = Some(position);
725
726        let pos = if self.is_vertical {
727            position.y
728        } else {
729            position.x
730        };
731        let event_sample_ms = gs
732            .gesture_start_event_time_ms
733            .zip(time_ms)
734            .map(|(start_ms, now_ms)| now_ms - start_ms);
735        let sample_ms = if let Some(event_sample_ms) = event_sample_ms {
736            Some(match gs.last_velocity_sample_ms {
737                Some(last_sample_ms) => event_sample_ms.max(last_sample_ms),
738                None => event_sample_ms.max(0),
739            })
740        } else if let Some(start_time) = gs.gesture_start_time {
741            let elapsed_ms = start_time.elapsed().as_millis() as i64;
742            Some(match gs.last_velocity_sample_ms {
743                Some(last_sample_ms) => {
744                    let mut sample_ms = if elapsed_ms <= last_sample_ms {
745                        last_sample_ms + 1
746                    } else {
747                        elapsed_ms
748                    };
749                    if sample_ms - last_sample_ms > ASSUME_STOPPED_MS {
750                        sample_ms = last_sample_ms + ASSUME_STOPPED_MS;
751                    }
752                    sample_ms
753                }
754                None => elapsed_ms,
755            })
756        } else {
757            None
758        };
759        if let Some(sample_ms) = sample_ms {
760            log::trace!(
761                target: "cranpose::velocity",
762                "sample t={sample_ms}ms pos={pos:.2} event_time={time_ms:?}"
763            );
764            gs.velocity_tracker.add_data_point(sample_ms, pos);
765            gs.last_velocity_sample_ms = Some(sample_ms);
766        }
767
768        if gs.is_dragging {
769            drop(gs);
770            let delta = if self.reverse_scrolling {
771                -incremental_delta
772            } else {
773                incremental_delta
774            };
775            let overscroll = self.overscroll.clone();
776            overscroll.apply_to_scroll(delta, |delta| self.scroll_target.apply_delta(delta));
777            self.scroll_target.invalidate();
778            true
779        } else if gs.is_overscrolling {
780            drop(gs);
781            let delta = if self.reverse_scrolling {
782                -incremental_delta
783            } else {
784                incremental_delta
785            };
786            self.apply_overscroll_delta(delta)
787        } else if edge_candidate {
788            drop(gs);
789            let delta = if self.reverse_scrolling {
790                -incremental_delta
791            } else {
792                incremental_delta
793            };
794            let detector = self.clone_for_watcher();
795            event.defer_post_dispatch_action(move || detector.apply_overscroll_candidate(delta));
796            false
797        } else {
798            false
799        }
800    }
801
802    fn apply_overscroll_delta(&self, delta: f32) -> bool {
803        self.motion_context.set_active(true);
804        let overscroll = self.overscroll.clone();
805        let target_consumed = Cell::new(0.0);
806        let before_overscroll = overscroll.offset();
807        overscroll.apply_to_scroll(delta, |delta| {
808            let consumed = self.scroll_target.apply_delta(delta);
809            target_consumed.set(consumed);
810            consumed
811        });
812        self.scroll_target.invalidate();
813        let consumed = target_consumed.get().abs() > 0.001
814            || (overscroll.offset() - before_overscroll).abs() > 0.001;
815        if !consumed {
816            self.motion_context.set_active(false);
817        }
818        consumed
819    }
820
821    fn apply_overscroll_candidate(&self, delta: f32) -> bool {
822        if self.gesture_state.borrow().disposed {
823            return false;
824        }
825        let consumed = self.apply_overscroll_delta(delta);
826        if consumed {
827            self.gesture_state.borrow_mut().is_overscrolling = true;
828        }
829        consumed
830    }
831
832    fn finish_gesture(&self, allow_fling: bool, release_time_ms: Option<i64>) -> bool {
833        let (was_dragging, interrupted, gesture_owned, velocity, start_fling) = {
834            let mut gs = self.gesture_state.borrow_mut();
835            let was_dragging = gs.is_dragging;
836            let interrupted = std::mem::take(&mut gs.interrupted_animation);
837            let gesture_owned = was_dragging || gs.is_overscrolling;
838            let velocity = if allow_fling && gesture_owned {
839                gs.release_velocity(release_time_ms)
840            } else {
841                0.0
842            };
843
844            let start_fling = allow_fling && was_dragging && velocity.abs() > MIN_FLING_VELOCITY;
845            // Whatever the release starts replaces a fling still running.
846            if start_fling && let Some(fling) = gs.fling_animation.take() {
847                fling.cancel();
848            }
849
850            if was_dragging {
851                self.scroll_target.set_dragging(false);
852            }
853            gs.drag_down_position = None;
854            gs.last_position = None;
855            gs.is_dragging = false;
856            gs.is_overscrolling = false;
857            gs.axis_locked_out = false;
858            gs.gesture_start_time = None;
859            gs.gesture_start_event_time_ms = None;
860            gs.last_velocity_sample_ms = None;
861
862            (
863                was_dragging,
864                interrupted,
865                gesture_owned,
866                velocity,
867                start_fling,
868            )
869        };
870
871        if allow_fling && gesture_owned {
872            log::debug!(
873                target: "cranpose::velocity",
874                "gesture finished: fling velocity={velocity:.2} dp/s start_fling={start_fling}"
875            );
876            set_last_fling_velocity(velocity);
877        }
878
879        let adjusted_velocity = if self.reverse_scrolling {
880            -velocity
881        } else {
882            velocity
883        };
884        // The containers around the scroller take their part first; a fling
885        // with too little left does not start.
886        let adjusted_velocity = if gesture_owned {
887            adjusted_velocity - self.scroll_target.pre_fling(adjusted_velocity)
888        } else {
889            adjusted_velocity
890        };
891        let start_fling = start_fling && adjusted_velocity.abs() > MIN_FLING_VELOCITY;
892        let fling_velocity = -adjusted_velocity;
893        let has_overscroll = self.overscroll.offset().abs() > 0.001;
894
895        let settle_target = (was_dragging || interrupted)
896            .then(|| self.settle_target(start_fling, fling_velocity))
897            .flatten();
898
899        if has_overscroll {
900            self.start_overscroll_settle(-fling_velocity);
901        } else if let Some(target) = settle_target {
902            self.start_settle_animation(target, fling_velocity);
903        } else if start_fling {
904            self.start_fling_animation(fling_velocity);
905        } else {
906            self.motion_context.set_active(false);
907        }
908
909        gesture_owned
910    }
911
912    /// Where the target's settle policy moves the rest of a release, if it
913    /// moves it at all.
914    fn settle_target(&self, start_fling: bool, fling_velocity: f32) -> Option<f32> {
915        let policy = self.scroll_target.settle_policy()?;
916        let current = self.scroll_target.current_offset();
917        let proposed = if start_fling {
918            fling_rest_position(current, fling_velocity)
919        } else {
920            current
921        };
922        let target = policy(proposed, fling_velocity);
923        ((target - proposed).abs() > 0.5).then_some(target)
924    }
925
926    fn start_fling_animation(&self, fling_velocity: f32) {
927        let Some(runtime) = current_runtime_handle() else {
928            self.motion_context.set_active(false);
929            return;
930        };
931        self.motion_context.set_active(true);
932        let scroll_target = self.scroll_target.clone();
933        let fling = FlingAnimation::new(runtime);
934        let motion_context = self.motion_context.clone();
935        let initial_value = scroll_target.current_offset();
936        let scroll_target_for_fling = scroll_target.clone();
937        let detector_for_end = self.clone_for_watcher();
938        let overscroll_for_fling = self.overscroll.clone();
939
940        fling.start_fling(
941            initial_value,
942            fling_velocity,
943            move |delta| {
944                let consumed = overscroll_for_fling.apply_to_fling(delta, |delta| {
945                    scroll_target_for_fling.apply_fling_delta(delta)
946                });
947                scroll_target_for_fling.invalidate();
948                consumed
949            },
950            move || {
951                scroll_target.invalidate();
952                let settle_running = detector_for_end
953                    .gesture_state
954                    .borrow()
955                    .settle_animation
956                    .as_ref()
957                    .is_some_and(SettleAnimation::is_running);
958                if detector_for_end.overscroll.offset().abs() > 0.001 && !settle_running {
959                    detector_for_end.start_overscroll_settle(0.0);
960                }
961                detector_for_end.update_motion_active(&motion_context);
962            },
963        );
964
965        let mut gs = self.gesture_state.borrow_mut();
966        gs.fling_animation = Some(fling);
967    }
968
969    fn start_settle_animation(&self, target: f32, initial_velocity: f32) {
970        let Some(runtime) = current_runtime_handle() else {
971            self.motion_context.set_active(false);
972            return;
973        };
974        self.motion_context.set_active(true);
975        let settle = SettleAnimation::new(runtime, SpringParams::SETTLE_POLICY);
976        let scroll_target_for_settle = self.scroll_target.clone();
977        let scroll_target_for_end = self.scroll_target.clone();
978        let detector_for_end = self.clone_for_watcher();
979        let motion_context = self.motion_context.clone();
980        settle.start_settle(
981            self.scroll_target.current_offset(),
982            initial_velocity,
983            target,
984            move |delta| {
985                let consumed = scroll_target_for_settle.apply_fling_delta(delta);
986                scroll_target_for_settle.invalidate();
987                consumed
988            },
989            move |_| {
990                scroll_target_for_end.invalidate();
991                detector_for_end.update_motion_active(&motion_context);
992            },
993        );
994        let mut gs = self.gesture_state.borrow_mut();
995        gs.settle_animation = Some(settle);
996    }
997
998    fn start_overscroll_settle(&self, initial_velocity: f32) {
999        let Some(runtime) = current_runtime_handle() else {
1000            let current = self.overscroll.offset();
1001            self.overscroll.apply_settle_delta(-current);
1002            self.motion_context.set_active(false);
1003            return;
1004        };
1005        let settle = SettleAnimation::new(runtime, SpringParams::OVERSCROLL_BOUNCE);
1006        let overscroll_for_settle = self.overscroll.clone();
1007        let overscroll_for_end = self.overscroll.clone();
1008        let detector_for_end = self.clone_for_watcher();
1009        let motion_context = self.motion_context.clone();
1010        let initial = self.overscroll.offset();
1011        settle.start_settle(
1012            initial,
1013            initial_velocity,
1014            0.0,
1015            move |delta| overscroll_for_settle.apply_settle_delta(delta),
1016            move |end| {
1017                let current = overscroll_for_end.offset();
1018                overscroll_for_end.apply_settle_delta(-current);
1019                if end.hit_boundary && end.velocity.abs() > MIN_FLING_VELOCITY {
1020                    detector_for_end.start_fling_animation(-end.velocity);
1021                } else {
1022                    detector_for_end.update_motion_active(&motion_context);
1023                }
1024            },
1025        );
1026        let mut gs = self.gesture_state.borrow_mut();
1027        gs.is_overscrolling = false;
1028        gs.settle_animation = Some(settle);
1029    }
1030
1031    fn update_motion_active(&self, motion_context: &ScrollMotionContext) {
1032        let running = self.gesture_state.borrow().is_active();
1033        if !running {
1034            motion_context.set_active(false);
1035        }
1036    }
1037
1038    fn on_up(&self, time_ms: Option<i64>) -> bool {
1039        self.finish_gesture(self.scroll_target.allows_fling(), time_ms)
1040    }
1041
1042    fn on_cancel(&self) -> bool {
1043        self.finish_gesture(false, None)
1044    }
1045
1046    fn on_scroll(&self, axis_delta: f32, event: &PointerEvent) -> bool {
1047        if axis_delta.abs() <= f32::EPSILON {
1048            return false;
1049        }
1050
1051        let delta = if self.reverse_scrolling {
1052            -axis_delta
1053        } else {
1054            axis_delta
1055        };
1056        if !self.scroll_target.can_consume(delta) && self.scroll_target.can_scroll() {
1057            let detector = self.clone_for_watcher();
1058            event.defer_post_dispatch_action(move || detector.apply_wheel_delta(delta));
1059            return false;
1060        }
1061        self.apply_wheel_delta(delta)
1062    }
1063
1064    fn apply_wheel_delta(&self, delta: f32) -> bool {
1065        {
1066            let mut gs = self.gesture_state.borrow_mut();
1067            if gs.disposed {
1068                return false;
1069            }
1070            if let Some(fling) = gs.fling_animation.take() {
1071                fling.cancel();
1072            }
1073            if let Some(settle) = gs.settle_animation.take() {
1074                settle.cancel();
1075            }
1076            gs.drag_down_position = None;
1077            gs.last_position = None;
1078            gs.is_dragging = false;
1079            gs.axis_locked_out = false;
1080            gs.gesture_start_time = None;
1081            gs.gesture_start_event_time_ms = None;
1082            gs.last_velocity_sample_ms = None;
1083            gs.velocity_tracker.reset();
1084        }
1085
1086        self.motion_context.activate_for_current_frame();
1087        let overscroll = self.overscroll.clone();
1088        let consumed =
1089            overscroll.apply_to_scroll(delta, |delta| self.scroll_target.apply_wheel_delta(delta));
1090        let overscroll_active = self.overscroll.offset().abs() > 0.001;
1091        if consumed.abs() > 0.001 {
1092            self.scroll_target.invalidate();
1093        }
1094        if consumed.abs() > 0.001 || overscroll_active {
1095            self.ensure_wheel_settle_watcher();
1096            true
1097        } else {
1098            false
1099        }
1100    }
1101
1102    fn ensure_wheel_settle_watcher(&self) {
1103        if self.scroll_target.settle_policy().is_none() && self.overscroll.offset().abs() <= 0.001 {
1104            return;
1105        }
1106        {
1107            let gs = self.gesture_state.borrow();
1108            if gs
1109                .wheel_settle_watcher
1110                .as_ref()
1111                .is_some_and(|watcher| watcher.is_running.get())
1112            {
1113                return;
1114            }
1115        }
1116        let Some(runtime) = current_runtime_handle() else {
1117            return;
1118        };
1119
1120        let is_running = Rc::new(Cell::new(true));
1121        let registration = Rc::new(RefCell::new(None));
1122
1123        struct WheelSettleLoop<S: ScrollTarget> {
1124            detector: ScrollGestureDetector<S>,
1125            gesture_state: Rc<RefCell<ScrollGestureState>>,
1126            frame_clock: cranpose_core::internal::FrameClock,
1127            is_running: Rc<Cell<bool>>,
1128            registration: Rc<RefCell<Option<FrameCallbackRegistration>>>,
1129            last_offset: Rc<Cell<f32>>,
1130            idle_nanos: Rc<Cell<u64>>,
1131            last_frame: Rc<Cell<Option<u64>>>,
1132        }
1133
1134        impl<S: ScrollTarget + 'static> WheelSettleLoop<S> {
1135            fn next(&self) -> Self {
1136                Self {
1137                    detector: self.detector.clone_for_watcher(),
1138                    gesture_state: Rc::clone(&self.gesture_state),
1139                    frame_clock: self.frame_clock.clone(),
1140                    is_running: Rc::clone(&self.is_running),
1141                    registration: Rc::clone(&self.registration),
1142                    last_offset: Rc::clone(&self.last_offset),
1143                    idle_nanos: Rc::clone(&self.idle_nanos),
1144                    last_frame: Rc::clone(&self.last_frame),
1145                }
1146            }
1147
1148            fn schedule(self) {
1149                let continuation = self.next();
1150                let registration_slot = Rc::clone(&self.registration);
1151                let new_registration = self.frame_clock.with_frame_nanos(move |frame_time_nanos| {
1152                    let this = &continuation;
1153                    if !this.is_running.get() {
1154                        return;
1155                    }
1156                    {
1157                        let gs = this.gesture_state.borrow();
1158                        let animating = gs.is_dragging
1159                            || gs
1160                                .fling_animation
1161                                .as_ref()
1162                                .is_some_and(FlingAnimation::is_running)
1163                            || gs
1164                                .settle_animation
1165                                .as_ref()
1166                                .is_some_and(SettleAnimation::is_running);
1167                        if animating {
1168                            this.is_running.set(false);
1169                            return;
1170                        }
1171                    }
1172
1173                    let offset = this.detector.scroll_target.current_offset();
1174                    let dt = this
1175                        .last_frame
1176                        .get()
1177                        .map_or(0, |last| frame_time_nanos.saturating_sub(last));
1178                    this.last_frame.set(Some(frame_time_nanos));
1179                    if (offset - this.last_offset.get()).abs() > 0.01 {
1180                        this.last_offset.set(offset);
1181                        this.idle_nanos.set(0);
1182                    } else {
1183                        this.idle_nanos.set(this.idle_nanos.get() + dt);
1184                    }
1185
1186                    if this.idle_nanos.get() >= WHEEL_SETTLE_IDLE_NANOS {
1187                        this.is_running.set(false);
1188                        if this.detector.overscroll.offset().abs() > 0.001 {
1189                            this.detector.start_overscroll_settle(0.0);
1190                            return;
1191                        }
1192                        if let Some(policy) = this.detector.scroll_target.settle_policy() {
1193                            let target = policy(offset, 0.0);
1194                            if (target - offset).abs() > 0.5 {
1195                                this.detector.start_settle_animation(target, 0.0);
1196                            }
1197                        }
1198                        return;
1199                    }
1200
1201                    continuation.next().schedule();
1202                });
1203                *registration_slot.borrow_mut() = Some(new_registration);
1204            }
1205        }
1206
1207        WheelSettleLoop {
1208            detector: self.clone_for_watcher(),
1209            gesture_state: Rc::clone(&self.gesture_state),
1210            frame_clock: runtime.frame_clock(),
1211            is_running: Rc::clone(&is_running),
1212            registration: Rc::clone(&registration),
1213            last_offset: Rc::new(Cell::new(self.scroll_target.current_offset())),
1214            idle_nanos: Rc::new(Cell::new(0u64)),
1215            last_frame: Rc::new(Cell::new(None::<u64>)),
1216        }
1217        .schedule();
1218
1219        self.gesture_state.borrow_mut().wheel_settle_watcher = Some(WheelSettleWatcher {
1220            is_running,
1221            registration,
1222        });
1223    }
1224
1225    fn clone_for_watcher(&self) -> ScrollGestureDetector<S> {
1226        ScrollGestureDetector {
1227            gesture_state: Rc::clone(&self.gesture_state),
1228            scroll_target: self.scroll_target.clone(),
1229            is_vertical: self.is_vertical,
1230            reverse_scrolling: self.reverse_scrolling,
1231            overscroll: self.overscroll.clone(),
1232            motion_context: self.motion_context.clone(),
1233        }
1234    }
1235}
1236
1237pub(crate) struct MotionContextAnimatedNode {
1238    state: NodeState,
1239    motion_context: ScrollMotionContext,
1240    invalidation_callback_id: Option<u64>,
1241    overscroll_callback_id: Option<u64>,
1242    node_id: Option<NodeId>,
1243}
1244
1245impl MotionContextAnimatedNode {
1246    fn new(motion_context: ScrollMotionContext) -> Self {
1247        Self {
1248            state: NodeState::new(),
1249            motion_context,
1250            invalidation_callback_id: None,
1251            overscroll_callback_id: None,
1252            node_id: None,
1253        }
1254    }
1255
1256    pub(crate) fn is_active(&self) -> bool {
1257        self.motion_context.is_active()
1258    }
1259
1260    fn attach_callbacks(&mut self) {
1261        if let Some(node_id) = self.node_id {
1262            self.invalidation_callback_id = Some(self.motion_context.add_invalidate_callback(
1263                Box::new(move || schedule_modifier_slices_repass(node_id)),
1264            ));
1265            self.overscroll_callback_id = Some(
1266                self.motion_context
1267                    .overscroll()
1268                    .add_invalidate_callback(Box::new(move || {
1269                        crate::schedule_measure_repass(node_id);
1270                        schedule_modifier_slices_repass(node_id);
1271                    })),
1272            );
1273        }
1274    }
1275
1276    fn detach_callbacks(&mut self) {
1277        if let Some(id) = self.invalidation_callback_id.take() {
1278            self.motion_context.remove_invalidate_callback(id);
1279        }
1280        if let Some(id) = self.overscroll_callback_id.take() {
1281            self.motion_context
1282                .overscroll()
1283                .remove_invalidate_callback(id);
1284        }
1285    }
1286}
1287
1288pub(crate) struct TranslatedContentContextNode {
1289    state: NodeState,
1290    identity: usize,
1291    offset_source: TranslatedContentOffsetSource,
1292    overscroll: crate::scroll::OverscrollEffect,
1293    overscroll_callback_id: Option<u64>,
1294    node_id: Option<NodeId>,
1295}
1296
1297impl TranslatedContentContextNode {
1298    fn new(
1299        identity: usize,
1300        offset_source: TranslatedContentOffsetSource,
1301        overscroll: crate::scroll::OverscrollEffect,
1302    ) -> Self {
1303        Self {
1304            state: NodeState::new(),
1305            identity,
1306            offset_source,
1307            overscroll,
1308            overscroll_callback_id: None,
1309            node_id: None,
1310        }
1311    }
1312
1313    pub(crate) fn is_active(&self) -> bool {
1314        true
1315    }
1316
1317    pub(crate) fn identity(&self) -> usize {
1318        self.identity
1319    }
1320
1321    pub(crate) fn content_offset_reader(&self) -> Option<Rc<dyn Fn() -> Point>> {
1322        self.offset_source
1323            .content_offset_reader(self.overscroll.clone())
1324    }
1325
1326    fn attach_callback(&mut self) {
1327        if let Some(node_id) = self.node_id {
1328            self.overscroll_callback_id =
1329                Some(self.overscroll.add_invalidate_callback(Box::new(move || {
1330                    schedule_modifier_slices_repass(node_id);
1331                })));
1332        }
1333    }
1334
1335    fn detach_callback(&mut self) {
1336        if let Some(id) = self.overscroll_callback_id.take() {
1337            self.overscroll.remove_invalidate_callback(id);
1338        }
1339    }
1340}
1341
1342impl DelegatableNode for TranslatedContentContextNode {
1343    fn node_state(&self) -> &NodeState {
1344        &self.state
1345    }
1346}
1347
1348impl ModifierNode for TranslatedContentContextNode {
1349    fn on_attach(&mut self, context: &mut dyn cranpose_foundation::ModifierNodeContext) {
1350        self.node_id = context.node_id();
1351        self.attach_callback();
1352    }
1353
1354    fn on_detach(&mut self) {
1355        self.detach_callback();
1356        self.node_id = None;
1357    }
1358}
1359
1360impl DelegatableNode for MotionContextAnimatedNode {
1361    fn node_state(&self) -> &NodeState {
1362        &self.state
1363    }
1364}
1365
1366impl ModifierNode for MotionContextAnimatedNode {
1367    fn on_attach(&mut self, context: &mut dyn cranpose_foundation::ModifierNodeContext) {
1368        self.node_id = context.node_id();
1369        self.attach_callbacks();
1370    }
1371
1372    fn on_detach(&mut self) {
1373        self.detach_callbacks();
1374        self.node_id = None;
1375    }
1376}
1377
1378#[derive(Clone)]
1379struct MotionContextAnimatedElement {
1380    motion_context: ScrollMotionContext,
1381}
1382
1383impl MotionContextAnimatedElement {
1384    fn new(motion_context: ScrollMotionContext) -> Self {
1385        Self { motion_context }
1386    }
1387}
1388
1389impl std::fmt::Debug for MotionContextAnimatedElement {
1390    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1391        f.debug_struct("MotionContextAnimatedElement").finish()
1392    }
1393}
1394
1395impl PartialEq for MotionContextAnimatedElement {
1396    fn eq(&self, other: &Self) -> bool {
1397        self.motion_context.ptr_eq(&other.motion_context)
1398    }
1399}
1400
1401impl Eq for MotionContextAnimatedElement {}
1402
1403impl std::hash::Hash for MotionContextAnimatedElement {
1404    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
1405        self.motion_context.stable_key().hash(state);
1406    }
1407}
1408
1409impl ModifierNodeElement for MotionContextAnimatedElement {
1410    type Node = MotionContextAnimatedNode;
1411
1412    fn create(&self) -> Self::Node {
1413        MotionContextAnimatedNode::new(self.motion_context.clone())
1414    }
1415
1416    fn update(&self, node: &mut Self::Node) {
1417        if node.motion_context.ptr_eq(&self.motion_context) {
1418            return;
1419        }
1420        node.detach_callbacks();
1421        node.motion_context = self.motion_context.clone();
1422        node.attach_callbacks();
1423    }
1424
1425    fn capabilities(&self) -> NodeCapabilities {
1426        NodeCapabilities::LAYOUT
1427    }
1428}
1429
1430#[derive(Clone)]
1431enum TranslatedContentOffsetSource {
1432    LayoutContentOffset,
1433    LazyList {
1434        state: LazyListState,
1435        is_vertical: bool,
1436        reverse_scrolling: bool,
1437    },
1438}
1439
1440impl TranslatedContentOffsetSource {
1441    fn content_offset_reader(
1442        &self,
1443        overscroll: crate::scroll::OverscrollEffect,
1444    ) -> Option<Rc<dyn Fn() -> Point>> {
1445        match self {
1446            Self::LayoutContentOffset => None,
1447            Self::LazyList {
1448                state, is_vertical, ..
1449            } => Some(Rc::new(lazy_list_content_offset_reader(
1450                *state,
1451                *is_vertical,
1452                overscroll,
1453            ))),
1454        }
1455    }
1456
1457    fn is_vertical(&self) -> Option<bool> {
1458        match self {
1459            Self::LayoutContentOffset => None,
1460            Self::LazyList { is_vertical, .. } => Some(*is_vertical),
1461        }
1462    }
1463
1464    fn reverse_scrolling(&self) -> Option<bool> {
1465        match self {
1466            Self::LayoutContentOffset => None,
1467            Self::LazyList {
1468                reverse_scrolling, ..
1469            } => Some(*reverse_scrolling),
1470        }
1471    }
1472}
1473
1474fn lazy_list_content_offset_reader(
1475    state: LazyListState,
1476    is_vertical: bool,
1477    overscroll: crate::scroll::OverscrollEffect,
1478) -> impl Fn() -> Point {
1479    move || {
1480        let Some((viewport, main_offset)) = state
1481            .with_layout_info(|info| {
1482                (!info.visible_items_info.is_empty())
1483                    .then_some((info.viewport_size, info.snap_anchor_offset))
1484            })
1485            .flatten()
1486        else {
1487            return Point::default();
1488        };
1489        overscroll.set_dimension(viewport);
1490        let bounce = overscroll.offset();
1491        if is_vertical {
1492            Point::new(0.0, main_offset + bounce)
1493        } else {
1494            Point::new(main_offset + bounce, 0.0)
1495        }
1496    }
1497}
1498
1499#[derive(Clone)]
1500struct TranslatedContentContextElement {
1501    identity: usize,
1502    offset_source: TranslatedContentOffsetSource,
1503    overscroll: crate::scroll::OverscrollEffect,
1504}
1505
1506impl TranslatedContentContextElement {
1507    fn new(
1508        identity: usize,
1509        offset_source: TranslatedContentOffsetSource,
1510        overscroll: crate::scroll::OverscrollEffect,
1511    ) -> Self {
1512        Self {
1513            identity,
1514            offset_source,
1515            overscroll,
1516        }
1517    }
1518}
1519
1520impl std::fmt::Debug for TranslatedContentContextElement {
1521    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1522        let offset_source = match &self.offset_source {
1523            TranslatedContentOffsetSource::LayoutContentOffset => "layout",
1524            TranslatedContentOffsetSource::LazyList { .. } => "lazy_list",
1525        };
1526        f.debug_struct("TranslatedContentContextElement")
1527            .field("identity", &self.identity)
1528            .field("offset_source", &offset_source)
1529            .finish()
1530    }
1531}
1532
1533impl PartialEq for TranslatedContentContextElement {
1534    fn eq(&self, other: &Self) -> bool {
1535        self.identity == other.identity
1536            && self.overscroll.ptr_eq(&other.overscroll)
1537            && self.offset_source.is_vertical() == other.offset_source.is_vertical()
1538            && self.offset_source.reverse_scrolling() == other.offset_source.reverse_scrolling()
1539    }
1540}
1541
1542impl Eq for TranslatedContentContextElement {}
1543
1544impl std::hash::Hash for TranslatedContentContextElement {
1545    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
1546        self.identity.hash(state);
1547        self.offset_source.is_vertical().hash(state);
1548        self.offset_source.reverse_scrolling().hash(state);
1549    }
1550}
1551
1552impl ModifierNodeElement for TranslatedContentContextElement {
1553    type Node = TranslatedContentContextNode;
1554
1555    fn create(&self) -> Self::Node {
1556        TranslatedContentContextNode::new(
1557            self.identity,
1558            self.offset_source.clone(),
1559            self.overscroll.clone(),
1560        )
1561    }
1562
1563    fn update(&self, node: &mut Self::Node) {
1564        node.identity = self.identity;
1565        node.offset_source = self.offset_source.clone();
1566        if !node.overscroll.ptr_eq(&self.overscroll) {
1567            node.detach_callback();
1568            node.overscroll = self.overscroll.clone();
1569            node.attach_callback();
1570        }
1571    }
1572
1573    fn capabilities(&self) -> NodeCapabilities {
1574        NodeCapabilities::LAYOUT
1575    }
1576}
1577
1578impl Modifier {
1579    /// Creates a horizontally scrollable modifier.
1580    ///
1581    /// # Arguments
1582    /// * `state` - The ScrollState to control scroll position
1583    /// * `reverse_scrolling` - If true, reverses the scroll direction in layout.
1584    ///   Note: This affects how scroll offset is applied to content (via `ScrollNode`),
1585    ///   NOT the drag direction. Drag gestures always follow natural touch semantics:
1586    ///   drag right = scroll left (content moves right under finger).
1587    ///
1588    /// # Example
1589    /// ```text
1590    /// let scroll_state = ScrollState::new(0.0);
1591    /// Row(
1592    ///     Modifier::empty().horizontal_scroll(scroll_state, false),
1593    ///     // ... content
1594    /// );
1595    /// ```
1596    pub fn horizontal_scroll(self, state: ScrollState, reverse_scrolling: bool) -> Self {
1597        self.then(scroll_impl(
1598            state,
1599            false,
1600            reverse_scrolling,
1601            None,
1602            (0.0, 0.0),
1603        ))
1604    }
1605
1606    /// Creates a vertically scrollable modifier.
1607    ///
1608    /// # Arguments
1609    /// * `state` - The ScrollState to control scroll position
1610    /// * `reverse_scrolling` - If true, reverses the scroll direction in layout.
1611    ///   Note: This affects how scroll offset is applied to content (via `ScrollNode`),
1612    ///   NOT the drag direction. Drag gestures always follow natural touch semantics:
1613    ///   drag down = scroll up (content moves down under finger).
1614    pub fn vertical_scroll(self, state: ScrollState, reverse_scrolling: bool) -> Self {
1615        self.then(scroll_impl(
1616            state,
1617            true,
1618            reverse_scrolling,
1619            None,
1620            (0.0, 0.0),
1621        ))
1622    }
1623
1624    /// Adds a vertical scroll container with content padding in logical pixels.
1625    ///
1626    /// The padding scrolls with the content. Keyboard and accessibility focus
1627    /// keep controls inside the padded viewport, clear of overlaid system UI.
1628    pub fn vertical_scroll_with_content_padding(
1629        self,
1630        state: ScrollState,
1631        reverse_scrolling: bool,
1632        padding: cranpose_ui_graphics::EdgeInsets,
1633    ) -> Self {
1634        let density = crate::density();
1635        self.then(scroll_impl(
1636            state,
1637            true,
1638            reverse_scrolling,
1639            None,
1640            (density.dp(padding.top), density.dp(padding.bottom)),
1641        ))
1642        .window_insets_padding(padding)
1643    }
1644
1645    /// Adds a horizontal scroll container with content padding in logical pixels.
1646    ///
1647    /// The padding scrolls with the content. Keyboard and accessibility focus
1648    /// keep controls inside the padded viewport. Insets use physical edges.
1649    pub fn horizontal_scroll_with_content_padding(
1650        self,
1651        state: ScrollState,
1652        reverse_scrolling: bool,
1653        padding: cranpose_ui_graphics::EdgeInsets,
1654    ) -> Self {
1655        let density = crate::density();
1656        self.then(scroll_impl(
1657            state,
1658            false,
1659            reverse_scrolling,
1660            None,
1661            (density.dp(padding.left), density.dp(padding.right)),
1662        ))
1663        .window_insets_padding(padding)
1664    }
1665
1666    /// A horizontal scroll that never claims a pointer gesture.
1667    ///
1668    /// The position still moves through [`ScrollState`], but drags and wheel
1669    /// events pass straight through, so an owner can run its own
1670    /// `pointer_input` on the same row -- a drag-to-reorder list, say --
1671    /// without the two competing for the gesture.
1672    pub fn horizontal_scroll_without_gestures(
1673        self,
1674        state: ScrollState,
1675        reverse_scrolling: bool,
1676    ) -> Self {
1677        self.then(scroll_impl(
1678            state,
1679            false,
1680            reverse_scrolling,
1681            Some(Rc::new(|| false)),
1682            (0.0, 0.0),
1683        ))
1684    }
1685
1686    /// A vertical scroll that never claims a pointer gesture.
1687    ///
1688    /// The vertical counterpart of [`Modifier::horizontal_scroll_without_gestures`].
1689    pub fn vertical_scroll_without_gestures(
1690        self,
1691        state: ScrollState,
1692        reverse_scrolling: bool,
1693    ) -> Self {
1694        self.then(scroll_impl(
1695            state,
1696            true,
1697            reverse_scrolling,
1698            Some(Rc::new(|| false)),
1699            (0.0, 0.0),
1700        ))
1701    }
1702}
1703
1704fn scroll_impl(
1705    state: ScrollState,
1706    is_vertical: bool,
1707    reverse_scrolling: bool,
1708    guard: Option<Rc<dyn Fn() -> bool>>,
1709    content_padding: (f32, f32),
1710) -> Modifier {
1711    let motion_context = scroll_motion_context_for_key(ScrollMotionContextKey::ScrollState {
1712        state_id: state.id(),
1713        is_vertical,
1714        reverse_scrolling,
1715    });
1716    motion_context
1717        .observe_activity(|| Box::new(move |active| state.set_scroll_in_progress(active)));
1718
1719    let pointer_input = nested_drag_gesture_input(
1720        (state.id(), is_vertical),
1721        DragGesture {
1722            target: state,
1723            is_vertical,
1724            reverse_input: false,
1725            motion_context: motion_context.clone(),
1726            guard,
1727        },
1728    );
1729
1730    let overscroll = motion_context.overscroll();
1731    let element = ScrollElement::new(state, overscroll.clone(), is_vertical, reverse_scrolling);
1732    let layout_modifier =
1733        Modifier::with_element(element).with_inspector_metadata(inspector_metadata(
1734            if is_vertical {
1735                "verticalScroll"
1736            } else {
1737                "horizontalScroll"
1738            },
1739            move |info| {
1740                info.add_property("isVertical", is_vertical.to_string());
1741                info.add_property("reverseScrolling", reverse_scrolling.to_string());
1742            },
1743        ));
1744    let motion_modifier = Modifier::with_element(MotionContextAnimatedElement::new(motion_context));
1745    let translated_content_modifier = Modifier::with_element(TranslatedContentContextElement::new(
1746        state.id() as usize,
1747        TranslatedContentOffsetSource::LayoutContentOffset,
1748        overscroll,
1749    ));
1750
1751    // The clip wraps the scroll layout, as Compose's `scrollingContainer`
1752    // clips before it scrolls: it cuts at the viewport, not at the content
1753    // the layout moves.
1754    pointer_input
1755        .clip_to_bounds()
1756        .then(motion_modifier)
1757        .then(translated_content_modifier)
1758        .then(layout_modifier)
1759        .semantics(scroll_semantics(
1760            state,
1761            is_vertical,
1762            reverse_scrolling,
1763            content_padding,
1764        ))
1765}
1766
1767/// What a screen reader learns about a scroll container: how far it has
1768/// scrolled, whether it can go on, and how to page it. Compose's
1769/// `verticalScrollAxisRange` / `horizontalScrollAxisRange` and `scrollBy`.
1770/// Without this a reader's cursor stops at the last row it can see.
1771fn scroll_semantics(
1772    state: ScrollState,
1773    is_vertical: bool,
1774    reverse_scrolling: bool,
1775    content_padding: (f32, f32),
1776) -> impl Fn(&mut cranpose_foundation::SemanticsConfiguration) + 'static {
1777    move |config| {
1778        let range = cranpose_foundation::ScrollAxisRange::new(
1779            state.value(),
1780            state.max_value(),
1781            reverse_scrolling,
1782        )
1783        .with_content_padding(content_padding.0, content_padding.1);
1784        if is_vertical {
1785            config.vertical_scroll = Some(range);
1786        } else {
1787            config.horizontal_scroll = Some(range);
1788        }
1789        config.scroll_by = Some(cranpose_foundation::SemanticsScrollBy::new(
1790            move |dx, dy| {
1791                let delta = if is_vertical { dy } else { dx };
1792                state.dispatch_raw_delta(delta).abs() > f32::EPSILON
1793            },
1794        ));
1795    }
1796}
1797
1798use cranpose_foundation::lazy::{LazyListState, LazySettlePolicy};
1799
1800impl Modifier {
1801    /// Creates a vertically scrollable modifier for lazy lists.
1802    ///
1803    /// This connects pointer gestures to LazyListState for scroll handling.
1804    /// Unlike regular vertical_scroll, no layout offset is applied here
1805    /// since LazyListState manages item positioning internally.
1806    /// Creates a vertically scrollable modifier for lazy lists.
1807    ///
1808    /// This connects pointer gestures to LazyListState for scroll handling.
1809    /// Unlike regular vertical_scroll, no layout offset is applied here
1810    /// since LazyListState manages item positioning internally.
1811    pub fn lazy_vertical_scroll(self, state: LazyListState, reverse_scrolling: bool) -> Self {
1812        let motion_context = scroll_motion_context_for_key(ScrollMotionContextKey::LazyList {
1813            state_identity: state.inner_ptr() as usize,
1814            is_vertical: true,
1815            reverse_scrolling,
1816        });
1817        self.lazy_vertical_scroll_with_context(state, reverse_scrolling, (0.0, 0.0), motion_context)
1818    }
1819
1820    pub(crate) fn lazy_vertical_scroll_with_context(
1821        self,
1822        state: LazyListState,
1823        reverse_scrolling: bool,
1824        content_padding: (f32, f32),
1825        motion_context: ScrollMotionContext,
1826    ) -> Self {
1827        self.then(lazy_scroll_impl(
1828            state,
1829            true,
1830            reverse_scrolling,
1831            content_padding,
1832            motion_context,
1833        ))
1834    }
1835
1836    pub(crate) fn lazy_horizontal_scroll_with_context(
1837        self,
1838        state: LazyListState,
1839        reverse_scrolling: bool,
1840        content_padding: (f32, f32),
1841        motion_context: ScrollMotionContext,
1842    ) -> Self {
1843        self.then(lazy_scroll_impl(
1844            state,
1845            false,
1846            reverse_scrolling,
1847            content_padding,
1848            motion_context,
1849        ))
1850    }
1851}
1852
1853fn lazy_scroll_impl(
1854    state: LazyListState,
1855    is_vertical: bool,
1856    reverse_scrolling: bool,
1857    content_padding: (f32, f32),
1858    motion_context: ScrollMotionContext,
1859) -> Modifier {
1860    let list_state = state;
1861    let state_id = state.inner_ptr() as usize;
1862    let key = (state_id, is_vertical, reverse_scrolling);
1863    motion_context
1864        .observe_activity(|| Box::new(move |active| state.set_gesture_scroll_active(active)));
1865    let overscroll = motion_context.overscroll();
1866    let translated_content_modifier = Modifier::with_element(TranslatedContentContextElement::new(
1867        state_id,
1868        TranslatedContentOffsetSource::LazyList {
1869            state,
1870            is_vertical,
1871            reverse_scrolling,
1872        },
1873        overscroll,
1874    ));
1875
1876    Modifier::with_element(MotionContextAnimatedElement::new(motion_context.clone()))
1877        .then(translated_content_modifier)
1878        .then(nested_drag_gesture_input(
1879            key,
1880            DragGesture {
1881                target: list_state,
1882                is_vertical,
1883                reverse_input: reverse_scrolling,
1884                motion_context,
1885                guard: None,
1886            },
1887        ))
1888        .semantics(lazy_scroll_semantics(
1889            list_state,
1890            is_vertical,
1891            reverse_scrolling,
1892            content_padding,
1893        ))
1894}
1895
1896/// What a screen reader learns about a lazy list. The list has no whole
1897/// extent to give, so the first visible item stands in for the position, a
1898/// half step marks a list scrolled part way into that item, and one more step
1899/// stays ahead while the list can still scroll forward. A reader needs only
1900/// the two answers: can it page back, can it page on.
1901pub(crate) fn lazy_scroll_semantics(
1902    state: LazyListState,
1903    is_vertical: bool,
1904    reverse_scrolling: bool,
1905    content_padding: (f32, f32),
1906) -> impl Fn(&mut cranpose_foundation::SemanticsConfiguration) + 'static {
1907    move |config| {
1908        let mut value = state.first_visible_item_index() as f32;
1909        if state.first_visible_item_scroll_offset() > 0.0 {
1910            value += 0.5;
1911        }
1912        let max_value = if state.can_scroll_forward() {
1913            value + 1.0
1914        } else {
1915            value
1916        };
1917        let range = cranpose_foundation::ScrollAxisRange::new(value, max_value, reverse_scrolling)
1918            .with_content_padding(content_padding.0, content_padding.1);
1919        let count = state.total_items_count();
1920        config.collection = Some(cranpose_foundation::CollectionInfo {
1921            rows: if is_vertical { count } else { 1 },
1922            columns: if is_vertical { 1 } else { count },
1923        });
1924        config
1925            .role
1926            .get_or_insert(cranpose_foundation::SemanticsWidgetRole::List);
1927        if is_vertical {
1928            config.vertical_scroll = Some(range);
1929        } else {
1930            config.horizontal_scroll = Some(range);
1931        }
1932        config.scroll_by = Some(cranpose_foundation::SemanticsScrollBy::new(
1933            move |dx, dy| {
1934                let delta = if is_vertical { dy } else { dx };
1935                match state.settle_policy() {
1936                    Some(policy) => settle_reader_scroll(&state, &policy, delta),
1937                    None => state.dispatch_scroll_delta(-delta).abs() > f32::EPSILON,
1938                }
1939            },
1940        ));
1941        config.scroll_to_index = Some(cranpose_foundation::SemanticsScrollToIndex::new(
1942            move |index| jump_to_row(&state, index),
1943        ));
1944    }
1945}
1946
1947/// Moves a list whose scroll comes to rest by a settle policy `delta` along
1948/// its items, and on to where the policy rests the item that reaches the
1949/// start, as a release there would. A reader's page of a pager turns one
1950/// page.
1951fn settle_reader_scroll(state: &LazyListState, policy: &LazySettlePolicy, delta: f32) -> bool {
1952    let can_move = if delta > 0.0 {
1953        state.can_scroll_forward_non_reactive()
1954    } else {
1955        state.can_scroll_backward_non_reactive()
1956    };
1957    let size = state.average_item_size();
1958    if !can_move || size <= 0.0 {
1959        return false;
1960    }
1961    let first = state.first_visible_item_index_non_reactive() as f32;
1962    let reached =
1963        (first * size + state.first_visible_item_scroll_offset_non_reactive() + delta).max(0.0);
1964    let last = state.total_items_count().saturating_sub(1) as f32;
1965    let index = (reached / size).floor().min(last);
1966    let target = policy(reached - index * size, 0.0);
1967    crate::AnimateScroll::animate_scroll_to_item(state, index as usize, target);
1968    true
1969}
1970
1971/// Puts the row at `index` at the top of a lazy list for a screen reader that
1972/// asked for it. A list with no rows, or an index past the last row, takes
1973/// nothing and says so.
1974fn jump_to_row(state: &LazyListState, index: usize) -> bool {
1975    if index >= state.total_items_count() {
1976        return false;
1977    }
1978    state.scroll_to_item(index, 0.0);
1979    true
1980}
1981
1982impl Modifier {
1983    /// Drags along `axis`, reporting each delta to `state`.
1984    ///
1985    /// The gesture is the one the scroll containers run — touch slop before a
1986    /// drag begins, axis locking so a mostly-vertical drag never steals a
1987    /// horizontal one, the primary pointer only, and yielding to whoever
1988    /// consumed the event first — so a dragged control and a scrolled list feel
1989    /// the same under a finger. Unlike a scroll, a release does not fling: a
1990    /// control the user placed stays where it was let go.
1991    ///
1992    /// Deltas are logical pixels, positive to the right for [`Axis::Horizontal`]
1993    /// and downwards for [`Axis::Vertical`].
1994    pub fn draggable(self, axis: Axis, state: DraggableState) -> Self {
1995        self.then(draggable_impl(axis, state, None))
1996    }
1997
1998    /// Scrolls content with custom placement along `axis`.
1999    ///
2000    /// Supplies drag, wheel and inertial scrolling without translating or
2001    /// clipping layout. A new pointer press interrupts the fling. The state's
2002    /// consumption handler stops it when the content reaches a boundary.
2003    pub fn scrollable(self, axis: Axis, state: ScrollableState) -> Self {
2004        let is_vertical = axis.is_vertical();
2005        let identity = state.identity();
2006        let motion_context = scroll_motion_context_for_key(ScrollMotionContextKey::Draggable {
2007            state_identity: identity,
2008            is_vertical,
2009        });
2010        self.then(Modifier::with_element(MotionContextAnimatedElement::new(
2011            motion_context.clone(),
2012        )))
2013        .then(nested_drag_gesture_input(
2014            (identity, is_vertical),
2015            DragGesture {
2016                target: state,
2017                is_vertical,
2018                reverse_input: false,
2019                motion_context,
2020                guard: None,
2021            },
2022        ))
2023    }
2024
2025    /// Drags along `axis` only while `guard` says so.
2026    ///
2027    /// The guard is asked per event, so a control can stop accepting a drag
2028    /// half way through one and the gesture ends rather than being applied to
2029    /// a control that has since been disabled.
2030    pub fn draggable_guarded(
2031        self,
2032        axis: Axis,
2033        state: DraggableState,
2034        guard: impl Fn() -> bool + 'static,
2035    ) -> Self {
2036        self.then(draggable_impl(axis, state, Some(Rc::new(guard))))
2037    }
2038}
2039
2040fn draggable_impl(
2041    axis: Axis,
2042    state: DraggableState,
2043    guard: Option<Rc<dyn Fn() -> bool>>,
2044) -> Modifier {
2045    let is_vertical = axis.is_vertical();
2046    let identity = state.identity();
2047    drag_gesture_input(
2048        (identity, is_vertical),
2049        DragGesture {
2050            target: state,
2051            is_vertical,
2052            reverse_input: false,
2053            motion_context: scroll_motion_context_for_key(ScrollMotionContextKey::Draggable {
2054                state_identity: identity,
2055                is_vertical,
2056            }),
2057            guard,
2058        },
2059    )
2060}