cranpose_ui/modifier/scroll.rs
1//! Scroll modifier extensions for Modifier.
2//!
3//! # Overview
4//! This module implements scrollable containers with gesture-based interaction.
5//! It follows the pattern of separating:
6//! - **State management** (`ScrollGestureState`) - tracks pointer/drag state
7//! - **Event handling** (`ScrollGestureDetector`) - processes events and updates state
8//! - **Layout** (`ScrollElement`/`ScrollNode` in `scroll.rs`) - applies scroll offset
9//!
10//! # Gesture Flow
11//! 1. **Down**: Record initial position, reset drag state
12//! 2. **Move**: Check if total movement exceeds `DRAG_THRESHOLD` (8px)
13//! - If threshold crossed: start consuming events, apply scroll delta
14//! - This prevents child click handlers from firing during scrolls
15//! 3. **Up/Cancel**: Clean up state, consume if was dragging
16
17use super::{inspector_metadata, Modifier, Point, PointerEventKind};
18use crate::current_density;
19use crate::fling_animation::FlingAnimation;
20use crate::fling_animation::MIN_FLING_VELOCITY;
21use crate::scroll::{ScrollElement, ScrollState};
22use cranpose_core::current_runtime_handle;
23use cranpose_foundation::{
24 velocity_tracker::ASSUME_STOPPED_MS, PointerButton, PointerButtons, VelocityTracker1D,
25 DRAG_THRESHOLD, MAX_FLING_VELOCITY,
26};
27use std::cell::RefCell;
28use std::rc::Rc;
29use web_time::Instant;
30
31// ============================================================================
32// Test Accessibility: Last Fling Velocity (only in test-helpers builds)
33// ============================================================================
34
35#[cfg(feature = "test-helpers")]
36mod test_velocity_tracking {
37 use std::sync::atomic::{AtomicU32, Ordering};
38
39 /// Stores the last fling velocity calculated for test verification.
40 ///
41 /// Uses `AtomicU32` (not thread_local) because the test driver runs on a separate
42 /// thread from the UI thread, and the test needs to read values written by the UI.
43 ///
44 /// # Parallel Test Safety
45 /// This global state means parallel tests could interfere with each other.
46 /// For test isolation, run robot tests sequentially (cargo test -- --test-threads=1)
47 /// or use test harnesses that reset state between runs.
48 static LAST_FLING_VELOCITY: AtomicU32 = AtomicU32::new(0);
49
50 /// Returns the last fling velocity calculated (in px/sec).
51 ///
52 /// This is primarily for testing - allows robot tests to verify that
53 /// velocity detection is working correctly instead of relying on log output.
54 pub fn last_fling_velocity() -> f32 {
55 f32::from_bits(LAST_FLING_VELOCITY.load(Ordering::SeqCst))
56 }
57
58 /// Resets the last fling velocity to 0.0.
59 ///
60 /// Call this at the start of a test to ensure clean state.
61 pub fn reset_last_fling_velocity() {
62 LAST_FLING_VELOCITY.store(0.0f32.to_bits(), Ordering::SeqCst);
63 }
64
65 /// Internal: Set the last fling velocity (called from gesture detection).
66 pub(super) fn set_last_fling_velocity(velocity: f32) {
67 LAST_FLING_VELOCITY.store(velocity.to_bits(), Ordering::SeqCst);
68 }
69}
70
71#[cfg(feature = "test-helpers")]
72pub use test_velocity_tracking::{last_fling_velocity, reset_last_fling_velocity};
73
74/// Internal: Set the last fling velocity (called from gesture detection).
75/// No-op in production builds without test-helpers feature.
76#[inline]
77fn set_last_fling_velocity(velocity: f32) {
78 #[cfg(feature = "test-helpers")]
79 test_velocity_tracking::set_last_fling_velocity(velocity);
80 #[cfg(not(feature = "test-helpers"))]
81 let _ = velocity; // Silence unused variable warning
82}
83
84/// Local gesture state for scroll drag handling.
85///
86/// This is NOT part of `ScrollState` to keep the scroll model pure.
87/// Each scroll modifier instance has its own gesture state, which enables
88/// multiple independent scroll regions without state interference.
89struct ScrollGestureState {
90 /// Position where pointer was pressed down.
91 /// Used to calculate total drag distance for threshold detection.
92 drag_down_position: Option<Point>,
93
94 /// Last known pointer position during drag.
95 /// Used to calculate incremental delta for each move event.
96 last_position: Option<Point>,
97
98 /// Whether we've crossed the drag threshold and are actively scrolling.
99 /// Once true, we consume all events until Up/Cancel to prevent child
100 /// handlers from receiving drag events.
101 is_dragging: bool,
102
103 /// Velocity tracker for fling gesture detection.
104 velocity_tracker: VelocityTracker1D,
105
106 /// Time when gesture down started (for velocity calculation).
107 gesture_start_time: Option<Instant>,
108
109 /// Last time a velocity sample was recorded (milliseconds since gesture start).
110 last_velocity_sample_ms: Option<i64>,
111
112 /// Current fling animation (if any).
113 fling_animation: Option<FlingAnimation>,
114}
115
116impl Default for ScrollGestureState {
117 fn default() -> Self {
118 Self {
119 drag_down_position: None,
120 last_position: None,
121 is_dragging: false,
122 velocity_tracker: VelocityTracker1D::new(),
123 gesture_start_time: None,
124 last_velocity_sample_ms: None,
125 fling_animation: None,
126 }
127 }
128}
129
130// ============================================================================
131// Helper Functions
132// ============================================================================
133
134/// Calculates the total movement distance from the original down position.
135///
136/// This is used to determine if we've crossed the drag threshold.
137/// Returns the distance in the scroll axis direction (Y for vertical, X for horizontal).
138#[inline]
139fn calculate_total_delta(from: Point, to: Point, is_vertical: bool) -> f32 {
140 if is_vertical {
141 to.y - from.y
142 } else {
143 to.x - from.x
144 }
145}
146
147/// Calculates the incremental movement delta from the previous position.
148///
149/// This is used to update the scroll offset incrementally during drag.
150/// Returns the distance in the scroll axis direction (Y for vertical, X for horizontal).
151#[inline]
152fn calculate_incremental_delta(from: Point, to: Point, is_vertical: bool) -> f32 {
153 if is_vertical {
154 to.y - from.y
155 } else {
156 to.x - from.x
157 }
158}
159
160// ============================================================================
161// Scroll Gesture Detector (Generic Implementation)
162// ============================================================================
163
164/// Trait for scroll targets that can receive scroll deltas.
165///
166/// Implemented by both `ScrollState` (regular scroll) and `LazyListState` (lazy lists).
167trait ScrollTarget: Clone {
168 /// Apply a gesture delta. Returns the consumed amount in gesture coordinates.
169 fn apply_delta(&self, delta: f32) -> f32;
170
171 /// Apply a scroll delta during fling. Returns consumed delta in scroll coordinates.
172 fn apply_fling_delta(&self, delta: f32) -> f32;
173
174 /// Called after scroll to trigger any necessary invalidation.
175 fn invalidate(&self);
176
177 /// Get the current scroll offset.
178 fn current_offset(&self) -> f32;
179}
180
181impl ScrollTarget for ScrollState {
182 fn apply_delta(&self, delta: f32) -> f32 {
183 // Regular scroll uses negative delta (natural scrolling)
184 self.dispatch_raw_delta(-delta)
185 }
186
187 fn apply_fling_delta(&self, delta: f32) -> f32 {
188 self.dispatch_raw_delta(delta)
189 }
190
191 fn invalidate(&self) {
192 // ScrollState triggers invalidation internally
193 }
194
195 fn current_offset(&self) -> f32 {
196 self.value()
197 }
198}
199
200impl ScrollTarget for LazyListState {
201 fn apply_delta(&self, delta: f32) -> f32 {
202 // LazyListState uses positive delta directly
203 // dispatch_scroll_delta already calls self.invalidate() which triggers the
204 // layout invalidation callback registered in lazy_scroll_impl
205 self.dispatch_scroll_delta(delta)
206 }
207
208 fn apply_fling_delta(&self, delta: f32) -> f32 {
209 -self.dispatch_scroll_delta(-delta)
210 }
211
212 fn invalidate(&self) {
213 // dispatch_scroll_delta already handles invalidation internally via callback.
214 // We do NOT call request_layout_invalidation() here - that's the global
215 // nuclear option that invalidates ALL layout caches app-wide.
216 // The registered callback uses schedule_layout_repass for scoped invalidation.
217 }
218
219 fn current_offset(&self) -> f32 {
220 // LazyListState doesn't have a simple offset - use first visible item offset
221 self.first_visible_item_scroll_offset()
222 }
223}
224
225/// Generic scroll gesture detector that works with any ScrollTarget.
226///
227/// This struct provides a clean interface for processing pointer events
228/// and managing scroll interactions. The generic parameter S determines
229/// how scroll deltas are applied.
230struct ScrollGestureDetector<S: ScrollTarget> {
231 /// Shared gesture state (position tracking, drag status).
232 gesture_state: Rc<RefCell<ScrollGestureState>>,
233
234 /// The scroll target to update when drag is detected.
235 scroll_target: S,
236
237 /// Whether this is vertical or horizontal scroll.
238 is_vertical: bool,
239
240 /// Whether to reverse the scroll direction (flip delta).
241 reverse_scrolling: bool,
242}
243
244impl<S: ScrollTarget + 'static> ScrollGestureDetector<S> {
245 /// Creates a new detector for the given scroll configuration.
246 fn new(
247 gesture_state: Rc<RefCell<ScrollGestureState>>,
248 scroll_target: S,
249 is_vertical: bool,
250 reverse_scrolling: bool,
251 ) -> Self {
252 Self {
253 gesture_state,
254 scroll_target,
255 is_vertical,
256 reverse_scrolling,
257 }
258 }
259
260 /// Handles pointer down event.
261 ///
262 /// Records the initial position for threshold calculation and
263 /// resets drag state. We don't consume Down events because we
264 /// don't know yet if this will become a drag or a click.
265 ///
266 /// Returns `false` - Down events are never consumed to allow
267 /// potential child click handlers to receive the initial press.
268 fn on_down(&self, position: Point) -> bool {
269 let mut gs = self.gesture_state.borrow_mut();
270
271 // Cancel any running fling animation
272 if let Some(fling) = gs.fling_animation.take() {
273 fling.cancel();
274 }
275
276 gs.drag_down_position = Some(position);
277 gs.last_position = Some(position);
278 gs.is_dragging = false;
279 gs.velocity_tracker.reset();
280 gs.gesture_start_time = Some(Instant::now());
281
282 // Add initial position to velocity tracker
283 let pos = if self.is_vertical {
284 position.y
285 } else {
286 position.x
287 };
288 gs.velocity_tracker.add_data_point(0, pos);
289 gs.last_velocity_sample_ms = Some(0);
290
291 // Never consume Down - we don't know if this is a drag yet
292 false
293 }
294
295 /// Handles pointer move event.
296 ///
297 /// This is the core gesture detection logic:
298 /// 1. Safety check: if no primary button is pressed but we think we're
299 /// tracking, we missed an Up event - reset state.
300 /// 2. Calculate total movement from down position.
301 /// 3. If total movement exceeds `DRAG_THRESHOLD` (8px), start dragging.
302 /// 4. While dragging, apply scroll delta and consume events.
303 ///
304 /// Returns `true` if event should be consumed (we're actively dragging).
305 fn on_move(&self, position: Point, buttons: PointerButtons) -> bool {
306 let mut gs = self.gesture_state.borrow_mut();
307
308 // Safety: detect missed Up events (hit test delivered to wrong target)
309 if !buttons.contains(PointerButton::Primary) && gs.drag_down_position.is_some() {
310 gs.drag_down_position = None;
311 gs.last_position = None;
312 gs.is_dragging = false;
313 gs.gesture_start_time = None;
314 gs.last_velocity_sample_ms = None;
315 gs.velocity_tracker.reset();
316 return false;
317 }
318
319 let Some(down_pos) = gs.drag_down_position else {
320 return false;
321 };
322
323 let Some(last_pos) = gs.last_position else {
324 gs.last_position = Some(position);
325 return false;
326 };
327
328 let total_delta = calculate_total_delta(down_pos, position, self.is_vertical);
329 let incremental_delta = calculate_incremental_delta(last_pos, position, self.is_vertical);
330
331 // Threshold check: start dragging only after moving 8px from down position.
332 if !gs.is_dragging && total_delta.abs() > DRAG_THRESHOLD {
333 gs.is_dragging = true;
334 }
335
336 gs.last_position = Some(position);
337
338 // Track velocity for fling
339 if let Some(start_time) = gs.gesture_start_time {
340 let elapsed_ms = start_time.elapsed().as_millis() as i64;
341 let pos = if self.is_vertical {
342 position.y
343 } else {
344 position.x
345 };
346 // Keep sample times strictly increasing so velocity stays stable when
347 // multiple move events land in the same millisecond.
348 let sample_ms = match gs.last_velocity_sample_ms {
349 Some(last_sample_ms) => {
350 let mut sample_ms = if elapsed_ms <= last_sample_ms {
351 last_sample_ms + 1
352 } else {
353 elapsed_ms
354 };
355 // Clamp large processing gaps so frame stalls don't erase fling velocity.
356 if sample_ms - last_sample_ms > ASSUME_STOPPED_MS {
357 sample_ms = last_sample_ms + ASSUME_STOPPED_MS;
358 }
359 sample_ms
360 }
361 None => elapsed_ms,
362 };
363 gs.velocity_tracker.add_data_point(sample_ms, pos);
364 gs.last_velocity_sample_ms = Some(sample_ms);
365 }
366
367 if gs.is_dragging {
368 drop(gs); // Release borrow before calling scroll target
369 let delta = if self.reverse_scrolling {
370 -incremental_delta
371 } else {
372 incremental_delta
373 };
374 let _ = self.scroll_target.apply_delta(delta);
375 self.scroll_target.invalidate();
376 true // Consume event while dragging
377 } else {
378 false
379 }
380 }
381
382 /// Handles pointer up event.
383 ///
384 /// Cleans up drag state. If we were actively dragging, calculates fling
385 /// velocity and starts fling animation if velocity is above threshold.
386 ///
387 /// Returns `true` if we were dragging (event should be consumed).
388 fn finish_gesture(&self, allow_fling: bool) -> bool {
389 let (was_dragging, velocity, start_fling, existing_fling) = {
390 let mut gs = self.gesture_state.borrow_mut();
391 let was_dragging = gs.is_dragging;
392 let mut velocity = 0.0;
393
394 if allow_fling && was_dragging && gs.gesture_start_time.is_some() {
395 velocity = gs
396 .velocity_tracker
397 .calculate_velocity_with_max(MAX_FLING_VELOCITY);
398 }
399
400 let start_fling = allow_fling && was_dragging && velocity.abs() > MIN_FLING_VELOCITY;
401 let existing_fling = if start_fling {
402 gs.fling_animation.take()
403 } else {
404 None
405 };
406
407 gs.drag_down_position = None;
408 gs.last_position = None;
409 gs.is_dragging = false;
410 gs.gesture_start_time = None;
411 gs.last_velocity_sample_ms = None;
412
413 (was_dragging, velocity, start_fling, existing_fling)
414 };
415
416 // Always record velocity for test accessibility (even if below fling threshold)
417 if allow_fling && was_dragging {
418 set_last_fling_velocity(velocity);
419 }
420
421 // Start fling animation if velocity is significant
422 if start_fling {
423 if let Some(old_fling) = existing_fling {
424 old_fling.cancel();
425 }
426
427 // Get runtime handle for frame callbacks
428 if let Some(runtime) = current_runtime_handle() {
429 let scroll_target = self.scroll_target.clone();
430 let reverse = self.reverse_scrolling;
431 let fling = FlingAnimation::new(runtime);
432
433 // Get current scroll position for fling start
434 let initial_value = scroll_target.current_offset();
435
436 // Convert gesture velocity to scroll velocity.
437 let adjusted_velocity = if reverse { -velocity } else { velocity };
438 let fling_velocity = -adjusted_velocity;
439
440 let scroll_target_for_fling = scroll_target.clone();
441 let scroll_target_for_end = scroll_target.clone();
442
443 fling.start_fling(
444 initial_value,
445 fling_velocity,
446 current_density(),
447 move |delta| {
448 // Apply scroll delta during fling, return consumed amount
449 let consumed = scroll_target_for_fling.apply_fling_delta(delta);
450 scroll_target_for_fling.invalidate();
451 consumed
452 },
453 move || {
454 // Animation complete - invalidate to ensure final render
455 scroll_target_for_end.invalidate();
456 },
457 );
458
459 let mut gs = self.gesture_state.borrow_mut();
460 gs.fling_animation = Some(fling);
461 }
462 }
463
464 was_dragging
465 }
466
467 /// Handles pointer up event.
468 ///
469 /// Cleans up drag state. If we were actively dragging, calculates fling
470 /// velocity and starts fling animation if velocity is above threshold.
471 ///
472 /// Returns `true` if we were dragging (event should be consumed).
473 fn on_up(&self) -> bool {
474 self.finish_gesture(true)
475 }
476
477 /// Handles pointer cancel event.
478 ///
479 /// Cleans up state without starting a fling. Returns `true` if we were dragging.
480 fn on_cancel(&self) -> bool {
481 self.finish_gesture(false)
482 }
483}
484
485// ============================================================================
486// Modifier Extensions
487// ============================================================================
488
489impl Modifier {
490 /// Creates a horizontally scrollable modifier.
491 ///
492 /// # Arguments
493 /// * `state` - The ScrollState to control scroll position
494 /// * `reverse_scrolling` - If true, reverses the scroll direction in layout.
495 /// Note: This affects how scroll offset is applied to content (via `ScrollNode`),
496 /// NOT the drag direction. Drag gestures always follow natural touch semantics:
497 /// drag right = scroll left (content moves right under finger).
498 ///
499 /// # Example
500 /// ```text
501 /// let scroll_state = ScrollState::new(0.0);
502 /// Row(
503 /// Modifier::empty().horizontal_scroll(scroll_state, false),
504 /// // ... content
505 /// );
506 /// ```
507 pub fn horizontal_scroll(self, state: ScrollState, reverse_scrolling: bool) -> Self {
508 self.then(scroll_impl(state, false, reverse_scrolling))
509 }
510
511 /// Creates a vertically scrollable modifier.
512 ///
513 /// # Arguments
514 /// * `state` - The ScrollState to control scroll position
515 /// * `reverse_scrolling` - If true, reverses the scroll direction in layout.
516 /// Note: This affects how scroll offset is applied to content (via `ScrollNode`),
517 /// NOT the drag direction. Drag gestures always follow natural touch semantics:
518 /// drag down = scroll up (content moves down under finger).
519 pub fn vertical_scroll(self, state: ScrollState, reverse_scrolling: bool) -> Self {
520 self.then(scroll_impl(state, true, reverse_scrolling))
521 }
522}
523
524/// Internal implementation for scroll modifiers.
525///
526/// Creates a combined modifier consisting of:
527/// 1. Pointer input handler (for gesture detection)
528/// 2. Layout modifier (for applying scroll offset)
529///
530/// The pointer input is added FIRST so it appears earlier in the modifier
531/// chain, allowing it to intercept events before layout-related handlers.
532fn scroll_impl(state: ScrollState, is_vertical: bool, reverse_scrolling: bool) -> Modifier {
533 // Create local gesture state - each scroll modifier instance is independent
534 let gesture_state = Rc::new(RefCell::new(ScrollGestureState::default()));
535
536 // Set up pointer input handler
537 let scroll_state = state.clone();
538 let key = (state.id(), is_vertical);
539 let pointer_input = Modifier::empty().pointer_input(key, move |scope| {
540 // Create detector inside the async closure to capture the cloned state
541 let detector = ScrollGestureDetector::new(
542 gesture_state.clone(),
543 scroll_state.clone(),
544 is_vertical,
545 false, // ScrollState handles reversing in layout, not input
546 );
547
548 async move {
549 scope
550 .await_pointer_event_scope(|await_scope| async move {
551 // Main event loop - processes events until scope is cancelled
552 loop {
553 let event = await_scope.await_pointer_event().await;
554
555 // Delegate to detector's lifecycle methods
556 let should_consume = match event.kind {
557 PointerEventKind::Down => detector.on_down(event.position),
558 PointerEventKind::Move => {
559 detector.on_move(event.position, event.buttons)
560 }
561 PointerEventKind::Up => detector.on_up(),
562 PointerEventKind::Cancel => detector.on_cancel(),
563 };
564
565 if should_consume {
566 event.consume();
567 }
568 }
569 })
570 .await;
571 }
572 });
573
574 // Create layout modifier for applying scroll offset to content
575 let element = ScrollElement::new(state.clone(), is_vertical, reverse_scrolling);
576 let layout_modifier =
577 Modifier::with_element(element).with_inspector_metadata(inspector_metadata(
578 if is_vertical {
579 "verticalScroll"
580 } else {
581 "horizontalScroll"
582 },
583 move |info| {
584 info.add_property("isVertical", is_vertical.to_string());
585 info.add_property("reverseScrolling", reverse_scrolling.to_string());
586 },
587 ));
588
589 // Combine: pointer input THEN layout modifier
590 pointer_input.then(layout_modifier)
591}
592
593// ============================================================================
594// Lazy Scroll Support for LazyListState
595// ============================================================================
596
597use cranpose_foundation::lazy::LazyListState;
598
599impl Modifier {
600 /// Creates a vertically scrollable modifier for lazy lists.
601 ///
602 /// This connects pointer gestures to LazyListState for scroll handling.
603 /// Unlike regular vertical_scroll, no layout offset is applied here
604 /// since LazyListState manages item positioning internally.
605 /// Creates a vertically scrollable modifier for lazy lists.
606 ///
607 /// This connects pointer gestures to LazyListState for scroll handling.
608 /// Unlike regular vertical_scroll, no layout offset is applied here
609 /// since LazyListState manages item positioning internally.
610 pub fn lazy_vertical_scroll(self, state: LazyListState, reverse_scrolling: bool) -> Self {
611 self.then(lazy_scroll_impl(state, true, reverse_scrolling))
612 }
613
614 /// Creates a horizontally scrollable modifier for lazy lists.
615 pub fn lazy_horizontal_scroll(self, state: LazyListState, reverse_scrolling: bool) -> Self {
616 self.then(lazy_scroll_impl(state, false, reverse_scrolling))
617 }
618}
619
620/// Internal implementation for lazy scroll modifiers.
621fn lazy_scroll_impl(state: LazyListState, is_vertical: bool, reverse_scrolling: bool) -> Modifier {
622 let gesture_state = Rc::new(RefCell::new(ScrollGestureState::default()));
623 let list_state = state;
624
625 // Note: Layout invalidation callback is registered in LazyColumnImpl/LazyRowImpl
626 // after the node is created, using schedule_layout_repass(node_id) for O(subtree)
627 // performance instead of request_layout_invalidation() which is O(entire app).
628
629 // Use a unique key per LazyListState
630 let state_id = std::ptr::addr_of!(*state.inner_ptr()) as usize;
631 let key = (state_id, is_vertical, reverse_scrolling);
632
633 Modifier::empty().pointer_input(key, move |scope| {
634 // Use the same generic detector with LazyListState
635 let detector = ScrollGestureDetector::new(
636 gesture_state.clone(),
637 list_state,
638 is_vertical,
639 reverse_scrolling,
640 );
641
642 async move {
643 scope
644 .await_pointer_event_scope(|await_scope| async move {
645 loop {
646 let event = await_scope.await_pointer_event().await;
647
648 // Delegate to detector's lifecycle methods
649 let should_consume = match event.kind {
650 PointerEventKind::Down => detector.on_down(event.position),
651 PointerEventKind::Move => {
652 detector.on_move(event.position, event.buttons)
653 }
654 PointerEventKind::Up => detector.on_up(),
655 PointerEventKind::Cancel => detector.on_cancel(),
656 };
657
658 if should_consume {
659 event.consume();
660 }
661 }
662 })
663 .await;
664 }
665 })
666}