Skip to main content

cranpose_foundation/lazy/
lazy_list_state.rs

1//! Lazy list state management.
2//!
3//! Provides [`LazyListState`] for controlling and observing lazy list scroll position.
4//!
5//! Design follows Jetpack Compose's LazyListState/LazyListScrollPosition pattern:
6//! - Reactive properties are backed by `MutableState<T>`:
7//!   - `first_visible_item_index`, `first_visible_item_scroll_offset`
8//!   - `can_scroll_forward`, `can_scroll_backward`
9//!   - `stats` (items_in_use, items_in_pool)
10//! - Non-reactive internals (caches, callbacks, prefetch, diagnostic counters) are in inner state
11
12use std::{cell::RefCell, cmp::Reverse, collections::BinaryHeap, rc::Rc};
13
14use cranpose_core::{MutableState, NodeId, StateId};
15use cranpose_macros::composable;
16
17use super::{
18    diagnostics,
19    nearest_range::NearestRangeState,
20    prefetch::{PrefetchScheduler, PrefetchStrategy},
21};
22
23const MAX_PENDING_SCROLL_DELTA: f32 = 2000.0;
24const ITEM_SIZE_CACHE_CAPACITY: usize = 8192;
25
26#[derive(Clone, Copy, Debug, PartialEq)]
27pub(crate) struct LazyListMeasureStateSnapshot {
28    pub(crate) first_visible_item_index: usize,
29    pub(crate) first_visible_item_scroll_offset: f32,
30    pub(crate) pending_scroll_delta: f32,
31    pub(crate) pending_scroll_to: Option<(usize, f32)>,
32    pub(crate) average_item_size: f32,
33}
34
35/// Statistics about lazy layout item lifecycle.
36///
37/// Used for testing and debugging virtualization behavior.
38#[derive(Clone, Debug, Default, PartialEq)]
39pub struct LazyLayoutStats {
40    /// Number of items currently composed and visible.
41    pub items_in_use: usize,
42
43    /// Number of items in the recycle pool (available for reuse).
44    pub items_in_pool: usize,
45
46    /// Total number of items that have been composed.
47    pub total_composed: usize,
48
49    /// Number of items that were reused instead of newly composed.
50    pub reuse_count: usize,
51}
52
53/// Contains the current scroll position represented by the first visible item
54/// index and the first visible item scroll offset.
55///
56/// This is a `Copy` type that holds reactive state. Reading `index` or `scroll_offset`
57/// during composition creates a snapshot dependency for automatic recomposition.
58///
59/// Matches Jetpack Compose's `LazyListScrollPosition` design.
60#[derive(Clone, Copy)]
61pub struct LazyListScrollPosition {
62    index: MutableState<usize>,
63    scroll_offset: MutableState<f32>,
64    inner: MutableState<Rc<RefCell<ScrollPositionInner>>>,
65}
66
67/// Non-reactive internal state for scroll position.
68struct ScrollPositionInner {
69    current_index: usize,
70    current_scroll_offset: f32,
71    last_known_first_item_key: Option<u64>,
72    nearest_range_state: NearestRangeState,
73}
74
75impl LazyListScrollPosition {
76    fn is_alive(&self) -> bool {
77        self.index.is_alive() && self.scroll_offset.is_alive() && self.inner.is_alive()
78    }
79
80    fn current_index(&self) -> usize {
81        self.inner
82            .try_with(|rc| rc.borrow().current_index)
83            .unwrap_or(0)
84    }
85
86    fn current_scroll_offset(&self) -> f32 {
87        self.inner
88            .try_with(|rc| rc.borrow().current_scroll_offset)
89            .unwrap_or(0.0)
90    }
91
92    /// Returns the index of the first visible item (reactive read).
93    pub fn index(&self) -> usize {
94        if !self.index.is_alive() || !self.inner.is_alive() {
95            return 0;
96        }
97        self.index.subscribe_current_scope_only();
98        self.current_index()
99    }
100
101    /// Returns the scroll offset of the first visible item (reactive read).
102    pub fn scroll_offset(&self) -> f32 {
103        if !self.scroll_offset.is_alive() || !self.inner.is_alive() {
104            return 0.0;
105        }
106        self.scroll_offset.subscribe_current_scope_only();
107        self.current_scroll_offset()
108    }
109
110    pub(crate) fn update_from_measure_result(
111        &self,
112        first_visible_index: usize,
113        first_visible_scroll_offset: f32,
114        first_visible_item_key: Option<u64>,
115    ) {
116        if !self.is_alive() {
117            return;
118        }
119        self.inner.with(|rc| {
120            let mut inner = rc.borrow_mut();
121            inner.current_index = first_visible_index;
122            inner.current_scroll_offset = first_visible_scroll_offset;
123            inner.last_known_first_item_key = first_visible_item_key;
124            inner.nearest_range_state.update(first_visible_index);
125        });
126
127        if self.index.get_non_reactive() != first_visible_index {
128            self.index.set(first_visible_index);
129        }
130        if (self.scroll_offset.get_non_reactive() - first_visible_scroll_offset).abs() > 0.001 {
131            self.scroll_offset.set(first_visible_scroll_offset);
132        }
133    }
134
135    pub(crate) fn request_position_and_forget_last_known_key(
136        &self,
137        index: usize,
138        scroll_offset: f32,
139    ) {
140        if !self.is_alive() {
141            return;
142        }
143        self.inner.with(|rc| {
144            let mut inner = rc.borrow_mut();
145            inner.current_index = index;
146            inner.current_scroll_offset = scroll_offset;
147            inner.last_known_first_item_key = None;
148            inner.nearest_range_state.update(index);
149        });
150
151        if self.index.get_non_reactive() != index {
152            self.index.set(index);
153        }
154        if (self.scroll_offset.get_non_reactive() - scroll_offset).abs() > 0.001 {
155            self.scroll_offset.set(scroll_offset);
156        }
157    }
158
159    pub(crate) fn update_if_first_item_moved<F>(
160        &self,
161        new_item_count: usize,
162        find_by_key: F,
163    ) -> usize
164    where
165        F: Fn(u64) -> Option<usize>,
166    {
167        if !self.index.is_alive() || !self.inner.is_alive() {
168            return 0;
169        }
170
171        let current_index = self.current_index();
172        let last_key = self
173            .inner
174            .try_with(|rc| rc.borrow().last_known_first_item_key)
175            .flatten();
176
177        let new_index = match last_key {
178            None => current_index.min(new_item_count.saturating_sub(1)),
179            Some(key) => find_by_key(key)
180                .unwrap_or_else(|| current_index.min(new_item_count.saturating_sub(1))),
181        };
182
183        if current_index != new_index {
184            self.inner.with(|rc| {
185                let mut inner = rc.borrow_mut();
186                inner.current_index = new_index;
187                inner.nearest_range_state.update(new_index);
188            });
189            self.index.set(new_index);
190        }
191        new_index
192    }
193
194    /// Returns the nearest range for optimized key lookups.
195    pub fn nearest_range(&self) -> std::ops::Range<usize> {
196        self.inner
197            .try_with(|rc| rc.borrow().nearest_range_state.range())
198            .unwrap_or(0..0)
199    }
200}
201
202/// State object for lazy list scroll position tracking.
203///
204/// Holds the current scroll position and provides methods to programmatically
205/// control scrolling. Create with [`rememberLazyListState()`] in composition.
206///
207/// This type is `Copy`, so it can be passed to multiple closures without explicit `.clone()` calls.
208///
209/// # Reactive Properties (read during composition triggers recomposition)
210/// - `first_visible_item_index()` - index of first visible item
211/// - `first_visible_item_scroll_offset()` - scroll offset within first item
212/// - `can_scroll_forward()` - whether more items exist below/right
213/// - `can_scroll_backward()` - whether more items exist above/left
214/// - `stats()` - lifecycle statistics (`items_in_use`, `items_in_pool`)
215///
216/// # Non-Reactive Properties
217/// - `stats().total_composed` - total items composed (diagnostic)
218/// - `stats().reuse_count` - items reused from pool (diagnostic)
219/// - `layout_info()` - detailed layout information
220///
221/// # Example
222///
223/// ```rust,ignore
224/// let state = rememberLazyListState();
225///
226/// // Scroll to item 50
227/// state.scroll_to_item(50, 0.0);
228///
229/// // Get current visible item (reactive read)
230/// println!("First visible: {}", state.first_visible_item_index());
231/// ```
232#[derive(Clone, Copy)]
233pub struct LazyListState {
234    scroll_position: LazyListScrollPosition,
235    can_scroll_forward_state: MutableState<bool>,
236    can_scroll_backward_state: MutableState<bool>,
237    stats_state: MutableState<LazyLayoutStats>,
238    inner: MutableState<Rc<RefCell<LazyListStateInner>>>,
239}
240
241impl PartialEq for LazyListState {
242    fn eq(&self, other: &Self) -> bool {
243        self.inner == other.inner
244    }
245}
246
247#[derive(Clone, Copy)]
248struct CachedItemSize {
249    size: f32,
250    last_used: u64,
251}
252
253/// Non-reactive internal state for LazyListState.
254struct LazyListStateInner {
255    scroll_to_be_consumed: f32,
256
257    pending_scroll_to_index: Option<(usize, f32)>,
258
259    layout_info: LazyListLayoutInfo,
260    current_can_scroll_forward: bool,
261    current_can_scroll_backward: bool,
262
263    invalidate_callbacks: Vec<(u64, Rc<dyn Fn()>)>,
264    next_callback_id: u64,
265
266    layout_invalidation_callback_id: Option<u64>,
267    layout_invalidation_node_id: Option<NodeId>,
268
269    total_composed: usize,
270    reuse_count: usize,
271
272    item_size_cache: std::collections::HashMap<usize, CachedItemSize>,
273    item_size_eviction_queue: BinaryHeap<Reverse<(u64, usize)>>,
274    item_size_clock: u64,
275
276    average_item_size: f32,
277    total_measured_items: usize,
278    next_measure_cycle_id: u64,
279    next_item_measure_pass_id: u64,
280
281    prefetch_scheduler: PrefetchScheduler,
282
283    prefetch_strategy: PrefetchStrategy,
284
285    last_scroll_direction: f32,
286}
287
288/// Creates a remembered [`LazyListState`] with default initial position.
289///
290/// This is the recommended way to create a `LazyListState` in composition.
291/// The returned state is `Copy` and can be passed to multiple closures without `.clone()`.
292///
293/// # Example
294///
295/// ```rust,ignore
296/// let list_state = rememberLazyListState();
297///
298/// // Pass to multiple closures - no .clone() needed!
299/// LazyColumn(modifier, list_state, spec, content);
300/// Button(move || list_state.scroll_to_item(0, 0.0));
301/// ```
302#[composable]
303#[track_caller]
304pub fn rememberLazyListState() -> LazyListState {
305    rememberLazyListStateWithPosition(0, 0.0)
306}
307
308/// Creates a remembered [`LazyListState`] with the specified initial position.
309///
310/// The returned state is `Copy` and can be passed to multiple closures without `.clone()`.
311#[composable]
312pub fn rememberLazyListStateWithPosition(
313    initial_first_visible_item_index: usize,
314    initial_first_visible_item_scroll_offset: f32,
315) -> LazyListState {
316    cranpose_core::remember(move || {
317        LazyListState::new(
318            initial_first_visible_item_index,
319            initial_first_visible_item_scroll_offset,
320        )
321    })
322    .with(|state| *state)
323}
324
325impl LazyListState {
326    /// A list's scroll states, for code that remembers the list state itself.
327    ///
328    /// Call it inside a `remember` -- [`rememberLazyListState`] is this plus
329    /// the slot -- so that the states belong to that slot and are released
330    /// with it.
331    pub fn new(
332        initial_first_visible_item_index: usize,
333        initial_first_visible_item_scroll_offset: f32,
334    ) -> Self {
335        LazyListState {
336            scroll_position: LazyListScrollPosition {
337                index: cranpose_core::mutableStateOf(initial_first_visible_item_index),
338                scroll_offset: cranpose_core::mutableStateOf(
339                    initial_first_visible_item_scroll_offset,
340                ),
341                inner: cranpose_core::mutableStateOfNeverEqual(Rc::new(RefCell::new(
342                    ScrollPositionInner {
343                        current_index: initial_first_visible_item_index,
344                        current_scroll_offset: initial_first_visible_item_scroll_offset,
345                        last_known_first_item_key: None,
346                        nearest_range_state: NearestRangeState::new(
347                            initial_first_visible_item_index,
348                        ),
349                    },
350                ))),
351            },
352            can_scroll_forward_state: cranpose_core::mutableStateOf(false),
353            can_scroll_backward_state: cranpose_core::mutableStateOf(false),
354            stats_state: cranpose_core::mutableStateOf(LazyLayoutStats::default()),
355            inner: cranpose_core::mutableStateOfNeverEqual(Rc::new(RefCell::new(
356                LazyListStateInner {
357                    scroll_to_be_consumed: 0.0,
358                    pending_scroll_to_index: None,
359                    layout_info: LazyListLayoutInfo::default(),
360                    current_can_scroll_forward: false,
361                    current_can_scroll_backward: false,
362                    invalidate_callbacks: Vec::new(),
363                    next_callback_id: 1,
364                    layout_invalidation_callback_id: None,
365                    layout_invalidation_node_id: None,
366                    total_composed: 0,
367                    reuse_count: 0,
368                    item_size_cache: std::collections::HashMap::new(),
369                    item_size_eviction_queue: BinaryHeap::new(),
370                    item_size_clock: 0,
371                    average_item_size: super::DEFAULT_ITEM_SIZE_ESTIMATE,
372                    total_measured_items: 0,
373                    next_measure_cycle_id: 1,
374                    next_item_measure_pass_id: 1,
375                    prefetch_scheduler: PrefetchScheduler::new(),
376                    prefetch_strategy: PrefetchStrategy::default(),
377                    last_scroll_direction: 0.0,
378                },
379            ))),
380        }
381    }
382
383    /// Returns a stable identity pointer for the live inner state allocation.
384    ///
385    /// The pointer comes from the `Rc` stored inside `inner`, so it remains stable for the
386    /// lifetime of a live `LazyListState` and can be used as a composition identity key.
387    pub fn inner_ptr(&self) -> *const () {
388        self.inner
389            .try_with(|rc| Rc::as_ptr(rc) as *const ())
390            .unwrap_or(std::ptr::null())
391    }
392
393    /// Returns the index of the first visible item.
394    ///
395    /// When called during composition, this creates a reactive subscription
396    /// so that changes to the index will trigger recomposition.
397    pub fn first_visible_item_index(&self) -> usize {
398        self.scroll_position.index()
399    }
400
401    /// How many items the list holds, as of its last measure.
402    pub fn total_items_count(&self) -> usize {
403        self.inner
404            .with(|rc| rc.borrow().layout_info.total_items_count)
405    }
406
407    /// Returns the first visible item index without subscribing the current composition scope.
408    ///
409    /// Use this from draw/input/diagnostic code that needs the latest position but must not
410    /// recompose when the scroll position changes.
411    pub fn first_visible_item_index_non_reactive(&self) -> usize {
412        self.scroll_position.current_index()
413    }
414
415    /// Returns the scroll offset of the first visible item.
416    ///
417    /// This is the amount the first item is scrolled off-screen (positive = scrolled up/left).
418    /// When called during composition, this creates a reactive subscription
419    /// so that changes to the offset will trigger recomposition.
420    pub fn first_visible_item_scroll_offset(&self) -> f32 {
421        self.scroll_position.scroll_offset()
422    }
423
424    /// Returns the first visible item scroll offset without subscribing the current composition scope.
425    ///
426    /// Use this from draw/input/diagnostic code that needs the latest position but must not
427    /// recompose when the scroll position changes.
428    pub fn first_visible_item_scroll_offset_non_reactive(&self) -> f32 {
429        self.scroll_position.current_scroll_offset()
430    }
431
432    #[doc(hidden)]
433    pub fn reactive_state_ids(&self) -> [StateId; 5] {
434        [
435            self.scroll_position.index.runtime_state_id(),
436            self.scroll_position.scroll_offset.runtime_state_id(),
437            self.can_scroll_forward_state.runtime_state_id(),
438            self.can_scroll_backward_state.runtime_state_id(),
439            self.stats_state.runtime_state_id(),
440        ]
441    }
442
443    /// Returns the layout info from the last measure pass.
444    pub fn layout_info(&self) -> LazyListLayoutInfo {
445        self.inner
446            .try_with(|rc| rc.borrow().layout_info.clone())
447            .unwrap_or_default()
448    }
449
450    /// Returns the current item lifecycle statistics.
451    ///
452    /// When called during composition, this creates a reactive subscription
453    /// so that changes to `items_in_use` or `items_in_pool` will trigger recomposition.
454    /// The `total_composed` and `reuse_count` fields are diagnostic and non-reactive.
455    pub fn stats(&self) -> LazyLayoutStats {
456        if !self.stats_state.is_alive() || !self.inner.is_alive() {
457            return LazyLayoutStats::default();
458        }
459        let reactive = self.stats_state.get();
460        let (total_composed, reuse_count) = self.inner.with(|rc| {
461            let inner = rc.borrow();
462            (inner.total_composed, inner.reuse_count)
463        });
464        LazyLayoutStats {
465            items_in_use: reactive.items_in_use,
466            items_in_pool: reactive.items_in_pool,
467            total_composed,
468            reuse_count,
469        }
470    }
471
472    /// Updates the item lifecycle statistics.
473    ///
474    /// Called by the layout measurement after updating slot pools.
475    /// Triggers recomposition if `items_in_use` or `items_in_pool` changed.
476    pub fn update_stats(&self, items_in_use: usize, items_in_pool: usize) {
477        if !self.stats_state.is_alive() || !self.inner.is_alive() {
478            return;
479        }
480
481        let current = self.stats_state.get_non_reactive();
482
483        let should_update_reactive = if items_in_use > current.items_in_use {
484            true
485        } else if items_in_use < current.items_in_use {
486            current.items_in_use - items_in_use > 1
487        } else {
488            false
489        };
490
491        if should_update_reactive {
492            self.stats_state.set(LazyLayoutStats {
493                items_in_use,
494                items_in_pool,
495                ..current
496            });
497        }
498    }
499
500    /// Records that an item was composed (either new or reused).
501    ///
502    /// This updates diagnostic counters in non-reactive state.
503    /// Does NOT trigger recomposition.
504    pub fn record_composition(&self, was_reused: bool) {
505        if !self.inner.is_alive() {
506            return;
507        }
508        self.inner.with(|rc| {
509            let mut inner = rc.borrow_mut();
510            inner.total_composed += 1;
511            if was_reused {
512                inner.reuse_count += 1;
513            }
514        });
515    }
516
517    /// Records the raw scroll delta for prefetch calculations.
518    ///
519    /// Cranpose lazy lists use gesture-style deltas:
520    /// - Negative delta = scrolling forward (content moves up)
521    /// - Positive delta = scrolling backward (content moves down)
522    pub fn record_scroll_direction(&self, delta: f32) {
523        if delta.abs() > 0.001 {
524            if !self.inner.is_alive() {
525                return;
526            }
527            self.inner.with(|rc| {
528                rc.borrow_mut().last_scroll_direction = -delta.signum();
529            });
530        }
531    }
532
533    /// Updates the prefetch queue based on current visible items.
534    /// Should be called after measurement to queue items for pre-composition.
535    pub fn update_prefetch_queue(
536        &self,
537        first_visible_index: usize,
538        last_visible_index: usize,
539        total_items: usize,
540    ) {
541        if !self.inner.is_alive() {
542            return;
543        }
544        self.inner.with(|rc| {
545            let mut inner = rc.borrow_mut();
546            let direction = inner.last_scroll_direction;
547            let strategy = inner.prefetch_strategy.clone();
548            inner.prefetch_scheduler.update(
549                first_visible_index,
550                last_visible_index,
551                total_items,
552                direction,
553                &strategy,
554            );
555        });
556    }
557
558    /// Returns the indices that should be prefetched.
559    /// Consumes the prefetch queue.
560    pub fn take_prefetch_indices(&self) -> Vec<usize> {
561        self.inner
562            .try_with(|rc| {
563                let mut inner = rc.borrow_mut();
564                let mut indices = Vec::new();
565                while let Some(idx) = inner.prefetch_scheduler.next_prefetch() {
566                    indices.push(idx);
567                }
568                indices
569            })
570            .unwrap_or_default()
571    }
572
573    /// Scrolls to the specified item index.
574    ///
575    /// # Arguments
576    /// * `index` - The index of the item to scroll to
577    /// * `scroll_offset` - Additional offset within the item (default 0)
578    pub fn scroll_to_item(&self, index: usize, scroll_offset: f32) {
579        if !self.inner.is_alive() {
580            return;
581        }
582        if diagnostics::telemetry_enabled() {
583            log::warn!(
584                "[lazy-measure-telemetry] scroll_to_item request index={index} offset={scroll_offset:.2}"
585            );
586        }
587        self.inner.with(|rc| {
588            rc.borrow_mut().pending_scroll_to_index = Some((index, scroll_offset));
589        });
590
591        self.scroll_position
592            .request_position_and_forget_last_known_key(index, scroll_offset);
593
594        self.invalidate();
595    }
596
597    /// Dispatches a raw scroll delta.
598    ///
599    /// Returns the amount of scroll actually consumed.
600    ///
601    /// This triggers layout invalidation via registered callbacks. The callbacks
602    /// are registered by LazyColumnImpl/LazyRowImpl with
603    /// `schedule_measure_repass(node_id)` — the list's own item sizes are what
604    /// changes, so the repass has to bubble measure dirtiness, not just
605    /// placement. The node id carries through to the scene phase, which scopes
606    /// its graph update to that subtree: O(subtree) instead of O(entire app).
607    pub fn dispatch_scroll_delta(&self, delta: f32) -> f32 {
608        if !self.inner.is_alive() {
609            return 0.0;
610        }
611        let has_scroll_bounds = self
612            .inner
613            .with(|rc| rc.borrow().layout_info.total_items_count > 0);
614        let pushing_forward = delta < -0.001;
615        let pushing_backward = delta > 0.001;
616        let can_scroll_forward =
617            self.can_scroll_forward_state.is_alive() && self.can_scroll_forward_non_reactive();
618        let can_scroll_backward =
619            self.can_scroll_backward_state.is_alive() && self.can_scroll_backward_non_reactive();
620        let blocked_by_bounds = has_scroll_bounds
621            && ((pushing_forward && !can_scroll_forward)
622                || (pushing_backward && !can_scroll_backward));
623
624        if blocked_by_bounds {
625            let should_invalidate = self.inner.with(|rc| {
626                let mut inner = rc.borrow_mut();
627                let pending_before = inner.scroll_to_be_consumed;
628                if pending_before.abs() > 0.001 && pending_before.signum() == delta.signum() {
629                    inner.scroll_to_be_consumed = 0.0;
630                }
631                if diagnostics::telemetry_enabled() {
632                    log::warn!(
633                        "[lazy-measure-telemetry] dispatch_scroll_delta blocked_by_bounds delta={:.2} pending_before={:.2} pending_after={:.2}",
634                        delta,
635                        pending_before,
636                        inner.scroll_to_be_consumed
637                    );
638                }
639                (inner.scroll_to_be_consumed - pending_before).abs() > 0.001
640            });
641            if should_invalidate {
642                self.invalidate();
643            }
644            return 0.0;
645        }
646
647        let mut accepted_delta = 0.0f32;
648        let should_invalidate = self.inner.with(|rc| {
649            let mut inner = rc.borrow_mut();
650            accepted_delta = delta;
651            let pending_before = inner.scroll_to_be_consumed;
652            let pending = inner.scroll_to_be_consumed;
653            let reverse_input = pending.abs() > 0.001
654                && delta.abs() > 0.001
655                && pending.signum() != delta.signum();
656            if reverse_input {
657                if diagnostics::telemetry_enabled() {
658                    log::warn!(
659                        "[lazy-measure-telemetry] dispatch_scroll_delta direction_change pending={pending:.2} new_delta={delta:.2}"
660                    );
661                }
662                inner.scroll_to_be_consumed = delta;
663            } else {
664                inner.scroll_to_be_consumed += delta;
665            }
666            inner.scroll_to_be_consumed = inner
667                .scroll_to_be_consumed
668                .clamp(-MAX_PENDING_SCROLL_DELTA, MAX_PENDING_SCROLL_DELTA);
669            if diagnostics::telemetry_enabled() {
670                log::warn!(
671                    "[lazy-measure-telemetry] dispatch_scroll_delta delta={:.2} pending={:.2}",
672                    delta,
673                    inner.scroll_to_be_consumed
674                );
675            }
676            (inner.scroll_to_be_consumed - pending_before).abs() > 0.001
677        });
678        if should_invalidate {
679            self.invalidate();
680        }
681        accepted_delta
682    }
683
684    /// Peeks at the pending scroll delta without consuming it.
685    ///
686    /// Used for direction inference before measurement consumes the delta.
687    /// This is more accurate than comparing first visible index, especially for:
688    /// - Scrolling within the same item (partial scroll)
689    /// - Variable height items where scroll offset changes without index change
690    pub fn peek_scroll_delta(&self) -> f32 {
691        self.inner
692            .try_with(|rc| rc.borrow().scroll_to_be_consumed)
693            .unwrap_or(0.0)
694    }
695
696    pub(crate) fn begin_measure_pass(&self) -> LazyListMeasureStateSnapshot {
697        let (pending_scroll_delta, pending_scroll_to, average_item_size) = self
698            .inner
699            .try_with(|rc| {
700                let mut inner = rc.borrow_mut();
701                let pending_scroll_to = inner.pending_scroll_to_index.take();
702                let pending_scroll_delta = inner.scroll_to_be_consumed;
703                inner.scroll_to_be_consumed = 0.0;
704                (
705                    pending_scroll_delta,
706                    pending_scroll_to,
707                    inner.average_item_size,
708                )
709            })
710            .unwrap_or((0.0, None, super::DEFAULT_ITEM_SIZE_ESTIMATE));
711
712        LazyListMeasureStateSnapshot {
713            first_visible_item_index: self.scroll_position.current_index(),
714            first_visible_item_scroll_offset: self.scroll_position.current_scroll_offset(),
715            pending_scroll_delta,
716            pending_scroll_to,
717            average_item_size,
718        }
719    }
720
721    pub(crate) fn next_measure_cycle_id(&self) -> u64 {
722        self.inner
723            .try_with(|rc| {
724                let mut inner = rc.borrow_mut();
725                let id = inner.next_measure_cycle_id;
726                inner.next_measure_cycle_id = inner.next_measure_cycle_id.saturating_add(1);
727                id
728            })
729            .unwrap_or(0)
730    }
731
732    pub(crate) fn next_item_measure_pass_id(&self) -> u64 {
733        self.inner
734            .try_with(|rc| {
735                let mut inner = rc.borrow_mut();
736                let id = inner.next_item_measure_pass_id;
737                inner.next_item_measure_pass_id = inner.next_item_measure_pass_id.saturating_add(1);
738                id
739            })
740            .unwrap_or(0)
741    }
742
743    fn record_item_size_sample(inner: &mut LazyListStateInner, size: f32) {
744        inner.total_measured_items += 1;
745        let n = inner.total_measured_items as f32;
746        inner.average_item_size = inner.average_item_size * ((n - 1.0) / n) + size / n;
747    }
748
749    fn next_item_size_cache_tick(inner: &mut LazyListStateInner) -> u64 {
750        inner.item_size_clock = inner.item_size_clock.saturating_add(1);
751        inner.item_size_clock
752    }
753
754    fn insert_item_size(inner: &mut LazyListStateInner, index: usize, size: f32) -> bool {
755        use std::collections::hash_map::Entry;
756
757        let tick = Self::next_item_size_cache_tick(inner);
758        if let Entry::Occupied(mut entry) = inner.item_size_cache.entry(index) {
759            entry.insert(CachedItemSize {
760                size,
761                last_used: tick,
762            });
763            Self::push_item_size_cache_ticket(inner, tick, index);
764            return false;
765        }
766
767        if inner.item_size_cache.len() >= ITEM_SIZE_CACHE_CAPACITY {
768            Self::evict_one_item_size(inner);
769        }
770
771        inner.item_size_cache.insert(
772            index,
773            CachedItemSize {
774                size,
775                last_used: tick,
776            },
777        );
778        Self::push_item_size_cache_ticket(inner, tick, index);
779        true
780    }
781
782    fn push_item_size_cache_ticket(inner: &mut LazyListStateInner, last_used: u64, index: usize) {
783        inner
784            .item_size_eviction_queue
785            .push(Reverse((last_used, index)));
786        let compact_limit = inner
787            .item_size_cache
788            .len()
789            .saturating_mul(4)
790            .max(ITEM_SIZE_CACHE_CAPACITY);
791        if inner.item_size_eviction_queue.len() > compact_limit {
792            Self::rebuild_item_size_eviction_queue(inner);
793        }
794    }
795
796    fn rebuild_item_size_eviction_queue(inner: &mut LazyListStateInner) {
797        inner.item_size_eviction_queue = inner
798            .item_size_cache
799            .iter()
800            .map(|(index, item)| Reverse((item.last_used, *index)))
801            .collect();
802    }
803
804    fn evict_one_item_size(inner: &mut LazyListStateInner) {
805        while let Some(Reverse((last_used, index))) = inner.item_size_eviction_queue.pop() {
806            let Some(current) = inner.item_size_cache.get(&index) else {
807                continue;
808            };
809            if current.last_used != last_used {
810                continue;
811            }
812            inner.item_size_cache.remove(&index);
813            return;
814        }
815    }
816
817    /// Caches the measured size of an item for scroll estimation.
818    pub fn cache_item_size(&self, index: usize, size: f32) {
819        if !self.inner.is_alive() {
820            return;
821        }
822        self.inner.with(|rc| {
823            let mut inner = rc.borrow_mut();
824            if Self::insert_item_size(&mut inner, index, size) {
825                Self::record_item_size_sample(&mut inner, size);
826            }
827        });
828    }
829
830    /// Caches multiple measured item sizes in one pass and returns the updated average.
831    pub fn cache_item_sizes<I>(&self, sizes: I) -> f32
832    where
833        I: IntoIterator<Item = (usize, f32)>,
834    {
835        if !self.inner.is_alive() {
836            return super::DEFAULT_ITEM_SIZE_ESTIMATE;
837        }
838
839        self.inner.with(|rc| {
840            let mut inner = rc.borrow_mut();
841            for (index, size) in sizes {
842                if Self::insert_item_size(&mut inner, index, size) {
843                    Self::record_item_size_sample(&mut inner, size);
844                }
845            }
846            inner.average_item_size
847        })
848    }
849
850    /// Gets a cached item size if available.
851    pub fn get_cached_size(&self, index: usize) -> Option<f32> {
852        self.inner
853            .try_with(|rc| {
854                let mut inner = rc.borrow_mut();
855                let tick = Self::next_item_size_cache_tick(&mut inner);
856                let item = inner.item_size_cache.get_mut(&index)?;
857                item.last_used = tick;
858                let size = item.size;
859                Self::push_item_size_cache_ticket(&mut inner, tick, index);
860                Some(size)
861            })
862            .flatten()
863    }
864
865    /// Returns the running average of measured item sizes.
866    pub fn average_item_size(&self) -> f32 {
867        self.inner
868            .try_with(|rc| rc.borrow().average_item_size)
869            .unwrap_or(super::DEFAULT_ITEM_SIZE_ESTIMATE)
870    }
871
872    /// Returns the current nearest range for optimized key lookup.
873    pub fn nearest_range(&self) -> std::ops::Range<usize> {
874        self.scroll_position.nearest_range()
875    }
876
877    pub(crate) fn update_scroll_position(
878        &self,
879        first_visible_item_index: usize,
880        first_visible_item_scroll_offset: f32,
881    ) {
882        self.scroll_position.update_from_measure_result(
883            first_visible_item_index,
884            first_visible_item_scroll_offset,
885            None,
886        );
887    }
888
889    pub(crate) fn update_scroll_position_with_key(
890        &self,
891        first_visible_item_index: usize,
892        first_visible_item_scroll_offset: f32,
893        first_visible_item_key: u64,
894    ) {
895        self.scroll_position.update_from_measure_result(
896            first_visible_item_index,
897            first_visible_item_scroll_offset,
898            Some(first_visible_item_key),
899        );
900    }
901
902    /// Adjusts scroll position if the first visible item was moved due to data changes.
903    ///
904    /// Matches JC's `updateScrollPositionIfTheFirstItemWasMoved`.
905    /// If items were inserted/removed before the current scroll position,
906    /// this finds the item by its key and updates the index accordingly.
907    ///
908    /// Returns the adjusted first visible item index.
909    pub fn update_scroll_position_if_item_moved<F>(
910        &self,
911        new_item_count: usize,
912        get_index_by_key: F,
913    ) -> usize
914    where
915        F: Fn(u64) -> Option<usize>,
916    {
917        self.scroll_position
918            .update_if_first_item_moved(new_item_count, get_index_by_key)
919    }
920
921    pub(crate) fn update_layout_info(&self, mut info: LazyListLayoutInfo) {
922        if !self.inner.is_alive() {
923            return;
924        }
925        self.inner.with(|rc| {
926            let mut inner = rc.borrow_mut();
927            info.snap_anchor_offset = continuous_snap_anchor_offset(&inner.layout_info, &info);
928            inner.layout_info = info;
929        });
930    }
931
932    /// Returns whether we can scroll forward (more items below/right).
933    ///
934    /// When called during composition, this creates a reactive subscription
935    /// so that changes will trigger recomposition.
936    pub fn can_scroll_forward(&self) -> bool {
937        if !self.can_scroll_forward_state.is_alive() {
938            return false;
939        }
940        self.can_scroll_forward_state.subscribe_current_scope_only();
941        self.can_scroll_forward_non_reactive()
942    }
943
944    /// Returns whether the list can scroll forward without subscribing the current composition scope.
945    pub fn can_scroll_forward_non_reactive(&self) -> bool {
946        if !self.can_scroll_forward_state.is_alive() {
947            return false;
948        }
949        self.inner
950            .try_with(|rc| rc.borrow().current_can_scroll_forward)
951            .unwrap_or(false)
952    }
953
954    /// Returns whether we can scroll backward (more items above/left).
955    ///
956    /// When called during composition, this creates a reactive subscription
957    /// so that changes will trigger recomposition.
958    pub fn can_scroll_backward(&self) -> bool {
959        if !self.can_scroll_backward_state.is_alive() {
960            return false;
961        }
962        self.can_scroll_backward_state
963            .subscribe_current_scope_only();
964        self.can_scroll_backward_non_reactive()
965    }
966
967    /// Returns whether the list can scroll backward without subscribing the current composition scope.
968    pub fn can_scroll_backward_non_reactive(&self) -> bool {
969        if !self.can_scroll_backward_state.is_alive() {
970            return false;
971        }
972        self.inner
973            .try_with(|rc| rc.borrow().current_can_scroll_backward)
974            .unwrap_or(false)
975    }
976
977    pub(crate) fn update_scroll_bounds(&self) {
978        if !self.inner.is_alive()
979            || !self.can_scroll_forward_state.is_alive()
980            || !self.can_scroll_backward_state.is_alive()
981        {
982            return;
983        }
984        let can_forward = self.inner.with(|rc| {
985            let inner = rc.borrow();
986            let info = &inner.layout_info;
987            let viewport_end = info.viewport_size - info.after_content_padding;
988            if let Some(last_visible) = info.visible_items_info.last() {
989                last_visible.index < info.total_items_count.saturating_sub(1)
990                    || (last_visible.offset + last_visible.size) > viewport_end
991            } else {
992                false
993            }
994        });
995
996        let can_backward = self.scroll_position.current_index() > 0
997            || self.scroll_position.current_scroll_offset() > 0.0;
998
999        self.inner.with(|rc| {
1000            let mut inner = rc.borrow_mut();
1001            inner.current_can_scroll_forward = can_forward;
1002            inner.current_can_scroll_backward = can_backward;
1003        });
1004
1005        if self.can_scroll_forward_state.get_non_reactive() != can_forward {
1006            self.can_scroll_forward_state.set(can_forward);
1007        }
1008        if self.can_scroll_backward_state.get_non_reactive() != can_backward {
1009            self.can_scroll_backward_state.set(can_backward);
1010        }
1011    }
1012
1013    /// Adds an invalidation callback.
1014    pub fn add_invalidate_callback(&self, callback: Rc<dyn Fn()>) -> u64 {
1015        if !self.inner.is_alive() {
1016            return 0;
1017        }
1018        self.inner.with(|rc| {
1019            let mut inner = rc.borrow_mut();
1020            let id = inner.next_callback_id;
1021            inner.next_callback_id += 1;
1022            inner.invalidate_callbacks.push((id, callback));
1023            id
1024        })
1025    }
1026
1027    /// Tries to register a layout invalidation callback for the specified node.
1028    ///
1029    /// Returns the callback id for the active layout callback.
1030    ///
1031    /// Registering again always replaces the previous active layout callback, even when
1032    /// the node id stays the same. This keeps ownership tied to the latest effect
1033    /// instance so disposing an older scope cannot unregister the live callback.
1034    pub fn try_register_layout_callback(
1035        &self,
1036        node_id: NodeId,
1037        callback: Rc<dyn Fn()>,
1038    ) -> Option<u64> {
1039        if !self.inner.is_alive() {
1040            return None;
1041        }
1042        self.inner.with(|rc| {
1043            let mut inner = rc.borrow_mut();
1044            if let Some(existing_id) = inner.layout_invalidation_callback_id {
1045                inner
1046                    .invalidate_callbacks
1047                    .retain(|(cb_id, _)| *cb_id != existing_id);
1048            }
1049            let id = inner.next_callback_id;
1050            inner.next_callback_id += 1;
1051            inner.invalidate_callbacks.push((id, callback));
1052            inner.layout_invalidation_callback_id = Some(id);
1053            inner.layout_invalidation_node_id = Some(node_id);
1054            Some(id)
1055        })
1056    }
1057
1058    /// Removes an invalidation callback.
1059    pub fn remove_invalidate_callback(&self, id: u64) {
1060        if !self.inner.is_alive() {
1061            return;
1062        }
1063        self.inner.with(|rc| {
1064            let mut inner = rc.borrow_mut();
1065            inner.invalidate_callbacks.retain(|(cb_id, _)| *cb_id != id);
1066            if inner.layout_invalidation_callback_id == Some(id) {
1067                inner.layout_invalidation_callback_id = None;
1068                inner.layout_invalidation_node_id = None;
1069            }
1070        });
1071    }
1072
1073    fn invalidate(&self) {
1074        if !self.inner.is_alive() {
1075            return;
1076        }
1077        let callbacks: Vec<_> = self.inner.with(|rc| {
1078            rc.borrow()
1079                .invalidate_callbacks
1080                .iter()
1081                .map(|(_, cb)| Rc::clone(cb))
1082                .collect()
1083        });
1084
1085        for callback in callbacks {
1086            callback();
1087        }
1088    }
1089}
1090
1091/// Information about the currently visible items in a lazy list.
1092#[derive(Clone, Default, Debug)]
1093pub struct LazyListLayoutInfo {
1094    /// Information about each visible item.
1095    pub visible_items_info: Vec<LazyListItemInfo>,
1096
1097    /// Total number of items in the list.
1098    pub total_items_count: usize,
1099
1100    /// Raw viewport size reported by parent constraints (before infinite fallback).
1101    pub raw_viewport_size: f32,
1102
1103    /// Whether the viewport was treated as infinite/unbounded.
1104    pub is_infinite_viewport: bool,
1105
1106    /// Size of the viewport in the main axis.
1107    pub viewport_size: f32,
1108
1109    /// Start offset of the viewport in layout coordinates.
1110    pub viewport_start_offset: f32,
1111
1112    /// End offset of the viewport in layout coordinates.
1113    pub viewport_end_offset: f32,
1114
1115    /// Content padding before the first item.
1116    pub before_content_padding: f32,
1117
1118    /// Content padding after the last item.
1119    pub after_content_padding: f32,
1120
1121    /// Continuous main-axis visual offset used to snap translated lazy-list content.
1122    pub snap_anchor_offset: f32,
1123
1124    /// Whether item offsets are placed from the end edge of the viewport.
1125    pub reverse_layout: bool,
1126}
1127
1128/// Information about a single visible item in a lazy list.
1129#[derive(Clone, Debug)]
1130pub struct LazyListItemInfo {
1131    /// Index of the item in the data source.
1132    pub index: usize,
1133
1134    /// Key of the item.
1135    pub key: u64,
1136
1137    /// Offset of the item from the start of the list content.
1138    pub offset: f32,
1139
1140    /// Size of the item in the main axis.
1141    pub size: f32,
1142}
1143
1144fn continuous_snap_anchor_offset(
1145    previous: &LazyListLayoutInfo,
1146    current: &LazyListLayoutInfo,
1147) -> f32 {
1148    let Some(first_current) = current.visible_items_info.first() else {
1149        return 0.0;
1150    };
1151
1152    for current_item in &current.visible_items_info {
1153        if let Some(previous_item) = previous
1154            .visible_items_info
1155            .iter()
1156            .find(|item| item.key == current_item.key)
1157        {
1158            let previous_offset = snap_anchor_item_offset(previous, previous_item);
1159            let current_offset = snap_anchor_item_offset(current, current_item);
1160            return previous.snap_anchor_offset + current_offset - previous_offset;
1161        }
1162    }
1163
1164    snap_anchor_item_offset(current, first_current)
1165}
1166
1167fn snap_anchor_item_offset(info: &LazyListLayoutInfo, item: &LazyListItemInfo) -> f32 {
1168    if info.reverse_layout {
1169        info.viewport_size - item.offset - item.size
1170    } else {
1171        item.offset
1172    }
1173}
1174
1175#[cfg(test)]
1176#[path = "tests/lazy_list_state_test_helpers.rs"]
1177pub mod test_helpers;
1178
1179#[cfg(test)]
1180#[path = "tests/lazy_list_state_tests.rs"]
1181mod tests;