Skip to main content

cranpose_foundation/lazy/
lazy_list_state.rs

1//! Lazy list state management.
2//!
3//! Provides [`LazyListState`] for controlling and observing lazy list scroll position.
4//!
5//! Design follows Jetpack Compose's LazyListState/LazyListScrollPosition pattern:
6//! - Reactive properties are backed by `MutableState<T>`:
7//!   - `first_visible_item_index`, `first_visible_item_scroll_offset`
8//!   - `can_scroll_forward`, `can_scroll_backward`
9//!   - `stats` (items_in_use, items_in_pool)
10//! - Non-reactive internals (caches, callbacks, prefetch, diagnostic counters) are in inner state
11
12use std::cell::RefCell;
13use std::rc::Rc;
14
15use cranpose_core::{MutableState, NodeId};
16use cranpose_macros::composable;
17
18use super::diagnostics;
19use super::nearest_range::NearestRangeState;
20use super::prefetch::{PrefetchScheduler, PrefetchStrategy};
21
22const MAX_PENDING_SCROLL_DELTA: f32 = 2000.0;
23const ITEM_SIZE_CACHE_CAPACITY: usize = 100;
24
25#[derive(Clone, Copy, Debug, PartialEq)]
26pub(crate) struct LazyListMeasureStateSnapshot {
27    pub(crate) first_visible_item_index: usize,
28    pub(crate) first_visible_item_scroll_offset: f32,
29    pub(crate) pending_scroll_delta: f32,
30    pub(crate) pending_scroll_to: Option<(usize, f32)>,
31    pub(crate) average_item_size: f32,
32}
33
34/// Statistics about lazy layout item lifecycle.
35///
36/// Used for testing and debugging virtualization behavior.
37#[derive(Clone, Debug, Default, PartialEq)]
38pub struct LazyLayoutStats {
39    /// Number of items currently composed and visible.
40    pub items_in_use: usize,
41
42    /// Number of items in the recycle pool (available for reuse).
43    pub items_in_pool: usize,
44
45    /// Total number of items that have been composed.
46    pub total_composed: usize,
47
48    /// Number of items that were reused instead of newly composed.
49    pub reuse_count: usize,
50}
51
52// ─────────────────────────────────────────────────────────────────────────────
53// LazyListScrollPosition - Reactive scroll position (matches JC design)
54// ─────────────────────────────────────────────────────────────────────────────
55
56/// Contains the current scroll position represented by the first visible item
57/// index and the first visible item scroll offset.
58///
59/// This is a `Copy` type that holds reactive state. Reading `index` or `scroll_offset`
60/// during composition creates a snapshot dependency for automatic recomposition.
61///
62/// Matches Jetpack Compose's `LazyListScrollPosition` design.
63#[derive(Clone, Copy)]
64pub struct LazyListScrollPosition {
65    /// The index of the first visible item (reactive).
66    index: MutableState<usize>,
67    /// The scroll offset of the first visible item (reactive).
68    scroll_offset: MutableState<f32>,
69    /// Non-reactive internal state (key tracking, nearest range).
70    inner: MutableState<Rc<RefCell<ScrollPositionInner>>>,
71}
72
73/// Non-reactive internal state for scroll position.
74struct ScrollPositionInner {
75    /// The last known key of the item at index position.
76    /// Used for scroll position stability across data changes.
77    last_known_first_item_key: Option<u64>,
78    /// Sliding window range for optimized key lookups.
79    nearest_range_state: NearestRangeState,
80}
81
82impl LazyListScrollPosition {
83    fn is_alive(&self) -> bool {
84        self.index.is_alive() && self.scroll_offset.is_alive() && self.inner.is_alive()
85    }
86
87    fn current_index(&self) -> usize {
88        self.index.try_value().unwrap_or(0)
89    }
90
91    fn current_scroll_offset(&self) -> f32 {
92        self.scroll_offset.try_value().unwrap_or(0.0)
93    }
94
95    /// Returns the index of the first visible item (reactive read).
96    pub fn index(&self) -> usize {
97        if !self.index.is_alive() {
98            return 0;
99        }
100        self.index.get()
101    }
102
103    /// Returns the scroll offset of the first visible item (reactive read).
104    pub fn scroll_offset(&self) -> f32 {
105        if !self.scroll_offset.is_alive() {
106            return 0.0;
107        }
108        self.scroll_offset.get()
109    }
110
111    /// Updates the scroll position from a measurement result.
112    ///
113    /// Called after layout measurement to update the reactive scroll position.
114    /// This stores the key for scroll position stability and updates the nearest range.
115    pub(crate) fn update_from_measure_result(
116        &self,
117        first_visible_index: usize,
118        first_visible_scroll_offset: f32,
119        first_visible_item_key: Option<u64>,
120    ) {
121        if !self.is_alive() {
122            return;
123        }
124        // Update internal state (key tracking, nearest range)
125        self.inner.with(|rc| {
126            let mut inner = rc.borrow_mut();
127            inner.last_known_first_item_key = first_visible_item_key;
128            inner.nearest_range_state.update(first_visible_index);
129        });
130
131        // Only update reactive state if value changed (avoids recomposition loops)
132        let old_index = self.index.get_non_reactive();
133        if old_index != first_visible_index {
134            self.index.set(first_visible_index);
135        }
136        let old_offset = self.scroll_offset.get_non_reactive();
137        if (old_offset - first_visible_scroll_offset).abs() > 0.001 {
138            self.scroll_offset.set(first_visible_scroll_offset);
139        }
140    }
141
142    /// Requests a new position and clears the last known key.
143    /// Used for programmatic scrolls (scroll_to_item).
144    pub(crate) fn request_position_and_forget_last_known_key(
145        &self,
146        index: usize,
147        scroll_offset: f32,
148    ) {
149        if !self.is_alive() {
150            return;
151        }
152        // Update reactive state
153        if self.index.get_non_reactive() != index {
154            self.index.set(index);
155        }
156        if (self.scroll_offset.get_non_reactive() - scroll_offset).abs() > 0.001 {
157            self.scroll_offset.set(scroll_offset);
158        }
159        // Clear key and update nearest range
160        self.inner.with(|rc| {
161            let mut inner = rc.borrow_mut();
162            inner.last_known_first_item_key = None;
163            inner.nearest_range_state.update(index);
164        });
165    }
166
167    /// Adjusts scroll position if the first visible item was moved.
168    /// Returns the adjusted index.
169    pub(crate) fn update_if_first_item_moved<F>(
170        &self,
171        new_item_count: usize,
172        find_by_key: F,
173    ) -> usize
174    where
175        F: Fn(u64) -> Option<usize>,
176    {
177        if !self.index.is_alive() || !self.inner.is_alive() {
178            return 0;
179        }
180
181        let current_index = self.index.get_non_reactive();
182        let last_key = self
183            .inner
184            .try_with(|rc| rc.borrow().last_known_first_item_key)
185            .flatten();
186
187        let new_index = match last_key {
188            None => current_index.min(new_item_count.saturating_sub(1)),
189            Some(key) => find_by_key(key)
190                .unwrap_or_else(|| current_index.min(new_item_count.saturating_sub(1))),
191        };
192
193        if current_index != new_index {
194            self.index.set(new_index);
195            self.inner.with(|rc| {
196                rc.borrow_mut().nearest_range_state.update(new_index);
197            });
198        }
199        new_index
200    }
201
202    /// Returns the nearest range for optimized key lookups.
203    pub fn nearest_range(&self) -> std::ops::Range<usize> {
204        self.inner
205            .try_with(|rc| rc.borrow().nearest_range_state.range())
206            .unwrap_or(0..0)
207    }
208}
209
210// ─────────────────────────────────────────────────────────────────────────────
211// LazyListState - Main state object
212// ─────────────────────────────────────────────────────────────────────────────
213
214/// State object for lazy list scroll position tracking.
215///
216/// Holds the current scroll position and provides methods to programmatically
217/// control scrolling. Create with [`remember_lazy_list_state()`] in composition.
218///
219/// This type is `Copy`, so it can be passed to multiple closures without explicit `.clone()` calls.
220///
221/// # Reactive Properties (read during composition triggers recomposition)
222/// - `first_visible_item_index()` - index of first visible item
223/// - `first_visible_item_scroll_offset()` - scroll offset within first item
224/// - `can_scroll_forward()` - whether more items exist below/right
225/// - `can_scroll_backward()` - whether more items exist above/left
226/// - `stats()` - lifecycle statistics (`items_in_use`, `items_in_pool`)
227///
228/// # Non-Reactive Properties
229/// - `stats().total_composed` - total items composed (diagnostic)
230/// - `stats().reuse_count` - items reused from pool (diagnostic)
231/// - `layout_info()` - detailed layout information
232///
233/// # Example
234///
235/// ```rust,ignore
236/// let state = remember_lazy_list_state();
237///
238/// // Scroll to item 50
239/// state.scroll_to_item(50, 0.0);
240///
241/// // Get current visible item (reactive read)
242/// println!("First visible: {}", state.first_visible_item_index());
243/// ```
244#[derive(Clone, Copy)]
245pub struct LazyListState {
246    /// Scroll position with reactive index and offset (matches JC design).
247    scroll_position: LazyListScrollPosition,
248    /// Whether we can scroll forward (reactive, matches JC).
249    can_scroll_forward_state: MutableState<bool>,
250    /// Whether we can scroll backward (reactive, matches JC).
251    can_scroll_backward_state: MutableState<bool>,
252    /// Reactive stats state for triggering recomposition when stats change.
253    /// Only contains items_in_use and items_in_pool (diagnostic counters are in inner).
254    stats_state: MutableState<LazyLayoutStats>,
255    /// Non-reactive internal state (caches, callbacks, prefetch, layout info).
256    inner: MutableState<Rc<RefCell<LazyListStateInner>>>,
257}
258
259// Implement PartialEq by comparing the stable inner state handle identity.
260// This allows LazyListState to be used as a composable function parameter
261// without dereferencing released state cells during parameter updates.
262impl PartialEq for LazyListState {
263    fn eq(&self, other: &Self) -> bool {
264        self.inner == other.inner
265    }
266}
267
268/// Non-reactive internal state for LazyListState.
269struct LazyListStateInner {
270    /// Scroll delta to be consumed in the next layout pass.
271    scroll_to_be_consumed: f32,
272
273    /// Pending scroll-to-item request.
274    pending_scroll_to_index: Option<(usize, f32)>,
275
276    /// Layout info from the last measure pass.
277    layout_info: LazyListLayoutInfo,
278
279    /// Invalidation callbacks.
280    invalidate_callbacks: Vec<(u64, Rc<dyn Fn()>)>,
281    next_callback_id: u64,
282
283    /// Registered layout invalidation callback id, if any.
284    /// Used to prevent duplicate registrations on recomposition and to
285    /// allow clean re-registration after a branch is disposed and restored.
286    layout_invalidation_callback_id: Option<u64>,
287    layout_invalidation_node_id: Option<NodeId>,
288
289    /// Diagnostic counters (non-reactive - not typically displayed in UI).
290    total_composed: usize,
291    reuse_count: usize,
292
293    /// Cache of recently measured item sizes (index -> main_axis_size).
294    item_size_cache: std::collections::HashMap<usize, f32>,
295    item_size_lru: std::collections::VecDeque<usize>,
296
297    /// Running average of measured item sizes for estimation.
298    average_item_size: f32,
299    total_measured_items: usize,
300    next_measure_cycle_id: u64,
301    next_item_measure_pass_id: u64,
302
303    /// Prefetch scheduler for pre-composing items.
304    prefetch_scheduler: PrefetchScheduler,
305
306    /// Prefetch strategy configuration.
307    prefetch_strategy: PrefetchStrategy,
308
309    /// Last scroll delta direction for prefetch.
310    last_scroll_direction: f32,
311}
312
313/// Creates a remembered [`LazyListState`] with default initial position.
314///
315/// This is the recommended way to create a `LazyListState` in composition.
316/// The returned state is `Copy` and can be passed to multiple closures without `.clone()`.
317///
318/// # Example
319///
320/// ```rust,ignore
321/// let list_state = remember_lazy_list_state();
322///
323/// // Pass to multiple closures - no .clone() needed!
324/// LazyColumn(modifier, list_state, spec, content);
325/// Button(move || list_state.scroll_to_item(0, 0.0));
326/// ```
327#[composable]
328pub fn remember_lazy_list_state() -> LazyListState {
329    remember_lazy_list_state_with_position(0, 0.0)
330}
331
332/// Creates a remembered [`LazyListState`] with the specified initial position.
333///
334/// The returned state is `Copy` and can be passed to multiple closures without `.clone()`.
335#[composable]
336pub fn remember_lazy_list_state_with_position(
337    initial_first_visible_item_index: usize,
338    initial_first_visible_item_scroll_offset: f32,
339) -> LazyListState {
340    // Create scroll position with reactive fields (matches JC LazyListScrollPosition)
341    let scroll_position = LazyListScrollPosition {
342        index: cranpose_core::useState(|| initial_first_visible_item_index),
343        scroll_offset: cranpose_core::useState(|| initial_first_visible_item_scroll_offset),
344        inner: cranpose_core::useState(|| {
345            Rc::new(RefCell::new(ScrollPositionInner {
346                last_known_first_item_key: None,
347                nearest_range_state: NearestRangeState::new(initial_first_visible_item_index),
348            }))
349        }),
350    };
351
352    // Non-reactive internal state
353    let inner = cranpose_core::useState(|| {
354        Rc::new(RefCell::new(LazyListStateInner {
355            scroll_to_be_consumed: 0.0,
356            pending_scroll_to_index: None,
357            layout_info: LazyListLayoutInfo::default(),
358            invalidate_callbacks: Vec::new(),
359            next_callback_id: 1,
360            layout_invalidation_callback_id: None,
361            layout_invalidation_node_id: None,
362            total_composed: 0,
363            reuse_count: 0,
364            item_size_cache: std::collections::HashMap::new(),
365            item_size_lru: std::collections::VecDeque::new(),
366            average_item_size: super::DEFAULT_ITEM_SIZE_ESTIMATE,
367            total_measured_items: 0,
368            next_measure_cycle_id: 1,
369            next_item_measure_pass_id: 1,
370            prefetch_scheduler: PrefetchScheduler::new(),
371            prefetch_strategy: PrefetchStrategy::default(),
372            last_scroll_direction: 0.0,
373        }))
374    });
375
376    // Reactive state
377    let can_scroll_forward_state = cranpose_core::useState(|| false);
378    let can_scroll_backward_state = cranpose_core::useState(|| false);
379    let stats_state = cranpose_core::useState(LazyLayoutStats::default);
380
381    LazyListState {
382        scroll_position,
383        can_scroll_forward_state,
384        can_scroll_backward_state,
385        stats_state,
386        inner,
387    }
388}
389
390impl LazyListState {
391    /// Returns a stable identity pointer for the live inner state allocation.
392    ///
393    /// The pointer comes from the `Rc` stored inside `inner`, so it remains stable for the
394    /// lifetime of a live `LazyListState` and can be used as a composition identity key.
395    pub fn inner_ptr(&self) -> *const () {
396        self.inner
397            .try_with(|rc| Rc::as_ptr(rc) as *const ())
398            .unwrap_or(std::ptr::null())
399    }
400
401    /// Returns the index of the first visible item.
402    ///
403    /// When called during composition, this creates a reactive subscription
404    /// so that changes to the index will trigger recomposition.
405    pub fn first_visible_item_index(&self) -> usize {
406        // Delegate to scroll_position (reactive read)
407        self.scroll_position.index()
408    }
409
410    /// Returns the scroll offset of the first visible item.
411    ///
412    /// This is the amount the first item is scrolled off-screen (positive = scrolled up/left).
413    /// When called during composition, this creates a reactive subscription
414    /// so that changes to the offset will trigger recomposition.
415    pub fn first_visible_item_scroll_offset(&self) -> f32 {
416        // Delegate to scroll_position (reactive read)
417        self.scroll_position.scroll_offset()
418    }
419
420    /// Returns whether the list is positioned away from its origin without creating
421    /// a reactive subscription.
422    pub fn is_scrolled_non_reactive(&self) -> bool {
423        self.scroll_position.current_index() > 0
424            || self.scroll_position.current_scroll_offset().abs() > 0.001
425            || self
426                .inner
427                .try_with(|rc| {
428                    let inner = rc.borrow();
429                    inner.scroll_to_be_consumed.abs() > 0.001
430                        || inner
431                            .pending_scroll_to_index
432                            .is_some_and(|(index, offset)| index > 0 || offset.abs() > 0.001)
433                })
434                .unwrap_or(false)
435    }
436
437    /// Returns the layout info from the last measure pass.
438    pub fn layout_info(&self) -> LazyListLayoutInfo {
439        self.inner
440            .try_with(|rc| rc.borrow().layout_info.clone())
441            .unwrap_or_default()
442    }
443
444    /// Returns the current item lifecycle statistics.
445    ///
446    /// When called during composition, this creates a reactive subscription
447    /// so that changes to `items_in_use` or `items_in_pool` will trigger recomposition.
448    /// The `total_composed` and `reuse_count` fields are diagnostic and non-reactive.
449    pub fn stats(&self) -> LazyLayoutStats {
450        if !self.stats_state.is_alive() || !self.inner.is_alive() {
451            return LazyLayoutStats::default();
452        }
453        // Read reactive state (creates subscription) and combine with non-reactive counters
454        let reactive = self.stats_state.get();
455        let (total_composed, reuse_count) = self.inner.with(|rc| {
456            let inner = rc.borrow();
457            (inner.total_composed, inner.reuse_count)
458        });
459        LazyLayoutStats {
460            items_in_use: reactive.items_in_use,
461            items_in_pool: reactive.items_in_pool,
462            total_composed,
463            reuse_count,
464        }
465    }
466
467    /// Updates the item lifecycle statistics.
468    ///
469    /// Called by the layout measurement after updating slot pools.
470    /// Triggers recomposition if `items_in_use` or `items_in_pool` changed.
471    pub fn update_stats(&self, items_in_use: usize, items_in_pool: usize) {
472        if !self.stats_state.is_alive() || !self.inner.is_alive() {
473            return;
474        }
475
476        let current = self.stats_state.get_non_reactive();
477
478        // Hysteresis: only trigger reactive update when items_in_use INCREASES
479        // or DECREASES by more than 1. This prevents the 5→4→5→4 oscillation
480        // that happens at boundary conditions during slow upward scroll.
481        //
482        // Rationale:
483        // - Items becoming visible (increase): user should see count update immediately
484        // - Items going off-screen by 1: minor fluctuation, wait for significant change
485        // - Items going off-screen by 2+: significant change, update immediately
486        let should_update_reactive = if items_in_use > current.items_in_use {
487            // Increase: always update (new items visible)
488            true
489        } else if items_in_use < current.items_in_use {
490            // Decrease: only update if by more than 1 (prevents oscillation)
491            current.items_in_use - items_in_use > 1
492        } else {
493            false
494        };
495
496        if should_update_reactive {
497            self.stats_state.set(LazyLayoutStats {
498                items_in_use,
499                items_in_pool,
500                ..current
501            });
502        }
503        // Note: pool-only changes are intentionally not committed to reactive state
504        // to prevent the 5→4→5 oscillation that caused slow upward scroll hang.
505    }
506
507    /// Records that an item was composed (either new or reused).
508    ///
509    /// This updates diagnostic counters in non-reactive state.
510    /// Does NOT trigger recomposition.
511    pub fn record_composition(&self, was_reused: bool) {
512        if !self.inner.is_alive() {
513            return;
514        }
515        self.inner.with(|rc| {
516            let mut inner = rc.borrow_mut();
517            inner.total_composed += 1;
518            if was_reused {
519                inner.reuse_count += 1;
520            }
521        });
522    }
523
524    /// Records the raw scroll delta for prefetch calculations.
525    ///
526    /// Cranpose lazy lists use gesture-style deltas:
527    /// - Negative delta = scrolling forward (content moves up)
528    /// - Positive delta = scrolling backward (content moves down)
529    pub fn record_scroll_direction(&self, delta: f32) {
530        if delta.abs() > 0.001 {
531            if !self.inner.is_alive() {
532                return;
533            }
534            self.inner.with(|rc| {
535                rc.borrow_mut().last_scroll_direction = -delta.signum();
536            });
537        }
538    }
539
540    /// Updates the prefetch queue based on current visible items.
541    /// Should be called after measurement to queue items for pre-composition.
542    pub fn update_prefetch_queue(
543        &self,
544        first_visible_index: usize,
545        last_visible_index: usize,
546        total_items: usize,
547    ) {
548        if !self.inner.is_alive() {
549            return;
550        }
551        self.inner.with(|rc| {
552            let mut inner = rc.borrow_mut();
553            let direction = inner.last_scroll_direction;
554            let strategy = inner.prefetch_strategy.clone();
555            inner.prefetch_scheduler.update(
556                first_visible_index,
557                last_visible_index,
558                total_items,
559                direction,
560                &strategy,
561            );
562        });
563    }
564
565    /// Returns the indices that should be prefetched.
566    /// Consumes the prefetch queue.
567    pub fn take_prefetch_indices(&self) -> Vec<usize> {
568        self.inner
569            .try_with(|rc| {
570                let mut inner = rc.borrow_mut();
571                let mut indices = Vec::new();
572                while let Some(idx) = inner.prefetch_scheduler.next_prefetch() {
573                    indices.push(idx);
574                }
575                indices
576            })
577            .unwrap_or_default()
578    }
579
580    /// Scrolls to the specified item index.
581    ///
582    /// # Arguments
583    /// * `index` - The index of the item to scroll to
584    /// * `scroll_offset` - Additional offset within the item (default 0)
585    pub fn scroll_to_item(&self, index: usize, scroll_offset: f32) {
586        if !self.inner.is_alive() {
587            return;
588        }
589        if diagnostics::telemetry_enabled() {
590            log::warn!(
591                "[lazy-measure-telemetry] scroll_to_item request index={} offset={:.2}",
592                index,
593                scroll_offset
594            );
595        }
596        // Store pending scroll request
597        self.inner.with(|rc| {
598            rc.borrow_mut().pending_scroll_to_index = Some((index, scroll_offset));
599        });
600
601        // Delegate to scroll_position which handles reactive updates and key clearing
602        self.scroll_position
603            .request_position_and_forget_last_known_key(index, scroll_offset);
604
605        self.invalidate();
606    }
607
608    /// Dispatches a raw scroll delta.
609    ///
610    /// Returns the amount of scroll actually consumed.
611    ///
612    /// This triggers layout invalidation via registered callbacks. The callbacks are
613    /// registered by LazyColumnImpl/LazyRowImpl with schedule_layout_repass(node_id),
614    /// which provides O(subtree) performance instead of O(entire app).
615    pub fn dispatch_scroll_delta(&self, delta: f32) -> f32 {
616        // Guard against stale handles: fling animation frame callbacks can fire
617        // after a tab switch disposes the composition group that owns this state.
618        if !self.inner.is_alive() {
619            return 0.0;
620        }
621        let has_scroll_bounds = self
622            .inner
623            .with(|rc| rc.borrow().layout_info.total_items_count > 0);
624        let pushing_forward = delta < -0.001;
625        let pushing_backward = delta > 0.001;
626        let can_scroll_forward = self.can_scroll_forward_state.is_alive()
627            && self.can_scroll_forward_state.get_non_reactive();
628        let can_scroll_backward = self.can_scroll_backward_state.is_alive()
629            && self.can_scroll_backward_state.get_non_reactive();
630        let blocked_by_bounds = has_scroll_bounds
631            && ((pushing_forward && !can_scroll_forward)
632                || (pushing_backward && !can_scroll_backward));
633
634        if blocked_by_bounds {
635            let should_invalidate = self.inner.with(|rc| {
636                let mut inner = rc.borrow_mut();
637                let pending_before = inner.scroll_to_be_consumed;
638                // If we're already at an edge, clear stale backlog in the same blocked direction.
639                if pending_before.abs() > 0.001 && pending_before.signum() == delta.signum() {
640                    inner.scroll_to_be_consumed = 0.0;
641                }
642                if diagnostics::telemetry_enabled() {
643                    log::warn!(
644                        "[lazy-measure-telemetry] dispatch_scroll_delta blocked_by_bounds delta={:.2} pending_before={:.2} pending_after={:.2}",
645                        delta,
646                        pending_before,
647                        inner.scroll_to_be_consumed
648                    );
649                }
650                (inner.scroll_to_be_consumed - pending_before).abs() > 0.001
651            });
652            if should_invalidate {
653                self.invalidate();
654            }
655            return 0.0;
656        }
657
658        let should_invalidate = self.inner.with(|rc| {
659            let mut inner = rc.borrow_mut();
660            let pending_before = inner.scroll_to_be_consumed;
661            let pending = inner.scroll_to_be_consumed;
662            let reverse_input = pending.abs() > 0.001
663                && delta.abs() > 0.001
664                && pending.signum() != delta.signum();
665            if reverse_input {
666                if diagnostics::telemetry_enabled() {
667                    log::warn!(
668                        "[lazy-measure-telemetry] dispatch_scroll_delta direction_change pending={:.2} new_delta={:.2}",
669                        pending,
670                        delta
671                    );
672                }
673                // When gesture direction reverses, stale unconsumed backlog from the previous
674                // direction causes "snap back" behavior on slow frames. Keep only the latest
675                // direction intent.
676                inner.scroll_to_be_consumed = delta;
677            } else {
678                inner.scroll_to_be_consumed += delta;
679            }
680            inner.scroll_to_be_consumed = inner
681                .scroll_to_be_consumed
682                .clamp(-MAX_PENDING_SCROLL_DELTA, MAX_PENDING_SCROLL_DELTA);
683            if diagnostics::telemetry_enabled() {
684                log::warn!(
685                    "[lazy-measure-telemetry] dispatch_scroll_delta delta={:.2} pending={:.2}",
686                    delta,
687                    inner.scroll_to_be_consumed
688                );
689            }
690            (inner.scroll_to_be_consumed - pending_before).abs() > 0.001
691        });
692        if should_invalidate {
693            self.invalidate();
694        }
695        delta // Will be adjusted during layout
696    }
697
698    /// Peeks at the pending scroll delta without consuming it.
699    ///
700    /// Used for direction inference before measurement consumes the delta.
701    /// This is more accurate than comparing first visible index, especially for:
702    /// - Scrolling within the same item (partial scroll)
703    /// - Variable height items where scroll offset changes without index change
704    pub fn peek_scroll_delta(&self) -> f32 {
705        self.inner
706            .try_with(|rc| rc.borrow().scroll_to_be_consumed)
707            .unwrap_or(0.0)
708    }
709
710    pub(crate) fn begin_measure_pass(&self) -> LazyListMeasureStateSnapshot {
711        let (pending_scroll_delta, pending_scroll_to, average_item_size) = self
712            .inner
713            .try_with(|rc| {
714                let mut inner = rc.borrow_mut();
715                let pending_scroll_delta = inner.scroll_to_be_consumed;
716                inner.scroll_to_be_consumed = 0.0;
717                let pending_scroll_to = inner.pending_scroll_to_index.take();
718                (
719                    pending_scroll_delta,
720                    pending_scroll_to,
721                    inner.average_item_size,
722                )
723            })
724            .unwrap_or((0.0, None, super::DEFAULT_ITEM_SIZE_ESTIMATE));
725
726        LazyListMeasureStateSnapshot {
727            first_visible_item_index: self.scroll_position.current_index(),
728            first_visible_item_scroll_offset: self.scroll_position.current_scroll_offset(),
729            pending_scroll_delta,
730            pending_scroll_to,
731            average_item_size,
732        }
733    }
734
735    pub(crate) fn next_measure_cycle_id(&self) -> u64 {
736        self.inner
737            .try_with(|rc| {
738                let mut inner = rc.borrow_mut();
739                let id = inner.next_measure_cycle_id;
740                inner.next_measure_cycle_id = inner.next_measure_cycle_id.saturating_add(1);
741                id
742            })
743            .unwrap_or(0)
744    }
745
746    pub(crate) fn next_item_measure_pass_id(&self) -> u64 {
747        self.inner
748            .try_with(|rc| {
749                let mut inner = rc.borrow_mut();
750                let id = inner.next_item_measure_pass_id;
751                inner.next_item_measure_pass_id = inner.next_item_measure_pass_id.saturating_add(1);
752                id
753            })
754            .unwrap_or(0)
755    }
756
757    fn record_item_size_sample(inner: &mut LazyListStateInner, size: f32) {
758        inner.total_measured_items += 1;
759        let n = inner.total_measured_items as f32;
760        inner.average_item_size = inner.average_item_size * ((n - 1.0) / n) + size / n;
761    }
762
763    fn insert_item_size(inner: &mut LazyListStateInner, index: usize, size: f32) -> bool {
764        use std::collections::hash_map::Entry;
765
766        if let Entry::Occupied(mut entry) = inner.item_size_cache.entry(index) {
767            entry.insert(size);
768            if let Some(pos) = inner
769                .item_size_lru
770                .iter()
771                .position(|&cached| cached == index)
772            {
773                inner.item_size_lru.remove(pos);
774            }
775            inner.item_size_lru.push_back(index);
776            return false;
777        }
778
779        while inner.item_size_cache.len() >= ITEM_SIZE_CACHE_CAPACITY {
780            if let Some(oldest) = inner.item_size_lru.pop_front() {
781                if inner.item_size_cache.remove(&oldest).is_some() {
782                    break;
783                }
784            } else {
785                break;
786            }
787        }
788
789        inner.item_size_cache.insert(index, size);
790        inner.item_size_lru.push_back(index);
791        true
792    }
793
794    /// Caches the measured size of an item for scroll estimation.
795    pub fn cache_item_size(&self, index: usize, size: f32) {
796        if !self.inner.is_alive() {
797            return;
798        }
799        self.inner.with(|rc| {
800            let mut inner = rc.borrow_mut();
801            if Self::insert_item_size(&mut inner, index, size) {
802                Self::record_item_size_sample(&mut inner, size);
803            }
804        });
805    }
806
807    /// Caches multiple measured item sizes in one pass and returns the updated average.
808    pub fn cache_item_sizes<I>(&self, sizes: I) -> f32
809    where
810        I: IntoIterator<Item = (usize, f32)>,
811    {
812        if !self.inner.is_alive() {
813            return super::DEFAULT_ITEM_SIZE_ESTIMATE;
814        }
815
816        self.inner.with(|rc| {
817            let mut inner = rc.borrow_mut();
818            for (index, size) in sizes {
819                if Self::insert_item_size(&mut inner, index, size) {
820                    Self::record_item_size_sample(&mut inner, size);
821                }
822            }
823            inner.average_item_size
824        })
825    }
826
827    /// Gets a cached item size if available.
828    pub fn get_cached_size(&self, index: usize) -> Option<f32> {
829        self.inner
830            .try_with(|rc| rc.borrow().item_size_cache.get(&index).copied())
831            .flatten()
832    }
833
834    /// Returns the running average of measured item sizes.
835    pub fn average_item_size(&self) -> f32 {
836        self.inner
837            .try_with(|rc| rc.borrow().average_item_size)
838            .unwrap_or(super::DEFAULT_ITEM_SIZE_ESTIMATE)
839    }
840
841    /// Returns the current nearest range for optimized key lookup.
842    pub fn nearest_range(&self) -> std::ops::Range<usize> {
843        // Delegate to scroll_position
844        self.scroll_position.nearest_range()
845    }
846
847    /// Updates the scroll position from a layout pass.
848    ///
849    /// Called by the layout after measurement.
850    pub(crate) fn update_scroll_position(
851        &self,
852        first_visible_item_index: usize,
853        first_visible_item_scroll_offset: f32,
854    ) {
855        self.scroll_position.update_from_measure_result(
856            first_visible_item_index,
857            first_visible_item_scroll_offset,
858            None,
859        );
860    }
861
862    /// Updates the scroll position and stores the key of the first visible item.
863    ///
864    /// Called by the layout after measurement to enable scroll position stability.
865    pub(crate) fn update_scroll_position_with_key(
866        &self,
867        first_visible_item_index: usize,
868        first_visible_item_scroll_offset: f32,
869        first_visible_item_key: u64,
870    ) {
871        self.scroll_position.update_from_measure_result(
872            first_visible_item_index,
873            first_visible_item_scroll_offset,
874            Some(first_visible_item_key),
875        );
876    }
877
878    /// Adjusts scroll position if the first visible item was moved due to data changes.
879    ///
880    /// Matches JC's `updateScrollPositionIfTheFirstItemWasMoved`.
881    /// If items were inserted/removed before the current scroll position,
882    /// this finds the item by its key and updates the index accordingly.
883    ///
884    /// Returns the adjusted first visible item index.
885    pub fn update_scroll_position_if_item_moved<F>(
886        &self,
887        new_item_count: usize,
888        get_index_by_key: F,
889    ) -> usize
890    where
891        F: Fn(u64) -> Option<usize>,
892    {
893        // Delegate to scroll_position
894        self.scroll_position
895            .update_if_first_item_moved(new_item_count, get_index_by_key)
896    }
897
898    /// Updates the layout info from a layout pass.
899    pub(crate) fn update_layout_info(&self, mut info: LazyListLayoutInfo) {
900        if !self.inner.is_alive() {
901            return;
902        }
903        self.inner.with(|rc| {
904            let mut inner = rc.borrow_mut();
905            info.snap_anchor_offset = continuous_snap_anchor_offset(&inner.layout_info, &info);
906            inner.layout_info = info;
907        });
908    }
909
910    /// Returns whether we can scroll forward (more items below/right).
911    ///
912    /// When called during composition, this creates a reactive subscription
913    /// so that changes will trigger recomposition.
914    pub fn can_scroll_forward(&self) -> bool {
915        if !self.can_scroll_forward_state.is_alive() {
916            return false;
917        }
918        self.can_scroll_forward_state.get()
919    }
920
921    /// Returns whether we can scroll backward (more items above/left).
922    ///
923    /// When called during composition, this creates a reactive subscription
924    /// so that changes will trigger recomposition.
925    pub fn can_scroll_backward(&self) -> bool {
926        if !self.can_scroll_backward_state.is_alive() {
927            return false;
928        }
929        self.can_scroll_backward_state.get()
930    }
931
932    /// Updates the scroll bounds after layout measurement.
933    ///
934    /// Called by the layout after measurement to update can_scroll_forward/backward.
935    pub(crate) fn update_scroll_bounds(&self) {
936        if !self.inner.is_alive()
937            || !self.can_scroll_forward_state.is_alive()
938            || !self.can_scroll_backward_state.is_alive()
939        {
940            return;
941        }
942        // Compute can_scroll_forward from layout info
943        let can_forward = self.inner.with(|rc| {
944            let inner = rc.borrow();
945            let info = &inner.layout_info;
946            // Use effective viewport end (accounting for after_content_padding)
947            // Without this, lists with padding can report false while still scrollable
948            let viewport_end = info.viewport_size - info.after_content_padding;
949            if let Some(last_visible) = info.visible_items_info.last() {
950                last_visible.index < info.total_items_count.saturating_sub(1)
951                    || (last_visible.offset + last_visible.size) > viewport_end
952            } else {
953                false
954            }
955        });
956
957        // Compute can_scroll_backward from scroll position
958        let can_backward = self.scroll_position.current_index() > 0
959            || self.scroll_position.current_scroll_offset() > 0.0;
960
961        // Update reactive state only if changed
962        if self.can_scroll_forward_state.get_non_reactive() != can_forward {
963            self.can_scroll_forward_state.set(can_forward);
964        }
965        if self.can_scroll_backward_state.get_non_reactive() != can_backward {
966            self.can_scroll_backward_state.set(can_backward);
967        }
968    }
969
970    /// Adds an invalidation callback.
971    pub fn add_invalidate_callback(&self, callback: Rc<dyn Fn()>) -> u64 {
972        if !self.inner.is_alive() {
973            return 0;
974        }
975        self.inner.with(|rc| {
976            let mut inner = rc.borrow_mut();
977            let id = inner.next_callback_id;
978            inner.next_callback_id += 1;
979            inner.invalidate_callbacks.push((id, callback));
980            id
981        })
982    }
983
984    /// Tries to register a layout invalidation callback for the specified node.
985    ///
986    /// Returns the callback id for the active layout callback.
987    ///
988    /// Registering again always replaces the previous active layout callback, even when
989    /// the node id stays the same. This keeps ownership tied to the latest effect
990    /// instance so disposing an older scope cannot unregister the live callback.
991    pub fn try_register_layout_callback(
992        &self,
993        node_id: NodeId,
994        callback: Rc<dyn Fn()>,
995    ) -> Option<u64> {
996        if !self.inner.is_alive() {
997            return None;
998        }
999        self.inner.with(|rc| {
1000            let mut inner = rc.borrow_mut();
1001            if let Some(existing_id) = inner.layout_invalidation_callback_id {
1002                inner
1003                    .invalidate_callbacks
1004                    .retain(|(cb_id, _)| *cb_id != existing_id);
1005            }
1006            let id = inner.next_callback_id;
1007            inner.next_callback_id += 1;
1008            inner.invalidate_callbacks.push((id, callback));
1009            inner.layout_invalidation_callback_id = Some(id);
1010            inner.layout_invalidation_node_id = Some(node_id);
1011            Some(id)
1012        })
1013    }
1014
1015    /// Removes an invalidation callback.
1016    pub fn remove_invalidate_callback(&self, id: u64) {
1017        if !self.inner.is_alive() {
1018            return;
1019        }
1020        self.inner.with(|rc| {
1021            let mut inner = rc.borrow_mut();
1022            inner.invalidate_callbacks.retain(|(cb_id, _)| *cb_id != id);
1023            if inner.layout_invalidation_callback_id == Some(id) {
1024                inner.layout_invalidation_callback_id = None;
1025                inner.layout_invalidation_node_id = None;
1026            }
1027        });
1028    }
1029
1030    fn invalidate(&self) {
1031        if !self.inner.is_alive() {
1032            return;
1033        }
1034        // Clone callbacks to avoid holding the borrow while calling them
1035        // This prevents re-entrancy issues if a callback triggers another state update
1036        let callbacks: Vec<_> = self.inner.with(|rc| {
1037            rc.borrow()
1038                .invalidate_callbacks
1039                .iter()
1040                .map(|(_, cb)| Rc::clone(cb))
1041                .collect()
1042        });
1043
1044        for callback in callbacks {
1045            callback();
1046        }
1047    }
1048}
1049
1050/// Information about the currently visible items in a lazy list.
1051#[derive(Clone, Default, Debug)]
1052pub struct LazyListLayoutInfo {
1053    /// Information about each visible item.
1054    pub visible_items_info: Vec<LazyListItemInfo>,
1055
1056    /// Total number of items in the list.
1057    pub total_items_count: usize,
1058
1059    /// Raw viewport size reported by parent constraints (before infinite fallback).
1060    pub raw_viewport_size: f32,
1061
1062    /// Whether the viewport was treated as infinite/unbounded.
1063    pub is_infinite_viewport: bool,
1064
1065    /// Size of the viewport in the main axis.
1066    pub viewport_size: f32,
1067
1068    /// Start offset of the viewport (content padding before).
1069    pub viewport_start_offset: f32,
1070
1071    /// End offset of the viewport (content padding after).
1072    pub viewport_end_offset: f32,
1073
1074    /// Content padding before the first item.
1075    pub before_content_padding: f32,
1076
1077    /// Content padding after the last item.
1078    pub after_content_padding: f32,
1079
1080    /// Continuous main-axis visual offset used to snap translated lazy-list content.
1081    pub snap_anchor_offset: f32,
1082
1083    /// Whether item offsets are placed from the end edge of the viewport.
1084    pub reverse_layout: bool,
1085}
1086
1087/// Information about a single visible item in a lazy list.
1088#[derive(Clone, Debug)]
1089pub struct LazyListItemInfo {
1090    /// Index of the item in the data source.
1091    pub index: usize,
1092
1093    /// Key of the item.
1094    pub key: u64,
1095
1096    /// Offset of the item from the start of the list content.
1097    pub offset: f32,
1098
1099    /// Size of the item in the main axis.
1100    pub size: f32,
1101}
1102
1103fn continuous_snap_anchor_offset(
1104    previous: &LazyListLayoutInfo,
1105    current: &LazyListLayoutInfo,
1106) -> f32 {
1107    let Some(first_current) = current.visible_items_info.first() else {
1108        return 0.0;
1109    };
1110
1111    for current_item in &current.visible_items_info {
1112        if let Some(previous_item) = previous
1113            .visible_items_info
1114            .iter()
1115            .find(|item| item.key == current_item.key)
1116        {
1117            let previous_offset = snap_anchor_item_offset(previous, previous_item);
1118            let current_offset = snap_anchor_item_offset(current, current_item);
1119            return previous.snap_anchor_offset + current_offset - previous_offset;
1120        }
1121    }
1122
1123    snap_anchor_item_offset(current, first_current)
1124}
1125
1126fn snap_anchor_item_offset(info: &LazyListLayoutInfo, item: &LazyListItemInfo) -> f32 {
1127    if info.reverse_layout {
1128        info.viewport_size - item.offset - item.size
1129    } else {
1130        item.offset
1131    }
1132}
1133
1134/// Test helpers for creating LazyListState without composition context.
1135#[cfg(test)]
1136pub mod test_helpers {
1137    use super::*;
1138    use cranpose_core::{DefaultScheduler, Runtime};
1139    use std::sync::Arc;
1140
1141    /// Creates a test runtime and keeps it alive for the duration of the closure.
1142    /// Use this to create LazyListState in unit tests.
1143    pub fn with_test_runtime<T>(f: impl FnOnce() -> T) -> T {
1144        let _runtime = Runtime::new(Arc::new(DefaultScheduler));
1145        f()
1146    }
1147
1148    /// Creates a new LazyListState for testing.
1149    /// Must be called within `with_test_runtime`.
1150    pub fn new_lazy_list_state() -> LazyListState {
1151        new_lazy_list_state_with_position(0, 0.0)
1152    }
1153
1154    /// Creates a new LazyListState for testing with initial position.
1155    /// Must be called within `with_test_runtime`.
1156    pub fn new_lazy_list_state_with_position(
1157        initial_first_visible_item_index: usize,
1158        initial_first_visible_item_scroll_offset: f32,
1159    ) -> LazyListState {
1160        // Create scroll position with reactive fields (matches JC LazyListScrollPosition)
1161        let scroll_position = LazyListScrollPosition {
1162            index: cranpose_core::mutableStateOf(initial_first_visible_item_index),
1163            scroll_offset: cranpose_core::mutableStateOf(initial_first_visible_item_scroll_offset),
1164            inner: cranpose_core::mutableStateOf(Rc::new(RefCell::new(ScrollPositionInner {
1165                last_known_first_item_key: None,
1166                nearest_range_state: NearestRangeState::new(initial_first_visible_item_index),
1167            }))),
1168        };
1169
1170        // Non-reactive internal state
1171        let inner = cranpose_core::mutableStateOf(Rc::new(RefCell::new(LazyListStateInner {
1172            scroll_to_be_consumed: 0.0,
1173            pending_scroll_to_index: None,
1174            layout_info: LazyListLayoutInfo::default(),
1175            invalidate_callbacks: Vec::new(),
1176            next_callback_id: 1,
1177            layout_invalidation_callback_id: None,
1178            layout_invalidation_node_id: None,
1179            total_composed: 0,
1180            reuse_count: 0,
1181            item_size_cache: std::collections::HashMap::new(),
1182            item_size_lru: std::collections::VecDeque::new(),
1183            average_item_size: super::super::DEFAULT_ITEM_SIZE_ESTIMATE,
1184            total_measured_items: 0,
1185            next_measure_cycle_id: 1,
1186            next_item_measure_pass_id: 1,
1187            prefetch_scheduler: PrefetchScheduler::new(),
1188            prefetch_strategy: PrefetchStrategy::default(),
1189            last_scroll_direction: 0.0,
1190        })));
1191
1192        // Reactive state
1193        let can_scroll_forward_state = cranpose_core::mutableStateOf(false);
1194        let can_scroll_backward_state = cranpose_core::mutableStateOf(false);
1195        let stats_state = cranpose_core::mutableStateOf(LazyLayoutStats::default());
1196
1197        LazyListState {
1198            scroll_position,
1199            can_scroll_forward_state,
1200            can_scroll_backward_state,
1201            stats_state,
1202            inner,
1203        }
1204    }
1205}
1206
1207#[cfg(test)]
1208mod tests {
1209    use super::test_helpers::{
1210        new_lazy_list_state, new_lazy_list_state_with_position, with_test_runtime,
1211    };
1212    use super::{LazyListItemInfo, LazyListLayoutInfo, LazyListState};
1213    use cranpose_core::{location_key, Composition, MemoryApplier};
1214    use std::cell::Cell;
1215    use std::rc::Rc;
1216
1217    fn enable_bidirectional_scroll(state: &LazyListState) {
1218        state.can_scroll_forward_state.set(true);
1219        state.can_scroll_backward_state.set(true);
1220    }
1221
1222    fn mark_scroll_bounds_known(state: &LazyListState) {
1223        state.update_layout_info(LazyListLayoutInfo {
1224            total_items_count: 10,
1225            ..Default::default()
1226        });
1227    }
1228
1229    fn visible_item(index: usize, offset: f32, size: f32) -> LazyListItemInfo {
1230        LazyListItemInfo {
1231            index,
1232            key: index as u64,
1233            offset,
1234            size,
1235        }
1236    }
1237
1238    #[test]
1239    fn lazy_measure_telemetry_ids_are_state_owned() {
1240        with_test_runtime(|| {
1241            let first = new_lazy_list_state();
1242            let second = new_lazy_list_state();
1243
1244            assert_eq!(first.next_measure_cycle_id(), 1);
1245            assert_eq!(first.next_measure_cycle_id(), 2);
1246            assert_eq!(second.next_measure_cycle_id(), 1);
1247
1248            assert_eq!(first.next_item_measure_pass_id(), 1);
1249            assert_eq!(first.next_item_measure_pass_id(), 2);
1250            assert_eq!(second.next_item_measure_pass_id(), 1);
1251        });
1252    }
1253
1254    #[test]
1255    fn layout_info_snap_anchor_tracks_common_item_offset_delta() {
1256        let previous = LazyListLayoutInfo {
1257            visible_items_info: vec![visible_item(15, -31.4, 30.0), visible_item(16, 4.6, 30.0)],
1258            snap_anchor_offset: -31.4,
1259            ..Default::default()
1260        };
1261        let current = LazyListLayoutInfo {
1262            visible_items_info: vec![visible_item(16, 3.6, 30.0), visible_item(17, 39.6, 30.0)],
1263            ..Default::default()
1264        };
1265
1266        let anchor = super::continuous_snap_anchor_offset(&previous, &current);
1267
1268        assert!((anchor + 32.4).abs() <= 0.001);
1269    }
1270
1271    #[test]
1272    fn layout_info_snap_anchor_uses_reverse_visual_item_offset() {
1273        let previous = LazyListLayoutInfo {
1274            visible_items_info: vec![visible_item(15, 31.4, 30.0), visible_item(16, 67.4, 30.0)],
1275            snap_anchor_offset: 58.6,
1276            viewport_size: 120.0,
1277            reverse_layout: true,
1278            ..Default::default()
1279        };
1280        let current = LazyListLayoutInfo {
1281            visible_items_info: vec![visible_item(16, 68.4, 30.0), visible_item(17, 104.4, 30.0)],
1282            viewport_size: 120.0,
1283            reverse_layout: true,
1284            ..Default::default()
1285        };
1286
1287        let anchor = super::continuous_snap_anchor_offset(&previous, &current);
1288
1289        assert!((anchor - 57.6).abs() <= 0.001);
1290    }
1291
1292    #[test]
1293    fn update_layout_info_keeps_snap_anchor_continuous_when_first_visible_item_changes() {
1294        with_test_runtime(|| {
1295            let state = new_lazy_list_state();
1296            state.update_layout_info(LazyListLayoutInfo {
1297                visible_items_info: vec![
1298                    visible_item(15, -31.4, 30.0),
1299                    visible_item(16, 4.6, 30.0),
1300                ],
1301                ..Default::default()
1302            });
1303
1304            state.update_layout_info(LazyListLayoutInfo {
1305                visible_items_info: vec![visible_item(16, 3.6, 30.0), visible_item(17, 39.6, 30.0)],
1306                ..Default::default()
1307            });
1308
1309            let info = state.layout_info();
1310            assert!((info.snap_anchor_offset + 32.4).abs() <= 0.001);
1311        });
1312    }
1313
1314    #[test]
1315    fn dispatch_scroll_delta_accumulates_same_direction() {
1316        with_test_runtime(|| {
1317            let state = new_lazy_list_state();
1318            enable_bidirectional_scroll(&state);
1319
1320            state.dispatch_scroll_delta(-12.0);
1321            state.dispatch_scroll_delta(-8.0);
1322
1323            assert!((state.peek_scroll_delta() + 20.0).abs() < 0.001);
1324            let snapshot = state.begin_measure_pass();
1325            assert!((snapshot.pending_scroll_delta + 20.0).abs() < 0.001);
1326            assert_eq!(state.begin_measure_pass().pending_scroll_delta, 0.0);
1327        });
1328    }
1329
1330    #[test]
1331    fn dispatch_scroll_delta_drops_stale_backlog_on_direction_change() {
1332        with_test_runtime(|| {
1333            let state = new_lazy_list_state();
1334            enable_bidirectional_scroll(&state);
1335
1336            state.dispatch_scroll_delta(-120.0);
1337            state.dispatch_scroll_delta(-30.0);
1338            assert!((state.peek_scroll_delta() + 150.0).abs() < 0.001);
1339
1340            state.dispatch_scroll_delta(18.0);
1341
1342            assert!((state.peek_scroll_delta() - 18.0).abs() < 0.001);
1343            let snapshot = state.begin_measure_pass();
1344            assert!((snapshot.pending_scroll_delta - 18.0).abs() < 0.001);
1345            assert_eq!(state.begin_measure_pass().pending_scroll_delta, 0.0);
1346        });
1347    }
1348
1349    #[test]
1350    fn dispatch_scroll_delta_clamps_pending_backlog() {
1351        with_test_runtime(|| {
1352            let state = new_lazy_list_state();
1353            enable_bidirectional_scroll(&state);
1354
1355            state.dispatch_scroll_delta(-1_500.0);
1356            state.dispatch_scroll_delta(-1_500.0);
1357            assert!((state.peek_scroll_delta() + super::MAX_PENDING_SCROLL_DELTA).abs() < 0.001);
1358
1359            state.dispatch_scroll_delta(3_000.0);
1360            assert!((state.peek_scroll_delta() - super::MAX_PENDING_SCROLL_DELTA).abs() < 0.001);
1361        });
1362    }
1363
1364    #[test]
1365    fn dispatch_scroll_delta_skips_invalidate_when_clamped_value_is_unchanged() {
1366        with_test_runtime(|| {
1367            let state = new_lazy_list_state();
1368            enable_bidirectional_scroll(&state);
1369            let invalidations = Rc::new(Cell::new(0u32));
1370            let invalidations_clone = Rc::clone(&invalidations);
1371            state.add_invalidate_callback(Rc::new(move || {
1372                invalidations_clone.set(invalidations_clone.get() + 1);
1373            }));
1374
1375            state.dispatch_scroll_delta(-3_000.0);
1376            assert_eq!(invalidations.get(), 1);
1377            assert!((state.peek_scroll_delta() + super::MAX_PENDING_SCROLL_DELTA).abs() < 0.001);
1378
1379            // Additional same-direction input is clamped to the same pending value.
1380            state.dispatch_scroll_delta(-100.0);
1381            assert_eq!(invalidations.get(), 1);
1382
1383            // Opposite-direction input changes pending and should invalidate again.
1384            state.dispatch_scroll_delta(100.0);
1385            assert_eq!(invalidations.get(), 2);
1386        });
1387    }
1388
1389    #[test]
1390    fn begin_measure_pass_takes_coherent_snapshot_and_consumes_pending_inputs() {
1391        with_test_runtime(|| {
1392            let state = new_lazy_list_state_with_position(3, 12.0);
1393            state.dispatch_scroll_delta(-20.0);
1394            state.inner.with(|rc| {
1395                rc.borrow_mut().pending_scroll_to_index = Some((8, 4.0));
1396            });
1397
1398            let snapshot = state.begin_measure_pass();
1399
1400            assert_eq!(snapshot.first_visible_item_index, 3);
1401            assert!((snapshot.first_visible_item_scroll_offset - 12.0).abs() < 0.001);
1402            assert!((snapshot.pending_scroll_delta + 20.0).abs() < 0.001);
1403            assert_eq!(snapshot.pending_scroll_to, Some((8, 4.0)));
1404            assert_eq!(state.peek_scroll_delta(), 0.0);
1405            assert_eq!(state.begin_measure_pass().pending_scroll_to, None);
1406        });
1407    }
1408
1409    #[test]
1410    fn item_size_cache_refresh_keeps_recent_entry_and_evicts_oldest_live_entry() {
1411        with_test_runtime(|| {
1412            let state = new_lazy_list_state();
1413            for index in 0..super::ITEM_SIZE_CACHE_CAPACITY {
1414                state.cache_item_size(index, index as f32 + 10.0);
1415            }
1416
1417            state.cache_item_size(0, 999.0);
1418            state.cache_item_size(super::ITEM_SIZE_CACHE_CAPACITY, 123.0);
1419
1420            assert_eq!(state.get_cached_size(0), Some(999.0));
1421            assert_eq!(state.get_cached_size(1), None);
1422            assert_eq!(
1423                state.get_cached_size(super::ITEM_SIZE_CACHE_CAPACITY),
1424                Some(123.0),
1425            );
1426        });
1427    }
1428
1429    #[test]
1430    fn cache_item_sizes_updates_average_only_for_new_entries() {
1431        with_test_runtime(|| {
1432            let state = new_lazy_list_state();
1433
1434            let average = state.cache_item_sizes([(0, 10.0), (1, 20.0), (0, 12.0)]);
1435
1436            assert_eq!(state.get_cached_size(0), Some(12.0));
1437            assert_eq!(state.get_cached_size(1), Some(20.0));
1438            assert!((average - 15.0).abs() < 0.001);
1439        });
1440    }
1441
1442    #[test]
1443    fn layout_callback_can_be_registered_again_after_removal() {
1444        with_test_runtime(|| {
1445            let state = new_lazy_list_state();
1446            let first_node: cranpose_core::NodeId = 1;
1447            let second_node: cranpose_core::NodeId = 2;
1448
1449            let first_id = state
1450                .try_register_layout_callback(first_node, Rc::new(|| {}))
1451                .expect("first layout callback should register");
1452            let duplicate_id = state
1453                .try_register_layout_callback(first_node, Rc::new(|| {}))
1454                .expect("duplicate register should replace with a fresh callback id");
1455            assert_eq!(
1456                state
1457                    .inner
1458                    .with(|rc| rc.borrow().layout_invalidation_callback_id),
1459                Some(duplicate_id),
1460                "duplicate registration should become the active callback",
1461            );
1462            assert_ne!(
1463                first_id, duplicate_id,
1464                "duplicate registration should replace the old callback id",
1465            );
1466
1467            state.remove_invalidate_callback(first_id);
1468
1469            let second_id = state
1470                .try_register_layout_callback(second_node, Rc::new(|| {}))
1471                .expect("layout callback should register again after removal");
1472            assert_ne!(first_id, second_id);
1473        });
1474    }
1475
1476    #[test]
1477    fn layout_callback_rebinds_when_node_id_changes() {
1478        with_test_runtime(|| {
1479            let state = new_lazy_list_state();
1480            let first_node: cranpose_core::NodeId = 11;
1481            let second_node: cranpose_core::NodeId = 22;
1482
1483            let first_id = state
1484                .try_register_layout_callback(first_node, Rc::new(|| {}))
1485                .expect("first layout callback should register");
1486
1487            let second_id = state
1488                .try_register_layout_callback(second_node, Rc::new(|| {}))
1489                .expect("layout callback should rebind to a new node");
1490
1491            assert_ne!(first_id, second_id);
1492        });
1493    }
1494
1495    #[test]
1496    fn stale_layout_callback_disposer_cannot_remove_replaced_same_node_callback() {
1497        with_test_runtime(|| {
1498            let state = new_lazy_list_state();
1499            let node_id: cranpose_core::NodeId = 7;
1500            let first_hits = Rc::new(Cell::new(0u32));
1501            let second_hits = Rc::new(Cell::new(0u32));
1502
1503            let first_id = state
1504                .try_register_layout_callback(
1505                    node_id,
1506                    Rc::new({
1507                        let first_hits = Rc::clone(&first_hits);
1508                        move || first_hits.set(first_hits.get() + 1)
1509                    }),
1510                )
1511                .expect("first layout callback should register");
1512
1513            let second_id = state
1514                .try_register_layout_callback(
1515                    node_id,
1516                    Rc::new({
1517                        let second_hits = Rc::clone(&second_hits);
1518                        move || second_hits.set(second_hits.get() + 1)
1519                    }),
1520                )
1521                .expect("same-node registration should replace the active callback");
1522
1523            assert_ne!(first_id, second_id);
1524
1525            state.remove_invalidate_callback(first_id);
1526            state.dispatch_scroll_delta(-12.0);
1527
1528            assert_eq!(
1529                first_hits.get(),
1530                0,
1531                "replaced callback should not be invoked after removal",
1532            );
1533            assert_eq!(
1534                second_hits.get(),
1535                1,
1536                "active callback should survive stale disposer cleanup",
1537            );
1538        });
1539    }
1540
1541    #[test]
1542    fn dispatch_scroll_delta_returns_zero_when_forward_is_blocked() {
1543        with_test_runtime(|| {
1544            let state = new_lazy_list_state();
1545            mark_scroll_bounds_known(&state);
1546            state.can_scroll_forward_state.set(false);
1547            state.can_scroll_backward_state.set(true);
1548
1549            let consumed = state.dispatch_scroll_delta(-24.0);
1550
1551            assert_eq!(consumed, 0.0);
1552            assert_eq!(state.peek_scroll_delta(), 0.0);
1553        });
1554    }
1555
1556    #[test]
1557    fn equality_does_not_deref_released_inner_state() {
1558        let mut composition = Composition::new(MemoryApplier::new());
1559        let key = location_key(file!(), line!(), column!());
1560
1561        let mut first = None;
1562        composition
1563            .render(key, || {
1564                first = Some(super::remember_lazy_list_state());
1565            })
1566            .expect("initial render");
1567        let first = first.expect("first lazy state");
1568
1569        composition
1570            .render(key, || {})
1571            .expect("dispose first lazy state");
1572        assert!(
1573            !first.inner.is_alive(),
1574            "expected first lazy state to be released after disposal"
1575        );
1576
1577        let mut second = None;
1578        composition
1579            .render(key, || {
1580                second = Some(super::remember_lazy_list_state());
1581            })
1582            .expect("second render");
1583        let second = second.expect("second lazy state");
1584
1585        assert!(
1586            first != second,
1587            "released lazy state handle must compare by identity without panicking"
1588        );
1589    }
1590
1591    #[test]
1592    fn released_lazy_list_state_scroll_position_methods_do_not_panic() {
1593        let mut composition = Composition::new(MemoryApplier::new());
1594        let key = location_key(file!(), line!(), column!());
1595
1596        let mut released = None;
1597        composition
1598            .render(key, || {
1599                released = Some(super::remember_lazy_list_state());
1600            })
1601            .expect("initial render");
1602        let released = released.expect("lazy list state");
1603
1604        composition
1605            .render(key, || {})
1606            .expect("dispose lazy list state");
1607        assert!(
1608            !released.inner.is_alive(),
1609            "expected lazy list state to be released after disposal"
1610        );
1611
1612        assert_eq!(released.first_visible_item_index(), 0);
1613        assert_eq!(released.first_visible_item_scroll_offset(), 0.0);
1614        assert_eq!(released.nearest_range(), 0..0);
1615        assert_eq!(
1616            released.update_scroll_position_if_item_moved(10, |_| Some(0)),
1617            0
1618        );
1619        released.update_scroll_position(3, 12.0);
1620        released.update_scroll_position_with_key(3, 12.0, 42);
1621        released.update_scroll_bounds();
1622    }
1623
1624    #[test]
1625    fn dispatch_scroll_delta_clears_stale_pending_at_forward_edge() {
1626        with_test_runtime(|| {
1627            let state = new_lazy_list_state();
1628            mark_scroll_bounds_known(&state);
1629            enable_bidirectional_scroll(&state);
1630            state.dispatch_scroll_delta(-300.0);
1631            assert!((state.peek_scroll_delta() + 300.0).abs() < 0.001);
1632
1633            state.can_scroll_forward_state.set(false);
1634
1635            let blocked_consumed = state.dispatch_scroll_delta(-10.0);
1636            assert_eq!(blocked_consumed, 0.0);
1637            assert_eq!(state.peek_scroll_delta(), 0.0);
1638
1639            let reverse_consumed = state.dispatch_scroll_delta(12.0);
1640            assert_eq!(reverse_consumed, 12.0);
1641            assert!((state.peek_scroll_delta() - 12.0).abs() < 0.001);
1642        });
1643    }
1644
1645    #[test]
1646    fn negative_scroll_delta_prefetches_forward_items() {
1647        with_test_runtime(|| {
1648            let state = new_lazy_list_state();
1649            state.dispatch_scroll_delta(-24.0);
1650            state.record_scroll_direction(state.peek_scroll_delta());
1651            state.update_prefetch_queue(10, 15, 100);
1652
1653            assert_eq!(state.take_prefetch_indices(), vec![16, 17]);
1654        });
1655    }
1656}