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 => false,
527                        };
528
529                        if should_consume {
530                            event.consume();
531                        }
532                    }
533                })
534                .await;
535        }
536    })
537}
538
539impl ScrollTarget for DraggableState {
540    fn apply_delta(&self, delta: f32) -> f32 {
541        self.drag_by(delta);
542        delta
543    }
544
545    fn apply_fling_delta(&self, delta: f32) -> f32 {
546        self.drag_by(delta);
547        delta
548    }
549
550    fn invalidate(&self) {}
551
552    fn current_offset(&self) -> f32 {
553        self.offset()
554    }
555
556    fn allows_fling(&self) -> bool {
557        false
558    }
559
560    fn set_dragging(&self, dragging: bool) {
561        DraggableState::set_dragging(self, dragging);
562    }
563}
564
565impl ScrollTarget for ScrollableState {
566    fn apply_delta(&self, delta: f32) -> f32 {
567        self.dispatch_raw_delta(delta)
568    }
569
570    fn apply_fling_delta(&self, delta: f32) -> f32 {
571        -self.dispatch_raw_delta(-delta)
572    }
573
574    fn invalidate(&self) {}
575
576    fn current_offset(&self) -> f32 {
577        -self.offset()
578    }
579
580    fn set_dragging(&self, dragging: bool) {
581        ScrollableState::set_dragging(self, dragging);
582    }
583}
584
585const WHEEL_SETTLE_IDLE_NANOS: u64 = 180_000_000;
586
587struct WheelSettleWatcher {
588    is_running: Rc<Cell<bool>>,
589    registration: Rc<RefCell<Option<FrameCallbackRegistration>>>,
590}
591
592impl WheelSettleWatcher {
593    fn cancel(&self) {
594        self.is_running.set(false);
595        self.registration.borrow_mut().take();
596    }
597}
598
599struct ScrollGestureDetector<S: ScrollTarget> {
600    gesture_state: Rc<RefCell<ScrollGestureState>>,
601
602    scroll_target: S,
603
604    is_vertical: bool,
605
606    reverse_scrolling: bool,
607
608    overscroll: crate::scroll::OverscrollEffect,
609
610    motion_context: ScrollMotionContext,
611}
612
613impl<S: ScrollTarget + 'static> ScrollGestureDetector<S> {
614    fn new(
615        gesture_state: Rc<RefCell<ScrollGestureState>>,
616        scroll_target: S,
617        is_vertical: bool,
618        reverse_scrolling: bool,
619        overscroll: crate::scroll::OverscrollEffect,
620        motion_context: ScrollMotionContext,
621    ) -> Self {
622        Self {
623            gesture_state,
624            scroll_target,
625            is_vertical,
626            reverse_scrolling,
627            overscroll,
628            motion_context,
629        }
630    }
631
632    fn on_down(&self, position: Point, time_ms: Option<i64>) -> bool {
633        let interrupted = self.scroll_target.stop_animation();
634        let mut gs = self.gesture_state.borrow_mut();
635        gs.interrupted_animation = interrupted;
636
637        if let Some(fling) = gs.fling_animation.take() {
638            fling.cancel();
639        }
640        if let Some(settle) = gs.settle_animation.take() {
641            settle.cancel();
642        }
643        if let Some(watcher) = gs.wheel_settle_watcher.take() {
644            watcher.cancel();
645        }
646        self.motion_context.set_active(false);
647
648        gs.drag_down_position = Some(position);
649        gs.last_position = Some(position);
650        gs.is_dragging = false;
651        gs.axis_locked_out = false;
652        gs.velocity_tracker.reset();
653        gs.gesture_start_time = Some(Instant::now());
654        gs.gesture_start_event_time_ms = time_ms;
655        gs.is_overscrolling = self.overscroll.offset().abs() > 0.001;
656
657        let pos = if self.is_vertical {
658            position.y
659        } else {
660            position.x
661        };
662        gs.velocity_tracker.add_data_point(0, pos);
663        gs.last_velocity_sample_ms = Some(0);
664
665        false
666    }
667
668    fn on_move(
669        &self,
670        position: Point,
671        buttons: PointerButtons,
672        time_ms: Option<i64>,
673        event: &PointerEvent,
674    ) -> bool {
675        let mut gs = self.gesture_state.borrow_mut();
676
677        if !buttons.contains(PointerButton::Primary) && gs.drag_down_position.is_some() {
678            if gs.is_dragging {
679                self.scroll_target.set_dragging(false);
680            }
681            gs.drag_down_position = None;
682            gs.last_position = None;
683            gs.is_dragging = false;
684            gs.axis_locked_out = false;
685            gs.gesture_start_time = None;
686            gs.gesture_start_event_time_ms = None;
687            gs.last_velocity_sample_ms = None;
688            gs.velocity_tracker.reset();
689            self.motion_context.set_active(false);
690            return false;
691        }
692
693        let Some(down_pos) = gs.drag_down_position else {
694            return false;
695        };
696
697        let Some(last_pos) = gs.last_position else {
698            gs.last_position = Some(position);
699            return false;
700        };
701
702        let incremental_delta = calculate_incremental_delta(last_pos, position, self.is_vertical);
703
704        let mut edge_candidate = false;
705        if !gs.is_dragging && !gs.axis_locked_out {
706            let signed_main_delta = calculate_total_delta(down_pos, position, self.is_vertical);
707            let main_delta = signed_main_delta.abs();
708            let cross_delta = calculate_total_delta(down_pos, position, !self.is_vertical).abs();
709            if main_delta > DRAG_THRESHOLD && main_delta >= cross_delta {
710                if self.scroll_target.can_consume(signed_main_delta) {
711                    gs.is_dragging = true;
712                    self.scroll_target.set_dragging(true);
713                    self.motion_context.set_active(true);
714                } else if self.scroll_target.can_scroll() {
715                    edge_candidate = true;
716                }
717            } else if cross_delta > DRAG_THRESHOLD && cross_delta > main_delta {
718                gs.axis_locked_out = true;
719            }
720        }
721
722        gs.last_position = Some(position);
723
724        let pos = if self.is_vertical {
725            position.y
726        } else {
727            position.x
728        };
729        let event_sample_ms = gs
730            .gesture_start_event_time_ms
731            .zip(time_ms)
732            .map(|(start_ms, now_ms)| now_ms - start_ms);
733        let sample_ms = if let Some(event_sample_ms) = event_sample_ms {
734            Some(match gs.last_velocity_sample_ms {
735                Some(last_sample_ms) => event_sample_ms.max(last_sample_ms),
736                None => event_sample_ms.max(0),
737            })
738        } else if let Some(start_time) = gs.gesture_start_time {
739            let elapsed_ms = start_time.elapsed().as_millis() as i64;
740            Some(match gs.last_velocity_sample_ms {
741                Some(last_sample_ms) => {
742                    let mut sample_ms = if elapsed_ms <= last_sample_ms {
743                        last_sample_ms + 1
744                    } else {
745                        elapsed_ms
746                    };
747                    if sample_ms - last_sample_ms > ASSUME_STOPPED_MS {
748                        sample_ms = last_sample_ms + ASSUME_STOPPED_MS;
749                    }
750                    sample_ms
751                }
752                None => elapsed_ms,
753            })
754        } else {
755            None
756        };
757        if let Some(sample_ms) = sample_ms {
758            log::trace!(
759                target: "cranpose::velocity",
760                "sample t={sample_ms}ms pos={pos:.2} event_time={time_ms:?}"
761            );
762            gs.velocity_tracker.add_data_point(sample_ms, pos);
763            gs.last_velocity_sample_ms = Some(sample_ms);
764        }
765
766        if gs.is_dragging {
767            drop(gs);
768            let delta = if self.reverse_scrolling {
769                -incremental_delta
770            } else {
771                incremental_delta
772            };
773            let overscroll = self.overscroll.clone();
774            overscroll.apply_to_scroll(delta, |delta| self.scroll_target.apply_delta(delta));
775            self.scroll_target.invalidate();
776            true
777        } else if gs.is_overscrolling {
778            drop(gs);
779            let delta = if self.reverse_scrolling {
780                -incremental_delta
781            } else {
782                incremental_delta
783            };
784            self.apply_overscroll_delta(delta)
785        } else if edge_candidate {
786            drop(gs);
787            let delta = if self.reverse_scrolling {
788                -incremental_delta
789            } else {
790                incremental_delta
791            };
792            let detector = self.clone_for_watcher();
793            event.defer_post_dispatch_action(move || detector.apply_overscroll_candidate(delta));
794            false
795        } else {
796            false
797        }
798    }
799
800    fn apply_overscroll_delta(&self, delta: f32) -> bool {
801        self.motion_context.set_active(true);
802        let overscroll = self.overscroll.clone();
803        let target_consumed = Cell::new(0.0);
804        let before_overscroll = overscroll.offset();
805        overscroll.apply_to_scroll(delta, |delta| {
806            let consumed = self.scroll_target.apply_delta(delta);
807            target_consumed.set(consumed);
808            consumed
809        });
810        self.scroll_target.invalidate();
811        let consumed = target_consumed.get().abs() > 0.001
812            || (overscroll.offset() - before_overscroll).abs() > 0.001;
813        if !consumed {
814            self.motion_context.set_active(false);
815        }
816        consumed
817    }
818
819    fn apply_overscroll_candidate(&self, delta: f32) -> bool {
820        if self.gesture_state.borrow().disposed {
821            return false;
822        }
823        let consumed = self.apply_overscroll_delta(delta);
824        if consumed {
825            self.gesture_state.borrow_mut().is_overscrolling = true;
826        }
827        consumed
828    }
829
830    fn finish_gesture(&self, allow_fling: bool, release_time_ms: Option<i64>) -> bool {
831        let (was_dragging, interrupted, gesture_owned, velocity, start_fling) = {
832            let mut gs = self.gesture_state.borrow_mut();
833            let was_dragging = gs.is_dragging;
834            let interrupted = std::mem::take(&mut gs.interrupted_animation);
835            let gesture_owned = was_dragging || gs.is_overscrolling;
836            let velocity = if allow_fling && gesture_owned {
837                gs.release_velocity(release_time_ms)
838            } else {
839                0.0
840            };
841
842            let start_fling = allow_fling && was_dragging && velocity.abs() > MIN_FLING_VELOCITY;
843            // Whatever the release starts replaces a fling still running.
844            if start_fling && let Some(fling) = gs.fling_animation.take() {
845                fling.cancel();
846            }
847
848            if was_dragging {
849                self.scroll_target.set_dragging(false);
850            }
851            gs.drag_down_position = None;
852            gs.last_position = None;
853            gs.is_dragging = false;
854            gs.is_overscrolling = false;
855            gs.axis_locked_out = false;
856            gs.gesture_start_time = None;
857            gs.gesture_start_event_time_ms = None;
858            gs.last_velocity_sample_ms = None;
859
860            (
861                was_dragging,
862                interrupted,
863                gesture_owned,
864                velocity,
865                start_fling,
866            )
867        };
868
869        if allow_fling && gesture_owned {
870            log::debug!(
871                target: "cranpose::velocity",
872                "gesture finished: fling velocity={velocity:.2} dp/s start_fling={start_fling}"
873            );
874            set_last_fling_velocity(velocity);
875        }
876
877        let adjusted_velocity = if self.reverse_scrolling {
878            -velocity
879        } else {
880            velocity
881        };
882        // The containers around the scroller take their part first; a fling
883        // with too little left does not start.
884        let adjusted_velocity = if gesture_owned {
885            adjusted_velocity - self.scroll_target.pre_fling(adjusted_velocity)
886        } else {
887            adjusted_velocity
888        };
889        let start_fling = start_fling && adjusted_velocity.abs() > MIN_FLING_VELOCITY;
890        let fling_velocity = -adjusted_velocity;
891        let has_overscroll = self.overscroll.offset().abs() > 0.001;
892
893        let settle_target = (was_dragging || interrupted)
894            .then(|| self.settle_target(start_fling, fling_velocity))
895            .flatten();
896
897        if has_overscroll {
898            self.start_overscroll_settle(-fling_velocity);
899        } else if let Some(target) = settle_target {
900            self.start_settle_animation(target, fling_velocity);
901        } else if start_fling {
902            self.start_fling_animation(fling_velocity);
903        } else {
904            self.motion_context.set_active(false);
905        }
906
907        gesture_owned
908    }
909
910    /// Where the target's settle policy moves the rest of a release, if it
911    /// moves it at all.
912    fn settle_target(&self, start_fling: bool, fling_velocity: f32) -> Option<f32> {
913        let policy = self.scroll_target.settle_policy()?;
914        let current = self.scroll_target.current_offset();
915        let proposed = if start_fling {
916            fling_rest_position(current, fling_velocity)
917        } else {
918            current
919        };
920        let target = policy(proposed, fling_velocity);
921        ((target - proposed).abs() > 0.5).then_some(target)
922    }
923
924    fn start_fling_animation(&self, fling_velocity: f32) {
925        let Some(runtime) = current_runtime_handle() else {
926            self.motion_context.set_active(false);
927            return;
928        };
929        self.motion_context.set_active(true);
930        let scroll_target = self.scroll_target.clone();
931        let fling = FlingAnimation::new(runtime);
932        let motion_context = self.motion_context.clone();
933        let initial_value = scroll_target.current_offset();
934        let scroll_target_for_fling = scroll_target.clone();
935        let detector_for_end = self.clone_for_watcher();
936        let overscroll_for_fling = self.overscroll.clone();
937
938        fling.start_fling(
939            initial_value,
940            fling_velocity,
941            move |delta| {
942                let consumed = overscroll_for_fling.apply_to_fling(delta, |delta| {
943                    scroll_target_for_fling.apply_fling_delta(delta)
944                });
945                scroll_target_for_fling.invalidate();
946                consumed
947            },
948            move || {
949                scroll_target.invalidate();
950                let settle_running = detector_for_end
951                    .gesture_state
952                    .borrow()
953                    .settle_animation
954                    .as_ref()
955                    .is_some_and(SettleAnimation::is_running);
956                if detector_for_end.overscroll.offset().abs() > 0.001 && !settle_running {
957                    detector_for_end.start_overscroll_settle(0.0);
958                }
959                detector_for_end.update_motion_active(&motion_context);
960            },
961        );
962
963        let mut gs = self.gesture_state.borrow_mut();
964        gs.fling_animation = Some(fling);
965    }
966
967    fn start_settle_animation(&self, target: f32, initial_velocity: f32) {
968        let Some(runtime) = current_runtime_handle() else {
969            self.motion_context.set_active(false);
970            return;
971        };
972        self.motion_context.set_active(true);
973        let settle = SettleAnimation::new(runtime, SpringParams::SETTLE_POLICY);
974        let scroll_target_for_settle = self.scroll_target.clone();
975        let scroll_target_for_end = self.scroll_target.clone();
976        let detector_for_end = self.clone_for_watcher();
977        let motion_context = self.motion_context.clone();
978        settle.start_settle(
979            self.scroll_target.current_offset(),
980            initial_velocity,
981            target,
982            move |delta| {
983                let consumed = scroll_target_for_settle.apply_fling_delta(delta);
984                scroll_target_for_settle.invalidate();
985                consumed
986            },
987            move |_| {
988                scroll_target_for_end.invalidate();
989                detector_for_end.update_motion_active(&motion_context);
990            },
991        );
992        let mut gs = self.gesture_state.borrow_mut();
993        gs.settle_animation = Some(settle);
994    }
995
996    fn start_overscroll_settle(&self, initial_velocity: f32) {
997        let Some(runtime) = current_runtime_handle() else {
998            let current = self.overscroll.offset();
999            self.overscroll.apply_settle_delta(-current);
1000            self.motion_context.set_active(false);
1001            return;
1002        };
1003        let settle = SettleAnimation::new(runtime, SpringParams::OVERSCROLL_BOUNCE);
1004        let overscroll_for_settle = self.overscroll.clone();
1005        let overscroll_for_end = self.overscroll.clone();
1006        let detector_for_end = self.clone_for_watcher();
1007        let motion_context = self.motion_context.clone();
1008        let initial = self.overscroll.offset();
1009        settle.start_settle(
1010            initial,
1011            initial_velocity,
1012            0.0,
1013            move |delta| overscroll_for_settle.apply_settle_delta(delta),
1014            move |end| {
1015                let current = overscroll_for_end.offset();
1016                overscroll_for_end.apply_settle_delta(-current);
1017                if end.hit_boundary && end.velocity.abs() > MIN_FLING_VELOCITY {
1018                    detector_for_end.start_fling_animation(-end.velocity);
1019                } else {
1020                    detector_for_end.update_motion_active(&motion_context);
1021                }
1022            },
1023        );
1024        let mut gs = self.gesture_state.borrow_mut();
1025        gs.is_overscrolling = false;
1026        gs.settle_animation = Some(settle);
1027    }
1028
1029    fn update_motion_active(&self, motion_context: &ScrollMotionContext) {
1030        let running = self.gesture_state.borrow().is_active();
1031        if !running {
1032            motion_context.set_active(false);
1033        }
1034    }
1035
1036    fn on_up(&self, time_ms: Option<i64>) -> bool {
1037        self.finish_gesture(self.scroll_target.allows_fling(), time_ms)
1038    }
1039
1040    fn on_cancel(&self) -> bool {
1041        self.finish_gesture(false, None)
1042    }
1043
1044    fn on_scroll(&self, axis_delta: f32, event: &PointerEvent) -> bool {
1045        if axis_delta.abs() <= f32::EPSILON {
1046            return false;
1047        }
1048
1049        let delta = if self.reverse_scrolling {
1050            -axis_delta
1051        } else {
1052            axis_delta
1053        };
1054        if !self.scroll_target.can_consume(delta) && self.scroll_target.can_scroll() {
1055            let detector = self.clone_for_watcher();
1056            event.defer_post_dispatch_action(move || detector.apply_wheel_delta(delta));
1057            return false;
1058        }
1059        self.apply_wheel_delta(delta)
1060    }
1061
1062    fn apply_wheel_delta(&self, delta: f32) -> bool {
1063        {
1064            let mut gs = self.gesture_state.borrow_mut();
1065            if gs.disposed {
1066                return false;
1067            }
1068            if let Some(fling) = gs.fling_animation.take() {
1069                fling.cancel();
1070            }
1071            if let Some(settle) = gs.settle_animation.take() {
1072                settle.cancel();
1073            }
1074            gs.drag_down_position = None;
1075            gs.last_position = None;
1076            gs.is_dragging = false;
1077            gs.axis_locked_out = false;
1078            gs.gesture_start_time = None;
1079            gs.gesture_start_event_time_ms = None;
1080            gs.last_velocity_sample_ms = None;
1081            gs.velocity_tracker.reset();
1082        }
1083
1084        self.motion_context.activate_for_current_frame();
1085        let overscroll = self.overscroll.clone();
1086        let consumed =
1087            overscroll.apply_to_scroll(delta, |delta| self.scroll_target.apply_wheel_delta(delta));
1088        let overscroll_active = self.overscroll.offset().abs() > 0.001;
1089        if consumed.abs() > 0.001 {
1090            self.scroll_target.invalidate();
1091        }
1092        if consumed.abs() > 0.001 || overscroll_active {
1093            self.ensure_wheel_settle_watcher();
1094            true
1095        } else {
1096            false
1097        }
1098    }
1099
1100    fn ensure_wheel_settle_watcher(&self) {
1101        if self.scroll_target.settle_policy().is_none() && self.overscroll.offset().abs() <= 0.001 {
1102            return;
1103        }
1104        {
1105            let gs = self.gesture_state.borrow();
1106            if gs
1107                .wheel_settle_watcher
1108                .as_ref()
1109                .is_some_and(|watcher| watcher.is_running.get())
1110            {
1111                return;
1112            }
1113        }
1114        let Some(runtime) = current_runtime_handle() else {
1115            return;
1116        };
1117
1118        let is_running = Rc::new(Cell::new(true));
1119        let registration = Rc::new(RefCell::new(None));
1120
1121        struct WheelSettleLoop<S: ScrollTarget> {
1122            detector: ScrollGestureDetector<S>,
1123            gesture_state: Rc<RefCell<ScrollGestureState>>,
1124            frame_clock: cranpose_core::internal::FrameClock,
1125            is_running: Rc<Cell<bool>>,
1126            registration: Rc<RefCell<Option<FrameCallbackRegistration>>>,
1127            last_offset: Rc<Cell<f32>>,
1128            idle_nanos: Rc<Cell<u64>>,
1129            last_frame: Rc<Cell<Option<u64>>>,
1130        }
1131
1132        impl<S: ScrollTarget + 'static> WheelSettleLoop<S> {
1133            fn next(&self) -> Self {
1134                Self {
1135                    detector: self.detector.clone_for_watcher(),
1136                    gesture_state: Rc::clone(&self.gesture_state),
1137                    frame_clock: self.frame_clock.clone(),
1138                    is_running: Rc::clone(&self.is_running),
1139                    registration: Rc::clone(&self.registration),
1140                    last_offset: Rc::clone(&self.last_offset),
1141                    idle_nanos: Rc::clone(&self.idle_nanos),
1142                    last_frame: Rc::clone(&self.last_frame),
1143                }
1144            }
1145
1146            fn schedule(self) {
1147                let continuation = self.next();
1148                let registration_slot = Rc::clone(&self.registration);
1149                let new_registration = self.frame_clock.with_frame_nanos(move |frame_time_nanos| {
1150                    let this = &continuation;
1151                    if !this.is_running.get() {
1152                        return;
1153                    }
1154                    {
1155                        let gs = this.gesture_state.borrow();
1156                        let animating = gs.is_dragging
1157                            || gs
1158                                .fling_animation
1159                                .as_ref()
1160                                .is_some_and(FlingAnimation::is_running)
1161                            || gs
1162                                .settle_animation
1163                                .as_ref()
1164                                .is_some_and(SettleAnimation::is_running);
1165                        if animating {
1166                            this.is_running.set(false);
1167                            return;
1168                        }
1169                    }
1170
1171                    let offset = this.detector.scroll_target.current_offset();
1172                    let dt = this
1173                        .last_frame
1174                        .get()
1175                        .map_or(0, |last| frame_time_nanos.saturating_sub(last));
1176                    this.last_frame.set(Some(frame_time_nanos));
1177                    if (offset - this.last_offset.get()).abs() > 0.01 {
1178                        this.last_offset.set(offset);
1179                        this.idle_nanos.set(0);
1180                    } else {
1181                        this.idle_nanos.set(this.idle_nanos.get() + dt);
1182                    }
1183
1184                    if this.idle_nanos.get() >= WHEEL_SETTLE_IDLE_NANOS {
1185                        this.is_running.set(false);
1186                        if this.detector.overscroll.offset().abs() > 0.001 {
1187                            this.detector.start_overscroll_settle(0.0);
1188                            return;
1189                        }
1190                        if let Some(policy) = this.detector.scroll_target.settle_policy() {
1191                            let target = policy(offset, 0.0);
1192                            if (target - offset).abs() > 0.5 {
1193                                this.detector.start_settle_animation(target, 0.0);
1194                            }
1195                        }
1196                        return;
1197                    }
1198
1199                    continuation.next().schedule();
1200                });
1201                *registration_slot.borrow_mut() = Some(new_registration);
1202            }
1203        }
1204
1205        WheelSettleLoop {
1206            detector: self.clone_for_watcher(),
1207            gesture_state: Rc::clone(&self.gesture_state),
1208            frame_clock: runtime.frame_clock(),
1209            is_running: Rc::clone(&is_running),
1210            registration: Rc::clone(&registration),
1211            last_offset: Rc::new(Cell::new(self.scroll_target.current_offset())),
1212            idle_nanos: Rc::new(Cell::new(0u64)),
1213            last_frame: Rc::new(Cell::new(None::<u64>)),
1214        }
1215        .schedule();
1216
1217        self.gesture_state.borrow_mut().wheel_settle_watcher = Some(WheelSettleWatcher {
1218            is_running,
1219            registration,
1220        });
1221    }
1222
1223    fn clone_for_watcher(&self) -> ScrollGestureDetector<S> {
1224        ScrollGestureDetector {
1225            gesture_state: Rc::clone(&self.gesture_state),
1226            scroll_target: self.scroll_target.clone(),
1227            is_vertical: self.is_vertical,
1228            reverse_scrolling: self.reverse_scrolling,
1229            overscroll: self.overscroll.clone(),
1230            motion_context: self.motion_context.clone(),
1231        }
1232    }
1233}
1234
1235pub(crate) struct MotionContextAnimatedNode {
1236    state: NodeState,
1237    motion_context: ScrollMotionContext,
1238    invalidation_callback_id: Option<u64>,
1239    overscroll_callback_id: Option<u64>,
1240    node_id: Option<NodeId>,
1241}
1242
1243impl MotionContextAnimatedNode {
1244    fn new(motion_context: ScrollMotionContext) -> Self {
1245        Self {
1246            state: NodeState::new(),
1247            motion_context,
1248            invalidation_callback_id: None,
1249            overscroll_callback_id: None,
1250            node_id: None,
1251        }
1252    }
1253
1254    pub(crate) fn is_active(&self) -> bool {
1255        self.motion_context.is_active()
1256    }
1257
1258    fn attach_callbacks(&mut self) {
1259        if let Some(node_id) = self.node_id {
1260            self.invalidation_callback_id = Some(self.motion_context.add_invalidate_callback(
1261                Box::new(move || schedule_modifier_slices_repass(node_id)),
1262            ));
1263            self.overscroll_callback_id = Some(
1264                self.motion_context
1265                    .overscroll()
1266                    .add_invalidate_callback(Box::new(move || {
1267                        crate::schedule_measure_repass(node_id);
1268                        schedule_modifier_slices_repass(node_id);
1269                    })),
1270            );
1271        }
1272    }
1273
1274    fn detach_callbacks(&mut self) {
1275        if let Some(id) = self.invalidation_callback_id.take() {
1276            self.motion_context.remove_invalidate_callback(id);
1277        }
1278        if let Some(id) = self.overscroll_callback_id.take() {
1279            self.motion_context
1280                .overscroll()
1281                .remove_invalidate_callback(id);
1282        }
1283    }
1284}
1285
1286pub(crate) struct TranslatedContentContextNode {
1287    state: NodeState,
1288    identity: usize,
1289    offset_source: TranslatedContentOffsetSource,
1290    overscroll: crate::scroll::OverscrollEffect,
1291    overscroll_callback_id: Option<u64>,
1292    node_id: Option<NodeId>,
1293}
1294
1295impl TranslatedContentContextNode {
1296    fn new(
1297        identity: usize,
1298        offset_source: TranslatedContentOffsetSource,
1299        overscroll: crate::scroll::OverscrollEffect,
1300    ) -> Self {
1301        Self {
1302            state: NodeState::new(),
1303            identity,
1304            offset_source,
1305            overscroll,
1306            overscroll_callback_id: None,
1307            node_id: None,
1308        }
1309    }
1310
1311    pub(crate) fn is_active(&self) -> bool {
1312        true
1313    }
1314
1315    pub(crate) fn identity(&self) -> usize {
1316        self.identity
1317    }
1318
1319    pub(crate) fn content_offset_reader(&self) -> Option<Rc<dyn Fn() -> Point>> {
1320        self.offset_source
1321            .content_offset_reader(self.overscroll.clone())
1322    }
1323
1324    fn attach_callback(&mut self) {
1325        if let Some(node_id) = self.node_id {
1326            self.overscroll_callback_id =
1327                Some(self.overscroll.add_invalidate_callback(Box::new(move || {
1328                    schedule_modifier_slices_repass(node_id);
1329                })));
1330        }
1331    }
1332
1333    fn detach_callback(&mut self) {
1334        if let Some(id) = self.overscroll_callback_id.take() {
1335            self.overscroll.remove_invalidate_callback(id);
1336        }
1337    }
1338}
1339
1340impl DelegatableNode for TranslatedContentContextNode {
1341    fn node_state(&self) -> &NodeState {
1342        &self.state
1343    }
1344}
1345
1346impl ModifierNode for TranslatedContentContextNode {
1347    fn on_attach(&mut self, context: &mut dyn cranpose_foundation::ModifierNodeContext) {
1348        self.node_id = context.node_id();
1349        self.attach_callback();
1350    }
1351
1352    fn on_detach(&mut self) {
1353        self.detach_callback();
1354        self.node_id = None;
1355    }
1356}
1357
1358impl DelegatableNode for MotionContextAnimatedNode {
1359    fn node_state(&self) -> &NodeState {
1360        &self.state
1361    }
1362}
1363
1364impl ModifierNode for MotionContextAnimatedNode {
1365    fn on_attach(&mut self, context: &mut dyn cranpose_foundation::ModifierNodeContext) {
1366        self.node_id = context.node_id();
1367        self.attach_callbacks();
1368    }
1369
1370    fn on_detach(&mut self) {
1371        self.detach_callbacks();
1372        self.node_id = None;
1373    }
1374}
1375
1376#[derive(Clone)]
1377struct MotionContextAnimatedElement {
1378    motion_context: ScrollMotionContext,
1379}
1380
1381impl MotionContextAnimatedElement {
1382    fn new(motion_context: ScrollMotionContext) -> Self {
1383        Self { motion_context }
1384    }
1385}
1386
1387impl std::fmt::Debug for MotionContextAnimatedElement {
1388    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1389        f.debug_struct("MotionContextAnimatedElement").finish()
1390    }
1391}
1392
1393impl PartialEq for MotionContextAnimatedElement {
1394    fn eq(&self, other: &Self) -> bool {
1395        self.motion_context.ptr_eq(&other.motion_context)
1396    }
1397}
1398
1399impl Eq for MotionContextAnimatedElement {}
1400
1401impl std::hash::Hash for MotionContextAnimatedElement {
1402    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
1403        self.motion_context.stable_key().hash(state);
1404    }
1405}
1406
1407impl ModifierNodeElement for MotionContextAnimatedElement {
1408    type Node = MotionContextAnimatedNode;
1409
1410    fn create(&self) -> Self::Node {
1411        MotionContextAnimatedNode::new(self.motion_context.clone())
1412    }
1413
1414    fn update(&self, node: &mut Self::Node) {
1415        if node.motion_context.ptr_eq(&self.motion_context) {
1416            return;
1417        }
1418        node.detach_callbacks();
1419        node.motion_context = self.motion_context.clone();
1420        node.attach_callbacks();
1421    }
1422
1423    fn capabilities(&self) -> NodeCapabilities {
1424        NodeCapabilities::LAYOUT
1425    }
1426}
1427
1428#[derive(Clone)]
1429enum TranslatedContentOffsetSource {
1430    LayoutContentOffset,
1431    LazyList {
1432        state: LazyListState,
1433        is_vertical: bool,
1434        reverse_scrolling: bool,
1435    },
1436}
1437
1438impl TranslatedContentOffsetSource {
1439    fn content_offset_reader(
1440        &self,
1441        overscroll: crate::scroll::OverscrollEffect,
1442    ) -> Option<Rc<dyn Fn() -> Point>> {
1443        match self {
1444            Self::LayoutContentOffset => None,
1445            Self::LazyList {
1446                state, is_vertical, ..
1447            } => Some(Rc::new(lazy_list_content_offset_reader(
1448                *state,
1449                *is_vertical,
1450                overscroll,
1451            ))),
1452        }
1453    }
1454
1455    fn is_vertical(&self) -> Option<bool> {
1456        match self {
1457            Self::LayoutContentOffset => None,
1458            Self::LazyList { is_vertical, .. } => Some(*is_vertical),
1459        }
1460    }
1461
1462    fn reverse_scrolling(&self) -> Option<bool> {
1463        match self {
1464            Self::LayoutContentOffset => None,
1465            Self::LazyList {
1466                reverse_scrolling, ..
1467            } => Some(*reverse_scrolling),
1468        }
1469    }
1470}
1471
1472fn lazy_list_content_offset_reader(
1473    state: LazyListState,
1474    is_vertical: bool,
1475    overscroll: crate::scroll::OverscrollEffect,
1476) -> impl Fn() -> Point {
1477    move || {
1478        let Some((viewport, main_offset)) = state
1479            .with_layout_info(|info| {
1480                (!info.visible_items_info.is_empty())
1481                    .then_some((info.viewport_size, info.snap_anchor_offset))
1482            })
1483            .flatten()
1484        else {
1485            return Point::default();
1486        };
1487        overscroll.set_dimension(viewport);
1488        let bounce = overscroll.offset();
1489        if is_vertical {
1490            Point::new(0.0, main_offset + bounce)
1491        } else {
1492            Point::new(main_offset + bounce, 0.0)
1493        }
1494    }
1495}
1496
1497#[derive(Clone)]
1498struct TranslatedContentContextElement {
1499    identity: usize,
1500    offset_source: TranslatedContentOffsetSource,
1501    overscroll: crate::scroll::OverscrollEffect,
1502}
1503
1504impl TranslatedContentContextElement {
1505    fn new(
1506        identity: usize,
1507        offset_source: TranslatedContentOffsetSource,
1508        overscroll: crate::scroll::OverscrollEffect,
1509    ) -> Self {
1510        Self {
1511            identity,
1512            offset_source,
1513            overscroll,
1514        }
1515    }
1516}
1517
1518impl std::fmt::Debug for TranslatedContentContextElement {
1519    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1520        let offset_source = match &self.offset_source {
1521            TranslatedContentOffsetSource::LayoutContentOffset => "layout",
1522            TranslatedContentOffsetSource::LazyList { .. } => "lazy_list",
1523        };
1524        f.debug_struct("TranslatedContentContextElement")
1525            .field("identity", &self.identity)
1526            .field("offset_source", &offset_source)
1527            .finish()
1528    }
1529}
1530
1531impl PartialEq for TranslatedContentContextElement {
1532    fn eq(&self, other: &Self) -> bool {
1533        self.identity == other.identity
1534            && self.overscroll.ptr_eq(&other.overscroll)
1535            && self.offset_source.is_vertical() == other.offset_source.is_vertical()
1536            && self.offset_source.reverse_scrolling() == other.offset_source.reverse_scrolling()
1537    }
1538}
1539
1540impl Eq for TranslatedContentContextElement {}
1541
1542impl std::hash::Hash for TranslatedContentContextElement {
1543    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
1544        self.identity.hash(state);
1545        self.offset_source.is_vertical().hash(state);
1546        self.offset_source.reverse_scrolling().hash(state);
1547    }
1548}
1549
1550impl ModifierNodeElement for TranslatedContentContextElement {
1551    type Node = TranslatedContentContextNode;
1552
1553    fn create(&self) -> Self::Node {
1554        TranslatedContentContextNode::new(
1555            self.identity,
1556            self.offset_source.clone(),
1557            self.overscroll.clone(),
1558        )
1559    }
1560
1561    fn update(&self, node: &mut Self::Node) {
1562        node.identity = self.identity;
1563        node.offset_source = self.offset_source.clone();
1564        if !node.overscroll.ptr_eq(&self.overscroll) {
1565            node.detach_callback();
1566            node.overscroll = self.overscroll.clone();
1567            node.attach_callback();
1568        }
1569    }
1570
1571    fn capabilities(&self) -> NodeCapabilities {
1572        NodeCapabilities::LAYOUT
1573    }
1574}
1575
1576impl Modifier {
1577    /// Creates a horizontally scrollable modifier.
1578    ///
1579    /// # Arguments
1580    /// * `state` - The ScrollState to control scroll position
1581    /// * `reverse_scrolling` - If true, reverses the scroll direction in layout.
1582    ///   Note: This affects how scroll offset is applied to content (via `ScrollNode`),
1583    ///   NOT the drag direction. Drag gestures always follow natural touch semantics:
1584    ///   drag right = scroll left (content moves right under finger).
1585    ///
1586    /// # Example
1587    /// ```text
1588    /// let scroll_state = ScrollState::new(0.0);
1589    /// Row(
1590    ///     Modifier::empty().horizontal_scroll(scroll_state, false),
1591    ///     // ... content
1592    /// );
1593    /// ```
1594    pub fn horizontal_scroll(self, state: ScrollState, reverse_scrolling: bool) -> Self {
1595        self.then(scroll_impl(
1596            state,
1597            false,
1598            reverse_scrolling,
1599            None,
1600            (0.0, 0.0),
1601        ))
1602    }
1603
1604    /// Creates a vertically scrollable modifier.
1605    ///
1606    /// # Arguments
1607    /// * `state` - The ScrollState to control scroll position
1608    /// * `reverse_scrolling` - If true, reverses the scroll direction in layout.
1609    ///   Note: This affects how scroll offset is applied to content (via `ScrollNode`),
1610    ///   NOT the drag direction. Drag gestures always follow natural touch semantics:
1611    ///   drag down = scroll up (content moves down under finger).
1612    pub fn vertical_scroll(self, state: ScrollState, reverse_scrolling: bool) -> Self {
1613        self.then(scroll_impl(
1614            state,
1615            true,
1616            reverse_scrolling,
1617            None,
1618            (0.0, 0.0),
1619        ))
1620    }
1621
1622    /// Adds a vertical scroll container with content padding in logical pixels.
1623    ///
1624    /// The padding scrolls with the content. Keyboard and accessibility focus
1625    /// keep controls inside the padded viewport, clear of overlaid system UI.
1626    pub fn vertical_scroll_with_content_padding(
1627        self,
1628        state: ScrollState,
1629        reverse_scrolling: bool,
1630        padding: cranpose_ui_graphics::EdgeInsets,
1631    ) -> Self {
1632        let density = crate::density();
1633        self.then(scroll_impl(
1634            state,
1635            true,
1636            reverse_scrolling,
1637            None,
1638            (density.dp(padding.top), density.dp(padding.bottom)),
1639        ))
1640        .window_insets_padding(padding)
1641    }
1642
1643    /// Adds a horizontal scroll container with content padding in logical pixels.
1644    ///
1645    /// The padding scrolls with the content. Keyboard and accessibility focus
1646    /// keep controls inside the padded viewport. Insets use physical edges.
1647    pub fn horizontal_scroll_with_content_padding(
1648        self,
1649        state: ScrollState,
1650        reverse_scrolling: bool,
1651        padding: cranpose_ui_graphics::EdgeInsets,
1652    ) -> Self {
1653        let density = crate::density();
1654        self.then(scroll_impl(
1655            state,
1656            false,
1657            reverse_scrolling,
1658            None,
1659            (density.dp(padding.left), density.dp(padding.right)),
1660        ))
1661        .window_insets_padding(padding)
1662    }
1663
1664    /// A horizontal scroll that never claims a pointer gesture.
1665    ///
1666    /// The position still moves through [`ScrollState`], but drags and wheel
1667    /// events pass straight through, so an owner can run its own
1668    /// `pointer_input` on the same row -- a drag-to-reorder list, say --
1669    /// without the two competing for the gesture.
1670    pub fn horizontal_scroll_without_gestures(
1671        self,
1672        state: ScrollState,
1673        reverse_scrolling: bool,
1674    ) -> Self {
1675        self.then(scroll_impl(
1676            state,
1677            false,
1678            reverse_scrolling,
1679            Some(Rc::new(|| false)),
1680            (0.0, 0.0),
1681        ))
1682    }
1683
1684    /// A vertical scroll that never claims a pointer gesture.
1685    ///
1686    /// The vertical counterpart of [`Modifier::horizontal_scroll_without_gestures`].
1687    pub fn vertical_scroll_without_gestures(
1688        self,
1689        state: ScrollState,
1690        reverse_scrolling: bool,
1691    ) -> Self {
1692        self.then(scroll_impl(
1693            state,
1694            true,
1695            reverse_scrolling,
1696            Some(Rc::new(|| false)),
1697            (0.0, 0.0),
1698        ))
1699    }
1700}
1701
1702fn scroll_impl(
1703    state: ScrollState,
1704    is_vertical: bool,
1705    reverse_scrolling: bool,
1706    guard: Option<Rc<dyn Fn() -> bool>>,
1707    content_padding: (f32, f32),
1708) -> Modifier {
1709    let motion_context = scroll_motion_context_for_key(ScrollMotionContextKey::ScrollState {
1710        state_id: state.id(),
1711        is_vertical,
1712        reverse_scrolling,
1713    });
1714    motion_context
1715        .observe_activity(|| Box::new(move |active| state.set_scroll_in_progress(active)));
1716
1717    let pointer_input = nested_drag_gesture_input(
1718        (state.id(), is_vertical),
1719        DragGesture {
1720            target: state,
1721            is_vertical,
1722            reverse_input: false,
1723            motion_context: motion_context.clone(),
1724            guard,
1725        },
1726    );
1727
1728    let overscroll = motion_context.overscroll();
1729    let element = ScrollElement::new(state, overscroll.clone(), is_vertical, reverse_scrolling);
1730    let layout_modifier =
1731        Modifier::with_element(element).with_inspector_metadata(inspector_metadata(
1732            if is_vertical {
1733                "verticalScroll"
1734            } else {
1735                "horizontalScroll"
1736            },
1737            move |info| {
1738                info.add_property("isVertical", is_vertical.to_string());
1739                info.add_property("reverseScrolling", reverse_scrolling.to_string());
1740            },
1741        ));
1742    let motion_modifier = Modifier::with_element(MotionContextAnimatedElement::new(motion_context));
1743    let translated_content_modifier = Modifier::with_element(TranslatedContentContextElement::new(
1744        state.id() as usize,
1745        TranslatedContentOffsetSource::LayoutContentOffset,
1746        overscroll,
1747    ));
1748
1749    // The clip wraps the scroll layout, as Compose's `scrollingContainer`
1750    // clips before it scrolls: it cuts at the viewport, not at the content
1751    // the layout moves.
1752    pointer_input
1753        .clip_to_bounds()
1754        .then(motion_modifier)
1755        .then(translated_content_modifier)
1756        .then(layout_modifier)
1757        .semantics(scroll_semantics(
1758            state,
1759            is_vertical,
1760            reverse_scrolling,
1761            content_padding,
1762        ))
1763}
1764
1765/// What a screen reader learns about a scroll container: how far it has
1766/// scrolled, whether it can go on, and how to page it. Compose's
1767/// `verticalScrollAxisRange` / `horizontalScrollAxisRange` and `scrollBy`.
1768/// Without this a reader's cursor stops at the last row it can see.
1769fn scroll_semantics(
1770    state: ScrollState,
1771    is_vertical: bool,
1772    reverse_scrolling: bool,
1773    content_padding: (f32, f32),
1774) -> impl Fn(&mut cranpose_foundation::SemanticsConfiguration) + 'static {
1775    move |config| {
1776        let range = cranpose_foundation::ScrollAxisRange::new(
1777            state.value(),
1778            state.max_value(),
1779            reverse_scrolling,
1780        )
1781        .with_content_padding(content_padding.0, content_padding.1);
1782        if is_vertical {
1783            config.vertical_scroll = Some(range);
1784        } else {
1785            config.horizontal_scroll = Some(range);
1786        }
1787        config.scroll_by = Some(cranpose_foundation::SemanticsScrollBy::new(
1788            move |dx, dy| {
1789                let delta = if is_vertical { dy } else { dx };
1790                state.dispatch_raw_delta(delta).abs() > f32::EPSILON
1791            },
1792        ));
1793    }
1794}
1795
1796use cranpose_foundation::lazy::{LazyListState, LazySettlePolicy};
1797
1798impl Modifier {
1799    /// Creates a vertically scrollable modifier for lazy lists.
1800    ///
1801    /// This connects pointer gestures to LazyListState for scroll handling.
1802    /// Unlike regular vertical_scroll, no layout offset is applied here
1803    /// since LazyListState manages item positioning internally.
1804    /// Creates a vertically scrollable modifier for lazy lists.
1805    ///
1806    /// This connects pointer gestures to LazyListState for scroll handling.
1807    /// Unlike regular vertical_scroll, no layout offset is applied here
1808    /// since LazyListState manages item positioning internally.
1809    pub fn lazy_vertical_scroll(self, state: LazyListState, reverse_scrolling: bool) -> Self {
1810        let motion_context = scroll_motion_context_for_key(ScrollMotionContextKey::LazyList {
1811            state_identity: state.inner_ptr() as usize,
1812            is_vertical: true,
1813            reverse_scrolling,
1814        });
1815        self.lazy_vertical_scroll_with_context(state, reverse_scrolling, (0.0, 0.0), motion_context)
1816    }
1817
1818    pub(crate) fn lazy_vertical_scroll_with_context(
1819        self,
1820        state: LazyListState,
1821        reverse_scrolling: bool,
1822        content_padding: (f32, f32),
1823        motion_context: ScrollMotionContext,
1824    ) -> Self {
1825        self.then(lazy_scroll_impl(
1826            state,
1827            true,
1828            reverse_scrolling,
1829            content_padding,
1830            motion_context,
1831        ))
1832    }
1833
1834    pub(crate) fn lazy_horizontal_scroll_with_context(
1835        self,
1836        state: LazyListState,
1837        reverse_scrolling: bool,
1838        content_padding: (f32, f32),
1839        motion_context: ScrollMotionContext,
1840    ) -> Self {
1841        self.then(lazy_scroll_impl(
1842            state,
1843            false,
1844            reverse_scrolling,
1845            content_padding,
1846            motion_context,
1847        ))
1848    }
1849}
1850
1851fn lazy_scroll_impl(
1852    state: LazyListState,
1853    is_vertical: bool,
1854    reverse_scrolling: bool,
1855    content_padding: (f32, f32),
1856    motion_context: ScrollMotionContext,
1857) -> Modifier {
1858    let list_state = state;
1859    let state_id = state.inner_ptr() as usize;
1860    let key = (state_id, is_vertical, reverse_scrolling);
1861    motion_context
1862        .observe_activity(|| Box::new(move |active| state.set_gesture_scroll_active(active)));
1863    let overscroll = motion_context.overscroll();
1864    let translated_content_modifier = Modifier::with_element(TranslatedContentContextElement::new(
1865        state_id,
1866        TranslatedContentOffsetSource::LazyList {
1867            state,
1868            is_vertical,
1869            reverse_scrolling,
1870        },
1871        overscroll,
1872    ));
1873
1874    Modifier::with_element(MotionContextAnimatedElement::new(motion_context.clone()))
1875        .then(translated_content_modifier)
1876        .then(nested_drag_gesture_input(
1877            key,
1878            DragGesture {
1879                target: list_state,
1880                is_vertical,
1881                reverse_input: reverse_scrolling,
1882                motion_context,
1883                guard: None,
1884            },
1885        ))
1886        .semantics(lazy_scroll_semantics(
1887            list_state,
1888            is_vertical,
1889            reverse_scrolling,
1890            content_padding,
1891        ))
1892}
1893
1894/// What a screen reader learns about a lazy list. The list has no whole
1895/// extent to give, so the first visible item stands in for the position, a
1896/// half step marks a list scrolled part way into that item, and one more step
1897/// stays ahead while the list can still scroll forward. A reader needs only
1898/// the two answers: can it page back, can it page on.
1899pub(crate) fn lazy_scroll_semantics(
1900    state: LazyListState,
1901    is_vertical: bool,
1902    reverse_scrolling: bool,
1903    content_padding: (f32, f32),
1904) -> impl Fn(&mut cranpose_foundation::SemanticsConfiguration) + 'static {
1905    move |config| {
1906        let mut value = state.first_visible_item_index() as f32;
1907        if state.first_visible_item_scroll_offset() > 0.0 {
1908            value += 0.5;
1909        }
1910        let max_value = if state.can_scroll_forward() {
1911            value + 1.0
1912        } else {
1913            value
1914        };
1915        let range = cranpose_foundation::ScrollAxisRange::new(value, max_value, reverse_scrolling)
1916            .with_content_padding(content_padding.0, content_padding.1);
1917        let count = state.total_items_count();
1918        config.collection = Some(cranpose_foundation::CollectionInfo {
1919            rows: if is_vertical { count } else { 1 },
1920            columns: if is_vertical { 1 } else { count },
1921        });
1922        config
1923            .role
1924            .get_or_insert(cranpose_foundation::SemanticsWidgetRole::List);
1925        if is_vertical {
1926            config.vertical_scroll = Some(range);
1927        } else {
1928            config.horizontal_scroll = Some(range);
1929        }
1930        config.scroll_by = Some(cranpose_foundation::SemanticsScrollBy::new(
1931            move |dx, dy| {
1932                let delta = if is_vertical { dy } else { dx };
1933                match state.settle_policy() {
1934                    Some(policy) => settle_reader_scroll(&state, &policy, delta),
1935                    None => state.dispatch_scroll_delta(-delta).abs() > f32::EPSILON,
1936                }
1937            },
1938        ));
1939        config.scroll_to_index = Some(cranpose_foundation::SemanticsScrollToIndex::new(
1940            move |index| jump_to_row(&state, index),
1941        ));
1942    }
1943}
1944
1945/// Moves a list whose scroll comes to rest by a settle policy `delta` along
1946/// its items, and on to where the policy rests the item that reaches the
1947/// start, as a release there would. A reader's page of a pager turns one
1948/// page.
1949fn settle_reader_scroll(state: &LazyListState, policy: &LazySettlePolicy, delta: f32) -> bool {
1950    let can_move = if delta > 0.0 {
1951        state.can_scroll_forward_non_reactive()
1952    } else {
1953        state.can_scroll_backward_non_reactive()
1954    };
1955    let size = state.average_item_size();
1956    if !can_move || size <= 0.0 {
1957        return false;
1958    }
1959    let first = state.first_visible_item_index_non_reactive() as f32;
1960    let reached =
1961        (first * size + state.first_visible_item_scroll_offset_non_reactive() + delta).max(0.0);
1962    let last = state.total_items_count().saturating_sub(1) as f32;
1963    let index = (reached / size).floor().min(last);
1964    let target = policy(reached - index * size, 0.0);
1965    crate::AnimateScroll::animate_scroll_to_item(state, index as usize, target);
1966    true
1967}
1968
1969/// Puts the row at `index` at the top of a lazy list for a screen reader that
1970/// asked for it. A list with no rows, or an index past the last row, takes
1971/// nothing and says so.
1972fn jump_to_row(state: &LazyListState, index: usize) -> bool {
1973    if index >= state.total_items_count() {
1974        return false;
1975    }
1976    state.scroll_to_item(index, 0.0);
1977    true
1978}
1979
1980impl Modifier {
1981    /// Drags along `axis`, reporting each delta to `state`.
1982    ///
1983    /// The gesture is the one the scroll containers run — touch slop before a
1984    /// drag begins, axis locking so a mostly-vertical drag never steals a
1985    /// horizontal one, the primary pointer only, and yielding to whoever
1986    /// consumed the event first — so a dragged control and a scrolled list feel
1987    /// the same under a finger. Unlike a scroll, a release does not fling: a
1988    /// control the user placed stays where it was let go.
1989    ///
1990    /// Deltas are logical pixels, positive to the right for [`Axis::Horizontal`]
1991    /// and downwards for [`Axis::Vertical`].
1992    pub fn draggable(self, axis: Axis, state: DraggableState) -> Self {
1993        self.then(draggable_impl(axis, state, None))
1994    }
1995
1996    /// Scrolls content with custom placement along `axis`.
1997    ///
1998    /// Supplies drag, wheel and inertial scrolling without translating or
1999    /// clipping layout. A new pointer press interrupts the fling. The state's
2000    /// consumption handler stops it when the content reaches a boundary.
2001    pub fn scrollable(self, axis: Axis, state: ScrollableState) -> Self {
2002        let is_vertical = axis.is_vertical();
2003        let identity = state.identity();
2004        let motion_context = scroll_motion_context_for_key(ScrollMotionContextKey::Draggable {
2005            state_identity: identity,
2006            is_vertical,
2007        });
2008        self.then(Modifier::with_element(MotionContextAnimatedElement::new(
2009            motion_context.clone(),
2010        )))
2011        .then(nested_drag_gesture_input(
2012            (identity, is_vertical),
2013            DragGesture {
2014                target: state,
2015                is_vertical,
2016                reverse_input: false,
2017                motion_context,
2018                guard: None,
2019            },
2020        ))
2021    }
2022
2023    /// Drags along `axis` only while `guard` says so.
2024    ///
2025    /// The guard is asked per event, so a control can stop accepting a drag
2026    /// half way through one and the gesture ends rather than being applied to
2027    /// a control that has since been disabled.
2028    pub fn draggable_guarded(
2029        self,
2030        axis: Axis,
2031        state: DraggableState,
2032        guard: impl Fn() -> bool + 'static,
2033    ) -> Self {
2034        self.then(draggable_impl(axis, state, Some(Rc::new(guard))))
2035    }
2036}
2037
2038fn draggable_impl(
2039    axis: Axis,
2040    state: DraggableState,
2041    guard: Option<Rc<dyn Fn() -> bool>>,
2042) -> Modifier {
2043    let is_vertical = axis.is_vertical();
2044    let identity = state.identity();
2045    drag_gesture_input(
2046        (identity, is_vertical),
2047        DragGesture {
2048            target: state,
2049            is_vertical,
2050            reverse_input: false,
2051            motion_context: scroll_motion_context_for_key(ScrollMotionContextKey::Draggable {
2052                state_identity: identity,
2053                is_vertical,
2054            }),
2055            guard,
2056        },
2057    )
2058}