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