Skip to main content

cranpose_foundation/lazy/
lazy_list_state.rs

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