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