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