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 detector_for_end = self.clone_for_watcher();
763        let overscroll_for_fling = self.overscroll.clone();
764
765        fling.start_fling(
766            initial_value,
767            fling_velocity,
768            move |delta| {
769                let consumed = overscroll_for_fling.apply_to_fling(delta, |delta| {
770                    scroll_target_for_fling.apply_fling_delta(delta)
771                });
772                scroll_target_for_fling.invalidate();
773                consumed
774            },
775            move || {
776                scroll_target.invalidate();
777                let settle_running = detector_for_end
778                    .gesture_state
779                    .borrow()
780                    .settle_animation
781                    .as_ref()
782                    .is_some_and(SettleAnimation::is_running);
783                if detector_for_end.overscroll.offset().abs() > 0.001 && !settle_running {
784                    detector_for_end.start_overscroll_settle(0.0);
785                }
786                detector_for_end.update_motion_active(&motion_context);
787            },
788        );
789
790        let mut gs = self.gesture_state.borrow_mut();
791        gs.fling_animation = Some(fling);
792    }
793
794    fn start_settle_animation(&self, target: f32, initial_velocity: f32) {
795        let Some(runtime) = current_runtime_handle() else {
796            self.motion_context.set_active(false);
797            return;
798        };
799        self.motion_context.set_active(true);
800        let settle = SettleAnimation::new(runtime, SpringParams::SETTLE_POLICY);
801        let scroll_target_for_settle = self.scroll_target.clone();
802        let scroll_target_for_end = self.scroll_target.clone();
803        let detector_for_end = self.clone_for_watcher();
804        let motion_context = self.motion_context.clone();
805        settle.start_settle(
806            self.scroll_target.current_offset(),
807            initial_velocity,
808            target,
809            move |delta| {
810                let consumed = scroll_target_for_settle.apply_fling_delta(delta);
811                scroll_target_for_settle.invalidate();
812                consumed
813            },
814            move |_| {
815                scroll_target_for_end.invalidate();
816                detector_for_end.update_motion_active(&motion_context);
817            },
818        );
819        let mut gs = self.gesture_state.borrow_mut();
820        gs.settle_animation = Some(settle);
821    }
822
823    fn start_overscroll_settle(&self, initial_velocity: f32) {
824        let Some(runtime) = current_runtime_handle() else {
825            let current = self.overscroll.offset();
826            self.overscroll.apply_settle_delta(-current);
827            self.motion_context.set_active(false);
828            return;
829        };
830        let settle = SettleAnimation::new(runtime, SpringParams::OVERSCROLL_BOUNCE);
831        let overscroll_for_settle = self.overscroll.clone();
832        let overscroll_for_end = self.overscroll.clone();
833        let detector_for_end = self.clone_for_watcher();
834        let motion_context = self.motion_context.clone();
835        let initial = self.overscroll.offset();
836        settle.start_settle(
837            initial,
838            initial_velocity,
839            0.0,
840            move |delta| overscroll_for_settle.apply_settle_delta(delta),
841            move |end| {
842                let current = overscroll_for_end.offset();
843                overscroll_for_end.apply_settle_delta(-current);
844                if end.hit_boundary && end.velocity.abs() > MIN_FLING_VELOCITY {
845                    detector_for_end.start_fling_animation(-end.velocity);
846                } else {
847                    detector_for_end.update_motion_active(&motion_context);
848                }
849            },
850        );
851        let mut gs = self.gesture_state.borrow_mut();
852        gs.is_overscrolling = false;
853        gs.settle_animation = Some(settle);
854    }
855
856    fn update_motion_active(&self, motion_context: &ScrollMotionContext) {
857        let running = self.gesture_state.borrow().is_active();
858        if !running {
859            motion_context.set_active(false);
860        }
861    }
862
863    fn on_up(&self, time_ms: Option<i64>) -> bool {
864        self.finish_gesture(self.scroll_target.allows_fling(), time_ms)
865    }
866
867    fn on_cancel(&self) -> bool {
868        self.finish_gesture(false, None)
869    }
870
871    fn on_scroll(&self, axis_delta: f32, event: &PointerEvent) -> bool {
872        if axis_delta.abs() <= f32::EPSILON {
873            return false;
874        }
875
876        let delta = if self.reverse_scrolling {
877            -axis_delta
878        } else {
879            axis_delta
880        };
881        if !self.scroll_target.can_consume(delta) && self.scroll_target.can_scroll() {
882            let detector = self.clone_for_watcher();
883            event.defer_post_dispatch_action(move || detector.apply_wheel_delta(delta));
884            return false;
885        }
886        self.apply_wheel_delta(delta)
887    }
888
889    fn apply_wheel_delta(&self, delta: f32) -> bool {
890        {
891            let mut gs = self.gesture_state.borrow_mut();
892            if gs.disposed {
893                return false;
894            }
895            if let Some(fling) = gs.fling_animation.take() {
896                fling.cancel();
897            }
898            if let Some(settle) = gs.settle_animation.take() {
899                settle.cancel();
900            }
901            gs.drag_down_position = None;
902            gs.last_position = None;
903            gs.is_dragging = false;
904            gs.axis_locked_out = false;
905            gs.gesture_start_time = None;
906            gs.gesture_start_event_time_ms = None;
907            gs.last_velocity_sample_ms = None;
908            gs.velocity_tracker.reset();
909        }
910
911        self.motion_context.activate_for_current_frame();
912        let overscroll = self.overscroll.clone();
913        let consumed =
914            overscroll.apply_to_scroll(delta, |delta| self.scroll_target.apply_wheel_delta(delta));
915        let overscroll_active = self.overscroll.offset().abs() > 0.001;
916        if consumed.abs() > 0.001 {
917            self.scroll_target.invalidate();
918        }
919        if consumed.abs() > 0.001 || overscroll_active {
920            self.ensure_wheel_settle_watcher();
921            true
922        } else {
923            false
924        }
925    }
926
927    fn ensure_wheel_settle_watcher(&self) {
928        if self.scroll_target.settle_policy().is_none() && self.overscroll.offset().abs() <= 0.001 {
929            return;
930        }
931        {
932            let gs = self.gesture_state.borrow();
933            if gs
934                .wheel_settle_watcher
935                .as_ref()
936                .is_some_and(|watcher| watcher.is_running.get())
937            {
938                return;
939            }
940        }
941        let Some(runtime) = current_runtime_handle() else {
942            return;
943        };
944
945        let is_running = Rc::new(Cell::new(true));
946        let registration = Rc::new(RefCell::new(None));
947
948        struct WheelSettleLoop<S: ScrollTarget> {
949            detector: ScrollGestureDetector<S>,
950            gesture_state: Rc<RefCell<ScrollGestureState>>,
951            frame_clock: cranpose_core::internal::FrameClock,
952            is_running: Rc<Cell<bool>>,
953            registration: Rc<RefCell<Option<FrameCallbackRegistration>>>,
954            last_offset: Rc<Cell<f32>>,
955            idle_nanos: Rc<Cell<u64>>,
956            last_frame: Rc<Cell<Option<u64>>>,
957        }
958
959        impl<S: ScrollTarget + 'static> WheelSettleLoop<S> {
960            fn next(&self) -> Self {
961                Self {
962                    detector: self.detector.clone_for_watcher(),
963                    gesture_state: Rc::clone(&self.gesture_state),
964                    frame_clock: self.frame_clock.clone(),
965                    is_running: Rc::clone(&self.is_running),
966                    registration: Rc::clone(&self.registration),
967                    last_offset: Rc::clone(&self.last_offset),
968                    idle_nanos: Rc::clone(&self.idle_nanos),
969                    last_frame: Rc::clone(&self.last_frame),
970                }
971            }
972
973            fn schedule(self) {
974                let continuation = self.next();
975                let registration_slot = Rc::clone(&self.registration);
976                let new_registration = self.frame_clock.with_frame_nanos(move |frame_time_nanos| {
977                    let this = &continuation;
978                    if !this.is_running.get() {
979                        return;
980                    }
981                    {
982                        let gs = this.gesture_state.borrow();
983                        let animating = gs.is_dragging
984                            || gs
985                                .fling_animation
986                                .as_ref()
987                                .is_some_and(FlingAnimation::is_running)
988                            || gs
989                                .settle_animation
990                                .as_ref()
991                                .is_some_and(SettleAnimation::is_running);
992                        if animating {
993                            this.is_running.set(false);
994                            return;
995                        }
996                    }
997
998                    let offset = this.detector.scroll_target.current_offset();
999                    let dt = this
1000                        .last_frame
1001                        .get()
1002                        .map_or(0, |last| frame_time_nanos.saturating_sub(last));
1003                    this.last_frame.set(Some(frame_time_nanos));
1004                    if (offset - this.last_offset.get()).abs() > 0.01 {
1005                        this.last_offset.set(offset);
1006                        this.idle_nanos.set(0);
1007                    } else {
1008                        this.idle_nanos.set(this.idle_nanos.get() + dt);
1009                    }
1010
1011                    if this.idle_nanos.get() >= WHEEL_SETTLE_IDLE_NANOS {
1012                        this.is_running.set(false);
1013                        if this.detector.overscroll.offset().abs() > 0.001 {
1014                            this.detector.start_overscroll_settle(0.0);
1015                            return;
1016                        }
1017                        if let Some(policy) = this.detector.scroll_target.settle_policy() {
1018                            let target = policy(offset, 0.0);
1019                            if (target - offset).abs() > 0.5 {
1020                                this.detector.start_settle_animation(target, 0.0);
1021                            }
1022                        }
1023                        return;
1024                    }
1025
1026                    continuation.next().schedule();
1027                });
1028                *registration_slot.borrow_mut() = Some(new_registration);
1029            }
1030        }
1031
1032        WheelSettleLoop {
1033            detector: self.clone_for_watcher(),
1034            gesture_state: Rc::clone(&self.gesture_state),
1035            frame_clock: runtime.frame_clock(),
1036            is_running: Rc::clone(&is_running),
1037            registration: Rc::clone(&registration),
1038            last_offset: Rc::new(Cell::new(self.scroll_target.current_offset())),
1039            idle_nanos: Rc::new(Cell::new(0u64)),
1040            last_frame: Rc::new(Cell::new(None::<u64>)),
1041        }
1042        .schedule();
1043
1044        self.gesture_state.borrow_mut().wheel_settle_watcher = Some(WheelSettleWatcher {
1045            is_running,
1046            registration,
1047        });
1048    }
1049
1050    fn clone_for_watcher(&self) -> ScrollGestureDetector<S> {
1051        ScrollGestureDetector {
1052            gesture_state: Rc::clone(&self.gesture_state),
1053            scroll_target: self.scroll_target.clone(),
1054            is_vertical: self.is_vertical,
1055            reverse_scrolling: self.reverse_scrolling,
1056            overscroll: self.overscroll.clone(),
1057            motion_context: self.motion_context.clone(),
1058        }
1059    }
1060}
1061
1062pub(crate) struct MotionContextAnimatedNode {
1063    state: NodeState,
1064    motion_context: ScrollMotionContext,
1065    invalidation_callback_id: Option<u64>,
1066    overscroll_callback_id: Option<u64>,
1067    node_id: Option<NodeId>,
1068}
1069
1070impl MotionContextAnimatedNode {
1071    fn new(motion_context: ScrollMotionContext) -> Self {
1072        Self {
1073            state: NodeState::new(),
1074            motion_context,
1075            invalidation_callback_id: None,
1076            overscroll_callback_id: None,
1077            node_id: None,
1078        }
1079    }
1080
1081    pub(crate) fn is_active(&self) -> bool {
1082        self.motion_context.is_active()
1083    }
1084
1085    fn attach_callbacks(&mut self) {
1086        if let Some(node_id) = self.node_id {
1087            self.invalidation_callback_id = Some(self.motion_context.add_invalidate_callback(
1088                Box::new(move || schedule_modifier_slices_repass(node_id)),
1089            ));
1090            self.overscroll_callback_id = Some(
1091                self.motion_context
1092                    .overscroll()
1093                    .add_invalidate_callback(Box::new(move || {
1094                        crate::schedule_measure_repass(node_id);
1095                        schedule_modifier_slices_repass(node_id);
1096                    })),
1097            );
1098        }
1099    }
1100
1101    fn detach_callbacks(&mut self) {
1102        if let Some(id) = self.invalidation_callback_id.take() {
1103            self.motion_context.remove_invalidate_callback(id);
1104        }
1105        if let Some(id) = self.overscroll_callback_id.take() {
1106            self.motion_context
1107                .overscroll()
1108                .remove_invalidate_callback(id);
1109        }
1110    }
1111}
1112
1113pub(crate) struct TranslatedContentContextNode {
1114    state: NodeState,
1115    identity: usize,
1116    offset_source: TranslatedContentOffsetSource,
1117    overscroll: crate::scroll::OverscrollEffect,
1118    overscroll_callback_id: Option<u64>,
1119    node_id: Option<NodeId>,
1120}
1121
1122impl TranslatedContentContextNode {
1123    fn new(
1124        identity: usize,
1125        offset_source: TranslatedContentOffsetSource,
1126        overscroll: crate::scroll::OverscrollEffect,
1127    ) -> Self {
1128        Self {
1129            state: NodeState::new(),
1130            identity,
1131            offset_source,
1132            overscroll,
1133            overscroll_callback_id: None,
1134            node_id: None,
1135        }
1136    }
1137
1138    pub(crate) fn is_active(&self) -> bool {
1139        true
1140    }
1141
1142    pub(crate) fn identity(&self) -> usize {
1143        self.identity
1144    }
1145
1146    pub(crate) fn content_offset_reader(&self) -> Option<Rc<dyn Fn() -> Point>> {
1147        self.offset_source
1148            .content_offset_reader(self.overscroll.clone())
1149    }
1150
1151    fn attach_callback(&mut self) {
1152        if let Some(node_id) = self.node_id {
1153            self.overscroll_callback_id =
1154                Some(self.overscroll.add_invalidate_callback(Box::new(move || {
1155                    schedule_modifier_slices_repass(node_id);
1156                })));
1157        }
1158    }
1159
1160    fn detach_callback(&mut self) {
1161        if let Some(id) = self.overscroll_callback_id.take() {
1162            self.overscroll.remove_invalidate_callback(id);
1163        }
1164    }
1165}
1166
1167impl DelegatableNode for TranslatedContentContextNode {
1168    fn node_state(&self) -> &NodeState {
1169        &self.state
1170    }
1171}
1172
1173impl ModifierNode for TranslatedContentContextNode {
1174    fn on_attach(&mut self, context: &mut dyn cranpose_foundation::ModifierNodeContext) {
1175        self.node_id = context.node_id();
1176        self.attach_callback();
1177    }
1178
1179    fn on_detach(&mut self) {
1180        self.detach_callback();
1181        self.node_id = None;
1182    }
1183}
1184
1185impl DelegatableNode for MotionContextAnimatedNode {
1186    fn node_state(&self) -> &NodeState {
1187        &self.state
1188    }
1189}
1190
1191impl ModifierNode for MotionContextAnimatedNode {
1192    fn on_attach(&mut self, context: &mut dyn cranpose_foundation::ModifierNodeContext) {
1193        self.node_id = context.node_id();
1194        self.attach_callbacks();
1195    }
1196
1197    fn on_detach(&mut self) {
1198        self.detach_callbacks();
1199        self.node_id = None;
1200    }
1201}
1202
1203#[derive(Clone)]
1204struct MotionContextAnimatedElement {
1205    motion_context: ScrollMotionContext,
1206}
1207
1208impl MotionContextAnimatedElement {
1209    fn new(motion_context: ScrollMotionContext) -> Self {
1210        Self { motion_context }
1211    }
1212}
1213
1214impl std::fmt::Debug for MotionContextAnimatedElement {
1215    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1216        f.debug_struct("MotionContextAnimatedElement").finish()
1217    }
1218}
1219
1220impl PartialEq for MotionContextAnimatedElement {
1221    fn eq(&self, other: &Self) -> bool {
1222        self.motion_context.ptr_eq(&other.motion_context)
1223    }
1224}
1225
1226impl Eq for MotionContextAnimatedElement {}
1227
1228impl std::hash::Hash for MotionContextAnimatedElement {
1229    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
1230        self.motion_context.stable_key().hash(state);
1231    }
1232}
1233
1234impl ModifierNodeElement for MotionContextAnimatedElement {
1235    type Node = MotionContextAnimatedNode;
1236
1237    fn create(&self) -> Self::Node {
1238        MotionContextAnimatedNode::new(self.motion_context.clone())
1239    }
1240
1241    fn update(&self, node: &mut Self::Node) {
1242        if node.motion_context.ptr_eq(&self.motion_context) {
1243            return;
1244        }
1245        node.detach_callbacks();
1246        node.motion_context = self.motion_context.clone();
1247        node.attach_callbacks();
1248    }
1249
1250    fn capabilities(&self) -> NodeCapabilities {
1251        NodeCapabilities::LAYOUT
1252    }
1253}
1254
1255#[derive(Clone)]
1256enum TranslatedContentOffsetSource {
1257    LayoutContentOffset,
1258    LazyList {
1259        state: LazyListState,
1260        is_vertical: bool,
1261        reverse_scrolling: bool,
1262    },
1263}
1264
1265impl TranslatedContentOffsetSource {
1266    fn content_offset_reader(
1267        &self,
1268        overscroll: crate::scroll::OverscrollEffect,
1269    ) -> Option<Rc<dyn Fn() -> Point>> {
1270        match self {
1271            Self::LayoutContentOffset => None,
1272            Self::LazyList {
1273                state, is_vertical, ..
1274            } => Some(Rc::new(lazy_list_content_offset_reader(
1275                *state,
1276                *is_vertical,
1277                overscroll,
1278            ))),
1279        }
1280    }
1281
1282    fn is_vertical(&self) -> Option<bool> {
1283        match self {
1284            Self::LayoutContentOffset => None,
1285            Self::LazyList { is_vertical, .. } => Some(*is_vertical),
1286        }
1287    }
1288
1289    fn reverse_scrolling(&self) -> Option<bool> {
1290        match self {
1291            Self::LayoutContentOffset => None,
1292            Self::LazyList {
1293                reverse_scrolling, ..
1294            } => Some(*reverse_scrolling),
1295        }
1296    }
1297}
1298
1299fn lazy_list_content_offset_reader(
1300    state: LazyListState,
1301    is_vertical: bool,
1302    overscroll: crate::scroll::OverscrollEffect,
1303) -> impl Fn() -> Point {
1304    move || {
1305        let info = state.layout_info();
1306        if info.visible_items_info.is_empty() {
1307            return Point::default();
1308        };
1309        overscroll.set_dimension(info.viewport_size);
1310        let main_offset = info.snap_anchor_offset;
1311        let bounce = overscroll.offset();
1312        if is_vertical {
1313            Point::new(0.0, main_offset + bounce)
1314        } else {
1315            Point::new(main_offset + bounce, 0.0)
1316        }
1317    }
1318}
1319
1320#[derive(Clone)]
1321struct TranslatedContentContextElement {
1322    identity: usize,
1323    offset_source: TranslatedContentOffsetSource,
1324    overscroll: crate::scroll::OverscrollEffect,
1325}
1326
1327impl TranslatedContentContextElement {
1328    fn new(
1329        identity: usize,
1330        offset_source: TranslatedContentOffsetSource,
1331        overscroll: crate::scroll::OverscrollEffect,
1332    ) -> Self {
1333        Self {
1334            identity,
1335            offset_source,
1336            overscroll,
1337        }
1338    }
1339}
1340
1341impl std::fmt::Debug for TranslatedContentContextElement {
1342    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1343        let offset_source = match &self.offset_source {
1344            TranslatedContentOffsetSource::LayoutContentOffset => "layout",
1345            TranslatedContentOffsetSource::LazyList { .. } => "lazy_list",
1346        };
1347        f.debug_struct("TranslatedContentContextElement")
1348            .field("identity", &self.identity)
1349            .field("offset_source", &offset_source)
1350            .finish()
1351    }
1352}
1353
1354impl PartialEq for TranslatedContentContextElement {
1355    fn eq(&self, other: &Self) -> bool {
1356        self.identity == other.identity
1357            && self.overscroll.ptr_eq(&other.overscroll)
1358            && self.offset_source.is_vertical() == other.offset_source.is_vertical()
1359            && self.offset_source.reverse_scrolling() == other.offset_source.reverse_scrolling()
1360    }
1361}
1362
1363impl Eq for TranslatedContentContextElement {}
1364
1365impl std::hash::Hash for TranslatedContentContextElement {
1366    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
1367        self.identity.hash(state);
1368        self.offset_source.is_vertical().hash(state);
1369        self.offset_source.reverse_scrolling().hash(state);
1370    }
1371}
1372
1373impl ModifierNodeElement for TranslatedContentContextElement {
1374    type Node = TranslatedContentContextNode;
1375
1376    fn create(&self) -> Self::Node {
1377        TranslatedContentContextNode::new(
1378            self.identity,
1379            self.offset_source.clone(),
1380            self.overscroll.clone(),
1381        )
1382    }
1383
1384    fn update(&self, node: &mut Self::Node) {
1385        node.identity = self.identity;
1386        node.offset_source = self.offset_source.clone();
1387        if !node.overscroll.ptr_eq(&self.overscroll) {
1388            node.detach_callback();
1389            node.overscroll = self.overscroll.clone();
1390            node.attach_callback();
1391        }
1392    }
1393
1394    fn capabilities(&self) -> NodeCapabilities {
1395        NodeCapabilities::LAYOUT
1396    }
1397}
1398
1399impl Modifier {
1400    /// Creates a horizontally scrollable modifier.
1401    ///
1402    /// # Arguments
1403    /// * `state` - The ScrollState to control scroll position
1404    /// * `reverse_scrolling` - If true, reverses the scroll direction in layout.
1405    ///   Note: This affects how scroll offset is applied to content (via `ScrollNode`),
1406    ///   NOT the drag direction. Drag gestures always follow natural touch semantics:
1407    ///   drag right = scroll left (content moves right under finger).
1408    ///
1409    /// # Example
1410    /// ```text
1411    /// let scroll_state = ScrollState::new(0.0);
1412    /// Row(
1413    ///     Modifier::empty().horizontal_scroll(scroll_state, false),
1414    ///     // ... content
1415    /// );
1416    /// ```
1417    pub fn horizontal_scroll(self, state: ScrollState, reverse_scrolling: bool) -> Self {
1418        self.then(scroll_impl(state, false, reverse_scrolling, None))
1419    }
1420
1421    /// Creates a vertically scrollable modifier.
1422    ///
1423    /// # Arguments
1424    /// * `state` - The ScrollState to control scroll position
1425    /// * `reverse_scrolling` - If true, reverses the scroll direction in layout.
1426    ///   Note: This affects how scroll offset is applied to content (via `ScrollNode`),
1427    ///   NOT the drag direction. Drag gestures always follow natural touch semantics:
1428    ///   drag down = scroll up (content moves down under finger).
1429    pub fn vertical_scroll(self, state: ScrollState, reverse_scrolling: bool) -> Self {
1430        self.then(scroll_impl(state, true, reverse_scrolling, None))
1431    }
1432
1433    /// A horizontal scroll that never claims a pointer gesture.
1434    ///
1435    /// The position still moves through [`ScrollState`], but drags and wheel
1436    /// events pass straight through, so an owner can run its own
1437    /// `pointer_input` on the same row -- a drag-to-reorder list, say --
1438    /// without the two competing for the gesture.
1439    pub fn horizontal_scroll_without_gestures(
1440        self,
1441        state: ScrollState,
1442        reverse_scrolling: bool,
1443    ) -> Self {
1444        self.then(scroll_impl(
1445            state,
1446            false,
1447            reverse_scrolling,
1448            Some(Rc::new(|| false)),
1449        ))
1450    }
1451
1452    /// A vertical scroll that never claims a pointer gesture.
1453    ///
1454    /// The vertical counterpart of [`Modifier::horizontal_scroll_without_gestures`].
1455    pub fn vertical_scroll_without_gestures(
1456        self,
1457        state: ScrollState,
1458        reverse_scrolling: bool,
1459    ) -> Self {
1460        self.then(scroll_impl(
1461            state,
1462            true,
1463            reverse_scrolling,
1464            Some(Rc::new(|| false)),
1465        ))
1466    }
1467}
1468
1469fn scroll_impl(
1470    state: ScrollState,
1471    is_vertical: bool,
1472    reverse_scrolling: bool,
1473    guard: Option<Rc<dyn Fn() -> bool>>,
1474) -> Modifier {
1475    let motion_context = scroll_motion_context_for_key(ScrollMotionContextKey::ScrollState {
1476        state_id: state.id(),
1477        is_vertical,
1478        reverse_scrolling,
1479    });
1480
1481    let pointer_input = drag_gesture_input(
1482        (state.id(), is_vertical),
1483        DragGesture {
1484            target: state,
1485            is_vertical,
1486            reverse_input: false,
1487            motion_context: motion_context.clone(),
1488            guard,
1489        },
1490    );
1491
1492    let overscroll = motion_context.overscroll();
1493    let element = ScrollElement::new(state, overscroll.clone(), is_vertical, reverse_scrolling);
1494    let layout_modifier =
1495        Modifier::with_element(element).with_inspector_metadata(inspector_metadata(
1496            if is_vertical {
1497                "verticalScroll"
1498            } else {
1499                "horizontalScroll"
1500            },
1501            move |info| {
1502                info.add_property("isVertical", is_vertical.to_string());
1503                info.add_property("reverseScrolling", reverse_scrolling.to_string());
1504            },
1505        ));
1506    let motion_modifier = Modifier::with_element(MotionContextAnimatedElement::new(motion_context));
1507    let translated_content_modifier = Modifier::with_element(TranslatedContentContextElement::new(
1508        state.id() as usize,
1509        TranslatedContentOffsetSource::LayoutContentOffset,
1510        overscroll,
1511    ));
1512
1513    pointer_input
1514        .then(motion_modifier)
1515        .then(translated_content_modifier)
1516        .then(layout_modifier)
1517        .clip_to_bounds()
1518        .semantics(scroll_semantics(state, is_vertical, reverse_scrolling))
1519}
1520
1521/// What a screen reader learns about a scroll container: how far it has
1522/// scrolled, whether it can go on, and how to page it. Compose's
1523/// `verticalScrollAxisRange` / `horizontalScrollAxisRange` and `scrollBy`.
1524/// Without this a reader's cursor stops at the last row it can see.
1525fn scroll_semantics(
1526    state: ScrollState,
1527    is_vertical: bool,
1528    reverse_scrolling: bool,
1529) -> impl Fn(&mut cranpose_foundation::SemanticsConfiguration) + 'static {
1530    move |config| {
1531        let range = cranpose_foundation::ScrollAxisRange::new(
1532            state.value(),
1533            state.max_value(),
1534            reverse_scrolling,
1535        );
1536        if is_vertical {
1537            config.vertical_scroll = Some(range);
1538        } else {
1539            config.horizontal_scroll = Some(range);
1540        }
1541        config.scroll_by = Some(cranpose_foundation::SemanticsScrollBy::new(
1542            move |dx, dy| {
1543                let delta = if is_vertical { dy } else { dx };
1544                state.dispatch_raw_delta(delta).abs() > f32::EPSILON
1545            },
1546        ));
1547    }
1548}
1549
1550use cranpose_foundation::lazy::LazyListState;
1551
1552impl Modifier {
1553    /// Creates a vertically scrollable modifier for lazy lists.
1554    ///
1555    /// This connects pointer gestures to LazyListState for scroll handling.
1556    /// Unlike regular vertical_scroll, no layout offset is applied here
1557    /// since LazyListState manages item positioning internally.
1558    /// Creates a vertically scrollable modifier for lazy lists.
1559    ///
1560    /// This connects pointer gestures to LazyListState for scroll handling.
1561    /// Unlike regular vertical_scroll, no layout offset is applied here
1562    /// since LazyListState manages item positioning internally.
1563    pub fn lazy_vertical_scroll(self, state: LazyListState, reverse_scrolling: bool) -> Self {
1564        let motion_context = scroll_motion_context_for_key(ScrollMotionContextKey::LazyList {
1565            state_identity: state.inner_ptr() as usize,
1566            is_vertical: true,
1567            reverse_scrolling,
1568        });
1569        self.lazy_vertical_scroll_with_context(state, reverse_scrolling, (0.0, 0.0), motion_context)
1570    }
1571
1572    pub(crate) fn lazy_vertical_scroll_with_context(
1573        self,
1574        state: LazyListState,
1575        reverse_scrolling: bool,
1576        content_padding: (f32, f32),
1577        motion_context: ScrollMotionContext,
1578    ) -> Self {
1579        self.then(lazy_scroll_impl(
1580            state,
1581            true,
1582            reverse_scrolling,
1583            content_padding,
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        content_padding: (f32, f32),
1593        motion_context: ScrollMotionContext,
1594    ) -> Self {
1595        self.then(lazy_scroll_impl(
1596            state,
1597            false,
1598            reverse_scrolling,
1599            content_padding,
1600            motion_context,
1601        ))
1602    }
1603}
1604
1605fn lazy_scroll_impl(
1606    state: LazyListState,
1607    is_vertical: bool,
1608    reverse_scrolling: bool,
1609    content_padding: (f32, f32),
1610    motion_context: ScrollMotionContext,
1611) -> Modifier {
1612    let list_state = state;
1613    let state_id = state.inner_ptr() as usize;
1614    let key = (state_id, is_vertical, reverse_scrolling);
1615    let overscroll = motion_context.overscroll();
1616    let translated_content_modifier = Modifier::with_element(TranslatedContentContextElement::new(
1617        state_id,
1618        TranslatedContentOffsetSource::LazyList {
1619            state,
1620            is_vertical,
1621            reverse_scrolling,
1622        },
1623        overscroll,
1624    ));
1625
1626    Modifier::with_element(MotionContextAnimatedElement::new(motion_context.clone()))
1627        .then(translated_content_modifier)
1628        .then(drag_gesture_input(
1629            key,
1630            DragGesture {
1631                target: list_state,
1632                is_vertical,
1633                reverse_input: reverse_scrolling,
1634                motion_context,
1635                guard: None,
1636            },
1637        ))
1638        .semantics(lazy_scroll_semantics(
1639            list_state,
1640            is_vertical,
1641            reverse_scrolling,
1642            content_padding,
1643        ))
1644}
1645
1646/// What a screen reader learns about a lazy list. The list has no whole
1647/// extent to give, so the first visible item stands in for the position, a
1648/// half step marks a list scrolled part way into that item, and one more step
1649/// stays ahead while the list can still scroll forward. A reader needs only
1650/// the two answers: can it page back, can it page on.
1651pub(crate) fn lazy_scroll_semantics(
1652    state: LazyListState,
1653    is_vertical: bool,
1654    reverse_scrolling: bool,
1655    content_padding: (f32, f32),
1656) -> impl Fn(&mut cranpose_foundation::SemanticsConfiguration) + 'static {
1657    move |config| {
1658        let mut value = state.first_visible_item_index() as f32;
1659        if state.first_visible_item_scroll_offset() > 0.0 {
1660            value += 0.5;
1661        }
1662        let max_value = if state.can_scroll_forward() {
1663            value + 1.0
1664        } else {
1665            value
1666        };
1667        let range = cranpose_foundation::ScrollAxisRange::new(value, max_value, reverse_scrolling)
1668            .with_content_padding(content_padding.0, content_padding.1);
1669        let count = state.total_items_count();
1670        config.collection = Some(cranpose_foundation::CollectionInfo {
1671            rows: if is_vertical { count } else { 1 },
1672            columns: if is_vertical { 1 } else { count },
1673        });
1674        config
1675            .role
1676            .get_or_insert(cranpose_foundation::SemanticsWidgetRole::List);
1677        if is_vertical {
1678            config.vertical_scroll = Some(range);
1679        } else {
1680            config.horizontal_scroll = Some(range);
1681        }
1682        config.scroll_by = Some(cranpose_foundation::SemanticsScrollBy::new(
1683            move |dx, dy| {
1684                let delta = if is_vertical { dy } else { dx };
1685                state.dispatch_scroll_delta(-delta).abs() > f32::EPSILON
1686            },
1687        ));
1688        config.scroll_to_index = Some(cranpose_foundation::SemanticsScrollToIndex::new(
1689            move |index| jump_to_row(&state, index),
1690        ));
1691    }
1692}
1693
1694/// Puts the row at `index` at the top of a lazy list for a screen reader that
1695/// asked for it. A list with no rows, or an index past the last row, takes
1696/// nothing and says so.
1697fn jump_to_row(state: &LazyListState, index: usize) -> bool {
1698    if index >= state.total_items_count() {
1699        return false;
1700    }
1701    state.scroll_to_item(index, 0.0);
1702    true
1703}
1704
1705impl Modifier {
1706    /// Drags along `axis`, reporting each delta to `state`.
1707    ///
1708    /// The gesture is the one the scroll containers run — touch slop before a
1709    /// drag begins, axis locking so a mostly-vertical drag never steals a
1710    /// horizontal one, the primary pointer only, and yielding to whoever
1711    /// consumed the event first — so a dragged control and a scrolled list feel
1712    /// the same under a finger. Unlike a scroll, a release does not fling: a
1713    /// control the user placed stays where it was let go.
1714    ///
1715    /// Deltas are logical pixels, positive to the right for [`Axis::Horizontal`]
1716    /// and downwards for [`Axis::Vertical`].
1717    pub fn draggable(self, axis: Axis, state: DraggableState) -> Self {
1718        self.then(draggable_impl(axis, state, None))
1719    }
1720
1721    /// Drags along `axis` only while `guard` says so.
1722    ///
1723    /// The guard is asked per event, so a control can stop accepting a drag
1724    /// half way through one and the gesture ends rather than being applied to
1725    /// a control that has since been disabled.
1726    pub fn draggable_guarded(
1727        self,
1728        axis: Axis,
1729        state: DraggableState,
1730        guard: impl Fn() -> bool + 'static,
1731    ) -> Self {
1732        self.then(draggable_impl(axis, state, Some(Rc::new(guard))))
1733    }
1734}
1735
1736fn draggable_impl(
1737    axis: Axis,
1738    state: DraggableState,
1739    guard: Option<Rc<dyn Fn() -> bool>>,
1740) -> Modifier {
1741    let is_vertical = axis.is_vertical();
1742    let identity = state.identity();
1743    drag_gesture_input(
1744        (identity, is_vertical),
1745        DragGesture {
1746            target: state,
1747            is_vertical,
1748            reverse_input: false,
1749            motion_context: scroll_motion_context_for_key(ScrollMotionContextKey::Draggable {
1750                state_identity: identity,
1751                is_vertical,
1752            }),
1753            guard,
1754        },
1755    )
1756}