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(
1439            state,
1440            false,
1441            reverse_scrolling,
1442            None,
1443            (0.0, 0.0),
1444        ))
1445    }
1446
1447    /// Creates a vertically scrollable modifier.
1448    ///
1449    /// # Arguments
1450    /// * `state` - The ScrollState to control scroll position
1451    /// * `reverse_scrolling` - If true, reverses the scroll direction in layout.
1452    ///   Note: This affects how scroll offset is applied to content (via `ScrollNode`),
1453    ///   NOT the drag direction. Drag gestures always follow natural touch semantics:
1454    ///   drag down = scroll up (content moves down under finger).
1455    pub fn vertical_scroll(self, state: ScrollState, reverse_scrolling: bool) -> Self {
1456        self.then(scroll_impl(
1457            state,
1458            true,
1459            reverse_scrolling,
1460            None,
1461            (0.0, 0.0),
1462        ))
1463    }
1464
1465    /// Adds a vertical scroll container with content padding in logical pixels.
1466    ///
1467    /// The padding scrolls with the content. Keyboard and accessibility focus
1468    /// keep controls inside the padded viewport, clear of overlaid system UI.
1469    pub fn vertical_scroll_with_content_padding(
1470        self,
1471        state: ScrollState,
1472        reverse_scrolling: bool,
1473        padding: cranpose_ui_graphics::EdgeInsets,
1474    ) -> Self {
1475        let density = crate::density();
1476        self.then(scroll_impl(
1477            state,
1478            true,
1479            reverse_scrolling,
1480            None,
1481            (density.dp(padding.top), density.dp(padding.bottom)),
1482        ))
1483        .window_insets_padding(padding)
1484    }
1485
1486    /// Adds a horizontal scroll container with content padding in logical pixels.
1487    ///
1488    /// The padding scrolls with the content. Keyboard and accessibility focus
1489    /// keep controls inside the padded viewport. Insets use physical edges.
1490    pub fn horizontal_scroll_with_content_padding(
1491        self,
1492        state: ScrollState,
1493        reverse_scrolling: bool,
1494        padding: cranpose_ui_graphics::EdgeInsets,
1495    ) -> Self {
1496        let density = crate::density();
1497        self.then(scroll_impl(
1498            state,
1499            false,
1500            reverse_scrolling,
1501            None,
1502            (density.dp(padding.left), density.dp(padding.right)),
1503        ))
1504        .window_insets_padding(padding)
1505    }
1506
1507    /// A horizontal scroll that never claims a pointer gesture.
1508    ///
1509    /// The position still moves through [`ScrollState`], but drags and wheel
1510    /// events pass straight through, so an owner can run its own
1511    /// `pointer_input` on the same row -- a drag-to-reorder list, say --
1512    /// without the two competing for the gesture.
1513    pub fn horizontal_scroll_without_gestures(
1514        self,
1515        state: ScrollState,
1516        reverse_scrolling: bool,
1517    ) -> Self {
1518        self.then(scroll_impl(
1519            state,
1520            false,
1521            reverse_scrolling,
1522            Some(Rc::new(|| false)),
1523            (0.0, 0.0),
1524        ))
1525    }
1526
1527    /// A vertical scroll that never claims a pointer gesture.
1528    ///
1529    /// The vertical counterpart of [`Modifier::horizontal_scroll_without_gestures`].
1530    pub fn vertical_scroll_without_gestures(
1531        self,
1532        state: ScrollState,
1533        reverse_scrolling: bool,
1534    ) -> Self {
1535        self.then(scroll_impl(
1536            state,
1537            true,
1538            reverse_scrolling,
1539            Some(Rc::new(|| false)),
1540            (0.0, 0.0),
1541        ))
1542    }
1543}
1544
1545fn scroll_impl(
1546    state: ScrollState,
1547    is_vertical: bool,
1548    reverse_scrolling: bool,
1549    guard: Option<Rc<dyn Fn() -> bool>>,
1550    content_padding: (f32, f32),
1551) -> Modifier {
1552    let motion_context = scroll_motion_context_for_key(ScrollMotionContextKey::ScrollState {
1553        state_id: state.id(),
1554        is_vertical,
1555        reverse_scrolling,
1556    });
1557
1558    let pointer_input = drag_gesture_input(
1559        (state.id(), is_vertical),
1560        DragGesture {
1561            target: state,
1562            is_vertical,
1563            reverse_input: false,
1564            motion_context: motion_context.clone(),
1565            guard,
1566        },
1567    );
1568
1569    let overscroll = motion_context.overscroll();
1570    let element = ScrollElement::new(state, overscroll.clone(), is_vertical, reverse_scrolling);
1571    let layout_modifier =
1572        Modifier::with_element(element).with_inspector_metadata(inspector_metadata(
1573            if is_vertical {
1574                "verticalScroll"
1575            } else {
1576                "horizontalScroll"
1577            },
1578            move |info| {
1579                info.add_property("isVertical", is_vertical.to_string());
1580                info.add_property("reverseScrolling", reverse_scrolling.to_string());
1581            },
1582        ));
1583    let motion_modifier = Modifier::with_element(MotionContextAnimatedElement::new(motion_context));
1584    let translated_content_modifier = Modifier::with_element(TranslatedContentContextElement::new(
1585        state.id() as usize,
1586        TranslatedContentOffsetSource::LayoutContentOffset,
1587        overscroll,
1588    ));
1589
1590    // The clip wraps the scroll layout, as Compose's `scrollingContainer`
1591    // clips before it scrolls: it cuts at the viewport, not at the content
1592    // the layout moves.
1593    pointer_input
1594        .clip_to_bounds()
1595        .then(motion_modifier)
1596        .then(translated_content_modifier)
1597        .then(layout_modifier)
1598        .semantics(scroll_semantics(
1599            state,
1600            is_vertical,
1601            reverse_scrolling,
1602            content_padding,
1603        ))
1604}
1605
1606/// What a screen reader learns about a scroll container: how far it has
1607/// scrolled, whether it can go on, and how to page it. Compose's
1608/// `verticalScrollAxisRange` / `horizontalScrollAxisRange` and `scrollBy`.
1609/// Without this a reader's cursor stops at the last row it can see.
1610fn scroll_semantics(
1611    state: ScrollState,
1612    is_vertical: bool,
1613    reverse_scrolling: bool,
1614    content_padding: (f32, f32),
1615) -> impl Fn(&mut cranpose_foundation::SemanticsConfiguration) + 'static {
1616    move |config| {
1617        let range = cranpose_foundation::ScrollAxisRange::new(
1618            state.value(),
1619            state.max_value(),
1620            reverse_scrolling,
1621        )
1622        .with_content_padding(content_padding.0, content_padding.1);
1623        if is_vertical {
1624            config.vertical_scroll = Some(range);
1625        } else {
1626            config.horizontal_scroll = Some(range);
1627        }
1628        config.scroll_by = Some(cranpose_foundation::SemanticsScrollBy::new(
1629            move |dx, dy| {
1630                let delta = if is_vertical { dy } else { dx };
1631                state.dispatch_raw_delta(delta).abs() > f32::EPSILON
1632            },
1633        ));
1634    }
1635}
1636
1637use cranpose_foundation::lazy::LazyListState;
1638
1639impl Modifier {
1640    /// Creates a vertically scrollable modifier for lazy lists.
1641    ///
1642    /// This connects pointer gestures to LazyListState for scroll handling.
1643    /// Unlike regular vertical_scroll, no layout offset is applied here
1644    /// since LazyListState manages item positioning internally.
1645    /// Creates a vertically scrollable modifier for lazy lists.
1646    ///
1647    /// This connects pointer gestures to LazyListState for scroll handling.
1648    /// Unlike regular vertical_scroll, no layout offset is applied here
1649    /// since LazyListState manages item positioning internally.
1650    pub fn lazy_vertical_scroll(self, state: LazyListState, reverse_scrolling: bool) -> Self {
1651        let motion_context = scroll_motion_context_for_key(ScrollMotionContextKey::LazyList {
1652            state_identity: state.inner_ptr() as usize,
1653            is_vertical: true,
1654            reverse_scrolling,
1655        });
1656        self.lazy_vertical_scroll_with_context(state, reverse_scrolling, (0.0, 0.0), motion_context)
1657    }
1658
1659    pub(crate) fn lazy_vertical_scroll_with_context(
1660        self,
1661        state: LazyListState,
1662        reverse_scrolling: bool,
1663        content_padding: (f32, f32),
1664        motion_context: ScrollMotionContext,
1665    ) -> Self {
1666        self.then(lazy_scroll_impl(
1667            state,
1668            true,
1669            reverse_scrolling,
1670            content_padding,
1671            motion_context,
1672        ))
1673    }
1674
1675    pub(crate) fn lazy_horizontal_scroll_with_context(
1676        self,
1677        state: LazyListState,
1678        reverse_scrolling: bool,
1679        content_padding: (f32, f32),
1680        motion_context: ScrollMotionContext,
1681    ) -> Self {
1682        self.then(lazy_scroll_impl(
1683            state,
1684            false,
1685            reverse_scrolling,
1686            content_padding,
1687            motion_context,
1688        ))
1689    }
1690}
1691
1692fn lazy_scroll_impl(
1693    state: LazyListState,
1694    is_vertical: bool,
1695    reverse_scrolling: bool,
1696    content_padding: (f32, f32),
1697    motion_context: ScrollMotionContext,
1698) -> Modifier {
1699    let list_state = state;
1700    let state_id = state.inner_ptr() as usize;
1701    let key = (state_id, is_vertical, reverse_scrolling);
1702    let overscroll = motion_context.overscroll();
1703    let translated_content_modifier = Modifier::with_element(TranslatedContentContextElement::new(
1704        state_id,
1705        TranslatedContentOffsetSource::LazyList {
1706            state,
1707            is_vertical,
1708            reverse_scrolling,
1709        },
1710        overscroll,
1711    ));
1712
1713    Modifier::with_element(MotionContextAnimatedElement::new(motion_context.clone()))
1714        .then(translated_content_modifier)
1715        .then(drag_gesture_input(
1716            key,
1717            DragGesture {
1718                target: list_state,
1719                is_vertical,
1720                reverse_input: reverse_scrolling,
1721                motion_context,
1722                guard: None,
1723            },
1724        ))
1725        .semantics(lazy_scroll_semantics(
1726            list_state,
1727            is_vertical,
1728            reverse_scrolling,
1729            content_padding,
1730        ))
1731}
1732
1733/// What a screen reader learns about a lazy list. The list has no whole
1734/// extent to give, so the first visible item stands in for the position, a
1735/// half step marks a list scrolled part way into that item, and one more step
1736/// stays ahead while the list can still scroll forward. A reader needs only
1737/// the two answers: can it page back, can it page on.
1738pub(crate) fn lazy_scroll_semantics(
1739    state: LazyListState,
1740    is_vertical: bool,
1741    reverse_scrolling: bool,
1742    content_padding: (f32, f32),
1743) -> impl Fn(&mut cranpose_foundation::SemanticsConfiguration) + 'static {
1744    move |config| {
1745        let mut value = state.first_visible_item_index() as f32;
1746        if state.first_visible_item_scroll_offset() > 0.0 {
1747            value += 0.5;
1748        }
1749        let max_value = if state.can_scroll_forward() {
1750            value + 1.0
1751        } else {
1752            value
1753        };
1754        let range = cranpose_foundation::ScrollAxisRange::new(value, max_value, reverse_scrolling)
1755            .with_content_padding(content_padding.0, content_padding.1);
1756        let count = state.total_items_count();
1757        config.collection = Some(cranpose_foundation::CollectionInfo {
1758            rows: if is_vertical { count } else { 1 },
1759            columns: if is_vertical { 1 } else { count },
1760        });
1761        config
1762            .role
1763            .get_or_insert(cranpose_foundation::SemanticsWidgetRole::List);
1764        if is_vertical {
1765            config.vertical_scroll = Some(range);
1766        } else {
1767            config.horizontal_scroll = Some(range);
1768        }
1769        config.scroll_by = Some(cranpose_foundation::SemanticsScrollBy::new(
1770            move |dx, dy| {
1771                let delta = if is_vertical { dy } else { dx };
1772                state.dispatch_scroll_delta(-delta).abs() > f32::EPSILON
1773            },
1774        ));
1775        config.scroll_to_index = Some(cranpose_foundation::SemanticsScrollToIndex::new(
1776            move |index| jump_to_row(&state, index),
1777        ));
1778    }
1779}
1780
1781/// Puts the row at `index` at the top of a lazy list for a screen reader that
1782/// asked for it. A list with no rows, or an index past the last row, takes
1783/// nothing and says so.
1784fn jump_to_row(state: &LazyListState, index: usize) -> bool {
1785    if index >= state.total_items_count() {
1786        return false;
1787    }
1788    state.scroll_to_item(index, 0.0);
1789    true
1790}
1791
1792impl Modifier {
1793    /// Drags along `axis`, reporting each delta to `state`.
1794    ///
1795    /// The gesture is the one the scroll containers run — touch slop before a
1796    /// drag begins, axis locking so a mostly-vertical drag never steals a
1797    /// horizontal one, the primary pointer only, and yielding to whoever
1798    /// consumed the event first — so a dragged control and a scrolled list feel
1799    /// the same under a finger. Unlike a scroll, a release does not fling: a
1800    /// control the user placed stays where it was let go.
1801    ///
1802    /// Deltas are logical pixels, positive to the right for [`Axis::Horizontal`]
1803    /// and downwards for [`Axis::Vertical`].
1804    pub fn draggable(self, axis: Axis, state: DraggableState) -> Self {
1805        self.then(draggable_impl(axis, state, None))
1806    }
1807
1808    /// Scrolls content with custom placement along `axis`.
1809    ///
1810    /// Supplies drag, wheel and inertial scrolling without translating or
1811    /// clipping layout. A new pointer press interrupts the fling. The state's
1812    /// consumption handler stops it when the content reaches a boundary.
1813    pub fn scrollable(self, axis: Axis, state: ScrollableState) -> Self {
1814        let is_vertical = axis.is_vertical();
1815        let identity = state.identity();
1816        let motion_context = scroll_motion_context_for_key(ScrollMotionContextKey::Draggable {
1817            state_identity: identity,
1818            is_vertical,
1819        });
1820        self.then(Modifier::with_element(MotionContextAnimatedElement::new(
1821            motion_context.clone(),
1822        )))
1823        .then(drag_gesture_input(
1824            (identity, is_vertical),
1825            DragGesture {
1826                target: state,
1827                is_vertical,
1828                reverse_input: false,
1829                motion_context,
1830                guard: None,
1831            },
1832        ))
1833    }
1834
1835    /// Drags along `axis` only while `guard` says so.
1836    ///
1837    /// The guard is asked per event, so a control can stop accepting a drag
1838    /// half way through one and the gesture ends rather than being applied to
1839    /// a control that has since been disabled.
1840    pub fn draggable_guarded(
1841        self,
1842        axis: Axis,
1843        state: DraggableState,
1844        guard: impl Fn() -> bool + 'static,
1845    ) -> Self {
1846        self.then(draggable_impl(axis, state, Some(Rc::new(guard))))
1847    }
1848}
1849
1850fn draggable_impl(
1851    axis: Axis,
1852    state: DraggableState,
1853    guard: Option<Rc<dyn Fn() -> bool>>,
1854) -> Modifier {
1855    let is_vertical = axis.is_vertical();
1856    let identity = state.identity();
1857    drag_gesture_input(
1858        (identity, is_vertical),
1859        DragGesture {
1860            target: state,
1861            is_vertical,
1862            reverse_input: false,
1863            motion_context: scroll_motion_context_for_key(ScrollMotionContextKey::Draggable {
1864                state_identity: identity,
1865                is_vertical,
1866            }),
1867            guard,
1868        },
1869    )
1870}