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