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