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