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,
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
400const WHEEL_SETTLE_IDLE_NANOS: u64 = 180_000_000;
401
402struct WheelSettleWatcher {
403    is_running: Rc<Cell<bool>>,
404    registration: Rc<RefCell<Option<FrameCallbackRegistration>>>,
405}
406
407impl WheelSettleWatcher {
408    fn cancel(&self) {
409        self.is_running.set(false);
410        self.registration.borrow_mut().take();
411    }
412}
413
414struct ScrollGestureDetector<S: ScrollTarget> {
415    gesture_state: Rc<RefCell<ScrollGestureState>>,
416
417    scroll_target: S,
418
419    is_vertical: bool,
420
421    reverse_scrolling: bool,
422
423    overscroll: crate::scroll::OverscrollEffect,
424
425    motion_context: ScrollMotionContext,
426}
427
428impl<S: ScrollTarget + 'static> ScrollGestureDetector<S> {
429    fn new(
430        gesture_state: Rc<RefCell<ScrollGestureState>>,
431        scroll_target: S,
432        is_vertical: bool,
433        reverse_scrolling: bool,
434        overscroll: crate::scroll::OverscrollEffect,
435        motion_context: ScrollMotionContext,
436    ) -> Self {
437        Self {
438            gesture_state,
439            scroll_target,
440            is_vertical,
441            reverse_scrolling,
442            overscroll,
443            motion_context,
444        }
445    }
446
447    fn on_down(&self, position: Point, time_ms: Option<i64>) -> bool {
448        let mut gs = self.gesture_state.borrow_mut();
449
450        if let Some(fling) = gs.fling_animation.take() {
451            fling.cancel();
452        }
453        if let Some(settle) = gs.settle_animation.take() {
454            settle.cancel();
455        }
456        if let Some(watcher) = gs.wheel_settle_watcher.take() {
457            watcher.cancel();
458        }
459        self.motion_context.set_active(false);
460
461        gs.drag_down_position = Some(position);
462        gs.last_position = Some(position);
463        gs.is_dragging = false;
464        gs.axis_locked_out = false;
465        gs.velocity_tracker.reset();
466        gs.gesture_start_time = Some(Instant::now());
467        gs.gesture_start_event_time_ms = time_ms;
468        gs.is_overscrolling = self.overscroll.offset().abs() > 0.001;
469
470        let pos = if self.is_vertical {
471            position.y
472        } else {
473            position.x
474        };
475        gs.velocity_tracker.add_data_point(0, pos);
476        gs.last_velocity_sample_ms = Some(0);
477
478        false
479    }
480
481    fn on_move(
482        &self,
483        position: Point,
484        buttons: PointerButtons,
485        time_ms: Option<i64>,
486        event: &PointerEvent,
487    ) -> bool {
488        let mut gs = self.gesture_state.borrow_mut();
489
490        if !buttons.contains(PointerButton::Primary) && gs.drag_down_position.is_some() {
491            if gs.is_dragging {
492                self.scroll_target.set_dragging(false);
493            }
494            gs.drag_down_position = None;
495            gs.last_position = None;
496            gs.is_dragging = false;
497            gs.axis_locked_out = false;
498            gs.gesture_start_time = None;
499            gs.gesture_start_event_time_ms = None;
500            gs.last_velocity_sample_ms = None;
501            gs.velocity_tracker.reset();
502            self.motion_context.set_active(false);
503            return false;
504        }
505
506        let Some(down_pos) = gs.drag_down_position else {
507            return false;
508        };
509
510        let Some(last_pos) = gs.last_position else {
511            gs.last_position = Some(position);
512            return false;
513        };
514
515        let incremental_delta = calculate_incremental_delta(last_pos, position, self.is_vertical);
516
517        let mut edge_candidate = false;
518        if !gs.is_dragging && !gs.axis_locked_out {
519            let signed_main_delta = calculate_total_delta(down_pos, position, self.is_vertical);
520            let main_delta = signed_main_delta.abs();
521            let cross_delta = calculate_total_delta(down_pos, position, !self.is_vertical).abs();
522            if main_delta > DRAG_THRESHOLD && main_delta >= cross_delta {
523                if self.scroll_target.can_consume(signed_main_delta) {
524                    gs.is_dragging = true;
525                    self.scroll_target.set_dragging(true);
526                    self.motion_context.set_active(true);
527                } else if self.scroll_target.can_scroll() {
528                    edge_candidate = true;
529                }
530            } else if cross_delta > DRAG_THRESHOLD && cross_delta > main_delta {
531                gs.axis_locked_out = true;
532            }
533        }
534
535        gs.last_position = Some(position);
536
537        let pos = if self.is_vertical {
538            position.y
539        } else {
540            position.x
541        };
542        let event_sample_ms = gs
543            .gesture_start_event_time_ms
544            .zip(time_ms)
545            .map(|(start_ms, now_ms)| now_ms - start_ms);
546        let sample_ms = if let Some(event_sample_ms) = event_sample_ms {
547            Some(match gs.last_velocity_sample_ms {
548                Some(last_sample_ms) => event_sample_ms.max(last_sample_ms),
549                None => event_sample_ms.max(0),
550            })
551        } else if let Some(start_time) = gs.gesture_start_time {
552            let elapsed_ms = start_time.elapsed().as_millis() as i64;
553            Some(match gs.last_velocity_sample_ms {
554                Some(last_sample_ms) => {
555                    let mut sample_ms = if elapsed_ms <= last_sample_ms {
556                        last_sample_ms + 1
557                    } else {
558                        elapsed_ms
559                    };
560                    if sample_ms - last_sample_ms > ASSUME_STOPPED_MS {
561                        sample_ms = last_sample_ms + ASSUME_STOPPED_MS;
562                    }
563                    sample_ms
564                }
565                None => elapsed_ms,
566            })
567        } else {
568            None
569        };
570        if let Some(sample_ms) = sample_ms {
571            log::trace!(
572                target: "cranpose::velocity",
573                "sample t={sample_ms}ms pos={pos:.2} event_time={time_ms:?}"
574            );
575            gs.velocity_tracker.add_data_point(sample_ms, pos);
576            gs.last_velocity_sample_ms = Some(sample_ms);
577        }
578
579        if gs.is_dragging {
580            drop(gs);
581            let delta = if self.reverse_scrolling {
582                -incremental_delta
583            } else {
584                incremental_delta
585            };
586            let overscroll = self.overscroll.clone();
587            overscroll.apply_to_scroll(delta, |delta| self.scroll_target.apply_delta(delta));
588            self.scroll_target.invalidate();
589            true
590        } else if gs.is_overscrolling {
591            drop(gs);
592            let delta = if self.reverse_scrolling {
593                -incremental_delta
594            } else {
595                incremental_delta
596            };
597            self.apply_overscroll_delta(delta)
598        } else if edge_candidate {
599            drop(gs);
600            let delta = if self.reverse_scrolling {
601                -incremental_delta
602            } else {
603                incremental_delta
604            };
605            let detector = self.clone_for_watcher();
606            event.defer_post_dispatch_action(move || detector.apply_overscroll_candidate(delta));
607            false
608        } else {
609            false
610        }
611    }
612
613    fn apply_overscroll_delta(&self, delta: f32) -> bool {
614        self.motion_context.set_active(true);
615        let overscroll = self.overscroll.clone();
616        let target_consumed = Cell::new(0.0);
617        let before_overscroll = overscroll.offset();
618        overscroll.apply_to_scroll(delta, |delta| {
619            let consumed = self.scroll_target.apply_delta(delta);
620            target_consumed.set(consumed);
621            consumed
622        });
623        self.scroll_target.invalidate();
624        let consumed = target_consumed.get().abs() > 0.001
625            || (overscroll.offset() - before_overscroll).abs() > 0.001;
626        if !consumed {
627            self.motion_context.set_active(false);
628        }
629        consumed
630    }
631
632    fn apply_overscroll_candidate(&self, delta: f32) -> bool {
633        if self.gesture_state.borrow().disposed {
634            return false;
635        }
636        let consumed = self.apply_overscroll_delta(delta);
637        if consumed {
638            self.gesture_state.borrow_mut().is_overscrolling = true;
639        }
640        consumed
641    }
642
643    fn finish_gesture(&self, allow_fling: bool, release_time_ms: Option<i64>) -> bool {
644        let (was_dragging, gesture_owned, velocity, start_fling, existing_fling) = {
645            let mut gs = self.gesture_state.borrow_mut();
646            let was_dragging = gs.is_dragging;
647            let gesture_owned = was_dragging || gs.is_overscrolling;
648            let mut velocity = 0.0;
649
650            if allow_fling && gesture_owned && gs.gesture_start_time.is_some() {
651                let release_sample_ms = release_time_ms
652                    .zip(gs.gesture_start_event_time_ms)
653                    .map(|(release_ms, start_ms)| release_ms - start_ms)
654                    .or_else(|| {
655                        gs.gesture_start_time
656                            .map(|start| start.elapsed().as_millis() as i64)
657                    });
658                let rested_before_release = release_sample_ms
659                    .zip(gs.last_velocity_sample_ms)
660                    .is_some_and(|(release_ms, last_sample_ms)| {
661                        release_ms - last_sample_ms > ASSUME_STOPPED_MS
662                    });
663                if !rested_before_release {
664                    velocity = gs
665                        .velocity_tracker
666                        .calculate_velocity_with_max(MAX_FLING_VELOCITY);
667                }
668            }
669
670            let start_fling = allow_fling && was_dragging && velocity.abs() > MIN_FLING_VELOCITY;
671            let existing_fling = if start_fling {
672                gs.fling_animation.take()
673            } else {
674                None
675            };
676
677            if was_dragging {
678                self.scroll_target.set_dragging(false);
679            }
680            gs.drag_down_position = None;
681            gs.last_position = None;
682            gs.is_dragging = false;
683            gs.is_overscrolling = false;
684            gs.axis_locked_out = false;
685            gs.gesture_start_time = None;
686            gs.gesture_start_event_time_ms = None;
687            gs.last_velocity_sample_ms = None;
688
689            (
690                was_dragging,
691                gesture_owned,
692                velocity,
693                start_fling,
694                existing_fling,
695            )
696        };
697
698        if allow_fling && gesture_owned {
699            log::debug!(
700                target: "cranpose::velocity",
701                "gesture finished: fling velocity={velocity:.2} dp/s start_fling={start_fling}"
702            );
703            set_last_fling_velocity(velocity);
704        }
705
706        let adjusted_velocity = if self.reverse_scrolling {
707            -velocity
708        } else {
709            velocity
710        };
711        let fling_velocity = -adjusted_velocity;
712        let has_overscroll = self.overscroll.offset().abs() > 0.001;
713
714        let settle_target = if was_dragging {
715            self.scroll_target.settle_policy().and_then(|policy| {
716                let current = self.scroll_target.current_offset();
717                let proposed = if start_fling {
718                    fling_rest_position(current, fling_velocity)
719                } else {
720                    current
721                };
722                let target = policy(proposed, fling_velocity);
723                ((target - proposed).abs() > 0.5).then_some(target)
724            })
725        } else {
726            None
727        };
728
729        if has_overscroll {
730            if let Some(old_fling) = existing_fling {
731                old_fling.cancel();
732            }
733            self.start_overscroll_settle(-fling_velocity);
734        } else if let Some(target) = settle_target {
735            if let Some(old_fling) = existing_fling {
736                old_fling.cancel();
737            }
738            self.start_settle_animation(target, fling_velocity);
739        } else if start_fling {
740            if let Some(old_fling) = existing_fling {
741                old_fling.cancel();
742            }
743            self.start_fling_animation(fling_velocity);
744        } else {
745            self.motion_context.set_active(false);
746        }
747
748        gesture_owned
749    }
750
751    fn start_fling_animation(&self, fling_velocity: f32) {
752        let Some(runtime) = current_runtime_handle() else {
753            self.motion_context.set_active(false);
754            return;
755        };
756        self.motion_context.set_active(true);
757        let scroll_target = self.scroll_target.clone();
758        let fling = FlingAnimation::new(runtime);
759        let motion_context = self.motion_context.clone();
760        let initial_value = scroll_target.current_offset();
761        let scroll_target_for_fling = scroll_target.clone();
762        let scroll_target_for_end = scroll_target.clone();
763        let detector_for_end = self.clone_for_watcher();
764        let overscroll_for_fling = self.overscroll.clone();
765
766        fling.start_fling(
767            initial_value,
768            fling_velocity,
769            move |delta| {
770                let consumed = overscroll_for_fling.apply_to_fling(delta, |delta| {
771                    scroll_target_for_fling.apply_fling_delta(delta)
772                });
773                scroll_target_for_fling.invalidate();
774                consumed
775            },
776            move || {
777                scroll_target_for_end.invalidate();
778                let settle_running = detector_for_end
779                    .gesture_state
780                    .borrow()
781                    .settle_animation
782                    .as_ref()
783                    .is_some_and(SettleAnimation::is_running);
784                if detector_for_end.overscroll.offset().abs() > 0.001 && !settle_running {
785                    detector_for_end.start_overscroll_settle(0.0);
786                }
787                detector_for_end.update_motion_active(&motion_context);
788            },
789        );
790
791        let mut gs = self.gesture_state.borrow_mut();
792        gs.fling_animation = Some(fling);
793    }
794
795    fn start_settle_animation(&self, target: f32, initial_velocity: f32) {
796        let Some(runtime) = current_runtime_handle() else {
797            self.motion_context.set_active(false);
798            return;
799        };
800        self.motion_context.set_active(true);
801        let settle = SettleAnimation::new(runtime, SpringParams::SETTLE_POLICY);
802        let scroll_target_for_settle = self.scroll_target.clone();
803        let scroll_target_for_end = self.scroll_target.clone();
804        let detector_for_end = self.clone_for_watcher();
805        let motion_context = self.motion_context.clone();
806        settle.start_settle(
807            self.scroll_target.current_offset(),
808            initial_velocity,
809            target,
810            move |delta| {
811                let consumed = scroll_target_for_settle.apply_fling_delta(delta);
812                scroll_target_for_settle.invalidate();
813                consumed
814            },
815            move |_| {
816                scroll_target_for_end.invalidate();
817                detector_for_end.update_motion_active(&motion_context);
818            },
819        );
820        let mut gs = self.gesture_state.borrow_mut();
821        gs.settle_animation = Some(settle);
822    }
823
824    fn start_overscroll_settle(&self, initial_velocity: f32) {
825        let Some(runtime) = current_runtime_handle() else {
826            let current = self.overscroll.offset();
827            self.overscroll.apply_settle_delta(-current);
828            self.motion_context.set_active(false);
829            return;
830        };
831        let settle = SettleAnimation::new(runtime, SpringParams::OVERSCROLL_BOUNCE);
832        let overscroll_for_settle = self.overscroll.clone();
833        let overscroll_for_end = self.overscroll.clone();
834        let detector_for_end = self.clone_for_watcher();
835        let motion_context = self.motion_context.clone();
836        let initial = self.overscroll.offset();
837        settle.start_settle(
838            initial,
839            initial_velocity,
840            0.0,
841            move |delta| overscroll_for_settle.apply_settle_delta(delta),
842            move |end| {
843                let current = overscroll_for_end.offset();
844                overscroll_for_end.apply_settle_delta(-current);
845                if end.hit_boundary && end.velocity.abs() > MIN_FLING_VELOCITY {
846                    detector_for_end.start_fling_animation(-end.velocity);
847                } else {
848                    detector_for_end.update_motion_active(&motion_context);
849                }
850            },
851        );
852        let mut gs = self.gesture_state.borrow_mut();
853        gs.is_overscrolling = false;
854        gs.settle_animation = Some(settle);
855    }
856
857    fn update_motion_active(&self, motion_context: &ScrollMotionContext) {
858        let running = self.gesture_state.borrow().is_active();
859        if !running {
860            motion_context.set_active(false);
861        }
862    }
863
864    fn on_up(&self, time_ms: Option<i64>) -> bool {
865        self.finish_gesture(self.scroll_target.allows_fling(), time_ms)
866    }
867
868    fn on_cancel(&self) -> bool {
869        self.finish_gesture(false, None)
870    }
871
872    fn on_scroll(&self, axis_delta: f32, event: &PointerEvent) -> bool {
873        if axis_delta.abs() <= f32::EPSILON {
874            return false;
875        }
876
877        let delta = if self.reverse_scrolling {
878            -axis_delta
879        } else {
880            axis_delta
881        };
882        if !self.scroll_target.can_consume(delta) && self.scroll_target.can_scroll() {
883            let detector = self.clone_for_watcher();
884            event.defer_post_dispatch_action(move || detector.apply_wheel_delta(delta));
885            return false;
886        }
887        self.apply_wheel_delta(delta)
888    }
889
890    fn apply_wheel_delta(&self, delta: f32) -> bool {
891        {
892            let mut gs = self.gesture_state.borrow_mut();
893            if gs.disposed {
894                return false;
895            }
896            if let Some(fling) = gs.fling_animation.take() {
897                fling.cancel();
898            }
899            if let Some(settle) = gs.settle_animation.take() {
900                settle.cancel();
901            }
902            gs.drag_down_position = None;
903            gs.last_position = None;
904            gs.is_dragging = false;
905            gs.axis_locked_out = false;
906            gs.gesture_start_time = None;
907            gs.gesture_start_event_time_ms = None;
908            gs.last_velocity_sample_ms = None;
909            gs.velocity_tracker.reset();
910        }
911
912        self.motion_context.activate_for_current_frame();
913        let overscroll = self.overscroll.clone();
914        let consumed =
915            overscroll.apply_to_scroll(delta, |delta| self.scroll_target.apply_wheel_delta(delta));
916        let overscroll_active = self.overscroll.offset().abs() > 0.001;
917        if consumed.abs() > 0.001 {
918            self.scroll_target.invalidate();
919        }
920        if consumed.abs() > 0.001 || overscroll_active {
921            self.ensure_wheel_settle_watcher();
922            true
923        } else {
924            false
925        }
926    }
927
928    fn ensure_wheel_settle_watcher(&self) {
929        if self.scroll_target.settle_policy().is_none() && self.overscroll.offset().abs() <= 0.001 {
930            return;
931        }
932        {
933            let gs = self.gesture_state.borrow();
934            if gs
935                .wheel_settle_watcher
936                .as_ref()
937                .is_some_and(|watcher| watcher.is_running.get())
938            {
939                return;
940            }
941        }
942        let Some(runtime) = current_runtime_handle() else {
943            return;
944        };
945
946        let is_running = Rc::new(Cell::new(true));
947        let registration = Rc::new(RefCell::new(None));
948
949        struct WheelSettleLoop<S: ScrollTarget> {
950            detector: ScrollGestureDetector<S>,
951            gesture_state: Rc<RefCell<ScrollGestureState>>,
952            frame_clock: cranpose_core::internal::FrameClock,
953            is_running: Rc<Cell<bool>>,
954            registration: Rc<RefCell<Option<FrameCallbackRegistration>>>,
955            last_offset: Rc<Cell<f32>>,
956            idle_nanos: Rc<Cell<u64>>,
957            last_frame: Rc<Cell<Option<u64>>>,
958        }
959
960        impl<S: ScrollTarget + 'static> WheelSettleLoop<S> {
961            fn next(&self) -> Self {
962                Self {
963                    detector: self.detector.clone_for_watcher(),
964                    gesture_state: Rc::clone(&self.gesture_state),
965                    frame_clock: self.frame_clock.clone(),
966                    is_running: Rc::clone(&self.is_running),
967                    registration: Rc::clone(&self.registration),
968                    last_offset: Rc::clone(&self.last_offset),
969                    idle_nanos: Rc::clone(&self.idle_nanos),
970                    last_frame: Rc::clone(&self.last_frame),
971                }
972            }
973
974            fn schedule(self) {
975                let continuation = self.next();
976                let registration_slot = Rc::clone(&self.registration);
977                let new_registration = self.frame_clock.with_frame_nanos(move |frame_time_nanos| {
978                    let this = &continuation;
979                    if !this.is_running.get() {
980                        return;
981                    }
982                    {
983                        let gs = this.gesture_state.borrow();
984                        let animating = gs.is_dragging
985                            || gs
986                                .fling_animation
987                                .as_ref()
988                                .is_some_and(FlingAnimation::is_running)
989                            || gs
990                                .settle_animation
991                                .as_ref()
992                                .is_some_and(SettleAnimation::is_running);
993                        if animating {
994                            this.is_running.set(false);
995                            return;
996                        }
997                    }
998
999                    let offset = this.detector.scroll_target.current_offset();
1000                    let dt = this
1001                        .last_frame
1002                        .get()
1003                        .map_or(0, |last| frame_time_nanos.saturating_sub(last));
1004                    this.last_frame.set(Some(frame_time_nanos));
1005                    if (offset - this.last_offset.get()).abs() > 0.01 {
1006                        this.last_offset.set(offset);
1007                        this.idle_nanos.set(0);
1008                    } else {
1009                        this.idle_nanos.set(this.idle_nanos.get() + dt);
1010                    }
1011
1012                    if this.idle_nanos.get() >= WHEEL_SETTLE_IDLE_NANOS {
1013                        this.is_running.set(false);
1014                        if this.detector.overscroll.offset().abs() > 0.001 {
1015                            this.detector.start_overscroll_settle(0.0);
1016                            return;
1017                        }
1018                        if let Some(policy) = this.detector.scroll_target.settle_policy() {
1019                            let target = policy(offset, 0.0);
1020                            if (target - offset).abs() > 0.5 {
1021                                this.detector.start_settle_animation(target, 0.0);
1022                            }
1023                        }
1024                        return;
1025                    }
1026
1027                    continuation.next().schedule();
1028                });
1029                *registration_slot.borrow_mut() = Some(new_registration);
1030            }
1031        }
1032
1033        WheelSettleLoop {
1034            detector: self.clone_for_watcher(),
1035            gesture_state: Rc::clone(&self.gesture_state),
1036            frame_clock: runtime.frame_clock(),
1037            is_running: Rc::clone(&is_running),
1038            registration: Rc::clone(&registration),
1039            last_offset: Rc::new(Cell::new(self.scroll_target.current_offset())),
1040            idle_nanos: Rc::new(Cell::new(0u64)),
1041            last_frame: Rc::new(Cell::new(None::<u64>)),
1042        }
1043        .schedule();
1044
1045        self.gesture_state.borrow_mut().wheel_settle_watcher = Some(WheelSettleWatcher {
1046            is_running,
1047            registration,
1048        });
1049    }
1050
1051    fn clone_for_watcher(&self) -> ScrollGestureDetector<S> {
1052        ScrollGestureDetector {
1053            gesture_state: Rc::clone(&self.gesture_state),
1054            scroll_target: self.scroll_target.clone(),
1055            is_vertical: self.is_vertical,
1056            reverse_scrolling: self.reverse_scrolling,
1057            overscroll: self.overscroll.clone(),
1058            motion_context: self.motion_context.clone(),
1059        }
1060    }
1061}
1062
1063pub(crate) struct MotionContextAnimatedNode {
1064    state: NodeState,
1065    motion_context: ScrollMotionContext,
1066    invalidation_callback_id: Option<u64>,
1067    overscroll_callback_id: Option<u64>,
1068    node_id: Option<NodeId>,
1069}
1070
1071impl MotionContextAnimatedNode {
1072    fn new(motion_context: ScrollMotionContext) -> Self {
1073        Self {
1074            state: NodeState::new(),
1075            motion_context,
1076            invalidation_callback_id: None,
1077            overscroll_callback_id: None,
1078            node_id: None,
1079        }
1080    }
1081
1082    pub(crate) fn is_active(&self) -> bool {
1083        self.motion_context.is_active()
1084    }
1085
1086    fn attach_callbacks(&mut self) {
1087        if let Some(node_id) = self.node_id {
1088            self.invalidation_callback_id = Some(self.motion_context.add_invalidate_callback(
1089                Box::new(move || schedule_modifier_slices_repass(node_id)),
1090            ));
1091            self.overscroll_callback_id = Some(
1092                self.motion_context
1093                    .overscroll()
1094                    .add_invalidate_callback(Box::new(move || {
1095                        crate::schedule_measure_repass(node_id);
1096                        schedule_modifier_slices_repass(node_id);
1097                    })),
1098            );
1099        }
1100    }
1101
1102    fn detach_callbacks(&mut self) {
1103        if let Some(id) = self.invalidation_callback_id.take() {
1104            self.motion_context.remove_invalidate_callback(id);
1105        }
1106        if let Some(id) = self.overscroll_callback_id.take() {
1107            self.motion_context
1108                .overscroll()
1109                .remove_invalidate_callback(id);
1110        }
1111    }
1112}
1113
1114pub(crate) struct TranslatedContentContextNode {
1115    state: NodeState,
1116    identity: usize,
1117    offset_source: TranslatedContentOffsetSource,
1118    overscroll: crate::scroll::OverscrollEffect,
1119    overscroll_callback_id: Option<u64>,
1120    node_id: Option<NodeId>,
1121}
1122
1123impl TranslatedContentContextNode {
1124    fn new(
1125        identity: usize,
1126        offset_source: TranslatedContentOffsetSource,
1127        overscroll: crate::scroll::OverscrollEffect,
1128    ) -> Self {
1129        Self {
1130            state: NodeState::new(),
1131            identity,
1132            offset_source,
1133            overscroll,
1134            overscroll_callback_id: None,
1135            node_id: None,
1136        }
1137    }
1138
1139    pub(crate) fn is_active(&self) -> bool {
1140        true
1141    }
1142
1143    pub(crate) fn identity(&self) -> usize {
1144        self.identity
1145    }
1146
1147    pub(crate) fn content_offset_reader(&self) -> Option<Rc<dyn Fn() -> Point>> {
1148        self.offset_source
1149            .content_offset_reader(self.overscroll.clone())
1150    }
1151
1152    fn attach_callback(&mut self) {
1153        if let Some(node_id) = self.node_id {
1154            self.overscroll_callback_id =
1155                Some(self.overscroll.add_invalidate_callback(Box::new(move || {
1156                    schedule_modifier_slices_repass(node_id)
1157                })));
1158        }
1159    }
1160
1161    fn detach_callback(&mut self) {
1162        if let Some(id) = self.overscroll_callback_id.take() {
1163            self.overscroll.remove_invalidate_callback(id);
1164        }
1165    }
1166}
1167
1168impl DelegatableNode for TranslatedContentContextNode {
1169    fn node_state(&self) -> &NodeState {
1170        &self.state
1171    }
1172}
1173
1174impl ModifierNode for TranslatedContentContextNode {
1175    fn on_attach(&mut self, context: &mut dyn cranpose_foundation::ModifierNodeContext) {
1176        self.node_id = context.node_id();
1177        self.attach_callback();
1178    }
1179
1180    fn on_detach(&mut self) {
1181        self.detach_callback();
1182        self.node_id = None;
1183    }
1184}
1185
1186impl DelegatableNode for MotionContextAnimatedNode {
1187    fn node_state(&self) -> &NodeState {
1188        &self.state
1189    }
1190}
1191
1192impl ModifierNode for MotionContextAnimatedNode {
1193    fn on_attach(&mut self, context: &mut dyn cranpose_foundation::ModifierNodeContext) {
1194        self.node_id = context.node_id();
1195        self.attach_callbacks();
1196    }
1197
1198    fn on_detach(&mut self) {
1199        self.detach_callbacks();
1200        self.node_id = None;
1201    }
1202}
1203
1204#[derive(Clone)]
1205struct MotionContextAnimatedElement {
1206    motion_context: ScrollMotionContext,
1207}
1208
1209impl MotionContextAnimatedElement {
1210    fn new(motion_context: ScrollMotionContext) -> Self {
1211        Self { motion_context }
1212    }
1213}
1214
1215impl std::fmt::Debug for MotionContextAnimatedElement {
1216    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1217        f.debug_struct("MotionContextAnimatedElement").finish()
1218    }
1219}
1220
1221impl PartialEq for MotionContextAnimatedElement {
1222    fn eq(&self, other: &Self) -> bool {
1223        self.motion_context.ptr_eq(&other.motion_context)
1224    }
1225}
1226
1227impl Eq for MotionContextAnimatedElement {}
1228
1229impl std::hash::Hash for MotionContextAnimatedElement {
1230    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
1231        self.motion_context.stable_key().hash(state);
1232    }
1233}
1234
1235impl ModifierNodeElement for MotionContextAnimatedElement {
1236    type Node = MotionContextAnimatedNode;
1237
1238    fn create(&self) -> Self::Node {
1239        MotionContextAnimatedNode::new(self.motion_context.clone())
1240    }
1241
1242    fn update(&self, node: &mut Self::Node) {
1243        if node.motion_context.ptr_eq(&self.motion_context) {
1244            return;
1245        }
1246        node.detach_callbacks();
1247        node.motion_context = self.motion_context.clone();
1248        node.attach_callbacks();
1249    }
1250
1251    fn capabilities(&self) -> NodeCapabilities {
1252        NodeCapabilities::LAYOUT
1253    }
1254}
1255
1256#[derive(Clone)]
1257enum TranslatedContentOffsetSource {
1258    LayoutContentOffset,
1259    LazyList {
1260        state: LazyListState,
1261        is_vertical: bool,
1262        reverse_scrolling: bool,
1263    },
1264}
1265
1266impl TranslatedContentOffsetSource {
1267    fn content_offset_reader(
1268        &self,
1269        overscroll: crate::scroll::OverscrollEffect,
1270    ) -> Option<Rc<dyn Fn() -> Point>> {
1271        match self {
1272            Self::LayoutContentOffset => None,
1273            Self::LazyList {
1274                state, is_vertical, ..
1275            } => Some(Rc::new(lazy_list_content_offset_reader(
1276                *state,
1277                *is_vertical,
1278                overscroll,
1279            ))),
1280        }
1281    }
1282
1283    fn is_vertical(&self) -> Option<bool> {
1284        match self {
1285            Self::LayoutContentOffset => None,
1286            Self::LazyList { is_vertical, .. } => Some(*is_vertical),
1287        }
1288    }
1289
1290    fn reverse_scrolling(&self) -> Option<bool> {
1291        match self {
1292            Self::LayoutContentOffset => None,
1293            Self::LazyList {
1294                reverse_scrolling, ..
1295            } => Some(*reverse_scrolling),
1296        }
1297    }
1298}
1299
1300fn lazy_list_content_offset_reader(
1301    state: LazyListState,
1302    is_vertical: bool,
1303    overscroll: crate::scroll::OverscrollEffect,
1304) -> impl Fn() -> Point {
1305    move || {
1306        let info = state.layout_info();
1307        if info.visible_items_info.is_empty() {
1308            return Point::default();
1309        };
1310        overscroll.set_dimension(info.viewport_size);
1311        let main_offset = info.snap_anchor_offset;
1312        let bounce = overscroll.offset();
1313        if is_vertical {
1314            Point::new(0.0, main_offset + bounce)
1315        } else {
1316            Point::new(main_offset + bounce, 0.0)
1317        }
1318    }
1319}
1320
1321#[derive(Clone)]
1322struct TranslatedContentContextElement {
1323    identity: usize,
1324    offset_source: TranslatedContentOffsetSource,
1325    overscroll: crate::scroll::OverscrollEffect,
1326}
1327
1328impl TranslatedContentContextElement {
1329    fn new(
1330        identity: usize,
1331        offset_source: TranslatedContentOffsetSource,
1332        overscroll: crate::scroll::OverscrollEffect,
1333    ) -> Self {
1334        Self {
1335            identity,
1336            offset_source,
1337            overscroll,
1338        }
1339    }
1340}
1341
1342impl std::fmt::Debug for TranslatedContentContextElement {
1343    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1344        let offset_source = match &self.offset_source {
1345            TranslatedContentOffsetSource::LayoutContentOffset => "layout",
1346            TranslatedContentOffsetSource::LazyList { .. } => "lazy_list",
1347        };
1348        f.debug_struct("TranslatedContentContextElement")
1349            .field("identity", &self.identity)
1350            .field("offset_source", &offset_source)
1351            .finish()
1352    }
1353}
1354
1355impl PartialEq for TranslatedContentContextElement {
1356    fn eq(&self, other: &Self) -> bool {
1357        self.identity == other.identity
1358            && self.overscroll.ptr_eq(&other.overscroll)
1359            && self.offset_source.is_vertical() == other.offset_source.is_vertical()
1360            && self.offset_source.reverse_scrolling() == other.offset_source.reverse_scrolling()
1361    }
1362}
1363
1364impl Eq for TranslatedContentContextElement {}
1365
1366impl std::hash::Hash for TranslatedContentContextElement {
1367    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
1368        self.identity.hash(state);
1369        self.offset_source.is_vertical().hash(state);
1370        self.offset_source.reverse_scrolling().hash(state);
1371    }
1372}
1373
1374impl ModifierNodeElement for TranslatedContentContextElement {
1375    type Node = TranslatedContentContextNode;
1376
1377    fn create(&self) -> Self::Node {
1378        TranslatedContentContextNode::new(
1379            self.identity,
1380            self.offset_source.clone(),
1381            self.overscroll.clone(),
1382        )
1383    }
1384
1385    fn update(&self, node: &mut Self::Node) {
1386        node.identity = self.identity;
1387        node.offset_source = self.offset_source.clone();
1388        if !node.overscroll.ptr_eq(&self.overscroll) {
1389            node.detach_callback();
1390            node.overscroll = self.overscroll.clone();
1391            node.attach_callback();
1392        }
1393    }
1394
1395    fn capabilities(&self) -> NodeCapabilities {
1396        NodeCapabilities::LAYOUT
1397    }
1398}
1399
1400impl Modifier {
1401    /// Creates a horizontally scrollable modifier.
1402    ///
1403    /// # Arguments
1404    /// * `state` - The ScrollState to control scroll position
1405    /// * `reverse_scrolling` - If true, reverses the scroll direction in layout.
1406    ///   Note: This affects how scroll offset is applied to content (via `ScrollNode`),
1407    ///   NOT the drag direction. Drag gestures always follow natural touch semantics:
1408    ///   drag right = scroll left (content moves right under finger).
1409    ///
1410    /// # Example
1411    /// ```text
1412    /// let scroll_state = ScrollState::new(0.0);
1413    /// Row(
1414    ///     Modifier::empty().horizontal_scroll(scroll_state, false),
1415    ///     // ... content
1416    /// );
1417    /// ```
1418    pub fn horizontal_scroll(self, state: ScrollState, reverse_scrolling: bool) -> Self {
1419        self.then(scroll_impl(state, false, reverse_scrolling, None))
1420    }
1421
1422    /// Creates a vertically scrollable modifier.
1423    ///
1424    /// # Arguments
1425    /// * `state` - The ScrollState to control scroll position
1426    /// * `reverse_scrolling` - If true, reverses the scroll direction in layout.
1427    ///   Note: This affects how scroll offset is applied to content (via `ScrollNode`),
1428    ///   NOT the drag direction. Drag gestures always follow natural touch semantics:
1429    ///   drag down = scroll up (content moves down under finger).
1430    pub fn vertical_scroll(self, state: ScrollState, reverse_scrolling: bool) -> Self {
1431        self.then(scroll_impl(state, true, reverse_scrolling, None))
1432    }
1433
1434    /// A horizontal scroll that never claims a pointer gesture.
1435    ///
1436    /// The position still moves through [`ScrollState`], but drags and wheel
1437    /// events pass straight through, so an owner can run its own
1438    /// `pointer_input` on the same row -- a drag-to-reorder list, say --
1439    /// without the two competing for the gesture.
1440    pub fn horizontal_scroll_without_gestures(
1441        self,
1442        state: ScrollState,
1443        reverse_scrolling: bool,
1444    ) -> Self {
1445        self.then(scroll_impl(
1446            state,
1447            false,
1448            reverse_scrolling,
1449            Some(Rc::new(|| false)),
1450        ))
1451    }
1452
1453    /// A vertical scroll that never claims a pointer gesture.
1454    ///
1455    /// The vertical counterpart of [`Modifier::horizontal_scroll_without_gestures`].
1456    pub fn vertical_scroll_without_gestures(
1457        self,
1458        state: ScrollState,
1459        reverse_scrolling: bool,
1460    ) -> Self {
1461        self.then(scroll_impl(
1462            state,
1463            true,
1464            reverse_scrolling,
1465            Some(Rc::new(|| false)),
1466        ))
1467    }
1468}
1469
1470fn scroll_impl(
1471    state: ScrollState,
1472    is_vertical: bool,
1473    reverse_scrolling: bool,
1474    guard: Option<Rc<dyn Fn() -> bool>>,
1475) -> Modifier {
1476    let motion_context = scroll_motion_context_for_key(ScrollMotionContextKey::ScrollState {
1477        state_id: state.id(),
1478        is_vertical,
1479        reverse_scrolling,
1480    });
1481
1482    let pointer_input = drag_gesture_input(
1483        (state.id(), is_vertical),
1484        DragGesture {
1485            target: state,
1486            is_vertical,
1487            reverse_input: false,
1488            motion_context: motion_context.clone(),
1489            guard,
1490        },
1491    );
1492
1493    let overscroll = motion_context.overscroll();
1494    let element = ScrollElement::new(state, overscroll.clone(), is_vertical, reverse_scrolling);
1495    let layout_modifier =
1496        Modifier::with_element(element).with_inspector_metadata(inspector_metadata(
1497            if is_vertical {
1498                "verticalScroll"
1499            } else {
1500                "horizontalScroll"
1501            },
1502            move |info| {
1503                info.add_property("isVertical", is_vertical.to_string());
1504                info.add_property("reverseScrolling", reverse_scrolling.to_string());
1505            },
1506        ));
1507    let motion_modifier =
1508        Modifier::with_element(MotionContextAnimatedElement::new(motion_context.clone()));
1509    let translated_content_modifier = Modifier::with_element(TranslatedContentContextElement::new(
1510        state.id() as usize,
1511        TranslatedContentOffsetSource::LayoutContentOffset,
1512        overscroll,
1513    ));
1514
1515    pointer_input
1516        .then(motion_modifier)
1517        .then(translated_content_modifier)
1518        .then(layout_modifier)
1519        .clip_to_bounds()
1520        .semantics(scroll_semantics(state, is_vertical, reverse_scrolling))
1521}
1522
1523/// What a screen reader learns about a scroll container: how far it has
1524/// scrolled, whether it can go on, and how to page it. Compose's
1525/// `verticalScrollAxisRange` / `horizontalScrollAxisRange` and `scrollBy`.
1526/// Without this a reader's cursor stops at the last row it can see.
1527fn scroll_semantics(
1528    state: ScrollState,
1529    is_vertical: bool,
1530    reverse_scrolling: bool,
1531) -> impl Fn(&mut cranpose_foundation::SemanticsConfiguration) + 'static {
1532    move |config| {
1533        let range = cranpose_foundation::ScrollAxisRange::new(
1534            state.value(),
1535            state.max_value(),
1536            reverse_scrolling,
1537        );
1538        if is_vertical {
1539            config.vertical_scroll = Some(range);
1540        } else {
1541            config.horizontal_scroll = Some(range);
1542        }
1543        config.scroll_by = Some(cranpose_foundation::SemanticsScrollBy::new(
1544            move |dx, dy| {
1545                let delta = if is_vertical { dy } else { dx };
1546                state.dispatch_raw_delta(delta).abs() > f32::EPSILON
1547            },
1548        ));
1549    }
1550}
1551
1552use cranpose_foundation::lazy::LazyListState;
1553
1554impl Modifier {
1555    /// Creates a vertically scrollable modifier for lazy lists.
1556    ///
1557    /// This connects pointer gestures to LazyListState for scroll handling.
1558    /// Unlike regular vertical_scroll, no layout offset is applied here
1559    /// since LazyListState manages item positioning internally.
1560    /// Creates a vertically scrollable modifier for lazy lists.
1561    ///
1562    /// This connects pointer gestures to LazyListState for scroll handling.
1563    /// Unlike regular vertical_scroll, no layout offset is applied here
1564    /// since LazyListState manages item positioning internally.
1565    pub fn lazy_vertical_scroll(self, state: LazyListState, reverse_scrolling: bool) -> Self {
1566        let motion_context = scroll_motion_context_for_key(ScrollMotionContextKey::LazyList {
1567            state_identity: state.inner_ptr() as usize,
1568            is_vertical: true,
1569            reverse_scrolling,
1570        });
1571        self.lazy_vertical_scroll_with_context(state, reverse_scrolling, motion_context)
1572    }
1573
1574    pub(crate) fn lazy_vertical_scroll_with_context(
1575        self,
1576        state: LazyListState,
1577        reverse_scrolling: bool,
1578        motion_context: ScrollMotionContext,
1579    ) -> Self {
1580        self.then(lazy_scroll_impl(
1581            state,
1582            true,
1583            reverse_scrolling,
1584            motion_context,
1585        ))
1586    }
1587
1588    pub(crate) fn lazy_horizontal_scroll_with_context(
1589        self,
1590        state: LazyListState,
1591        reverse_scrolling: bool,
1592        motion_context: ScrollMotionContext,
1593    ) -> Self {
1594        self.then(lazy_scroll_impl(
1595            state,
1596            false,
1597            reverse_scrolling,
1598            motion_context,
1599        ))
1600    }
1601}
1602
1603fn lazy_scroll_impl(
1604    state: LazyListState,
1605    is_vertical: bool,
1606    reverse_scrolling: bool,
1607    motion_context: ScrollMotionContext,
1608) -> Modifier {
1609    let list_state = state;
1610    let state_id = state.inner_ptr() as usize;
1611    let key = (state_id, is_vertical, reverse_scrolling);
1612    let overscroll = motion_context.overscroll();
1613    let translated_content_modifier = Modifier::with_element(TranslatedContentContextElement::new(
1614        state_id,
1615        TranslatedContentOffsetSource::LazyList {
1616            state,
1617            is_vertical,
1618            reverse_scrolling,
1619        },
1620        overscroll,
1621    ));
1622
1623    Modifier::with_element(MotionContextAnimatedElement::new(motion_context.clone()))
1624        .then(translated_content_modifier)
1625        .then(drag_gesture_input(
1626            key,
1627            DragGesture {
1628                target: list_state,
1629                is_vertical,
1630                reverse_input: reverse_scrolling,
1631                motion_context,
1632                guard: None,
1633            },
1634        ))
1635        .semantics(lazy_scroll_semantics(
1636            list_state,
1637            is_vertical,
1638            reverse_scrolling,
1639        ))
1640}
1641
1642/// What a screen reader learns about a lazy list. The list has no whole
1643/// extent to give, so the first visible item stands in for the position, a
1644/// half step marks a list scrolled part way into that item, and one more step
1645/// stays ahead while the list can still scroll forward. A reader needs only
1646/// the two answers: can it page back, can it page on.
1647pub(crate) fn lazy_scroll_semantics(
1648    state: LazyListState,
1649    is_vertical: bool,
1650    reverse_scrolling: bool,
1651) -> impl Fn(&mut cranpose_foundation::SemanticsConfiguration) + 'static {
1652    move |config| {
1653        let mut value = state.first_visible_item_index() as f32;
1654        if state.first_visible_item_scroll_offset() > 0.0 {
1655            value += 0.5;
1656        }
1657        let max_value = if state.can_scroll_forward() {
1658            value + 1.0
1659        } else {
1660            value
1661        };
1662        let range = cranpose_foundation::ScrollAxisRange::new(value, max_value, reverse_scrolling);
1663        let count = state.total_items_count();
1664        config.collection = Some(cranpose_foundation::CollectionInfo {
1665            rows: if is_vertical { count } else { 1 },
1666            columns: if is_vertical { 1 } else { count },
1667        });
1668        config
1669            .role
1670            .get_or_insert(cranpose_foundation::SemanticsWidgetRole::List);
1671        if is_vertical {
1672            config.vertical_scroll = Some(range);
1673        } else {
1674            config.horizontal_scroll = Some(range);
1675        }
1676        config.scroll_by = Some(cranpose_foundation::SemanticsScrollBy::new(
1677            move |dx, dy| {
1678                let delta = if is_vertical { dy } else { dx };
1679                state.dispatch_scroll_delta(-delta).abs() > f32::EPSILON
1680            },
1681        ));
1682        config.scroll_to_index = Some(cranpose_foundation::SemanticsScrollToIndex::new(
1683            move |index| jump_to_row(&state, index),
1684        ));
1685    }
1686}
1687
1688/// Puts the row at `index` at the top of a lazy list for a screen reader that
1689/// asked for it. A list with no rows, or an index past the last row, takes
1690/// nothing and says so.
1691fn jump_to_row(state: &LazyListState, index: usize) -> bool {
1692    if index >= state.total_items_count() {
1693        return false;
1694    }
1695    state.scroll_to_item(index, 0.0);
1696    true
1697}
1698
1699impl Modifier {
1700    /// Drags along `axis`, reporting each delta to `state`.
1701    ///
1702    /// The gesture is the one the scroll containers run — touch slop before a
1703    /// drag begins, axis locking so a mostly-vertical drag never steals a
1704    /// horizontal one, the primary pointer only, and yielding to whoever
1705    /// consumed the event first — so a dragged control and a scrolled list feel
1706    /// the same under a finger. Unlike a scroll, a release does not fling: a
1707    /// control the user placed stays where it was let go.
1708    ///
1709    /// Deltas are logical pixels, positive to the right for [`Axis::Horizontal`]
1710    /// and downwards for [`Axis::Vertical`].
1711    pub fn draggable(self, axis: Axis, state: DraggableState) -> Self {
1712        self.then(draggable_impl(axis, state, None))
1713    }
1714
1715    /// Drags along `axis` only while `guard` says so.
1716    ///
1717    /// The guard is asked per event, so a control can stop accepting a drag
1718    /// half way through one and the gesture ends rather than being applied to
1719    /// a control that has since been disabled.
1720    pub fn draggable_guarded(
1721        self,
1722        axis: Axis,
1723        state: DraggableState,
1724        guard: impl Fn() -> bool + 'static,
1725    ) -> Self {
1726        self.then(draggable_impl(axis, state, Some(Rc::new(guard))))
1727    }
1728}
1729
1730fn draggable_impl(
1731    axis: Axis,
1732    state: DraggableState,
1733    guard: Option<Rc<dyn Fn() -> bool>>,
1734) -> Modifier {
1735    let is_vertical = axis.is_vertical();
1736    let identity = state.identity();
1737    drag_gesture_input(
1738        (identity, is_vertical),
1739        DragGesture {
1740            target: state,
1741            is_vertical,
1742            reverse_input: false,
1743            motion_context: scroll_motion_context_for_key(ScrollMotionContextKey::Draggable {
1744                state_identity: identity,
1745                is_vertical,
1746            }),
1747            guard,
1748        },
1749    )
1750}