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};
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.
251struct LazyListStateInner {
252    scroll_to_be_consumed: f32,
253
254    pending_scroll_to_index: Option<(usize, f32)>,
255
256    layout_info: LazyListLayoutInfo,
257    current_can_scroll_forward: bool,
258    current_can_scroll_backward: bool,
259
260    invalidate_callbacks: Vec<(u64, Rc<dyn Fn()>)>,
261    next_callback_id: u64,
262
263    layout_invalidation_callback_id: Option<u64>,
264    layout_invalidation_node_id: Option<NodeId>,
265
266    total_composed: usize,
267    reuse_count: usize,
268
269    item_size_cache: std::collections::HashMap<usize, CachedItemSize>,
270    item_size_eviction_queue: BinaryHeap<Reverse<(u64, usize)>>,
271    item_size_clock: u64,
272
273    average_item_size: f32,
274    total_measured_items: usize,
275    next_measure_cycle_id: u64,
276    next_item_measure_pass_id: u64,
277
278    last_scroll_delta: f32,
279
280    hold_scroll_window: bool,
281}
282
283/// Creates a remembered [`LazyListState`] with default initial position.
284///
285/// This is the recommended way to create a `LazyListState` in composition.
286/// The returned state is `Copy` and can be passed to multiple closures without `.clone()`.
287///
288/// # Example
289///
290/// ```rust,ignore
291/// let list_state = rememberLazyListState();
292///
293/// // Pass to multiple closures - no .clone() needed!
294/// LazyColumn(modifier, list_state, spec, content);
295/// Button(move || list_state.scroll_to_item(0, 0.0));
296/// ```
297#[composable]
298#[track_caller]
299pub fn rememberLazyListState() -> LazyListState {
300    rememberLazyListStateWithPosition(0, 0.0)
301}
302
303/// Creates a remembered [`LazyListState`] with the specified initial position.
304///
305/// The returned state is `Copy` and can be passed to multiple closures without `.clone()`.
306#[composable]
307pub fn rememberLazyListStateWithPosition(
308    initial_first_visible_item_index: usize,
309    initial_first_visible_item_scroll_offset: f32,
310) -> LazyListState {
311    cranpose_core::remember(move || {
312        LazyListState::new(
313            initial_first_visible_item_index,
314            initial_first_visible_item_scroll_offset,
315        )
316    })
317    .with(|state| *state)
318}
319
320impl LazyListState {
321    /// A list's scroll states, for code that remembers the list state itself.
322    ///
323    /// Call it inside a `remember` -- [`rememberLazyListState`] is this plus
324    /// the slot -- so that the states belong to that slot and are released
325    /// with it.
326    pub fn new(
327        initial_first_visible_item_index: usize,
328        initial_first_visible_item_scroll_offset: f32,
329    ) -> Self {
330        LazyListState {
331            scroll_position: LazyListScrollPosition {
332                index: cranpose_core::mutableStateOf(initial_first_visible_item_index),
333                scroll_offset: cranpose_core::mutableStateOf(
334                    initial_first_visible_item_scroll_offset,
335                ),
336                inner: cranpose_core::mutableStateOfNeverEqual(Rc::new(RefCell::new(
337                    ScrollPositionInner {
338                        current_index: initial_first_visible_item_index,
339                        current_scroll_offset: initial_first_visible_item_scroll_offset,
340                        last_known_first_item_key: None,
341                        nearest_range_state: NearestRangeState::new(
342                            initial_first_visible_item_index,
343                        ),
344                    },
345                ))),
346            },
347            can_scroll_forward_state: cranpose_core::mutableStateOf(false),
348            can_scroll_backward_state: cranpose_core::mutableStateOf(false),
349            stats_state: cranpose_core::mutableStateOf(LazyLayoutStats::default()),
350            inner: cranpose_core::mutableStateOfNeverEqual(Rc::new(RefCell::new(
351                LazyListStateInner {
352                    scroll_to_be_consumed: 0.0,
353                    pending_scroll_to_index: None,
354                    layout_info: LazyListLayoutInfo::default(),
355                    current_can_scroll_forward: false,
356                    current_can_scroll_backward: false,
357                    invalidate_callbacks: Vec::new(),
358                    next_callback_id: 1,
359                    layout_invalidation_callback_id: None,
360                    layout_invalidation_node_id: None,
361                    total_composed: 0,
362                    reuse_count: 0,
363                    item_size_cache: std::collections::HashMap::new(),
364                    item_size_eviction_queue: BinaryHeap::new(),
365                    item_size_clock: 0,
366                    average_item_size: super::DEFAULT_ITEM_SIZE_ESTIMATE,
367                    total_measured_items: 0,
368                    next_measure_cycle_id: 1,
369                    next_item_measure_pass_id: 1,
370                    last_scroll_delta: 0.0,
371                    hold_scroll_window: false,
372                },
373            ))),
374        }
375    }
376
377    /// Returns a stable identity pointer for the live inner state allocation.
378    ///
379    /// The pointer comes from the `Rc` stored inside `inner`, so it remains stable for the
380    /// lifetime of a live `LazyListState` and can be used as a composition identity key.
381    pub fn inner_ptr(&self) -> *const () {
382        self.inner
383            .try_with(|rc| Rc::as_ptr(rc) as *const ())
384            .unwrap_or(std::ptr::null())
385    }
386
387    /// Returns the index of the first visible item.
388    ///
389    /// When called during composition, this creates a reactive subscription
390    /// so that changes to the index will trigger recomposition.
391    pub fn first_visible_item_index(&self) -> usize {
392        self.scroll_position.index()
393    }
394
395    /// How many items the list holds, as of its last measure.
396    pub fn total_items_count(&self) -> usize {
397        self.inner
398            .with(|rc| rc.borrow().layout_info.total_items_count)
399    }
400
401    /// Returns the first visible item index without subscribing the current composition scope.
402    ///
403    /// Use this from draw/input/diagnostic code that needs the latest position but must not
404    /// recompose when the scroll position changes.
405    pub fn first_visible_item_index_non_reactive(&self) -> usize {
406        self.scroll_position.current_index()
407    }
408
409    /// Returns the scroll offset of the first visible item.
410    ///
411    /// This is the amount the first item is scrolled off-screen (positive = scrolled up/left).
412    /// When called during composition, this creates a reactive subscription
413    /// so that changes to the offset will trigger recomposition.
414    pub fn first_visible_item_scroll_offset(&self) -> f32 {
415        self.scroll_position.scroll_offset()
416    }
417
418    /// Returns the first visible item scroll offset without subscribing the current composition scope.
419    ///
420    /// Use this from draw/input/diagnostic code that needs the latest position but must not
421    /// recompose when the scroll position changes.
422    pub fn first_visible_item_scroll_offset_non_reactive(&self) -> f32 {
423        self.scroll_position.current_scroll_offset()
424    }
425
426    #[doc(hidden)]
427    pub fn reactive_state_ids(&self) -> [StateId; 5] {
428        [
429            self.scroll_position.index.runtime_state_id(),
430            self.scroll_position.scroll_offset.runtime_state_id(),
431            self.can_scroll_forward_state.runtime_state_id(),
432            self.can_scroll_backward_state.runtime_state_id(),
433            self.stats_state.runtime_state_id(),
434        ]
435    }
436
437    /// Returns the layout info from the last measure pass.
438    pub fn layout_info(&self) -> LazyListLayoutInfo {
439        self.inner
440            .try_with(|rc| rc.borrow().layout_info.clone())
441            .unwrap_or_default()
442    }
443
444    /// Returns the current item lifecycle statistics.
445    ///
446    /// When called during composition, this creates a reactive subscription
447    /// so that changes to `items_in_use` or `items_in_pool` will trigger recomposition.
448    /// The `total_composed` and `reuse_count` fields are diagnostic and non-reactive.
449    pub fn stats(&self) -> LazyLayoutStats {
450        if !self.stats_state.is_alive() || !self.inner.is_alive() {
451            return LazyLayoutStats::default();
452        }
453        let reactive = self.stats_state.get();
454        let (total_composed, reuse_count) = self.inner.with(|rc| {
455            let inner = rc.borrow();
456            (inner.total_composed, inner.reuse_count)
457        });
458        LazyLayoutStats {
459            items_in_use: reactive.items_in_use,
460            items_in_pool: reactive.items_in_pool,
461            total_composed,
462            reuse_count,
463        }
464    }
465
466    /// Updates the item lifecycle statistics.
467    ///
468    /// Called by the layout measurement after updating slot pools.
469    /// Triggers recomposition if `items_in_use` or `items_in_pool` changed.
470    pub fn update_stats(&self, items_in_use: usize, items_in_pool: usize) {
471        if !self.stats_state.is_alive() || !self.inner.is_alive() {
472            return;
473        }
474
475        let current = self.stats_state.get_non_reactive();
476
477        let should_update_reactive = if items_in_use > current.items_in_use {
478            true
479        } else if items_in_use < current.items_in_use {
480            current.items_in_use - items_in_use > 1
481        } else {
482            false
483        };
484
485        if should_update_reactive {
486            self.stats_state.set(LazyLayoutStats {
487                items_in_use,
488                items_in_pool,
489                ..current
490            });
491        }
492    }
493
494    /// Records that an item was composed (either new or reused).
495    ///
496    /// This updates diagnostic counters in non-reactive state.
497    /// Does NOT trigger recomposition.
498    pub fn record_composition(&self, was_reused: bool) {
499        if !self.inner.is_alive() {
500            return;
501        }
502        self.inner.with(|rc| {
503            let mut inner = rc.borrow_mut();
504            inner.total_composed += 1;
505            if was_reused {
506                inner.reuse_count += 1;
507            }
508        });
509    }
510
511    /// Makes the next measure pass size its beyond-bounds window by the last
512    /// scroll delta a pass consumed when it has none of its own, so a pass
513    /// between frames keeps the window the scroll gave the frame before it.
514    pub fn hold_scroll_window(&self) {
515        if !self.inner.is_alive() {
516            return;
517        }
518        self.inner
519            .with(|rc| rc.borrow_mut().hold_scroll_window = true);
520    }
521
522    /// Scrolls to the specified item index.
523    ///
524    /// # Arguments
525    /// * `index` - The index of the item to scroll to
526    /// * `scroll_offset` - Additional offset within the item (default 0)
527    pub fn scroll_to_item(&self, index: usize, scroll_offset: f32) {
528        if !self.inner.is_alive() {
529            return;
530        }
531        if diagnostics::telemetry_enabled() {
532            log::warn!(
533                "[lazy-measure-telemetry] scroll_to_item request index={index} offset={scroll_offset:.2}"
534            );
535        }
536        self.inner.with(|rc| {
537            rc.borrow_mut().pending_scroll_to_index = Some((index, scroll_offset));
538        });
539
540        self.scroll_position
541            .request_position_and_forget_last_known_key(index, scroll_offset);
542
543        self.invalidate();
544    }
545
546    /// Dispatches a raw scroll delta.
547    ///
548    /// Returns the amount of scroll actually consumed.
549    ///
550    /// This triggers layout invalidation via registered callbacks. The callbacks
551    /// are registered by LazyColumnImpl/LazyRowImpl with
552    /// `schedule_measure_repass(node_id)` — the list's own item sizes are what
553    /// changes, so the repass has to bubble measure dirtiness, not just
554    /// placement. The node id carries through to the scene phase, which scopes
555    /// its graph update to that subtree: O(subtree) instead of O(entire app).
556    pub fn dispatch_scroll_delta(&self, delta: f32) -> f32 {
557        if !self.inner.is_alive() {
558            return 0.0;
559        }
560        let has_scroll_bounds = self
561            .inner
562            .with(|rc| rc.borrow().layout_info.total_items_count > 0);
563        let pushing_forward = delta < -0.001;
564        let pushing_backward = delta > 0.001;
565        let can_scroll_forward =
566            self.can_scroll_forward_state.is_alive() && self.can_scroll_forward_non_reactive();
567        let can_scroll_backward =
568            self.can_scroll_backward_state.is_alive() && self.can_scroll_backward_non_reactive();
569        let blocked_by_bounds = has_scroll_bounds
570            && ((pushing_forward && !can_scroll_forward)
571                || (pushing_backward && !can_scroll_backward));
572
573        if blocked_by_bounds {
574            let should_invalidate = self.inner.with(|rc| {
575                let mut inner = rc.borrow_mut();
576                let pending_before = inner.scroll_to_be_consumed;
577                if pending_before.abs() > 0.001 && pending_before.signum() == delta.signum() {
578                    inner.scroll_to_be_consumed = 0.0;
579                }
580                if diagnostics::telemetry_enabled() {
581                    log::warn!(
582                        "[lazy-measure-telemetry] dispatch_scroll_delta blocked_by_bounds delta={:.2} pending_before={:.2} pending_after={:.2}",
583                        delta,
584                        pending_before,
585                        inner.scroll_to_be_consumed
586                    );
587                }
588                (inner.scroll_to_be_consumed - pending_before).abs() > 0.001
589            });
590            if should_invalidate {
591                self.invalidate();
592            }
593            return 0.0;
594        }
595
596        let mut accepted_delta = 0.0f32;
597        let should_invalidate = self.inner.with(|rc| {
598            let mut inner = rc.borrow_mut();
599            accepted_delta = delta;
600            let pending_before = inner.scroll_to_be_consumed;
601            let pending = inner.scroll_to_be_consumed;
602            let reverse_input = pending.abs() > 0.001
603                && delta.abs() > 0.001
604                && pending.signum() != delta.signum();
605            if reverse_input {
606                if diagnostics::telemetry_enabled() {
607                    log::warn!(
608                        "[lazy-measure-telemetry] dispatch_scroll_delta direction_change pending={pending:.2} new_delta={delta:.2}"
609                    );
610                }
611                inner.scroll_to_be_consumed = delta;
612            } else {
613                inner.scroll_to_be_consumed += delta;
614            }
615            inner.scroll_to_be_consumed = inner
616                .scroll_to_be_consumed
617                .clamp(-MAX_PENDING_SCROLL_DELTA, MAX_PENDING_SCROLL_DELTA);
618            if diagnostics::telemetry_enabled() {
619                log::warn!(
620                    "[lazy-measure-telemetry] dispatch_scroll_delta delta={:.2} pending={:.2}",
621                    delta,
622                    inner.scroll_to_be_consumed
623                );
624            }
625            (inner.scroll_to_be_consumed - pending_before).abs() > 0.001
626        });
627        if should_invalidate {
628            self.invalidate();
629        }
630        accepted_delta
631    }
632
633    /// Peeks at the pending scroll delta without consuming it.
634    ///
635    /// Used for direction inference before measurement consumes the delta.
636    /// This is more accurate than comparing first visible index, especially for:
637    /// - Scrolling within the same item (partial scroll)
638    /// - Variable height items where scroll offset changes without index change
639    pub fn peek_scroll_delta(&self) -> f32 {
640        self.inner
641            .try_with(|rc| rc.borrow().scroll_to_be_consumed)
642            .unwrap_or(0.0)
643    }
644
645    pub(crate) fn begin_measure_pass(&self) -> LazyListMeasureStateSnapshot {
646        let (pending_scroll_delta, window_scroll_delta, pending_scroll_to, average_item_size) =
647            self.inner
648                .try_with(|rc| {
649                    let mut inner = rc.borrow_mut();
650                    let pending_scroll_to = inner.pending_scroll_to_index.take();
651                    let pending_scroll_delta = inner.scroll_to_be_consumed;
652                    inner.scroll_to_be_consumed = 0.0;
653                    let held = std::mem::take(&mut inner.hold_scroll_window);
654                    let window_scroll_delta = if pending_scroll_delta.abs() > 0.001 {
655                        inner.last_scroll_delta = pending_scroll_delta;
656                        pending_scroll_delta
657                    } else if held {
658                        inner.last_scroll_delta
659                    } else {
660                        0.0
661                    };
662                    (
663                        pending_scroll_delta,
664                        window_scroll_delta,
665                        pending_scroll_to,
666                        inner.average_item_size,
667                    )
668                })
669                .unwrap_or((0.0, 0.0, None, super::DEFAULT_ITEM_SIZE_ESTIMATE));
670
671        LazyListMeasureStateSnapshot {
672            first_visible_item_index: self.scroll_position.current_index(),
673            first_visible_item_scroll_offset: self.scroll_position.current_scroll_offset(),
674            pending_scroll_delta,
675            window_scroll_delta,
676            pending_scroll_to,
677            average_item_size,
678        }
679    }
680
681    pub(crate) fn next_measure_cycle_id(&self) -> u64 {
682        self.inner
683            .try_with(|rc| {
684                let mut inner = rc.borrow_mut();
685                let id = inner.next_measure_cycle_id;
686                inner.next_measure_cycle_id = inner.next_measure_cycle_id.saturating_add(1);
687                id
688            })
689            .unwrap_or(0)
690    }
691
692    pub(crate) fn next_item_measure_pass_id(&self) -> u64 {
693        self.inner
694            .try_with(|rc| {
695                let mut inner = rc.borrow_mut();
696                let id = inner.next_item_measure_pass_id;
697                inner.next_item_measure_pass_id = inner.next_item_measure_pass_id.saturating_add(1);
698                id
699            })
700            .unwrap_or(0)
701    }
702
703    fn record_item_size_sample(inner: &mut LazyListStateInner, size: f32) {
704        inner.total_measured_items += 1;
705        let n = inner.total_measured_items as f32;
706        inner.average_item_size = inner.average_item_size * ((n - 1.0) / n) + size / n;
707    }
708
709    fn next_item_size_cache_tick(inner: &mut LazyListStateInner) -> u64 {
710        inner.item_size_clock = inner.item_size_clock.saturating_add(1);
711        inner.item_size_clock
712    }
713
714    fn insert_item_size(inner: &mut LazyListStateInner, index: usize, size: f32) -> bool {
715        use std::collections::hash_map::Entry;
716
717        let tick = Self::next_item_size_cache_tick(inner);
718        if let Entry::Occupied(mut entry) = inner.item_size_cache.entry(index) {
719            entry.insert(CachedItemSize {
720                size,
721                last_used: tick,
722            });
723            Self::push_item_size_cache_ticket(inner, tick, index);
724            return false;
725        }
726
727        if inner.item_size_cache.len() >= ITEM_SIZE_CACHE_CAPACITY {
728            Self::evict_one_item_size(inner);
729        }
730
731        inner.item_size_cache.insert(
732            index,
733            CachedItemSize {
734                size,
735                last_used: tick,
736            },
737        );
738        Self::push_item_size_cache_ticket(inner, tick, index);
739        true
740    }
741
742    fn push_item_size_cache_ticket(inner: &mut LazyListStateInner, last_used: u64, index: usize) {
743        inner
744            .item_size_eviction_queue
745            .push(Reverse((last_used, index)));
746        let compact_limit = inner
747            .item_size_cache
748            .len()
749            .saturating_mul(4)
750            .max(ITEM_SIZE_CACHE_CAPACITY);
751        if inner.item_size_eviction_queue.len() > compact_limit {
752            Self::rebuild_item_size_eviction_queue(inner);
753        }
754    }
755
756    fn rebuild_item_size_eviction_queue(inner: &mut LazyListStateInner) {
757        inner.item_size_eviction_queue = inner
758            .item_size_cache
759            .iter()
760            .map(|(index, item)| Reverse((item.last_used, *index)))
761            .collect();
762    }
763
764    fn evict_one_item_size(inner: &mut LazyListStateInner) {
765        while let Some(Reverse((last_used, index))) = inner.item_size_eviction_queue.pop() {
766            let Some(current) = inner.item_size_cache.get(&index) else {
767                continue;
768            };
769            if current.last_used != last_used {
770                continue;
771            }
772            inner.item_size_cache.remove(&index);
773            return;
774        }
775    }
776
777    /// Caches the measured size of an item for scroll estimation.
778    pub fn cache_item_size(&self, index: usize, size: f32) {
779        if !self.inner.is_alive() {
780            return;
781        }
782        self.inner.with(|rc| {
783            let mut inner = rc.borrow_mut();
784            if Self::insert_item_size(&mut inner, index, size) {
785                Self::record_item_size_sample(&mut inner, size);
786            }
787        });
788    }
789
790    /// Caches multiple measured item sizes in one pass and returns the updated average.
791    pub fn cache_item_sizes<I>(&self, sizes: I) -> f32
792    where
793        I: IntoIterator<Item = (usize, f32)>,
794    {
795        if !self.inner.is_alive() {
796            return super::DEFAULT_ITEM_SIZE_ESTIMATE;
797        }
798
799        self.inner.with(|rc| {
800            let mut inner = rc.borrow_mut();
801            for (index, size) in sizes {
802                if Self::insert_item_size(&mut inner, index, size) {
803                    Self::record_item_size_sample(&mut inner, size);
804                }
805            }
806            inner.average_item_size
807        })
808    }
809
810    /// Gets a cached item size if available.
811    pub fn get_cached_size(&self, index: usize) -> Option<f32> {
812        self.inner
813            .try_with(|rc| {
814                let mut inner = rc.borrow_mut();
815                let tick = Self::next_item_size_cache_tick(&mut inner);
816                let item = inner.item_size_cache.get_mut(&index)?;
817                item.last_used = tick;
818                let size = item.size;
819                Self::push_item_size_cache_ticket(&mut inner, tick, index);
820                Some(size)
821            })
822            .flatten()
823    }
824
825    /// Returns the running average of measured item sizes.
826    pub fn average_item_size(&self) -> f32 {
827        self.inner
828            .try_with(|rc| rc.borrow().average_item_size)
829            .unwrap_or(super::DEFAULT_ITEM_SIZE_ESTIMATE)
830    }
831
832    /// Returns the current nearest range for optimized key lookup.
833    pub fn nearest_range(&self) -> std::ops::Range<usize> {
834        self.scroll_position.nearest_range()
835    }
836
837    pub(crate) fn update_scroll_position(
838        &self,
839        first_visible_item_index: usize,
840        first_visible_item_scroll_offset: f32,
841    ) {
842        self.scroll_position.update_from_measure_result(
843            first_visible_item_index,
844            first_visible_item_scroll_offset,
845            None,
846        );
847    }
848
849    pub(crate) fn update_scroll_position_with_key(
850        &self,
851        first_visible_item_index: usize,
852        first_visible_item_scroll_offset: f32,
853        first_visible_item_key: u64,
854    ) {
855        self.scroll_position.update_from_measure_result(
856            first_visible_item_index,
857            first_visible_item_scroll_offset,
858            Some(first_visible_item_key),
859        );
860    }
861
862    /// Adjusts scroll position if the first visible item was moved due to data changes.
863    ///
864    /// Matches JC's `updateScrollPositionIfTheFirstItemWasMoved`.
865    /// If items were inserted/removed before the current scroll position,
866    /// this finds the item by its key and updates the index accordingly.
867    ///
868    /// Returns the adjusted first visible item index.
869    pub fn update_scroll_position_if_item_moved<F>(
870        &self,
871        new_item_count: usize,
872        get_index_by_key: F,
873    ) -> usize
874    where
875        F: Fn(u64) -> Option<usize>,
876    {
877        self.scroll_position
878            .update_if_first_item_moved(new_item_count, get_index_by_key)
879    }
880
881    pub(crate) fn update_layout_info(&self, mut info: LazyListLayoutInfo) {
882        if !self.inner.is_alive() {
883            return;
884        }
885        self.inner.with(|rc| {
886            let mut inner = rc.borrow_mut();
887            info.snap_anchor_offset = continuous_snap_anchor_offset(&inner.layout_info, &info);
888            inner.layout_info = info;
889        });
890    }
891
892    /// Returns whether we can scroll forward (more items below/right).
893    ///
894    /// When called during composition, this creates a reactive subscription
895    /// so that changes will trigger recomposition.
896    pub fn can_scroll_forward(&self) -> bool {
897        if !self.can_scroll_forward_state.is_alive() {
898            return false;
899        }
900        self.can_scroll_forward_state.subscribe_current_scope_only();
901        self.can_scroll_forward_non_reactive()
902    }
903
904    /// Returns whether the list can scroll forward without subscribing the current composition scope.
905    pub fn can_scroll_forward_non_reactive(&self) -> bool {
906        if !self.can_scroll_forward_state.is_alive() {
907            return false;
908        }
909        self.inner
910            .try_with(|rc| rc.borrow().current_can_scroll_forward)
911            .unwrap_or(false)
912    }
913
914    /// Returns whether we can scroll backward (more items above/left).
915    ///
916    /// When called during composition, this creates a reactive subscription
917    /// so that changes will trigger recomposition.
918    pub fn can_scroll_backward(&self) -> bool {
919        if !self.can_scroll_backward_state.is_alive() {
920            return false;
921        }
922        self.can_scroll_backward_state
923            .subscribe_current_scope_only();
924        self.can_scroll_backward_non_reactive()
925    }
926
927    /// Returns whether the list can scroll backward without subscribing the current composition scope.
928    pub fn can_scroll_backward_non_reactive(&self) -> bool {
929        if !self.can_scroll_backward_state.is_alive() {
930            return false;
931        }
932        self.inner
933            .try_with(|rc| rc.borrow().current_can_scroll_backward)
934            .unwrap_or(false)
935    }
936
937    pub(crate) fn update_scroll_bounds(&self) {
938        if !self.inner.is_alive()
939            || !self.can_scroll_forward_state.is_alive()
940            || !self.can_scroll_backward_state.is_alive()
941        {
942            return;
943        }
944        let can_forward = self.inner.with(|rc| {
945            let inner = rc.borrow();
946            let info = &inner.layout_info;
947            let viewport_end = info.viewport_size - info.after_content_padding;
948            if let Some(last_visible) = info.visible_items_info.last() {
949                last_visible.index < info.total_items_count.saturating_sub(1)
950                    || (last_visible.offset + last_visible.size) > viewport_end
951            } else {
952                false
953            }
954        });
955
956        let can_backward = self.scroll_position.current_index() > 0
957            || self.scroll_position.current_scroll_offset() > 0.0;
958
959        self.inner.with(|rc| {
960            let mut inner = rc.borrow_mut();
961            inner.current_can_scroll_forward = can_forward;
962            inner.current_can_scroll_backward = can_backward;
963        });
964
965        if self.can_scroll_forward_state.get_non_reactive() != can_forward {
966            self.can_scroll_forward_state.set(can_forward);
967        }
968        if self.can_scroll_backward_state.get_non_reactive() != can_backward {
969            self.can_scroll_backward_state.set(can_backward);
970        }
971    }
972
973    /// Adds an invalidation callback.
974    pub fn add_invalidate_callback(&self, callback: Rc<dyn Fn()>) -> u64 {
975        if !self.inner.is_alive() {
976            return 0;
977        }
978        self.inner.with(|rc| {
979            let mut inner = rc.borrow_mut();
980            let id = inner.next_callback_id;
981            inner.next_callback_id += 1;
982            inner.invalidate_callbacks.push((id, callback));
983            id
984        })
985    }
986
987    /// Tries to register a layout invalidation callback for the specified node.
988    ///
989    /// Returns the callback id for the active layout callback.
990    ///
991    /// Registering again always replaces the previous active layout callback, even when
992    /// the node id stays the same. This keeps ownership tied to the latest effect
993    /// instance so disposing an older scope cannot unregister the live callback.
994    pub fn try_register_layout_callback(
995        &self,
996        node_id: NodeId,
997        callback: Rc<dyn Fn()>,
998    ) -> Option<u64> {
999        if !self.inner.is_alive() {
1000            return None;
1001        }
1002        self.inner.with(|rc| {
1003            let mut inner = rc.borrow_mut();
1004            if let Some(existing_id) = inner.layout_invalidation_callback_id {
1005                inner
1006                    .invalidate_callbacks
1007                    .retain(|(cb_id, _)| *cb_id != existing_id);
1008            }
1009            let id = inner.next_callback_id;
1010            inner.next_callback_id += 1;
1011            inner.invalidate_callbacks.push((id, callback));
1012            inner.layout_invalidation_callback_id = Some(id);
1013            inner.layout_invalidation_node_id = Some(node_id);
1014            Some(id)
1015        })
1016    }
1017
1018    /// Removes an invalidation callback.
1019    pub fn remove_invalidate_callback(&self, id: u64) {
1020        if !self.inner.is_alive() {
1021            return;
1022        }
1023        self.inner.with(|rc| {
1024            let mut inner = rc.borrow_mut();
1025            inner.invalidate_callbacks.retain(|(cb_id, _)| *cb_id != id);
1026            if inner.layout_invalidation_callback_id == Some(id) {
1027                inner.layout_invalidation_callback_id = None;
1028                inner.layout_invalidation_node_id = None;
1029            }
1030        });
1031    }
1032
1033    fn invalidate(&self) {
1034        if !self.inner.is_alive() {
1035            return;
1036        }
1037        let callbacks: Vec<_> = self.inner.with(|rc| {
1038            rc.borrow()
1039                .invalidate_callbacks
1040                .iter()
1041                .map(|(_, cb)| Rc::clone(cb))
1042                .collect()
1043        });
1044
1045        for callback in callbacks {
1046            callback();
1047        }
1048    }
1049}
1050
1051/// Information about the currently visible items in a lazy list.
1052#[derive(Clone, Default, Debug)]
1053pub struct LazyListLayoutInfo {
1054    /// Information about each visible item.
1055    pub visible_items_info: Vec<LazyListItemInfo>,
1056
1057    /// Total number of items in the list.
1058    pub total_items_count: usize,
1059
1060    /// Raw viewport size reported by parent constraints (before infinite fallback).
1061    pub raw_viewport_size: f32,
1062
1063    /// Whether the viewport was treated as infinite/unbounded.
1064    pub is_infinite_viewport: bool,
1065
1066    /// Size of the viewport in the main axis.
1067    pub viewport_size: f32,
1068
1069    /// Start offset of the viewport in layout coordinates.
1070    pub viewport_start_offset: f32,
1071
1072    /// End offset of the viewport in layout coordinates.
1073    pub viewport_end_offset: f32,
1074
1075    /// Content padding before the first item.
1076    pub before_content_padding: f32,
1077
1078    /// Content padding after the last item.
1079    pub after_content_padding: f32,
1080
1081    /// Continuous main-axis visual offset used to snap translated lazy-list content.
1082    pub snap_anchor_offset: f32,
1083
1084    /// Whether item offsets are placed from the end edge of the viewport.
1085    pub reverse_layout: bool,
1086}
1087
1088/// Information about a single visible item in a lazy list.
1089#[derive(Clone, Debug)]
1090pub struct LazyListItemInfo {
1091    /// Index of the item in the data source.
1092    pub index: usize,
1093
1094    /// Key of the item.
1095    pub key: u64,
1096
1097    /// Offset of the item from the start of the list content.
1098    pub offset: f32,
1099
1100    /// Size of the item in the main axis.
1101    pub size: f32,
1102}
1103
1104fn continuous_snap_anchor_offset(
1105    previous: &LazyListLayoutInfo,
1106    current: &LazyListLayoutInfo,
1107) -> f32 {
1108    let Some(first_current) = current.visible_items_info.first() else {
1109        return 0.0;
1110    };
1111
1112    for current_item in &current.visible_items_info {
1113        if let Some(previous_item) = previous
1114            .visible_items_info
1115            .iter()
1116            .find(|item| item.key == current_item.key)
1117        {
1118            let previous_offset = snap_anchor_item_offset(previous, previous_item);
1119            let current_offset = snap_anchor_item_offset(current, current_item);
1120            return previous.snap_anchor_offset + current_offset - previous_offset;
1121        }
1122    }
1123
1124    snap_anchor_item_offset(current, first_current)
1125}
1126
1127fn snap_anchor_item_offset(info: &LazyListLayoutInfo, item: &LazyListItemInfo) -> f32 {
1128    if info.reverse_layout {
1129        info.viewport_size - item.offset - item.size
1130    } else {
1131        item.offset
1132    }
1133}
1134
1135#[cfg(test)]
1136#[path = "tests/lazy_list_state_test_helpers.rs"]
1137pub mod test_helpers;
1138
1139#[cfg(test)]
1140#[path = "tests/lazy_list_state_tests.rs"]
1141mod tests;