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