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