Skip to main content

cranpose_ui/modifier/
scroll.rs

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