Skip to main content

cranpose_foundation/lazy/
lazy_list_state.rs

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