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