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