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